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