Skip to content

Commit 114eb12

Browse files
authored
Merge pull request #1253 from petrasvestartas/triangulation_earclip
CHANGE triangulation_earclip
2 parents 3401a36 + 85c5434 commit 114eb12

File tree

3 files changed

+388
-44
lines changed

3 files changed

+388
-44
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8282
* Moved `compas.numerical.matrices` to `compas.topology.matrices`.
8383
* Moved `compas.numerical.linalg` to `compas.geometry.linalg`.
8484
* Changed `watchdog` dependency to be only required for platforms other than `emscripten`.
85+
* Changed `compas.geometry.earclip_polygon` algorithm because the current one does not handle several cases.
8586

8687
### Removed
8788

Lines changed: 273 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,258 @@
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

4252
def 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

Comments
 (0)