@@ -700,10 +700,147 @@ def signed_area(self, **kwargs):
700
700
# add final implied CLOSEPOLY, if necessary
701
701
if start_point is not None \
702
702
and not np .all (np .isclose (start_point , prev_point )):
703
- B = BezierSegment (np .array ([prev_point , start_point ]))
704
- area += B .arc_area ()
703
+ Bclose = BezierSegment (np .array ([prev_point , start_point ]))
704
+ area += Bclose .arc_area ()
705
705
return area
706
706
707
+ def center_of_mass (self , dimension = None , ** kwargs ):
708
+ r"""
709
+ Center of mass of the path, assuming constant density.
710
+
711
+ The center of mass is defined to be the expected value of a vector
712
+ located uniformly within either the filled area of the path
713
+ (:code:`dimension=2`) or the along path's edge (:code:`dimension=1`) or
714
+ along isolated points of the path (:code:`dimension=0`). Notice in
715
+ particular that for this definition, if the filled area is used, then
716
+ any 0- or 1-dimensional components of the path will not contribute to
717
+ the center of mass. Similarly, for if *dimension* is 1, then isolated
718
+ points in the path (i.e. "0-dimensional" strokes made up of only
719
+ :code:`Path.MOVETO`'s) will not contribute to the center of mass.
720
+
721
+ For the 2d case, the center of mass is computed using the same
722
+ filling strategy as `signed_area`. So, if a path is self-intersecting,
723
+ the drawing rule "even-odd" is used and only the filled area is
724
+ counted, and all sub paths are treated as if they had been closed. That
725
+ is, if there is a MOVETO without a preceding CLOSEPOLY, one is added.
726
+
727
+ For the 1d measure, the curve is averaged as-is (the implied CLOSEPOLY
728
+ is not added).
729
+
730
+ For the 0d measure, any non-isolated points are ignored.
731
+
732
+ Parameters
733
+ ----------
734
+ dimension : 2, 1, or 0 (optional)
735
+ Whether to compute the center of mass by taking the expected value
736
+ of a position uniformly distributed within the filled path
737
+ (2D-measure), the path's edge (1D-measure), or between the
738
+ discrete, isolated points of the path (0D-measure), respectively.
739
+ By default, the intended dimension of the path is inferred by
740
+ checking first if `Path.signed_area` is non-zero (implying a
741
+ *dimension* of 2), then if the `Path.length` is non-zero (implying
742
+ a *dimension* of 1), and finally falling back to the counting
743
+ measure (*dimension* of 0).
744
+ kwargs : Dict[str, object]
745
+ Passed thru to `Path.cleaned` via `Path.iter_bezier`.
746
+
747
+ Returns
748
+ -------
749
+ r_cm : (2,) np.array<float>
750
+ The center of mass of the path.
751
+
752
+ Raises
753
+ ------
754
+ ValueError
755
+ An empty path has no well-defined center of mass.
756
+
757
+ In addition, if a specific *dimension* is requested and that
758
+ dimension is not well-defined, an error is raised. This can happen
759
+ if::
760
+
761
+ 1) 2D expected value was requested but the path has zero area
762
+ 2) 1D expected value was requested but the path has only
763
+ `Path.MOVETO` directives
764
+ 3) 0D expected value was requested but the path has NO
765
+ subsequent `Path.MOVETO` directives.
766
+
767
+ This error cannot be raised if the function is allowed to infer
768
+ what *dimension* to use.
769
+ """
770
+ area = None
771
+ cleaned = self .cleaned (** kwargs )
772
+ move_codes = cleaned .codes == Path .MOVETO
773
+ if len (cleaned .codes ) == 0 :
774
+ raise ValueError ("An empty path has no center of mass." )
775
+ if dimension is None :
776
+ dimension = 2
777
+ area = cleaned .signed_area ()
778
+ if not np .isclose (area , 0 ):
779
+ dimension -= 1
780
+ if np .all (move_codes ):
781
+ dimension = 0
782
+ if dimension == 2 :
783
+ # area computation can be expensive, make sure we don't repeat it
784
+ if area is None :
785
+ area = cleaned .signed_area ()
786
+ if np .isclose (area , 0 ):
787
+ raise ValueError ("2d expected value over empty area is "
788
+ "ill-defined." )
789
+ return cleaned ._2d_center_of_mass (area )
790
+ if dimension == 1 :
791
+ if np .all (move_codes ):
792
+ raise ValueError ("1d expected value over empty arc-length is "
793
+ "ill-defined." )
794
+ return cleaned ._1d_center_of_mass ()
795
+ if dimension == 0 :
796
+ adjacent_moves = (move_codes [1 :] + move_codes [:- 1 ]) == 2
797
+ if len (move_codes ) > 1 and not np .any (adjacent_moves ):
798
+ raise ValueError ("0d expected value with no isolated points "
799
+ "is ill-defined." )
800
+ return cleaned ._0d_center_of_mass ()
801
+
802
+ def _2d_center_of_mass (self , normalization = None ):
803
+ #TODO: refactor this and signed_area (and maybe others, with
804
+ # close= parameter)?
805
+ if normalization is None :
806
+ normalization = self .signed_area ()
807
+ r_cm = np .zeros (2 )
808
+ prev_point = None
809
+ prev_code = None
810
+ start_point = None
811
+ for B , code in self .iter_bezier ():
812
+ if code == Path .MOVETO :
813
+ if prev_code is not None and prev_code is not Path .CLOSEPOLY :
814
+ Bclose = BezierSegment (np .array ([prev_point , start_point ]))
815
+ r_cm += Bclose .arc_center_of_mass ()
816
+ start_point = B .control_points [0 ]
817
+ r_cm += B .arc_center_of_mass ()
818
+ prev_point = B .control_points [- 1 ]
819
+ prev_code = code
820
+ # add final implied CLOSEPOLY, if necessary
821
+ if start_point is not None \
822
+ and not np .all (np .isclose (start_point , prev_point )):
823
+ Bclose = BezierSegment (np .array ([prev_point , start_point ]))
824
+ r_cm += Bclose .arc_center_of_mass ()
825
+ return r_cm / normalization
826
+
827
+ def _1d_center_of_mass (self ):
828
+ r_cm = np .zeros (2 )
829
+ Bs = list (self .iter_bezier ())
830
+ arc_lengths = np .array ([B .arc_length () for B in Bs ])
831
+ r_cms = np .array ([B .center_of_mass () for B in Bs ])
832
+ total_length = np .sum (arc_lengths )
833
+ return np .sum (r_cms * arc_lengths )/ total_length
834
+
835
+ def _0d_center_of_mass (self ):
836
+ move_verts = self .codes
837
+ isolated_verts = move_verts .copy ()
838
+ if len (move_verts ) > 1 :
839
+ isolated_verts [:- 1 ] = (move_verts [:- 1 ] + move_verts [1 :]) == 2
840
+ isolated_verts [- 1 ] = move_verts [- 1 ]
841
+ num_verts = np .sum (isolated_verts )
842
+ return np .sum (self .vertices [isolated_verts ], axis = 0 )/ num_verts
843
+
707
844
def interpolated (self , steps ):
708
845
"""
709
846
Return a new path resampled to length N x steps.
0 commit comments