Files
rpmroute/src/freeroute/geometry/line.py
T
rsp2k 68ab16a7cf Add PolygonShape.split_to_convex (polygon to convex tiles)
Ports geometry/planar/Polygon.java (corner de-duplication and collinear
removal, winding number) and PolygonShape.java, including the recursive
split_to_convex that decomposes a simple polygon into convex Simplex
tiles by dividing at concave corners along minimal axis-parallel lines.

Orientation and convexity tests are exact; the division-point search is
approximate (float line evaluation, split point rounded to an integer
corner) as upstream. The concave-corner search starts deterministically
at corner 0 rather than a seeded PRNG; this only affects which valid
decomposition is produced.

Supporting additions: Simplex.from_corners (convex polygon to simplex)
and Line.function_value_approx / function_in_y_value_approx.

Invariant tests over L, plus, staircase and square polygons: tile areas
sum to the polygon area (no gaps, no overlap), a point is in the polygon
iff in some tile, and no point is strictly inside more than one tile
(interiors disjoint). Also covers Polygon normalization and orientation.
2026-07-11 18:41:07 -06:00

302 lines
11 KiB
Python

"""``Line`` — a directed line through two integer points.
Ports ``geometry/planar/Line.java`` (the subset needed for the router's
half-plane work). Like the upstream class, a ``Line`` is defined by two
:class:`~freeroute.geometry.point.IntPoint` endpoints.
**Arithmetic model: exact.** :meth:`side_of` uses an exact integer determinant,
and :meth:`intersection` returns an :class:`~freeroute.geometry.point.IntPoint`
when the crossing is integral and a
:class:`~freeroute.geometry.point.RationalPoint` otherwise (``z == 0`` when the
lines are parallel — the "point at infinity"). :meth:`intersection_approx` is
the fast ``float`` path.
"""
from __future__ import annotations
import math
from .direction import Direction
from .float_point import FloatPoint
from .point import IntPoint, Point, rational_point
from .side import Side
from .vector import IntVector, Vector
__all__ = ["Line"]
class Line:
"""A directed line ``a -> b`` with integer endpoints."""
__slots__ = ("a", "b", "_dir")
def __init__(self, a: Point, b: Point) -> None:
self.a = a
self.b = b
self._dir: Direction | None = None
@staticmethod
def from_coords(ax: int, ay: int, bx: int, by: int) -> Line:
return Line(IntPoint(ax, ay), IntPoint(bx, by))
@staticmethod
def from_point_direction(a: Point, direction: Direction) -> Line:
return Line(a, a.translate_by(direction.get_vector()))
def __eq__(self, other: object) -> bool:
if not isinstance(other, Line):
return NotImplemented
if self.side_of(other.a) != Side.COLLINEAR:
return False
return self.direction().equals(other.direction())
def __hash__(self) -> int:
return hash((self.a, self.b))
def __repr__(self) -> str:
return f"Line({self.a!r}, {self.b!r})"
def direction(self) -> Direction:
if self._dir is None:
self._dir = Direction.get_instance(self.b.difference_by(self.a))
return self._dir
def opposite(self) -> Line:
return Line(self.b, self.a)
# --- orientation --------------------------------------------------------
def side_of(self, point: Point) -> Side:
"""Side of this line relative to ``point`` (exact).
``ON_THE_LEFT`` if the line passes to the left of the point,
``COLLINEAR`` if the point lies on the line.
"""
return point.side_of(self.a, self.b).negate()
def side_of_float(self, point: FloatPoint, tolerance: float = 0.0) -> Side:
"""Approximate side test for a :class:`FloatPoint` with a tolerance band."""
ax, ay = self.a.x, self.a.y
det = (self.b.y - ay) * (point.x - ax) - (self.b.x - ax) * (point.y - ay)
if det - tolerance > 0:
return Side.ON_THE_LEFT
if det + tolerance < 0:
return Side.ON_THE_RIGHT
return Side.COLLINEAR
def signed_distance(self, point: FloatPoint) -> float:
ax, ay = self.a.x, self.a.y
dx = self.b.x - ax
dy = self.b.y - ay
det = dy * (point.x - ax) - dx * (point.y - ay)
return det / math.sqrt(dx * dx + dy * dy)
def is_orthogonal(self) -> bool:
return self.direction().is_orthogonal()
def is_diagonal(self) -> bool:
return self.direction().is_diagonal()
def is_multiple_of_45_degree(self) -> bool:
return self.direction().is_multiple_of_45_degree()
def is_parallel(self, other: Line) -> bool:
return self.direction().side_of(other.direction()) == Side.COLLINEAR
def is_perpendicular(self, other: Line) -> bool:
v1 = self.direction().get_vector()
v2 = other.direction().get_vector()
return v1.projection(v2).value == 0
def overlaps(self, other: Line) -> bool:
return self.side_of(other.a) == Side.COLLINEAR and self.side_of(other.b) == Side.COLLINEAR
is_equal_or_opposite = overlaps
def fast_equals(self, other: Line) -> bool:
"""Same line and same direction; fast path for integer lines."""
dx1 = other.a.x - self.a.x
dy1 = other.a.y - self.a.y
dx2 = self.b.x - self.a.x
dy2 = self.b.y - self.a.y
if dx1 * dy2 - dx2 * dy1 != 0:
return False
return self.direction().equals(other.direction())
# --- translation --------------------------------------------------------
def translate_by(self, vector: Vector) -> Line:
if vector.is_zero():
return self
return Line(self.a.translate_by(vector), self.b.translate_by(vector))
def translate(self, dist: float) -> Line:
"""Translate the line perpendicular by ``dist`` (left if positive).
Ports ``Line.translate`` — approximate (the offset endpoint is rounded to
integer coordinates), used by ``Simplex.offset``.
"""
v = self.direction().get_vector()
vxvx = v.x * v.x
vyvy = v.y * v.y
length = math.sqrt(vxvx + vyvy)
if vxvx <= vyvy:
rel_x = _round_half_up(dist * length / v.y)
new_a = IntPoint(self.a.x - rel_x, self.a.y)
else:
rel_y = _round_half_up(dist * length / v.x)
new_a = IntPoint(self.a.x, self.a.y + rel_y)
return Line.from_point_direction(new_a, self.direction())
# --- ordering (by direction angle) -------------------------------------
def compare_to(self, other: Line) -> int:
"""Order lines by the angle of their direction with the +x axis (exact).
Ports ``Line.compareTo`` — the ordering ``Simplex`` relies on to sort its
half-plane border lines counter-clockwise.
"""
dx1 = self.b.x - self.a.x
dy1 = self.b.y - self.a.y
dx2 = other.b.x - other.a.x
dy2 = other.b.y - other.a.y
if dy1 > 0:
if dy2 < 0:
return -1
if dy2 == 0:
return 1 if dx2 > 0 else -1
elif dy1 < 0:
if dy2 >= 0:
return 1
else: # dy1 == 0
if dx1 > 0:
if dy2 != 0 or dx2 < 0:
return -1
return 0
if dy2 > 0 or (dy2 == 0 and dx2 > 0):
return 1
if dy2 < 0:
return -1
return 0
determinant = dx2 * dy1 - dy2 * dx1
if determinant > 0:
return 1
if determinant < 0:
return -1
return 0
def __lt__(self, other: Line) -> bool:
return self.compare_to(other) < 0
def side_of_intersection(self, line_1: Line, line_2: Line) -> Side:
"""Side of this line relative to the intersection of ``line_1`` and
``line_2`` (exact fallback after a fast float test). Ports
``Line.side_of_intersection``."""
approx = line_1.intersection_approx(line_2)
result = self.side_of_float(approx, 1.0)
if result == Side.COLLINEAR:
result = self.side_of(line_1.intersection(line_2))
return result
def function_value_approx(self, x: float) -> float:
"""Approximate ``y`` on this (non-vertical) line at abscissa ``x``."""
p1 = self.a.to_float()
p2 = self.b.to_float()
dx = p2.x - p1.x
if dx == 0:
return 0.0
dy = p2.y - p1.y
det = p1.x * p2.y - p2.x * p1.y
return (dy * x - det) / dx
def function_in_y_value_approx(self, y: float) -> float:
"""Approximate ``x`` on this (non-horizontal) line at ordinate ``y``."""
p1 = self.a.to_float()
p2 = self.b.to_float()
dy = p2.y - p1.y
if dy == 0:
return 0.0
dx = p2.x - p1.x
det = p1.x * p2.y - p2.x * p1.y
return (dx * y + det) / dy
# --- intersection (exact) ----------------------------------------------
def intersection(self, other: Line) -> Point:
"""Exact intersection point; ``result.is_infinite()`` iff parallel."""
a = self.a
b = self.b
oa = other.a
ob = other.b
delta_1 = b.difference_by(a)
delta_2 = ob.difference_by(oa)
if not isinstance(delta_1, IntVector) or not isinstance(delta_2, IntVector):
raise NotImplementedError("Line.intersection only supports integer lines")
fast = _fast_intersection(a, oa, delta_1, delta_2)
if fast is not None:
return fast
det_1 = a.determinant(b)
det_2 = oa.determinant(ob)
det = delta_2.determinant(delta_1)
is_x = det_1 * delta_2.x - det_2 * delta_1.x
is_y = det_1 * delta_2.y - det_2 * delta_1.y
if det == 0:
return rational_point(is_x, is_y, 0)
return rational_point(is_x, is_y, det)
def intersection_approx(self, other: Line) -> FloatPoint:
"""Fast ``float`` intersection; parallel lines give a huge coordinate."""
a, b = self.a, self.b
oa, ob = other.a, other.b
d1x = b.x - a.x
d1y = b.y - a.y
d2x = ob.x - oa.x
d2y = ob.y - oa.y
det_1 = a.x * b.y - a.y * b.x
det_2 = oa.x * ob.y - oa.y * ob.x
det = d2x * d1y - d2y * d1x
if det == 0:
big = float(2**31 - 1)
return FloatPoint(big, big)
return FloatPoint(
(d2x * det_1 - d1x * det_2) / det,
(d2y * det_1 - d1y * det_2) / det,
)
def _round_half_up(value: float) -> int:
return math.floor(value + 0.5)
def _fast_intersection(
a: IntPoint, oa: IntPoint, delta_1: IntVector, delta_2: IntVector
) -> Point | None:
"""Closed-form intersection for the orthogonal/45-degree combinations."""
if delta_1.x == 0: # this line vertical
if delta_2.y == 0: # other horizontal
return IntPoint(a.x, oa.y)
if delta_2.x == delta_2.y: # other right diagonal
return IntPoint(a.x, oa.y + a.x - oa.x)
if delta_2.x == -delta_2.y: # other left diagonal
return IntPoint(a.x, oa.y + oa.x - a.x)
elif delta_1.y == 0: # this line horizontal
if delta_2.x == 0: # other vertical
return IntPoint(oa.x, a.y)
if delta_2.x == delta_2.y: # other right diagonal
return IntPoint(oa.x + a.y - oa.y, a.y)
if delta_2.x == -delta_2.y: # other left diagonal
return IntPoint(oa.x + oa.y - a.y, a.y)
elif delta_1.x == delta_1.y: # this right diagonal
if delta_2.x == 0: # other vertical
return IntPoint(oa.x, a.y + oa.x - a.x)
if delta_2.y == 0: # other horizontal
return IntPoint(a.x + oa.y - a.y, oa.y)
elif delta_1.x == -delta_1.y: # this left diagonal
if delta_2.x == 0: # other vertical
return IntPoint(oa.x, a.y + a.x - oa.x)
if delta_2.y == 0: # other horizontal
return IntPoint(a.x + a.y - oa.y, oa.y)
return None