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