Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CHANGE triangulation_earclip #1253

Merged
merged 10 commits into from
Jan 11, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Moved `compas.numerical.matrices` to `compas.topology.matrices`.
* Moved `compas.numerical.linalg` to `compas.geometry.linalg`.
* Changed `watchdog` dependency to be only required for platforms other than `emscripten`.
* Changed `compas.geometry.earclip_polygon` algorithm because the current one does handle several cases.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny typo ;)

Suggested change
* Changed `compas.geometry.earclip_polygon` algorithm because the current one does handle several cases.
* Changed `compas.geometry.earclip_polygon` algorithm because the current one does not handle several cases.


### Removed

Expand Down
317 changes: 273 additions & 44 deletions src/compas/geometry/triangulation_earclip.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,258 @@
from compas.geometry import is_ccw_xy, is_point_in_triangle_xy
class Ear(object):
"""Represents an Ear of a polygon. An Ear is a triangle formed by three consecutive vertices of the polygon.

Parameters
----------
points : list
List of points representing the polygon.
indexes : list
List of indices of the points representing the polygon.
ind : int
Index of the vertex of the Ear triangle.

Attributes
----------
index : int
Index of the vertex of the Ear triangle.
coords : list
Coordinates of the vertex of the Ear triangle.
next : int
Index of the next vertex of the Ear triangle.
prew : int
Index of the previous vertex of the Ear triangle.
neighbour_coords : list
Coordinates of the next and previous vertices of the Ear triangle.

"""

def __init__(self, points, indexes, ind):
self.index = ind
self.coords = points[ind]
length = len(indexes)
index_in_indexes_arr = indexes.index(ind)
self.next = indexes[(index_in_indexes_arr + 1) % length]
if index_in_indexes_arr == 0:
self.prew = indexes[length - 1]
else:
self.prew = indexes[index_in_indexes_arr - 1]
self.neighbour_coords = [points[self.prew], points[self.next]]

def is_inside(self, point):
"""Check if a given point is inside the triangle formed by the Ear.

Returns
-------
bool
True, if the point is inside the triangle, False otherwise.

"""
p1 = self.coords
p2 = self.neighbour_coords[0]
p3 = self.neighbour_coords[1]
p0 = point

d = [
(p1[0] - p0[0]) * (p2[1] - p1[1]) - (p2[0] - p1[0]) * (p1[1] - p0[1]),
(p2[0] - p0[0]) * (p3[1] - p2[1]) - (p3[0] - p2[0]) * (p2[1] - p0[1]),
(p3[0] - p0[0]) * (p1[1] - p3[1]) - (p1[0] - p3[0]) * (p3[1] - p0[1]),
]

if d[0] * d[1] >= 0 and d[2] * d[1] >= 0 and d[0] * d[2] >= 0:
return True
return False

def is_ear_point(self, p):
"""Check if a given point is one of the vertices of the Ear triangle.

Returns
-------
bool
True, if the point is a vertex of the Ear triangle, False otherwise.

"""
if p == self.coords or p in self.neighbour_coords:
return True
return False

def validate(self, points, indexes, ears):
"""Validate if the Ear triangle is a valid Ear by checking its convexity and that no points lie inside.

Returns
-------
bool
True if the Ear triangle is valid, False otherwise.

"""

not_ear_points = [
points[i] for i in indexes if points[i] != self.coords and points[i] not in self.neighbour_coords
]
insides = [self.is_inside(p) for p in not_ear_points]
if self.is_convex() and True not in insides:
for e in ears:
if e.is_ear_point(self.coords):
return False
return True
return False

def is_convex(self):
"""Check if the Ear triangle is convex.

Returns
-------
bool
True if the Ear triangle is convex, False otherwise.

"""
a = self.neighbour_coords[0]
b = self.coords
c = self.neighbour_coords[1]
ab = [b[0] - a[0], b[1] - a[1]]
bc = [c[0] - b[0], c[1] - b[1]]
if ab[0] * bc[1] - ab[1] * bc[0] <= 0:
return False
return True

def get_triangle(self):
"""Get the indices of the vertices forming the Ear triangle.

Returns
-------
list
List of vertex indices forming the Ear triangle.

"""
return [self.prew, self.index, self.next]


class Earcut(object):
"""A class for triangulating points forming a polygon using the Ear-cutting algorithm.

Parameters
----------
points : list
List of points representing the polygon.

Attributes
----------
vertices : list
List of points representing the polygon.
ears : list
List of Ear objects representing the Ears of the polygon.
neighbours : list
List of indices of the neighbouring vertices.
triangles : list
List of triangles forming the triangulation of the polygon.
length : int
Number of vertices of the polygon.

"""

def __init__(self, points):
self.vertices = points
self.ears = []
self.neighbours = []
self.triangles = []
self.length = len(points)

def update_neighbours(self):
"""Update the list of neighboring vertices."""
neighbours = []
self.neighbours = neighbours

def add_ear(self, new_ear):
"""Add a new Ear to the list of Ears and update neighboring vertices.

Parameters
----------
new_ear : Ear
Ear object to be added to the list of Ears.

"""
self.ears.append(new_ear)
self.neighbours.append(new_ear.prew)
self.neighbours.append(new_ear.next)

def find_ears(self):
"""Find valid Ear triangles among the vertices and add them to the Ears list."""
i = 0
indexes = list(range(self.length))
while True:
if i >= self.length:
break
new_ear = Ear(self.vertices, indexes, i)
if new_ear.validate(self.vertices, indexes, self.ears):
self.add_ear(new_ear)
indexes.remove(new_ear.index)
i += 1

def triangulate(self):
"""Triangulate the polygon using the Ear-cutting algorithm.

Returns
-------
list[list[int]]
List of triangles forming the triangulation of the polygon.

Raises
------
ValueError
If no Ears were found for triangulation.
IndexError
If no more Ears were found for triangulation.

"""

if self.length < 3:
raise ValueError("Polygon must have at least 3 vertices.")
elif self.length == 3:
self.triangles.append([0, 1, 2])
return self.triangles

indexes = list(range(self.length))
self.find_ears()

num_of_ears = len(self.ears)

if num_of_ears == 0:
raise ValueError("No ears found for triangulation.")
if num_of_ears == 1:
self.triangles.append(self.ears[0].get_triangle())
return

while True:
if len(self.ears) == 2 and len(indexes) == 4:
self.triangles.append(self.ears[0].get_triangle())
self.triangles.append(self.ears[1].get_triangle())
break

if len(self.ears) == 0:
raise IndexError("Unable to find more Ears for triangulation.")
current = self.ears.pop(0)

indexes.remove(current.index)
self.neighbours.remove(current.prew)
self.neighbours.remove(current.next)

self.triangles.append(current.get_triangle())

# Check if prew and next vertices form new ears
prew_ear_new = Ear(self.vertices, indexes, current.prew)
next_ear_new = Ear(self.vertices, indexes, current.next)
if prew_ear_new.validate(self.vertices, indexes, self.ears) and prew_ear_new.index not in self.neighbours:
self.add_ear(prew_ear_new)
continue
if next_ear_new.validate(self.vertices, indexes, self.ears) and next_ear_new.index not in self.neighbours:
self.add_ear(next_ear_new)
continue

return self.triangles


def earclip_polygon(polygon):
"""Triangulate a polygon using the ear clipping method.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit test added does not explicitly test the earclip algorithm, and actually I find it hard to see the link from the code. I would add an additiona tests/compas/geometry/test_triangulation_earclip.py that tests directly the relevant methods of this module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have just made a new push to a new file: tests/compas/geometry/test_triangulation_earclip.py for testing.

I added one small detail to handle simple triangles.

The polygon is assumed to be planar and non-self-intersecting and position on XY plane.
The winding direction is checked. If the polygon is not oriented counter-clockwise, it is reversed.

Parameters
----------
Expand All @@ -16,49 +266,28 @@ def earclip_polygon(polygon):

Raises
------
Exception
If not all points were consumed by the procedure.
ValueError
If no ears were found for triangulation.
IndexError
If no more ears were found for triangulation.

"""

def find_ear(points, point_index):
p = len(points)
if p == 3:
triangle = [
point_index[id(points[0])],
point_index[id(points[1])],
point_index[id(points[2])],
]
del points[2]
del points[1]
del points[0]
return triangle
for i in range(-2, p - 2):
a = points[i]
b = points[i + 1]
c = points[i + 2]
is_valid = True
if not is_ccw_xy(b, c, a):
continue
for j in range(p):
if j == i or j == i + 1 or j == i + 2:
continue
if is_point_in_triangle_xy(points[j], (a, b, c)):
is_valid = False
break
if is_valid:
del points[i + 1]
return [point_index[id(a)], point_index[id(b)], point_index[id(c)]]

points = list(polygon)
point_index = {id(point): index for index, point in enumerate(points)}

triangles = []
while len(points) >= 3:
ear = find_ear(points, point_index)
triangles.append(ear)

if points:
raise Exception("Not all points were consumed by the clipping procedure.")

return triangles
# Orient the copy of polygon points to XY plane.
from compas.geometry import Plane, Frame, Transformation # Avoid circular import.

frame = Frame.from_plane(Plane(polygon.points[0], polygon.normal))
xform = Transformation.from_frame_to_frame(frame, Frame.worldXY())
points = [point.transformed(xform) for point in polygon.points]

# Check polygon winding by signed area of all current and next points pairs.
sum_val = 0.0
for p0, p1 in zip(points, points[1:] + [points[0]]):
sum_val += (p1[0] - p0[0]) * (p1[1] + p0[1])

if sum_val > 0.0:
points.reverse()

# Run the Earcut algorithm.
ear_cut = Earcut(points)
return ear_cut.triangulate()
Loading
Loading