Skip to content

Commit 7f521ae

Browse files
committed
Add Point in Polygon algorithm using ray casting
1 parent 14c971f commit 7f521ae

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

geometry/point_in_polygon.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""
2+
Point in Polygon (PIP) Algorithm using Ray Casting (Even-Odd Rule).
3+
4+
References:
5+
- https://en.wikipedia.org/wiki/Point_in_polygon
6+
- https://en.wikipedia.org/wiki/Even%E2%80%93odd_rule
7+
- Shimrat, M. (1962). "Algorithm 112: Position of point relative to polygon".
8+
Communications of the ACM, 5(8), 434.
9+
10+
The ray casting algorithm determines whether a given point in the plane lies
11+
inside, outside, or on the boundary of an arbitrary polygon (convex or concave).
12+
It works by casting a horizontal ray from the query point to infinity in the positive
13+
x-direction and counting how many polygon edges intersect this ray:
14+
- An odd number of intersections indicates the point is inside the polygon.
15+
- An even number of intersections indicates the point is outside the polygon.
16+
17+
Boundary handling:
18+
Points lying exactly on an edge or vertex of the polygon are detected explicitly
19+
via segment collinearity and bounding box checks.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import NamedTuple
25+
26+
27+
class Point(NamedTuple):
28+
"""
29+
A 2D point with real-valued coordinates.
30+
31+
>>> Point(0.0, 0.0)
32+
Point(x=0.0, y=0.0)
33+
>>> Point(1.5, -2.0)
34+
Point(x=1.5, y=-2.0)
35+
"""
36+
37+
x: float
38+
y: float
39+
40+
41+
def is_point_on_segment(
42+
point: Point, seg_start: Point, seg_end: Point, tolerance: float = 1e-9
43+
) -> bool:
44+
"""
45+
Determine whether a point lies on the line segment between seg_start and seg_end.
46+
47+
The check verifies that:
48+
1. The point is collinear with the segment endpoints (cross product is near zero).
49+
2. The point lies within the bounding box of the segment.
50+
51+
>>> is_point_on_segment(Point(1.0, 1.0), Point(0.0, 0.0), Point(2.0, 2.0))
52+
True
53+
>>> is_point_on_segment(Point(0.0, 0.0), Point(0.0, 0.0), Point(2.0, 2.0))
54+
True
55+
>>> is_point_on_segment(Point(2.0, 2.0), Point(0.0, 0.0), Point(2.0, 2.0))
56+
True
57+
>>> is_point_on_segment(Point(3.0, 3.0), Point(0.0, 0.0), Point(2.0, 2.0))
58+
False
59+
>>> is_point_on_segment(Point(1.0, 2.0), Point(0.0, 0.0), Point(2.0, 2.0))
60+
False
61+
>>> is_point_on_segment(Point(2.0, 0.0), Point(0.0, 0.0), Point(4.0, 0.0))
62+
True
63+
"""
64+
# Cross product of vector (seg_start -> seg_end) and (seg_start -> point)
65+
cross_product = (seg_end.x - seg_start.x) * (point.y - seg_start.y) - (
66+
seg_end.y - seg_start.y
67+
) * (point.x - seg_start.x)
68+
if abs(cross_product) > tolerance:
69+
return False
70+
71+
# Check bounding box
72+
is_within_x_bounds = (
73+
min(seg_start.x, seg_end.x) - tolerance
74+
<= point.x
75+
<= max(seg_start.x, seg_end.x) + tolerance
76+
)
77+
is_within_y_bounds = (
78+
min(seg_start.y, seg_end.y) - tolerance
79+
<= point.y
80+
<= max(seg_start.y, seg_end.y) + tolerance
81+
)
82+
return is_within_x_bounds and is_within_y_bounds
83+
84+
85+
def point_in_polygon(
86+
point: Point, polygon: list[Point], include_boundary: bool = True
87+
) -> bool:
88+
"""
89+
Determine whether a 2D point lies inside an arbitrary polygon using ray casting.
90+
91+
Parameters:
92+
point: The query Point(x, y).
93+
polygon: A list of Point instances representing vertices of the polygon
94+
in cyclic order (clockwise or counter-clockwise). Must have >= 3 vertices.
95+
include_boundary: Whether points on the boundary (edges or vertices)
96+
are considered inside (default True).
97+
98+
Returns:
99+
True if the point is inside (or on the boundary if include_boundary=True),
100+
False otherwise.
101+
102+
Raises:
103+
ValueError: If the polygon has fewer than 3 vertices.
104+
105+
Examples:
106+
>>> square = [Point(0.0, 0.0), Point(4.0, 0.0), Point(4.0, 4.0), Point(0.0, 4.0)]
107+
>>> point_in_polygon(Point(2.0, 2.0), square)
108+
True
109+
>>> point_in_polygon(Point(5.0, 2.0), square)
110+
False
111+
>>> point_in_polygon(Point(-1.0, 2.0), square)
112+
False
113+
>>> point_in_polygon(Point(2.0, 5.0), square)
114+
False
115+
116+
Boundary tests:
117+
>>> point_in_polygon(Point(0.0, 2.0), square, include_boundary=True)
118+
True
119+
>>> point_in_polygon(Point(0.0, 2.0), square, include_boundary=False)
120+
False
121+
>>> point_in_polygon(Point(4.0, 4.0), square, include_boundary=True)
122+
True
123+
>>> point_in_polygon(Point(4.0, 4.0), square, include_boundary=False)
124+
False
125+
>>> point_in_polygon(Point(2.0, 0.0), square, include_boundary=True)
126+
True
127+
>>> point_in_polygon(Point(2.0, 0.0), square, include_boundary=False)
128+
False
129+
130+
Concave (arrowhead) polygon:
131+
>>> arrowhead = [Point(0.0, 0.0), Point(5.0, 2.0), Point(0.0, 4.0), Point(2.0, 2.0)]
132+
>>> point_in_polygon(Point(3.0, 2.0), arrowhead)
133+
True
134+
>>> point_in_polygon(Point(1.0, 2.0), arrowhead)
135+
False
136+
137+
Triangle with negative and floating point coordinates:
138+
>>> triangle = [Point(-2.5, -2.5), Point(2.5, -2.5), Point(0.0, 2.5)]
139+
>>> point_in_polygon(Point(0.0, 0.0), triangle)
140+
True
141+
>>> point_in_polygon(Point(0.0, 3.0), triangle)
142+
False
143+
>>> point_in_polygon(Point(-3.0, 0.0), triangle)
144+
False
145+
146+
Invalid input (fewer than 3 vertices):
147+
>>> point_in_polygon(Point(0.0, 0.0), [Point(0.0, 0.0), Point(1.0, 1.0)])
148+
Traceback (most recent call last):
149+
...
150+
ValueError: A polygon must have at least 3 vertices.
151+
"""
152+
if len(polygon) < 3:
153+
raise ValueError("A polygon must have at least 3 vertices.")
154+
155+
num_vertices = len(polygon)
156+
157+
# Check if point lies on any boundary edge or vertex
158+
for vertex_index in range(num_vertices):
159+
edge_start = polygon[vertex_index]
160+
edge_end = polygon[(vertex_index + 1) % num_vertices]
161+
if is_point_on_segment(point, edge_start, edge_end):
162+
return include_boundary
163+
164+
# Ray casting: cast a horizontal ray from point towards positive x-infinity
165+
is_inside = False
166+
for vertex_index in range(num_vertices):
167+
current_vertex = polygon[vertex_index]
168+
next_vertex = polygon[(vertex_index + 1) % num_vertices]
169+
170+
# Check whether the edge crosses the horizontal ray.
171+
# The condition (current_vertex.y > point.y) != (next_vertex.y > point.y)
172+
# ensures:
173+
# 1. Strictly horizontal edges (current_vertex.y == next_vertex.y) are skipped.
174+
# 2. Vertices intersecting the ray are counted exactly once when crossed.
175+
if (current_vertex.y > point.y) != (next_vertex.y > point.y):
176+
# Compute x-coordinate of intersection with line y = point.y
177+
ray_x_intersection = current_vertex.x + (point.y - current_vertex.y) * (
178+
next_vertex.x - current_vertex.x
179+
) / (next_vertex.y - current_vertex.y)
180+
if point.x < ray_x_intersection:
181+
is_inside = not is_inside
182+
183+
return is_inside
184+
185+
186+
if __name__ == "__main__":
187+
import doctest
188+
189+
doctest.testmod()

0 commit comments

Comments
 (0)