151 lines
4.8 KiB
Python
151 lines
4.8 KiB
Python
# tools/geometry.py
|
|
|
|
from dataclasses import dataclass
|
|
import math
|
|
|
|
|
|
@dataclass
|
|
class Point:
|
|
"""Represents a 2D point in a Cartesian coordinate system."""
|
|
|
|
x: float
|
|
y: float
|
|
|
|
|
|
class Line:
|
|
"""Represents a finite line segment between two Points."""
|
|
|
|
def __init__(self, p1: Point, p2: Point) -> None:
|
|
self.p1 = p1
|
|
self.p2 = p2
|
|
|
|
def distance_to_point(self, point: Point) -> float:
|
|
"""
|
|
Calculates the shortest distance from this line segment to a given point.
|
|
Uses vector projection to find the closest point on the segment.
|
|
"""
|
|
x1, y1 = self.p1.x, self.p1.y
|
|
x2, y2 = self.p2.x, self.p2.y
|
|
x3, y3 = point.x, point.y
|
|
|
|
# If the line is actually just a single point
|
|
if x1 == x2 and y1 == y2:
|
|
return ((x1 - x3) ** 2 + (y1 - y3) ** 2) ** 0.5
|
|
|
|
px, py = x2 - x1, y2 - y1
|
|
norm = px * px + py * py
|
|
|
|
# Calculate the projection scalar (u) of the point onto the line
|
|
u = ((x3 - x1) * px + (y3 - y1) * py) / float(norm)
|
|
|
|
# Clamp u to the [0, 1] range to ensure we stay on the line segment
|
|
u = max(0.0, min(1.0, u))
|
|
|
|
# Find the exact coordinates of the closest point on the segment
|
|
closest_x = x1 + u * px
|
|
closest_y = y1 + u * py
|
|
|
|
# Return distance from the target point to the closest point
|
|
dx, dy = closest_x - x3, closest_y - y3
|
|
return (dx * dx + dy * dy) ** 0.5
|
|
|
|
|
|
class LineString:
|
|
"""Represents a path formed by a sequence of connected line segments."""
|
|
|
|
def __init__(self, lines: list[list[float]]) -> None:
|
|
if len(lines) < 2:
|
|
raise ValueError("A LineString requires at least 2 coordinate pairs.")
|
|
|
|
self.lineList: list[Line] = []
|
|
for i in range(len(lines) - 1):
|
|
p1 = Point(lines[i][0], lines[i][1])
|
|
p2 = Point(lines[i + 1][0], lines[i + 1][1])
|
|
self.lineList.append(Line(p1, p2))
|
|
|
|
def distance_to(self, point: Point) -> float:
|
|
"""Calculates the minimum distance from the given point to the LineString."""
|
|
# Cleanly check the distance to all segments and return the smallest one
|
|
return min(line.distance_to_point(point) for line in self.lineList)
|
|
|
|
def is_intersecting(self, point: Point) -> bool:
|
|
"""Checks if a given point lies exactly on the LineString (with tolerance)."""
|
|
return math.isclose(self.distance_to(point), 0.0, abs_tol=1e-9)
|
|
|
|
|
|
class Polygon:
|
|
"""Represents a 2D shape enclosed by a series of connected points."""
|
|
|
|
def __init__(self, points: list[list[float]]) -> None:
|
|
self.listPoints: list[Point] = [Point(p[0], p[1]) for p in points]
|
|
|
|
def contains(self, p: Point) -> bool:
|
|
"""
|
|
Determines if a point is inside the Polygon using the Even-Odd rule.
|
|
Includes an explicit check for points lying exactly on the boundary edges.
|
|
"""
|
|
n = len(self.listPoints)
|
|
if n < 3:
|
|
return False
|
|
|
|
inside = False
|
|
j = n - 1
|
|
for i in range(n):
|
|
pi = self.listPoints[i]
|
|
pj = self.listPoints[j]
|
|
|
|
cross_product = (p.y - pi.y) * (pj.x - pi.x) - (p.x - pi.x) * (pj.y - pi.y)
|
|
if math.isclose(cross_product, 0.0, abs_tol=1e-9):
|
|
if min(pi.x, pj.x) <= p.x <= max(pi.x, pj.x) and min(
|
|
pi.y, pj.y
|
|
) <= p.y <= max(pi.y, pj.y):
|
|
return True
|
|
|
|
if ((pi.y > p.y) != (pj.y > p.y)) and (
|
|
p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x
|
|
):
|
|
inside = not inside
|
|
j = i
|
|
|
|
return inside
|
|
|
|
|
|
class MultiPolygon:
|
|
"""Represents a collection of multiple separate Polygons."""
|
|
|
|
def __init__(self, polygons: list) -> None:
|
|
self.listPolygons: list[Polygon] = [Polygon(polygon[0]) for polygon in polygons]
|
|
|
|
def contains(self, p: Point) -> bool:
|
|
"""Returns True if the point is inside ANY of the contained Polygons."""
|
|
for polygon in self.listPolygons:
|
|
if polygon.contains(p):
|
|
return True
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class Coordinate:
|
|
"""
|
|
Custom type for the management of geographical coordinates.
|
|
Provides utility to switch between lat/long and x/y point spaces.
|
|
"""
|
|
|
|
lat: float
|
|
long: float
|
|
|
|
@property
|
|
def tuple(self) -> tuple[float, float]:
|
|
"""Returns the coordinates as a (Latitude, Longitude) tuple."""
|
|
return (self.lat, self.long)
|
|
|
|
def to_Point(self, reversed: bool = False) -> Point:
|
|
"""
|
|
Returns Coordinates mapped to a Cartesian Point.
|
|
|
|
Args:
|
|
reversed (bool): If True, switches orientation so x=long, y=lat.
|
|
Defaults to False (x=lat, y=long).
|
|
"""
|
|
return Point(self.long, self.lat) if reversed else Point(self.lat, self.long)
|