Skip to content

Commit f347c7f

Browse files
Add Jarvis March (Gift Wrapping) convex hull algorithm
1 parent 8106aea commit f347c7f

1 file changed

Lines changed: 212 additions & 0 deletions

File tree

geometry/jarvis_march.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""
2+
Jarvis March (Gift Wrapping) algorithm for finding the convex hull of a set of points.
3+
4+
The convex hull is the smallest convex polygon that contains all the points.
5+
6+
Time Complexity: O(n*h) where n is the number of points and h is the number of
7+
hull points.
8+
Space Complexity: O(h) where h is the number of hull points.
9+
10+
USAGE:
11+
-> Import this file into your project.
12+
-> Use the jarvis_march() function to find the convex hull of a set of points.
13+
-> Parameters:
14+
-> points: A list of Point objects representing 2D coordinates
15+
16+
REFERENCES:
17+
-> Wikipedia reference: https://en.wikipedia.org/wiki/Gift_wrapping_algorithm
18+
-> GeeksforGeeks: https://www.geeksforgeeks.org/convex-hull-set-1-jarviss-algorithm-or-wrapping/
19+
"""
20+
21+
from __future__ import annotations
22+
23+
24+
class Point:
25+
"""
26+
Represents a 2D point with x and y coordinates.
27+
28+
>>> p = Point(1.0, 2.0)
29+
>>> p.x
30+
1.0
31+
>>> p.y
32+
2.0
33+
"""
34+
35+
def __init__(self, x: float, y: float) -> None:
36+
self.x = x
37+
self.y = y
38+
39+
def __eq__(self, other: object) -> bool:
40+
if not isinstance(other, Point):
41+
return NotImplemented
42+
return self.x == other.x and self.y == other.y
43+
44+
def __repr__(self) -> str:
45+
return f"Point({self.x}, {self.y})"
46+
47+
def __hash__(self) -> int:
48+
return hash((self.x, self.y))
49+
50+
51+
def _cross_product(o: Point, a: Point, b: Point) -> float:
52+
"""
53+
Calculate the cross product of vectors OA and OB.
54+
55+
Returns:
56+
> 0: Counter-clockwise turn (left turn)
57+
= 0: Collinear
58+
< 0: Clockwise turn (right turn)
59+
60+
>>> o = Point(0, 0)
61+
>>> a = Point(1, 1)
62+
>>> b = Point(2, 0)
63+
>>> _cross_product(o, a, b) < 0
64+
True
65+
>>> _cross_product(o, Point(1, 0), Point(2, 0)) == 0
66+
True
67+
>>> _cross_product(o, Point(1, 0), Point(1, 1)) > 0
68+
True
69+
"""
70+
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
71+
72+
73+
def _is_point_on_segment(p1: Point, p2: Point, point: Point) -> bool:
74+
"""
75+
Check if a point lies on the line segment between p1 and p2.
76+
77+
>>> _is_point_on_segment(Point(0, 0), Point(2, 2), Point(1, 1))
78+
True
79+
>>> _is_point_on_segment(Point(0, 0), Point(2, 2), Point(3, 3))
80+
False
81+
>>> _is_point_on_segment(Point(0, 0), Point(2, 0), Point(1, 0))
82+
True
83+
"""
84+
# Check if point is collinear with segment endpoints
85+
cross = (point.y - p1.y) * (p2.x - p1.x) - (point.x - p1.x) * (p2.y - p1.y)
86+
87+
if abs(cross) > 1e-9:
88+
return False
89+
90+
# Check if point is within the bounding box of the segment
91+
return min(p1.x, p2.x) <= point.x <= max(p1.x, p2.x) and min(
92+
p1.y, p2.y
93+
) <= point.y <= max(p1.y, p2.y)
94+
95+
96+
def jarvis_march(points: list[Point]) -> list[Point]:
97+
"""
98+
Find the convex hull of a set of points using the Jarvis March algorithm.
99+
100+
The algorithm starts with the leftmost point and wraps around the set of points,
101+
selecting the most counter-clockwise point at each step.
102+
103+
Args:
104+
points: List of Point objects representing 2D coordinates
105+
106+
Returns:
107+
List of Points that form the convex hull in counter-clockwise order.
108+
Returns empty list if there are fewer than 3 non-collinear points.
109+
110+
Examples:
111+
>>> # Triangle
112+
>>> p1, p2, p3 = Point(1, 1), Point(2, 1), Point(1.5, 2)
113+
>>> hull = jarvis_march([p1, p2, p3])
114+
>>> len(hull)
115+
3
116+
>>> all(p in hull for p in [p1, p2, p3])
117+
True
118+
119+
>>> # Collinear points return empty hull
120+
>>> points = [Point(i, 0) for i in range(5)]
121+
>>> jarvis_march(points)
122+
[]
123+
124+
>>> # Rectangle with interior point - interior point excluded
125+
>>> p1, p2 = Point(1, 1), Point(2, 1)
126+
>>> p3, p4 = Point(2, 2), Point(1, 2)
127+
>>> p5 = Point(1.5, 1.5)
128+
>>> hull = jarvis_march([p1, p2, p3, p4, p5])
129+
>>> len(hull)
130+
4
131+
>>> p5 in hull
132+
False
133+
134+
>>> # Star shape - only tips are in hull
135+
>>> tips = [
136+
... Point(-5, 6), Point(-11, 0), Point(-9, -8),
137+
... Point(4, 4), Point(6, -7)
138+
... ]
139+
>>> interior = [Point(-7, -2), Point(-2, -4), Point(0, 1)]
140+
>>> hull = jarvis_march(tips + interior)
141+
>>> len(hull)
142+
5
143+
>>> all(p in hull for p in tips)
144+
True
145+
>>> any(p in hull for p in interior)
146+
False
147+
148+
>>> # Too few points
149+
>>> jarvis_march([])
150+
[]
151+
>>> jarvis_march([Point(0, 0)])
152+
[]
153+
>>> jarvis_march([Point(0, 0), Point(1, 1)])
154+
[]
155+
"""
156+
if len(points) <= 2:
157+
return []
158+
159+
convex_hull: list[Point] = []
160+
161+
# Find the leftmost point (and bottom-most in case of tie)
162+
left_point_idx = 0
163+
for i in range(1, len(points)):
164+
if points[i].x < points[left_point_idx].x or (
165+
points[i].x == points[left_point_idx].x
166+
and points[i].y < points[left_point_idx].y
167+
):
168+
left_point_idx = i
169+
170+
convex_hull.append(Point(points[left_point_idx].x, points[left_point_idx].y))
171+
172+
current_idx = left_point_idx
173+
while True:
174+
# Find the next counter-clockwise point
175+
next_idx = (current_idx + 1) % len(points)
176+
for i in range(len(points)):
177+
if _cross_product(points[current_idx], points[i], points[next_idx]) > 0:
178+
next_idx = i
179+
180+
if next_idx == left_point_idx:
181+
# Completed constructing the hull
182+
break
183+
184+
current_idx = next_idx
185+
186+
# Check if the last point is collinear with new point and second-to-last
187+
last = len(convex_hull) - 1
188+
if len(convex_hull) > 1 and _is_point_on_segment(
189+
points[current_idx], convex_hull[last - 1], convex_hull[last]
190+
):
191+
# Remove the last point from the hull
192+
convex_hull[last] = Point(points[current_idx].x, points[current_idx].y)
193+
else:
194+
convex_hull.append(Point(points[current_idx].x, points[current_idx].y))
195+
196+
# Check for edge case: last point collinear with first and second-to-last
197+
if len(convex_hull) <= 2:
198+
return []
199+
200+
last = len(convex_hull) - 1
201+
if _is_point_on_segment(convex_hull[0], convex_hull[last - 1], convex_hull[last]):
202+
convex_hull.pop()
203+
if len(convex_hull) == 2:
204+
return []
205+
206+
return convex_hull
207+
208+
209+
if __name__ == "__main__":
210+
import doctest
211+
212+
doctest.testmod()

0 commit comments

Comments
 (0)