Skip to content

Commit fbce887

Browse files
committed
Add Rotating Calipers algorithm for convex polygon diameter
1 parent 3d11d0c commit fbce887

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

geometry/rotating_calipers.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""
2+
Rotating Calipers Algorithm for Convex Polygon Diameter.
3+
4+
References:
5+
- https://en.wikipedia.org/wiki/Rotating_calipers
6+
- https://cp-algorithms.com/geometry/convex-hull-kernel.html
7+
- Toussaint, G. T. (1983). "Solving geometric problems with the rotating calipers".
8+
Proceedings of IEEE MELECON '83, Athens, Greece.
9+
10+
The rotating calipers paradigm allows computing the diameter (the maximum Euclidean
11+
distance between any pair of points) of a set of 2D points in O(n log n) time
12+
(O(n log n) for the convex hull and O(n) for the calipers sweep).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import math
18+
19+
Point = tuple[float, float]
20+
21+
22+
def cross_product(origin: Point, point_a: Point, point_b: Point) -> float:
23+
"""
24+
Compute the 2D cross product of vectors (origin -> point_a) and (origin -> point_b).
25+
26+
The return value represents twice the signed area of triangle
27+
(origin, point_a, point_b):
28+
> 0 : Counter-clockwise turn (left turn)
29+
< 0 : Clockwise turn (right turn)
30+
= 0 : Collinear points
31+
32+
>>> cross_product((0.0, 0.0), (1.0, 0.0), (1.0, 1.0))
33+
1.0
34+
>>> cross_product((0.0, 0.0), (1.0, 1.0), (1.0, 0.0))
35+
-1.0
36+
>>> cross_product((0.0, 0.0), (1.0, 1.0), (2.0, 2.0))
37+
0.0
38+
"""
39+
return (point_a[0] - origin[0]) * (point_b[1] - origin[1]) - (
40+
point_a[1] - origin[1]
41+
) * (point_b[0] - origin[0])
42+
43+
44+
def distance_squared(point_a: Point, point_b: Point) -> float:
45+
"""
46+
Compute the squared Euclidean distance between point_a and point_b.
47+
48+
>>> distance_squared((0.0, 0.0), (3.0, 4.0))
49+
25.0
50+
>>> distance_squared((1.0, 1.0), (1.0, 1.0))
51+
0.0
52+
>>> distance_squared((-1.0, -1.0), (2.0, 3.0))
53+
25.0
54+
"""
55+
return (point_a[0] - point_b[0]) ** 2 + (point_a[1] - point_b[1]) ** 2
56+
57+
58+
def convex_hull(points: list[Point]) -> list[Point]:
59+
"""
60+
Compute the convex hull of a set of 2D points in counter-clockwise order
61+
using Andrew's monotone chain algorithm.
62+
63+
Time Complexity: O(n log n) where n is the number of points.
64+
Space Complexity: O(n)
65+
66+
>>> convex_hull([(0.0, 0.0), (1.0, 1.0)])
67+
[(0.0, 0.0), (1.0, 1.0)]
68+
>>> convex_hull([(0.0, 0.0), (3.0, 0.0), (3.0, 3.0), (0.0, 3.0), (1.0, 1.0)])
69+
[(0.0, 0.0), (3.0, 0.0), (3.0, 3.0), (0.0, 3.0)]
70+
>>> convex_hull([(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)])
71+
[(0.0, 0.0), (2.0, 2.0)]
72+
>>> convex_hull([(1.0, 1.0)])
73+
[(1.0, 1.0)]
74+
"""
75+
unique_points = sorted(set(points))
76+
if len(unique_points) <= 1:
77+
return unique_points
78+
79+
lower_hull: list[Point] = []
80+
for candidate_point in unique_points:
81+
while (
82+
len(lower_hull) >= 2
83+
and cross_product(lower_hull[-2], lower_hull[-1], candidate_point) <= 0.0
84+
):
85+
lower_hull.pop()
86+
lower_hull.append(candidate_point)
87+
88+
upper_hull: list[Point] = []
89+
for candidate_point in reversed(unique_points):
90+
while (
91+
len(upper_hull) >= 2
92+
and cross_product(upper_hull[-2], upper_hull[-1], candidate_point) <= 0.0
93+
):
94+
upper_hull.pop()
95+
upper_hull.append(candidate_point)
96+
97+
return lower_hull[:-1] + upper_hull[:-1]
98+
99+
100+
def rotating_calipers(points: list[Point]) -> tuple[float, tuple[Point, Point]]:
101+
"""
102+
Find the maximum Euclidean distance (polygon diameter) and an antipodal pair
103+
of points for a given set of 2D points using the rotating calipers algorithm.
104+
105+
Time Complexity: O(n log n) for convex hull construction
106+
+ O(n) for the calipers sweep.
107+
Space Complexity: O(n) for the convex hull.
108+
109+
Raises:
110+
ValueError: If fewer than 2 points are provided.
111+
112+
>>> points = [(0.0, 0.0), (3.0, 0.0), (3.0, 4.0), (0.0, 4.0)]
113+
>>> max_dist, pair = rotating_calipers(points)
114+
>>> max_dist
115+
5.0
116+
>>> pair in [
117+
... ((0.0, 0.0), (3.0, 4.0)),
118+
... ((3.0, 4.0), (0.0, 0.0)),
119+
... ((3.0, 0.0), (0.0, 4.0)),
120+
... ((0.0, 4.0), (3.0, 0.0)),
121+
... ]
122+
True
123+
>>> rotating_calipers([(0.0, 0.0), (0.0, 5.0)])
124+
(5.0, ((0.0, 0.0), (0.0, 5.0)))
125+
>>> rotating_calipers([(1.0, 1.0), (1.0, 1.0)])
126+
(0.0, ((1.0, 1.0), (1.0, 1.0)))
127+
>>> rotating_calipers([(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (3.0, 3.0)])[0]
128+
4.242640687119285
129+
>>> rotating_calipers([(1.0, 1.0)])
130+
Traceback (most recent call last):
131+
...
132+
ValueError: At least 2 points are required to compute polygon diameter.
133+
"""
134+
if len(points) < 2:
135+
raise ValueError("At least 2 points are required to compute polygon diameter.")
136+
137+
hull = convex_hull(points)
138+
hull_size = len(hull)
139+
140+
if hull_size == 1:
141+
return 0.0, (hull[0], hull[0])
142+
if hull_size == 2:
143+
return math.hypot(hull[0][0] - hull[1][0], hull[0][1] - hull[1][1]), (
144+
hull[0],
145+
hull[1],
146+
)
147+
148+
max_dist_squared = 0.0
149+
best_pair = (hull[0], hull[1])
150+
151+
# Find initial antipodal point furthest from edge hull[0]-hull[1]
152+
antipodal_idx = 1
153+
while cross_product(
154+
hull[0], hull[1], hull[(antipodal_idx + 1) % hull_size]
155+
) > cross_product(hull[0], hull[1], hull[antipodal_idx]):
156+
antipodal_idx = (antipodal_idx + 1) % hull_size
157+
158+
for current_idx in range(hull_size):
159+
next_idx = (current_idx + 1) % hull_size
160+
while cross_product(
161+
hull[current_idx], hull[next_idx], hull[(antipodal_idx + 1) % hull_size]
162+
) > cross_product(hull[current_idx], hull[next_idx], hull[antipodal_idx]):
163+
antipodal_idx = (antipodal_idx + 1) % hull_size
164+
165+
for p in (hull[current_idx], hull[next_idx]):
166+
for candidate_idx in (antipodal_idx, (antipodal_idx + 1) % hull_size):
167+
dist_sq = distance_squared(p, hull[candidate_idx])
168+
if dist_sq > max_dist_squared:
169+
max_dist_squared = dist_sq
170+
best_pair = (p, hull[candidate_idx])
171+
172+
return math.sqrt(max_dist_squared), best_pair
173+
174+
175+
if __name__ == "__main__":
176+
import doctest
177+
178+
doctest.testmod()

0 commit comments

Comments
 (0)