Files
rpmroute/src/freeroute/geometry/point.py
T
rsp2k 33cb196bc8 Add exact planar points and vectors
Ports the point/vector foundation of geometry/planar: Side and Signum
(three-valued signs), Limits, FloatPoint (approximate), and the exact
IntPoint/IntVector plus projective RationalPoint/RationalVector.

Arithmetic model per type is documented in each module. The key
simplification over the Java source: Python's unbounded int makes the
exact orientation determinants and rational (x,y,z) coordinates trivial,
so side_of is kept exact (upstream uses a double for speed) and no
BigInteger or CRIT_INT overflow promotion is needed. Rational points use
the projective triple with z=0 denoting the point at infinity.

Tests cover collinearity, determinants beyond Java long range, rational
equality/reduction, and integer/rational promotion.
2026-07-11 18:01:21 -06:00

237 lines
7.3 KiB
Python

"""Points: ``Point`` base, ``IntPoint``, ``RationalPoint``, and factories.
Ports ``geometry/planar/{Point,IntPoint,RationalPoint}.java``.
**Arithmetic model:**
* :class:`IntPoint` — exact, Python ``int`` ``(x, y)``.
* :class:`RationalPoint` — exact, projective ``(x, y, z)`` Python ``int``
representing the affine point ``(x/z, y/z)``. ``z == 0`` is a point at
infinity (the intersection of parallel lines). ``z`` is kept non-negative.
Two points of different concrete type are compared/combined by promoting the
integer one to the rational representation, exactly as the Java double-dispatch
does — but with unbounded ``int`` instead of ``BigInteger``.
"""
from __future__ import annotations
import math
from .float_point import FloatPoint
from .side import Side
from .vector import IntVector, RationalVector, Vector, _add_rational
__all__ = ["Point", "IntPoint", "RationalPoint", "point", "rational_point", "ZERO_POINT"]
def _xyz(p: Point) -> tuple[int, int, int]:
if isinstance(p, IntPoint):
return (p.x, p.y, 1)
return (p.x, p.y, p.z) # RationalPoint
class Point:
"""Abstract base for exact plane points."""
def to_float(self) -> FloatPoint: # pragma: no cover - overridden
raise NotImplementedError
def is_infinite(self) -> bool: # pragma: no cover - overridden
raise NotImplementedError
def translate_by(self, vector: Vector) -> Point:
if vector.is_zero():
return self
return vector.add_to_point(self)
def difference_by(self, other: Point) -> Vector:
"""Return the vector ``self - other`` (exact)."""
if isinstance(self, IntPoint) and isinstance(other, IntPoint):
return IntVector(self.x - other.x, self.y - other.y)
sx, sy, sz = _xyz(self)
ox, oy, oz = _xyz(other)
x, y, z = _add_rational((sx, sy, sz), (-ox, -oy, oz))
return RationalVector(x, y, z)
def side_of(self, p1: Point, p2: Point) -> Side:
"""Side of ``self`` relative to the directed line ``p1 -> p2``."""
v1 = self.difference_by(p1)
v2 = p2.difference_by(p1)
return v1.side_of(v2)
def side_of_line(self, line) -> Side:
return self.side_of(line.a, line.b)
def compare_x(self, other: Point) -> int:
sx, _, sz = _xyz(self)
ox, _, oz = _xyz(other)
return _sign(sx * oz - ox * sz)
def compare_y(self, other: Point) -> int:
_, sy, sz = _xyz(self)
_, oy, oz = _xyz(other)
return _sign(sy * oz - oy * sz)
def compare_x_y(self, other: Point) -> int:
result = self.compare_x(other)
if result == 0:
result = self.compare_y(other)
return result
def turn_90_degree(self, factor: int, pole: Point) -> Point:
v = self.difference_by(pole).turn_90_degree(factor)
return pole.translate_by(v)
def mirror_vertical(self, pole: Point) -> Point:
v = self.difference_by(pole).mirror_at_y_axis()
return pole.translate_by(v)
def mirror_horizontal(self, pole: Point) -> Point:
v = self.difference_by(pole).mirror_at_x_axis()
return pole.translate_by(v)
class IntPoint(Point):
"""A point with exact integer coordinates."""
__slots__ = ("x", "y")
def __init__(self, x: int, y: int) -> None:
self.x = int(x)
self.y = int(y)
def __eq__(self, other: object) -> bool:
if not isinstance(other, IntPoint):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self) -> int:
return hash((self.x, self.y))
def __repr__(self) -> str:
return f"IntPoint({self.x}, {self.y})"
def to_float(self) -> FloatPoint:
return FloatPoint(self.x, self.y)
def is_infinite(self) -> bool:
return False
def get_id_no(self) -> int:
return 31 * self.x + self.y
def determinant(self, other: IntPoint) -> int:
"""Exact determinant of the position vectors of ``self`` and ``other``."""
return self.x * other.y - self.y * other.x
def distance_square(self, other: IntPoint) -> float:
dx = other.x - self.x
dy = other.y - self.y
return float(dx * dx + dy * dy)
def distance(self, other: IntPoint) -> float:
return math.sqrt(self.distance_square(other))
def surrounding_box(self):
from .box import IntBox
return IntBox(self.x, self.y, self.x, self.y)
def is_contained_in(self, box) -> bool:
return box.ll.x <= self.x <= box.ur.x and box.ll.y <= self.y <= box.ur.y
class RationalPoint(Point):
"""A point with exact rational coordinates ``(x/z, y/z)``, ``z`` >= 0."""
__slots__ = ("x", "y", "z")
def __init__(self, x: int, y: int, z: int) -> None:
if z < 0:
raise ValueError("RationalPoint: z is expected to be >= 0")
self.x = int(x)
self.y = int(y)
self.z = int(z)
@staticmethod
def from_int_point(p: IntPoint) -> RationalPoint:
return RationalPoint(p.x, p.y, 1)
def __eq__(self, other: object) -> bool:
if not isinstance(other, RationalPoint):
return NotImplemented
return self.x * other.z - other.x * self.z == 0 and self.y * other.z - other.y * self.z == 0
def __hash__(self) -> int:
if self.z != 0:
g = math.gcd(math.gcd(abs(self.x), abs(self.y)), self.z) or 1
return hash((self.x // g, self.y // g, self.z // g))
return hash((self.x, self.y, 0))
def __repr__(self) -> str:
return f"RationalPoint({self.x}, {self.y}, {self.z})"
def to_float(self) -> FloatPoint:
if self.z == 0:
big = 3.4e38
return FloatPoint(big, big)
return FloatPoint(self.x / self.z, self.y / self.z)
def is_infinite(self) -> bool:
return self.z == 0
def is_contained_in(self, box) -> bool:
# Exact: compare numerators against z * boundary (z >= 0).
if self.x < box.ll.x * self.z:
return False
if self.y < box.ll.y * self.z:
return False
if self.x > box.ur.x * self.z:
return False
return self.y <= box.ur.y * self.z
def surrounding_box(self):
from .box import IntBox
fp = self.to_float()
return IntBox(math.floor(fp.x), math.floor(fp.y), math.ceil(fp.x), math.ceil(fp.y))
ZERO_POINT = IntPoint(0, 0)
# --- factories (Point.get_instance) -----------------------------------------
def point(x: int, y: int) -> IntPoint:
"""Create an :class:`IntPoint`.
FreeRouting promotes to ``RationalPoint`` past ``CRIT_INT`` to avoid ``long``
overflow; unbounded Python ``int`` makes that unnecessary, so an integer
coordinate always yields an :class:`IntPoint`.
"""
return IntPoint(x, y)
def rational_point(x: int, y: int, z: int) -> Point:
"""Create a point from projective coordinates, reducing to integer when exact.
Mirrors ``Point.get_instance(BigInteger, BigInteger, BigInteger)``: a
negative denominator is normalized, and if ``z`` divides both numerators the
result collapses to an :class:`IntPoint`.
"""
if z < 0:
x, y, z = -x, -y, -z
if z != 0 and x % z == 0 and y % z == 0:
return IntPoint(x // z, y // z)
return RationalPoint(x, y, z)
def _sign(value: int) -> int:
if value > 0:
return 1
if value < 0:
return -1
return 0