1+ # Geometry Library - Reusable classes for geometric operations
12
3+ from functools import cache
4+ import bisect
25
6+
7+ class Line :
8+ """Represents an infinite line in 2D space using equation ax + by = c"""
9+
10+ def __init__ (self , p1 , p2 , epsilon = 1e-3 ):
11+ """
12+ Initialize a line through two points.
13+
14+ Args:
15+ p1: (x, y) tuple - first point
16+ p2: (x, y) tuple - second point
17+ epsilon: tolerance for floating point comparisons
18+ """
19+ self .epsilon = epsilon
20+ x1 , y1 = p1
21+ x2 , y2 = p2
22+
23+ if x1 == x2 :
24+ # Vertical line: x = x1
25+ self .a , self .b , self .c = 1 , 0 , x1
26+ else :
27+ # General line: y = ax + b -> ax - y = -b
28+ a = (y1 - y2 ) / (x1 - x2 )
29+ b = (x2 * y1 - x1 * y2 ) / (x2 - x1 )
30+ self .a , self .b , self .c = a , - 1 , - b
31+
32+ self .line = self
33+
34+ def has (self , p ):
35+ """Check if point p lies on this line"""
36+ if not p :
37+ return False
38+ x , y = p
39+ return abs (x * self .a + y * self .b - self .c ) <= self .epsilon
40+
41+ def inter (self , other ):
42+ """
43+ Find intersection point with another line.
44+
45+ Returns:
46+ (x, y) tuple if lines intersect, None if parallel
47+ """
48+ a , b , p = self .a , self .b , self .c
49+ c , d , q = other .a , other .b , other .c
50+
51+ det = a * d - b * c
52+ if abs (det ) <= self .epsilon :
53+ return None # Lines are parallel
54+
55+ # Solve system using Cramer's rule
56+ a , b , c , d = d , - b , - c , a
57+ x = a * p + b * q
58+ y = c * p + d * q
59+ return (x / det , y / det )
60+
61+
62+ class Ray :
63+ """Represents a line segment (bounded portion of a line)"""
64+
65+ def __init__ (self , line , condition ):
66+ """
67+ Initialize a ray with a line and boundary condition.
68+
69+ Args:
70+ line: Line object
71+ condition: function(p) -> bool that checks if point is within bounds
72+ """
73+ self .line = line
74+ self .condition = condition
75+
76+ def has (self , p ):
77+ """Check if point p lies on this ray (on line AND within bounds)"""
78+ if not p :
79+ return False
80+ if not self .line .has (p ):
81+ return False
82+ return self .condition (p )
83+
84+ def inter (self , ray ):
85+ """
86+ Check if this ray intersects with another ray.
87+
88+ Returns:
89+ True if rays intersect, False otherwise
90+ """
91+ inter = self .line .inter (ray .line )
92+ if not inter :
93+ return False
94+ return self .has (inter ) and ray .has (inter )
95+
96+
97+ class Polygon :
98+ """Represents a polygon defined by its vertices"""
99+
100+ def __init__ (self , vertices , precision = 2 , epsilon = 1e-3 ):
101+ """
102+ Initialize a polygon with vertices.
103+
104+ Args:
105+ vertices: List of (x, y) tuples (can be floats)
106+ precision: Number of decimal digits (e.g., 2 for 0.01 precision)
107+ Coordinates are scaled by 10^precision to work with integers
108+ epsilon: tolerance for floating point comparisons in Line operations
109+ """
110+ self .precision = precision
111+ self .multiplier = 10 ** precision
112+ self .epsilon = epsilon
113+
114+ # Extract unique x and y coordinates, sort them, and create mappings
115+ xs = sorted (set (x for x , y in vertices ))
116+ ys = sorted (set (y for x , y in vertices ))
117+
118+ # Store mappings for converting back to original coordinates
119+ self .xs = xs
120+ self .ys = ys
121+
122+ # Convert vertices to indices (scaled integer coordinates)
123+ indexedVertices = [(xs .index (x ), ys .index (y )) for x , y in vertices ]
124+
125+ # Build edges as rays using indexed coordinates
126+ self .edges = []
127+ n = len (indexedVertices )
128+
129+ for i in range (n ):
130+ x1 , y1 = indexedVertices [i ]
131+ x2 , y2 = indexedVertices [(i + 1 ) % n ]
132+
133+ line = Line ((x1 , y1 ), (x2 , y2 ), epsilon = self .epsilon )
134+
135+ # Create condition for ray based on edge direction
136+ if x1 == x2 :
137+ # Vertical edge: y must be in range
138+ def condition (p , Y1 = y1 , Y2 = y2 ):
139+ _ , y = p
140+ return min (Y1 , Y2 ) <= y <= max (Y1 , Y2 )
141+ elif y1 == y2 :
142+ # Horizontal edge: x must be in range
143+ def condition (p , X1 = x1 , X2 = x2 ):
144+ x , _ = p
145+ return min (X1 , X2 ) <= x <= max (X1 , X2 )
146+ else :
147+ # Diagonal edge: both x and y must be in range
148+ def condition (p , X1 = x1 , X2 = x2 , Y1 = y1 , Y2 = y2 ):
149+ x , y = p
150+ return (min (X1 , X2 ) <= x <= max (X1 , X2 ) and
151+ min (Y1 , Y2 ) <= y <= max (Y1 , Y2 ))
152+
153+ self .edges .append (Ray (line , condition ))
154+
155+ def addCoords (self , coords ):
156+ """
157+ Pre-add a list of coordinates to the mappings.
158+ Useful to add many coordinates at once efficiently.
159+
160+ Args:
161+ coords: List of (x, y) tuples to add to the coordinate mappings
162+ """
163+ # Extract all unique x and y values from coords
164+ newXs = set (x for x , y in coords )
165+ newYs = set (y for x , y in coords )
166+
167+ # Merge and sort if there are new coordinates
168+ xsChanged = False
169+ ysChanged = False
170+
171+ for x in newXs :
172+ if x not in self .xs :
173+ xsChanged = True
174+ break
175+
176+ for y in newYs :
177+ if y not in self .ys :
178+ ysChanged = True
179+ break
180+
181+ if xsChanged :
182+ self .xs = sorted (set (self .xs ) | newXs )
183+ if ysChanged :
184+ self .ys = sorted (set (self .ys ) | newYs )
185+
186+ def _toIndexed (self , p ):
187+ """
188+ Convert a point in original coordinates to indexed coordinates.
189+ If the coordinate doesn't exist, it's inserted in sorted order.
190+
191+ Args:
192+ p: (x, y) tuple in original coordinate system
193+
194+ Returns:
195+ (xIdx, yIdx) tuple in indexed coordinate system
196+ """
197+ xOrig , yOrig = p
198+
199+ # Find or insert x coordinate using binary search
200+ xIdx = bisect .bisect_left (self .xs , xOrig )
201+ if xIdx >= len (self .xs ) or self .xs [xIdx ] != xOrig :
202+ self .xs .insert (xIdx , xOrig )
203+
204+ # Find or insert y coordinate using binary search
205+ yIdx = bisect .bisect_left (self .ys , yOrig )
206+ if yIdx >= len (self .ys ) or self .ys [yIdx ] != yOrig :
207+ self .ys .insert (yIdx , yOrig )
208+
209+ return (xIdx , yIdx )
210+
211+ @cache
212+ def inside (self , p ):
213+ """
214+ Check if point p is inside the polygon using ray casting algorithm.
215+
216+ Args:
217+ p: (x, y) tuple in original coordinate system
218+
219+ Returns:
220+ True if point is inside polygon, False otherwise
221+ """
222+ # Convert original coordinates to indexed coordinates
223+ x , y = self ._toIndexed (p )
224+
225+ # Cast rays in four cardinal directions from the point
226+ # A point is inside if all four rays intersect at least one edge
227+ rays = [
228+ Ray (Line ((x , y ), (x - 10 , y ), epsilon = self .epsilon ),
229+ lambda p1 , X = x : p1 [0 ] <= X ), # Left
230+ Ray (Line ((x , y ), (x , y + 10 ), epsilon = self .epsilon ),
231+ lambda p1 , Y = y : p1 [1 ] >= Y ), # Up
232+ Ray (Line ((x , y ), (x + 10 , y ), epsilon = self .epsilon ),
233+ lambda p1 , X = x : p1 [0 ] >= X ), # Right
234+ Ray (Line ((x , y ), (x , y - 10 ), epsilon = self .epsilon ),
235+ lambda p1 , Y = y : p1 [1 ] <= Y ) # Down
236+ ]
237+
238+ hits = [0 , 0 , 0 , 0 ]
239+ for edge in self .edges :
240+ for i , ray in enumerate (rays ):
241+ if ray .inter (edge ):
242+ hits [i ] = 1
243+ # Early exit if all rays hit
244+ if sum (hits ) == 4 :
245+ return True
246+
247+ return sum (hits ) == 4
0 commit comments