Skip to content

Commit 8acffc6

Browse files
committed
refactor(geometry): use exact boundary testing by default and expose tolerance parameter
1 parent 7f521ae commit 8acffc6

1 file changed

Lines changed: 51 additions & 8 deletions

File tree

geometry/point_in_polygon.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,25 @@ class Point(NamedTuple):
3939

4040

4141
def is_point_on_segment(
42-
point: Point, seg_start: Point, seg_end: Point, tolerance: float = 1e-9
42+
point: Point, seg_start: Point, seg_end: Point, tolerance: float = 0.0
4343
) -> bool:
4444
"""
4545
Determine whether a point lies on the line segment between seg_start and seg_end.
4646
4747
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.
48+
1. The point is collinear with the segment endpoints
49+
(cross product within tolerance).
50+
2. The point lies within the bounding box of the segment (within tolerance).
51+
52+
Parameters:
53+
point: The query Point.
54+
seg_start: The start Point of the line segment.
55+
seg_end: The end Point of the line segment.
56+
tolerance: Non-negative tolerance for collinearity and bounding box
57+
checks (default 0.0 for exact mathematical boundary testing).
58+
59+
Raises:
60+
ValueError: If tolerance is negative.
5061
5162
>>> is_point_on_segment(Point(1.0, 1.0), Point(0.0, 0.0), Point(2.0, 2.0))
5263
True
@@ -60,7 +71,20 @@ def is_point_on_segment(
6071
False
6172
>>> is_point_on_segment(Point(2.0, 0.0), Point(0.0, 0.0), Point(4.0, 0.0))
6273
True
74+
>>> is_point_on_segment(Point(2.0, 1e-10), Point(0.0, 0.0), Point(4.0, 0.0))
75+
False
76+
>>> is_point_on_segment(
77+
... Point(2.0, 1e-10), Point(0.0, 0.0), Point(4.0, 0.0), tolerance=1e-9
78+
... )
79+
True
80+
>>> is_point_on_segment(Point(0.0, 0.0), Point(0.0, 0.0), Point(1.0, 1.0), -1.0)
81+
Traceback (most recent call last):
82+
...
83+
ValueError: tolerance must be non-negative.
6384
"""
85+
if tolerance < 0.0:
86+
raise ValueError("tolerance must be non-negative.")
87+
6488
# Cross product of vector (seg_start -> seg_end) and (seg_start -> point)
6589
cross_product = (seg_end.x - seg_start.x) * (point.y - seg_start.y) - (
6690
seg_end.y - seg_start.y
@@ -83,7 +107,10 @@ def is_point_on_segment(
83107

84108

85109
def point_in_polygon(
86-
point: Point, polygon: list[Point], include_boundary: bool = True
110+
point: Point,
111+
polygon: list[Point],
112+
include_boundary: bool = True,
113+
tolerance: float = 0.0,
87114
) -> bool:
88115
"""
89116
Determine whether a 2D point lies inside an arbitrary polygon using ray casting.
@@ -94,13 +121,15 @@ def point_in_polygon(
94121
in cyclic order (clockwise or counter-clockwise). Must have >= 3 vertices.
95122
include_boundary: Whether points on the boundary (edges or vertices)
96123
are considered inside (default True).
124+
tolerance: Non-negative tolerance for boundary testing (default 0.0
125+
for exact mathematical boundary testing).
97126
98127
Returns:
99128
True if the point is inside (or on the boundary if include_boundary=True),
100129
False otherwise.
101130
102131
Raises:
103-
ValueError: If the polygon has fewer than 3 vertices.
132+
ValueError: If the polygon has fewer than 3 vertices or tolerance is negative.
104133
105134
Examples:
106135
>>> square = [Point(0.0, 0.0), Point(4.0, 0.0), Point(4.0, 4.0), Point(0.0, 4.0)]
@@ -113,7 +142,7 @@ def point_in_polygon(
113142
>>> point_in_polygon(Point(2.0, 5.0), square)
114143
False
115144
116-
Boundary tests:
145+
Boundary tests (exact boundary testing with tolerance=0.0):
117146
>>> point_in_polygon(Point(0.0, 2.0), square, include_boundary=True)
118147
True
119148
>>> point_in_polygon(Point(0.0, 2.0), square, include_boundary=False)
@@ -127,6 +156,14 @@ def point_in_polygon(
127156
>>> point_in_polygon(Point(2.0, 0.0), square, include_boundary=False)
128157
False
129158
159+
Points very close to boundary:
160+
>>> point_in_polygon(Point(2.0, -1e-10), square)
161+
False
162+
>>> point_in_polygon(Point(2.0, 1e-10), square, include_boundary=False)
163+
True
164+
>>> point_in_polygon(Point(2.0, -1e-10), square, tolerance=1e-9)
165+
True
166+
130167
Concave (arrowhead) polygon:
131168
>>> arrowhead = [Point(0.0, 0.0), Point(5.0, 2.0), Point(0.0, 4.0), Point(2.0, 2.0)]
132169
>>> point_in_polygon(Point(3.0, 2.0), arrowhead)
@@ -143,22 +180,28 @@ def point_in_polygon(
143180
>>> point_in_polygon(Point(-3.0, 0.0), triangle)
144181
False
145182
146-
Invalid input (fewer than 3 vertices):
183+
Invalid input (fewer than 3 vertices or negative tolerance):
147184
>>> point_in_polygon(Point(0.0, 0.0), [Point(0.0, 0.0), Point(1.0, 1.0)])
148185
Traceback (most recent call last):
149186
...
150187
ValueError: A polygon must have at least 3 vertices.
188+
>>> point_in_polygon(Point(0.0, 0.0), square, tolerance=-1.0)
189+
Traceback (most recent call last):
190+
...
191+
ValueError: tolerance must be non-negative.
151192
"""
152193
if len(polygon) < 3:
153194
raise ValueError("A polygon must have at least 3 vertices.")
195+
if tolerance < 0.0:
196+
raise ValueError("tolerance must be non-negative.")
154197

155198
num_vertices = len(polygon)
156199

157200
# Check if point lies on any boundary edge or vertex
158201
for vertex_index in range(num_vertices):
159202
edge_start = polygon[vertex_index]
160203
edge_end = polygon[(vertex_index + 1) % num_vertices]
161-
if is_point_on_segment(point, edge_start, edge_end):
204+
if is_point_on_segment(point, edge_start, edge_end, tolerance=tolerance):
162205
return include_boundary
163206

164207
# Ray casting: cast a horizontal ray from point towards positive x-infinity

0 commit comments

Comments
 (0)