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