1- from compas .geometry import is_ccw_xy , is_point_in_triangle_xy
1+ class Ear (object ):
2+ """Represents an Ear of a polygon. An Ear is a triangle formed by three consecutive vertices of the polygon.
3+
4+ Parameters
5+ ----------
6+ points : list
7+ List of points representing the polygon.
8+ indexes : list
9+ List of indices of the points representing the polygon.
10+ ind : int
11+ Index of the vertex of the Ear triangle.
12+
13+ Attributes
14+ ----------
15+ index : int
16+ Index of the vertex of the Ear triangle.
17+ coords : list
18+ Coordinates of the vertex of the Ear triangle.
19+ next : int
20+ Index of the next vertex of the Ear triangle.
21+ prew : int
22+ Index of the previous vertex of the Ear triangle.
23+ neighbour_coords : list
24+ Coordinates of the next and previous vertices of the Ear triangle.
25+
26+ """
27+
28+ def __init__ (self , points , indexes , ind ):
29+ self .index = ind
30+ self .coords = points [ind ]
31+ length = len (indexes )
32+ index_in_indexes_arr = indexes .index (ind )
33+ self .next = indexes [(index_in_indexes_arr + 1 ) % length ]
34+ if index_in_indexes_arr == 0 :
35+ self .prew = indexes [length - 1 ]
36+ else :
37+ self .prew = indexes [index_in_indexes_arr - 1 ]
38+ self .neighbour_coords = [points [self .prew ], points [self .next ]]
39+
40+ def is_inside (self , point ):
41+ """Check if a given point is inside the triangle formed by the Ear.
42+
43+ Returns
44+ -------
45+ bool
46+ True, if the point is inside the triangle, False otherwise.
47+
48+ """
49+ p1 = self .coords
50+ p2 = self .neighbour_coords [0 ]
51+ p3 = self .neighbour_coords [1 ]
52+ p0 = point
53+
54+ d = [
55+ (p1 [0 ] - p0 [0 ]) * (p2 [1 ] - p1 [1 ]) - (p2 [0 ] - p1 [0 ]) * (p1 [1 ] - p0 [1 ]),
56+ (p2 [0 ] - p0 [0 ]) * (p3 [1 ] - p2 [1 ]) - (p3 [0 ] - p2 [0 ]) * (p2 [1 ] - p0 [1 ]),
57+ (p3 [0 ] - p0 [0 ]) * (p1 [1 ] - p3 [1 ]) - (p1 [0 ] - p3 [0 ]) * (p3 [1 ] - p0 [1 ]),
58+ ]
59+
60+ if d [0 ] * d [1 ] >= 0 and d [2 ] * d [1 ] >= 0 and d [0 ] * d [2 ] >= 0 :
61+ return True
62+ return False
63+
64+ def is_ear_point (self , p ):
65+ """Check if a given point is one of the vertices of the Ear triangle.
66+
67+ Returns
68+ -------
69+ bool
70+ True, if the point is a vertex of the Ear triangle, False otherwise.
71+
72+ """
73+ if p == self .coords or p in self .neighbour_coords :
74+ return True
75+ return False
76+
77+ def validate (self , points , indexes , ears ):
78+ """Validate if the Ear triangle is a valid Ear by checking its convexity and that no points lie inside.
79+
80+ Returns
81+ -------
82+ bool
83+ True if the Ear triangle is valid, False otherwise.
84+
85+ """
86+
87+ not_ear_points = [
88+ points [i ] for i in indexes if points [i ] != self .coords and points [i ] not in self .neighbour_coords
89+ ]
90+ insides = [self .is_inside (p ) for p in not_ear_points ]
91+ if self .is_convex () and True not in insides :
92+ for e in ears :
93+ if e .is_ear_point (self .coords ):
94+ return False
95+ return True
96+ return False
97+
98+ def is_convex (self ):
99+ """Check if the Ear triangle is convex.
100+
101+ Returns
102+ -------
103+ bool
104+ True if the Ear triangle is convex, False otherwise.
105+
106+ """
107+ a = self .neighbour_coords [0 ]
108+ b = self .coords
109+ c = self .neighbour_coords [1 ]
110+ ab = [b [0 ] - a [0 ], b [1 ] - a [1 ]]
111+ bc = [c [0 ] - b [0 ], c [1 ] - b [1 ]]
112+ if ab [0 ] * bc [1 ] - ab [1 ] * bc [0 ] <= 0 :
113+ return False
114+ return True
115+
116+ def get_triangle (self ):
117+ """Get the indices of the vertices forming the Ear triangle.
118+
119+ Returns
120+ -------
121+ list
122+ List of vertex indices forming the Ear triangle.
123+
124+ """
125+ return [self .prew , self .index , self .next ]
126+
127+
128+ class Earcut (object ):
129+ """A class for triangulating points forming a polygon using the Ear-cutting algorithm.
130+
131+ Parameters
132+ ----------
133+ points : list
134+ List of points representing the polygon.
135+
136+ Attributes
137+ ----------
138+ vertices : list
139+ List of points representing the polygon.
140+ ears : list
141+ List of Ear objects representing the Ears of the polygon.
142+ neighbours : list
143+ List of indices of the neighbouring vertices.
144+ triangles : list
145+ List of triangles forming the triangulation of the polygon.
146+ length : int
147+ Number of vertices of the polygon.
148+
149+ """
150+
151+ def __init__ (self , points ):
152+ self .vertices = points
153+ self .ears = []
154+ self .neighbours = []
155+ self .triangles = []
156+ self .length = len (points )
157+
158+ def update_neighbours (self ):
159+ """Update the list of neighboring vertices."""
160+ neighbours = []
161+ self .neighbours = neighbours
162+
163+ def add_ear (self , new_ear ):
164+ """Add a new Ear to the list of Ears and update neighboring vertices.
165+
166+ Parameters
167+ ----------
168+ new_ear : Ear
169+ Ear object to be added to the list of Ears.
170+
171+ """
172+ self .ears .append (new_ear )
173+ self .neighbours .append (new_ear .prew )
174+ self .neighbours .append (new_ear .next )
175+
176+ def find_ears (self ):
177+ """Find valid Ear triangles among the vertices and add them to the Ears list."""
178+ i = 0
179+ indexes = list (range (self .length ))
180+ while True :
181+ if i >= self .length :
182+ break
183+ new_ear = Ear (self .vertices , indexes , i )
184+ if new_ear .validate (self .vertices , indexes , self .ears ):
185+ self .add_ear (new_ear )
186+ indexes .remove (new_ear .index )
187+ i += 1
188+
189+ def triangulate (self ):
190+ """Triangulate the polygon using the Ear-cutting algorithm.
191+
192+ Returns
193+ -------
194+ list[list[int]]
195+ List of triangles forming the triangulation of the polygon.
196+
197+ Raises
198+ ------
199+ ValueError
200+ If no Ears were found for triangulation.
201+ IndexError
202+ If no more Ears were found for triangulation.
203+
204+ """
205+
206+ if self .length < 3 :
207+ raise ValueError ("Polygon must have at least 3 vertices." )
208+ elif self .length == 3 :
209+ self .triangles .append ([0 , 1 , 2 ])
210+ return self .triangles
211+
212+ indexes = list (range (self .length ))
213+ self .find_ears ()
214+
215+ num_of_ears = len (self .ears )
216+
217+ if num_of_ears == 0 :
218+ raise ValueError ("No ears found for triangulation." )
219+ if num_of_ears == 1 :
220+ self .triangles .append (self .ears [0 ].get_triangle ())
221+ return
222+
223+ while True :
224+ if len (self .ears ) == 2 and len (indexes ) == 4 :
225+ self .triangles .append (self .ears [0 ].get_triangle ())
226+ self .triangles .append (self .ears [1 ].get_triangle ())
227+ break
228+
229+ if len (self .ears ) == 0 :
230+ raise IndexError ("Unable to find more Ears for triangulation." )
231+ current = self .ears .pop (0 )
232+
233+ indexes .remove (current .index )
234+ self .neighbours .remove (current .prew )
235+ self .neighbours .remove (current .next )
236+
237+ self .triangles .append (current .get_triangle ())
238+
239+ # Check if prew and next vertices form new ears
240+ prew_ear_new = Ear (self .vertices , indexes , current .prew )
241+ next_ear_new = Ear (self .vertices , indexes , current .next )
242+ if prew_ear_new .validate (self .vertices , indexes , self .ears ) and prew_ear_new .index not in self .neighbours :
243+ self .add_ear (prew_ear_new )
244+ continue
245+ if next_ear_new .validate (self .vertices , indexes , self .ears ) and next_ear_new .index not in self .neighbours :
246+ self .add_ear (next_ear_new )
247+ continue
248+
249+ return self .triangles
2250
3251
4252def earclip_polygon (polygon ):
5253 """Triangulate a polygon using the ear clipping method.
254+ The polygon is assumed to be planar and non-self-intersecting and position on XY plane.
255+ The winding direction is checked. If the polygon is not oriented counter-clockwise, it is reversed.
6256
7257 Parameters
8258 ----------
@@ -16,49 +266,28 @@ def earclip_polygon(polygon):
16266
17267 Raises
18268 ------
19- Exception
20- If not all points were consumed by the procedure.
269+ ValueError
270+ If no ears were found for triangulation.
271+ IndexError
272+ If no more ears were found for triangulation.
21273
22274 """
23275
24- def find_ear (points , point_index ):
25- p = len (points )
26- if p == 3 :
27- triangle = [
28- point_index [id (points [0 ])],
29- point_index [id (points [1 ])],
30- point_index [id (points [2 ])],
31- ]
32- del points [2 ]
33- del points [1 ]
34- del points [0 ]
35- return triangle
36- for i in range (- 2 , p - 2 ):
37- a = points [i ]
38- b = points [i + 1 ]
39- c = points [i + 2 ]
40- is_valid = True
41- if not is_ccw_xy (b , c , a ):
42- continue
43- for j in range (p ):
44- if j == i or j == i + 1 or j == i + 2 :
45- continue
46- if is_point_in_triangle_xy (points [j ], (a , b , c )):
47- is_valid = False
48- break
49- if is_valid :
50- del points [i + 1 ]
51- return [point_index [id (a )], point_index [id (b )], point_index [id (c )]]
52-
53- points = list (polygon )
54- point_index = {id (point ): index for index , point in enumerate (points )}
55-
56- triangles = []
57- while len (points ) >= 3 :
58- ear = find_ear (points , point_index )
59- triangles .append (ear )
60-
61- if points :
62- raise Exception ("Not all points were consumed by the clipping procedure." )
63-
64- return triangles
276+ # Orient the copy of polygon points to XY plane.
277+ from compas .geometry import Plane , Frame , Transformation # Avoid circular import.
278+
279+ frame = Frame .from_plane (Plane (polygon .points [0 ], polygon .normal ))
280+ xform = Transformation .from_frame_to_frame (frame , Frame .worldXY ())
281+ points = [point .transformed (xform ) for point in polygon .points ]
282+
283+ # Check polygon winding by signed area of all current and next points pairs.
284+ sum_val = 0.0
285+ for p0 , p1 in zip (points , points [1 :] + [points [0 ]]):
286+ sum_val += (p1 [0 ] - p0 [0 ]) * (p1 [1 ] + p0 [1 ])
287+
288+ if sum_val > 0.0 :
289+ points .reverse ()
290+
291+ # Run the Earcut algorithm.
292+ ear_cut = Earcut (points )
293+ return ear_cut .triangulate ()
0 commit comments