Skip to content

Commit b472cbc

Browse files
ValentinKlingercclausspre-commit-ci[bot]
authored
Added a polygon triangulation algorithm with the ear clipping method. (#10466)
* Added a polygon triangulation algorithm with the ear clipping method. (ear_clipping_polygon_triangulation.py) * Clarification of parameter names and provide type hint for a parameter in maths/ear_clipping_polygon_triangulation.py. (Action requested by algorithms-keeper bot). * updating DIRECTORY.md * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * updating DIRECTORY.md --------- Co-authored-by: Christian Clauss <cclauss@me.com> Co-authored-by: cclauss <cclauss@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <pre-commit-ci[bot]@users.noreply.github.com>
1 parent ad99d5e commit b472cbc

2 files changed

Lines changed: 260 additions & 1 deletion

File tree

DIRECTORY.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,7 @@
522522

523523
## [Fractals](fractals)
524524
* [Barnsley Fern](fractals/barnsley_fern.py)
525+
* [Hilbert Curve](fractals/hilbert_curve.py)
525526
* [Julia Sets](fractals/julia_sets.py)
526527
* [Koch Snowflake](fractals/koch_snowflake.py)
527528
* [Mandelbrot](fractals/mandelbrot.py)
@@ -699,7 +700,7 @@
699700
* [Gradient Descent](machine_learning/gradient_descent.py)
700701
* [K Means Clust](machine_learning/k_means_clust.py)
701702
* [K Medoids](machine_learning/k_medoids.py)
702-
* [K Nearest Neighbours](machine_learning/k_nearest_neighbours.py)
703+
* [K Nearest Neighbors](machine_learning/k_nearest_neighbors.py)
703704
* [Linear Discriminant Analysis](machine_learning/linear_discriminant_analysis.py)
704705
* [Linear Regression](machine_learning/linear_regression.py)
705706
* [Linear Regression Vectorized](machine_learning/linear_regression_vectorized.py)
@@ -742,6 +743,7 @@
742743
* [Binary Multiplication](maths/binary_multiplication.py)
743744
* [Binomial Coefficient](maths/binomial_coefficient.py)
744745
* [Binomial Distribution](maths/binomial_distribution.py)
746+
* [Binomial Expansion](maths/binomial_expansion.py)
745747
* [Ceil](maths/ceil.py)
746748
* [Chebyshev Distance](maths/chebyshev_distance.py)
747749
* [Check Polygon](maths/check_polygon.py)
@@ -759,6 +761,7 @@
759761
* [Dodecahedron](maths/dodecahedron.py)
760762
* [Double Factorial](maths/double_factorial.py)
761763
* [Dual Number Automatic Differentiation](maths/dual_number_automatic_differentiation.py)
764+
* [Ear Clipping Polygon Triangulation](maths/ear_clipping_polygon_triangulation.py)
762765
* [Entropy](maths/entropy.py)
763766
* [Euclidean Distance](maths/euclidean_distance.py)
764767
* [Euler Method](maths/euler_method.py)
@@ -784,6 +787,7 @@
784787
* [Interquartile Range](maths/interquartile_range.py)
785788
* [Is Int Palindrome](maths/is_int_palindrome.py)
786789
* [Is Ip V4 Address Valid](maths/is_ip_v4_address_valid.py)
790+
* [Is Ipv6 Address Valid](maths/is_ipv6_address_valid.py)
787791
* [Is Square Free](maths/is_square_free.py)
788792
* [Jaccard Similarity](maths/jaccard_similarity.py)
789793
* [Joint Probability Distribution](maths/joint_probability_distribution.py)
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""
2+
An implementation of the ear clipping method for triangulating a simple polygon.
3+
4+
Wikipedia : https://en.wikipedia.org/wiki/Polygon_triangulation
5+
"""
6+
7+
8+
def is_ear(polygon: list[tuple[float, float]], point_idx: int, direction: str) -> bool:
9+
"""
10+
This function determines whether three points form an ear.
11+
12+
>>> is_ear([(0, 2), (2, 2), (2,0), (1, 0), (1, 1), (0, 1)], 4,
13+
... "counter-clockwise")
14+
False
15+
>>> is_ear([(0, 2), (2, 2), (2,0), (1, 0), (1, 1), (0, 1)], 3,
16+
... "counter-clockwise")
17+
True
18+
>>> is_ear([(0, 3), (2, 2), (3, 0), (3, 3)], 3, "clockwise")
19+
False
20+
>>> is_ear([(0, 0), (1, 0), (1, 1), (0, 1)], 0, "clockwise")
21+
True
22+
"""
23+
# Calculate indices for the previous and next vertices in the polygon.
24+
prev_idx = (point_idx - 1) % len(polygon)
25+
next_idx = (point_idx + 1) % len(polygon)
26+
27+
# Retrieve the coordinates of the previous, current, and next vertices.
28+
prev_point = polygon[prev_idx]
29+
point = polygon[point_idx]
30+
next_point = polygon[next_idx]
31+
32+
# Check if the vertex is convex based on the polygon's orientation.
33+
if is_convex(prev_point, point, next_point, direction):
34+
# Check if there are any points inside the triangle formed by the current vertex
35+
# and its neighbors.
36+
for j in range(len(polygon)):
37+
if j not in (prev_idx, point_idx, next_idx) and is_point_inside_triangle(
38+
prev_point, point, next_point, polygon[j]
39+
):
40+
return False # The 'ear' is not valid because there's a point
41+
# inside the triangle.
42+
return True # The vertex is an 'ear' because it's convex and no points are
43+
# inside the triangle.
44+
return False # The vertex is not an 'ear' because it's not convex.
45+
46+
47+
def is_convex(
48+
point: tuple[float, float],
49+
prev_p: tuple[float, float],
50+
next_p: tuple[float, float],
51+
direction: str,
52+
) -> bool:
53+
"""
54+
Determine with the ccw, if 3 points are convex.
55+
>>> is_convex((1,1), (2, 2), (1, 2), "clockwise")
56+
True
57+
>>> is_convex((1,1), (2, 2), (1, 2), "counter-clockwise")
58+
False
59+
>>> is_convex((1,1), (2, 2), (3, 3), "clockwise")
60+
True
61+
>>> is_convex((1,1), (2, 2), (3, 3), "counter-clockwise")
62+
True
63+
"""
64+
# Calculate the cross product based on the polygon's orientation.
65+
if direction == "counter-clockwise":
66+
cross_product = (next_p[0] - point[0]) * (prev_p[1] - point[1]) - (
67+
prev_p[0] - point[0]
68+
) * (next_p[1] - point[1])
69+
else:
70+
cross_product = (prev_p[0] - point[0]) * (next_p[1] - point[1]) - (
71+
next_p[0] - point[0]
72+
) * (prev_p[1] - point[1])
73+
# Determine if the angle is convex (cross product is non-negative).
74+
return cross_product >= 0
75+
76+
77+
def cross_product(
78+
p1: tuple[float, float], p2: tuple[float, float], p3: tuple[float, float]
79+
) -> float:
80+
"""
81+
This function computes the product of two vectors, with 3 points.
82+
If the vectors are collinear, the output is 0.
83+
If the three points rotate counterclockwise, the output is positive.
84+
If three points rotate clockwise, the output is negative.
85+
86+
>>> cross_product((0, 0), (1, 0), (1.5, 0.5))
87+
0.5
88+
>>> cross_product((1.5, 0.5), (1, 1), (1.5, 1.5))
89+
-0.5
90+
>>> cross_product((0, 0), (1, 1), (2, 2))
91+
0
92+
"""
93+
return (p2[0] - p1[0]) * (p3[1] - p1[1]) - (p2[1] - p1[1]) * (p3[0] - p1[0])
94+
95+
96+
def direction(polygon: list[tuple[float, float]]) -> str:
97+
"""
98+
Determine the orientation (clockwise or counterclockwise) of a polygon defined
99+
by a list of points.
100+
>>> direction([(1, 1), (2, 2), (3, 4)])
101+
'clockwise'
102+
>>> direction([(1, 1), (2, 2), (3, -1)])
103+
'counter-clockwise'
104+
"""
105+
# Find the point with the lowest y-coordinate (and leftmost if tied).
106+
point_0 = min(polygon, key=lambda point: (point[1], point[0]))
107+
idx_p0 = polygon.index(point_0)
108+
109+
# Calculate the indices of the previous and next points.
110+
prev_idx = (idx_p0 - 1) % len(polygon)
111+
next_idx = (idx_p0 + 1) % len(polygon)
112+
113+
prev_point = polygon[prev_idx]
114+
next_point = polygon[next_idx]
115+
# Handle cases where multiple points share the same y-coordinate.
116+
while cross_product(point_0, next_point, prev_point) == 0:
117+
next_idx += 1
118+
next_point = polygon[next_idx]
119+
# Determine the polygon's orientation based on the cross product.
120+
if cross_product(point_0, next_point, prev_point) > 0:
121+
return "clockwise" # The polygon is in a clockwise direction.
122+
return "counter-clockwise" # The polygon is in a counter-clockwise direction.
123+
124+
125+
def is_point_inside_triangle(
126+
p1: tuple[float, float],
127+
p2: tuple[float, float],
128+
p3: tuple[float, float],
129+
test_point: tuple[float, float],
130+
) -> bool:
131+
"""
132+
Determine whether a given point is located inside a triangle
133+
formed by three other points.
134+
135+
This function calculates the area of both the triangle formed by
136+
the three input points (p1, p2, p3)
137+
and the sub-triangles formed by replacing one vertex of the main
138+
triangle with the test_point.
139+
If the sum of the areas of the sub-triangles is equal to the area of
140+
the main triangle, the test_point
141+
is considered to be inside the triangle. Otherwise, it is considered outside.
142+
143+
>>> is_point_inside_triangle((0, 0), (0, 2), (2, 0), (3, 3))
144+
False
145+
>>> is_point_inside_triangle((0, 0), (0, 2), (2, 0), (1, 1))
146+
True
147+
>>> is_point_inside_triangle((0, 0), (2, 1), (2, 0), (1, 1))
148+
False
149+
>>> is_point_inside_triangle((0, 0), (2, 1), (2, 0), (2, 0))
150+
True
151+
>>> is_point_inside_triangle((0, 0), (1, 1), (2, 0), (1, 0))
152+
True
153+
"""
154+
# Calculate the area of the main triangle.
155+
area_triangle = abs(
156+
0.5
157+
* (p1[0] * (p2[1] - p3[1]) + p2[0] * (p3[1] - p1[1]) + p3[0] * (p1[1] - p2[1]))
158+
)
159+
# Calculate the areas of the sub-triangles formed by replacing one vertex
160+
# with the test_point.
161+
area1 = abs(
162+
0.5
163+
* (
164+
test_point[0] * (p2[1] - p3[1])
165+
+ p2[0] * (p3[1] - test_point[1])
166+
+ p3[0] * (test_point[1] - p2[1])
167+
)
168+
)
169+
area2 = abs(
170+
0.5
171+
* (
172+
p1[0] * (test_point[1] - p3[1])
173+
+ test_point[0] * (p3[1] - p1[1])
174+
+ p3[0] * (p1[1] - test_point[1])
175+
)
176+
)
177+
area3 = abs(
178+
0.5
179+
* (
180+
p1[0] * (p2[1] - test_point[1])
181+
+ p2[0] * (test_point[1] - p1[1])
182+
+ test_point[0] * (p1[1] - p2[1])
183+
)
184+
)
185+
186+
# Check if the test_point is inside the triangle by comparing areas.
187+
return area_triangle == area1 + area2 + area3
188+
189+
190+
def triangulate_polygon(
191+
coordinates: list[tuple[float, float]],
192+
) -> list[list[tuple[float, float]]]:
193+
"""
194+
Triangulate a polygon and provide the points of the resulting triangles.
195+
This function takes a list of coordinates that represent the vertices of a polygon.
196+
It iteratively finds and removes 'ears' from the polygon to create a list
197+
of triangles that triangulate the entire polygon.
198+
The order of vertices in the coordinates list is assumed to be
199+
consistent (either clockwise or counterclockwise).
200+
The function uses helper functions 'direction' to determine the polygon's
201+
orientation and 'is_ear' to identify 'ear' vertices.
202+
203+
Note: The function assumes that the input coordinates form a valid simple polygon.
204+
205+
>>> triangulate_polygon([(0, 2), (2, 2), (2, 0), (1, 0), (1, 1), (0, 1)])
206+
[[(0, 1), (0, 2), (2, 2)], [(2, 2), (2, 0), (1, 0)], [(2, 2), (1, 0), (1, 1)], \
207+
[(0, 1), (2, 2), (1, 1)]]
208+
>>> triangulate_polygon([(0, 3), (2, 2), (3, 0), (3, 3)])
209+
[[(3, 3), (0, 3), (2, 2)], [(3, 3), (2, 2), (3, 0)]]
210+
>>> triangulate_polygon([(0, 0),(2, 0), (1, 1), (2, 2), (0,2)])
211+
[[(0, 0), (2, 0), (1, 1)], [(0, 2), (0, 0), (1, 1)], [(0, 2), (1, 1), (2, 2)]]
212+
213+
"""
214+
polygon: list[tuple[float, float]] = coordinates.copy()
215+
216+
# Initialize an empty list to store the triangles
217+
triangles: list = []
218+
219+
# Determine the orientation (clockwise or counterclockwise) of the polygon.
220+
orientation: str = direction(polygon)
221+
222+
# Iterate while there are at least three vertices in the polygon.
223+
while len(polygon) >= 3:
224+
ear_found: bool = False
225+
226+
# Find an 'ear' vertex and append the triangle to the list.
227+
for i in range(len(polygon)):
228+
if is_ear(polygon, i, orientation):
229+
ear_found = True
230+
prev_idx: int = (i - 1) % len(polygon)
231+
next_idx: int = (i + 1) % len(polygon)
232+
prev_point: tuple[float, float] = polygon[prev_idx]
233+
point: tuple[float, float] = polygon[i]
234+
next_point: tuple[float, float] = polygon[next_idx]
235+
triangles.append(
236+
[
237+
prev_point,
238+
point,
239+
next_point,
240+
]
241+
)
242+
polygon.pop(i)
243+
break
244+
245+
# If no 'ear' is found, exit the loop.
246+
if not ear_found:
247+
break
248+
249+
return triangles
250+
251+
252+
if __name__ == "__main__":
253+
import doctest
254+
255+
doctest.testmod()

0 commit comments

Comments
 (0)