@@ -659,21 +659,10 @@ class Polygon(CompositeSurface):
659659 def __init__ (self , points , basis = 'rz' ):
660660 check_value ('basis' , basis , ('xy' , 'yz' , 'xz' , 'rz' ))
661661 self ._basis = basis
662- points = np .asarray (points , dtype = float )
663- check_iterable_type ('points' , points , float , min_depth = 2 , max_depth = 2 )
664- check_length ('points' , points [0 , :], 2 , 2 )
665-
666- # If the last point is the same as the first, remove it and make sure
667- # there are still at least 3 points for a valid polygon.
668- if np .allclose (points [0 , :], points [- 1 , :]):
669- points = points [:- 1 , :]
670- check_length ('points' , points , 3 )
671-
672- # Order the points counter-clockwise (necessary for offset method)
673- self ._points = self ._make_ccw (points )
674662
675- # Create a triangulation of the points.
676- self ._tri = Delaunay (self ._points , qhull_options = 'QJ' )
663+ # Create a constrained triangulation of the validated points.
664+ # The constrained triangulation is set to the _tri attribute
665+ self ._constrain_triangulation (self ._validate_points (points ))
677666
678667 # Decompose the polygon into groups of simplices forming convex subsets
679668 # and get the sets of (surface, operator) pairs defining the polygon
@@ -723,7 +712,7 @@ def boundary_type(self, boundary_type):
723712
724713 @property
725714 def points (self ):
726- return self ._points
715+ return self ._tri . points
727716
728717 @property
729718 def basis (self ):
@@ -738,7 +727,7 @@ def _normals(self):
738727 # Get the unit vectors that point from one point in the polygon to the
739728 # next given that they are ordered counterclockwise and that the final
740729 # point is connected to the first point
741- tangents = np .diff (self ._points , axis = 0 , append = [self ._points [0 , :]])
730+ tangents = np .diff (self .points , axis = 0 , append = [self .points [0 , :]])
742731 tangents /= np .linalg .norm (tangents , axis = - 1 , keepdims = True )
743732 # Rotate the tangent vectors clockwise by 90 degrees, which for a
744733 # counter-clockwise ordered polygon will produce the outward normal
@@ -761,8 +750,9 @@ def regions(self):
761750 def region (self ):
762751 return self ._region
763752
764- def _make_ccw (self , points ):
765- """Order a set of points counter-clockwise.
753+ def _validate_points (self , points ):
754+ """Ensure the closed path defined by points does not intersect and is
755+ oriented counter-clockwise.
766756
767757 Parameters
768758 ----------
@@ -773,15 +763,145 @@ def _make_ccw(self, points):
773763 -------
774764 ordered_points : the input points ordered counter-clockwise
775765 """
766+ points = np .asarray (points , dtype = float )
767+ check_iterable_type ('points' , points , float , min_depth = 2 , max_depth = 2 )
768+ check_length ('points' , points [0 , :], 2 , 2 )
769+
770+ # If the last point is the same as the first, remove it and make sure
771+ # there are still at least 3 points for a valid polygon.
772+ if np .allclose (points [0 , :], points [- 1 , :]):
773+ points = points [:- 1 , :]
774+ check_length ('points' , points , 3 )
775+
776+ if len (points ) != len (np .unique (points , axis = 0 )):
777+ raise ValueError ('Duplicate points were detected in the Polygon input' )
778+
779+ # Order the points counter-clockwise (necessary for offset method)
776780 # Calculates twice the signed area of the polygon using the "Shoelace
777781 # Formula" https://en.wikipedia.org/wiki/Shoelace_formula
782+ # If signed area is positive the curve is oriented counter-clockwise.
783+ # If the signed area is negative the curve is oriented clockwise.
778784 xpts , ypts = points .T
785+ if np .sum (ypts * (np .roll (xpts , 1 ) - np .roll (xpts , - 1 ))) < 0 :
786+ points = points [::- 1 , :]
787+
788+ # Check if polygon is self-intersecting by comparing edges pairwise
789+ n = len (points )
790+ for i in range (n ):
791+ p0 = points [i , :]
792+ p1 = points [(i + 1 ) % n , :]
793+ for j in range (i + 1 , n ):
794+ p2 = points [j , :]
795+ p3 = points [(j + 1 ) % n , :]
796+ # Compute orientation of p0 wrt p2->p3 line segment
797+ cp0 = np .cross (p3 - p0 , p2 - p0 )
798+ # Compute orientation of p1 wrt p2->p3 line segment
799+ cp1 = np .cross (p3 - p1 , p2 - p1 )
800+ # Compute orientation of p2 wrt p0->p1 line segment
801+ cp2 = np .cross (p1 - p2 , p0 - p2 )
802+ # Compute orientation of p3 wrt p0->p1 line segment
803+ cp3 = np .cross (p1 - p3 , p0 - p3 )
804+
805+ # Group cross products in an array and find out how many are 0
806+ cross_products = np .array ([[cp0 , cp1 ], [cp2 , cp3 ]])
807+ cps_near_zero = np .isclose (cross_products , 0 ).astype (int )
808+ num_zeros = np .sum (cps_near_zero )
809+
810+ # Topologies of 2 finite line segments categorized by the number
811+ # of zero-valued cross products:
812+ #
813+ # 0: No 3 points lie on the same line
814+ # 1: 1 point lies on the same line defined by the other line
815+ # segment, but is not coincident with either of the points
816+ # 2: 2 points are coincident, but the line segments are not
817+ # collinear which guarantees no intersection
818+ # 3: not possible, except maybe floating point issues?
819+ # 4: Both line segments are collinear, simply need to check if
820+ # they overlap or not
821+ # adapted from algorithm linked below and modified to only
822+ # consider intersections on the interior of line segments as
823+ # proper intersections: i.e. segments sharing end points do not
824+ # count as intersections.
825+ # https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/
826+
827+ if num_zeros == 0 :
828+ # If the orientations of p0 and p1 have opposite signs
829+ # and the orientations of p2 and p3 have opposite signs
830+ # then there is an intersection.
831+ if all (np .prod (cross_products , axis = - 1 ) < 0 ):
832+ raise ValueError ('Polygon cannot be self-intersecting' )
833+ continue
779834
780- # If signed area is positive the curve is oriented counter-clockwise
781- if np .sum (ypts * (np .roll (xpts , 1 ) - np .roll (xpts , - 1 ))) > 0 :
782- return points
835+ elif num_zeros == 1 :
836+ # determine which line segment has 2 out of the 3 collinear
837+ # points
838+ idx = np .argwhere (np .sum (cps_near_zero , axis = - 1 ) == 0 )
839+ if np .prod (cross_products [idx , :]) < 0 :
840+ raise ValueError ('Polygon cannot be self-intersecting' )
841+ continue
842+
843+ elif num_zeros == 2 :
844+ continue
845+
846+ elif num_zeros == 3 :
847+ warnings .warn ('Unclear if Polygon is self-intersecting' )
848+ continue
849+
850+ else :
851+ # All 4 cross products are zero
852+ # Determine number of unique points, x span and y span for
853+ # both line segments
854+ xmin1 , xmax1 = min (p0 [0 ], p1 [0 ]), max (p0 [0 ], p1 [0 ])
855+ ymin1 , ymax1 = min (p0 [1 ], p1 [1 ]), max (p0 [1 ], p1 [1 ])
856+ xmin2 , xmax2 = min (p2 [0 ], p3 [0 ]), max (p2 [0 ], p3 [0 ])
857+ ymin2 , ymax2 = min (p2 [1 ], p3 [1 ]), max (p2 [1 ], p3 [1 ])
858+ xlap = xmin1 < xmax2 and xmin2 < xmax1
859+ ylap = ymin1 < ymax2 and ymin2 < ymax1
860+ if xlap or ylap :
861+ raise ValueError ('Polygon cannot be self-intersecting' )
862+ continue
783863
784- return points [::- 1 , :]
864+ return points
865+
866+ def _constrain_triangulation (self , points , depth = 0 ):
867+ """Generate a constrained triangulation by ensuring all edges of the
868+ Polygon are contained within the simplices.
869+
870+ Parameters
871+ ----------
872+ points : np.ndarray (Nx2)
873+ An Nx2 array of coordinate pairs describing the vertices. These
874+ points represent a planar straight line graph.
875+
876+ Returns
877+ -------
878+ None
879+ """
880+ # Only attempt the triangulation up to 3 times.
881+ if depth > 2 :
882+ raise RuntimeError ('Could not create a valid triangulation after 3'
883+ ' attempts' )
884+
885+ tri = Delaunay (points , qhull_options = 'QJ' )
886+ # Loop through the boundary edges of the polygon. If an edge is not
887+ # included in the triangulation, break it into two line segments.
888+ n = len (points )
889+ new_pts = []
890+ for i , j in zip (range (n ), range (1 , n + 1 )):
891+ # If both vertices of any edge are not found in any simplex, insert
892+ # a new point between them.
893+ if not any ([i in s and j % n in s for s in tri .simplices ]):
894+ newpt = (points [i , :] + points [j % n , :]) / 2
895+ new_pts .append ((j , newpt ))
896+
897+ # If all the edges are included in the triangulation set it, otherwise
898+ # try again with additional points inserted on offending edges.
899+ if not new_pts :
900+ self ._tri = tri
901+ else :
902+ for i , pt in new_pts [::- 1 ]:
903+ points = np .insert (points , i , pt , axis = 0 )
904+ self ._constrain_triangulation (points , depth = depth + 1 )
785905
786906 def _group_simplices (self , neighbor_map , group = None ):
787907 """Generate a convex grouping of simplices.
@@ -819,7 +939,7 @@ def _group_simplices(self, neighbor_map, group=None):
819939 continue
820940 test_group = group + [n ]
821941 test_point_idx = np .unique (self ._tri .simplices [test_group , :])
822- test_points = self ._tri . points [test_point_idx ]
942+ test_points = self .points [test_point_idx ]
823943 # If test_points are convex keep adding to this group
824944 if len (test_points ) == len (ConvexHull (test_points ).vertices ):
825945 group = self ._group_simplices (neighbor_map , group = test_group )
@@ -902,8 +1022,8 @@ def _decompose_polygon_into_convex_sets(self):
9021022
9031023 # Get centroids of all the simplices and determine if they are inside
9041024 # the polygon defined by input vertices or not.
905- centroids = np .mean (self ._points [self ._tri .simplices ], axis = 1 )
906- in_polygon = Path (self ._points ).contains_points (centroids )
1025+ centroids = np .mean (self .points [self ._tri .simplices ], axis = 1 )
1026+ in_polygon = Path (self .points ).contains_points (centroids )
9071027 self ._in_polygon = in_polygon
9081028
9091029 # Build a map with keys of simplex indices inside the polygon whose
@@ -931,7 +1051,7 @@ def _decompose_polygon_into_convex_sets(self):
9311051 # generate the convex hull and find the resulting surfaces and
9321052 # unary operators that represent this convex subset of the polygon.
9331053 idx = np .unique (self ._tri .simplices [group , :])
934- qhull = ConvexHull (self ._tri . points [idx , :])
1054+ qhull = ConvexHull (self .points [idx , :])
9351055 surf_ops = self ._get_convex_hull_surfs (qhull )
9361056 surfsets .append (surf_ops )
9371057 return surfsets
0 commit comments