@@ -700,10 +700,147 @@ def signed_area(self, **kwargs):
700700 # add final implied CLOSEPOLY, if necessary
701701 if start_point is not None \
702702 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 ()
705705 return area
706706
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+
707844 def interpolated (self , steps ):
708845 """
709846 Return a new path resampled to length N x steps.
0 commit comments