From d4aa4c729d780a02ea9222e758f9dc3e0f3e67bc Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 11 Jul 2026 18:01:37 -0600 Subject: [PATCH] Add IntBox convex tile and geometry package exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports IntBox, the simplest concrete convex tile (RegularTileShape): exact integer-corner rectangle with contains (border-inclusive and interior), intersection, union, intersects/overlaps, offset (round half up), horizontal/vertical offset, shrink, box containment, translate, and dimension. Adds the package __init__ exporting the geometry API. The general convex machinery beyond IntBox — TileShape/Simplex/IntOctagon and polygon split_to_convex — is deferred to the next geometry phase. Tests cover boundary vs interior containment, degenerate tiles (empty/point/segment), edge-touching intersects-vs-overlaps, rational point on a border, and half-up offset rounding. --- src/freeroute/geometry/__init__.py | 45 ++++++ src/freeroute/geometry/box.py | 235 +++++++++++++++++++++++++++++ tests/geometry/test_box.py | 131 ++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 src/freeroute/geometry/__init__.py create mode 100644 src/freeroute/geometry/box.py create mode 100644 tests/geometry/test_box.py diff --git a/src/freeroute/geometry/__init__.py b/src/freeroute/geometry/__init__.py new file mode 100644 index 0000000..2dfd294 --- /dev/null +++ b/src/freeroute/geometry/__init__.py @@ -0,0 +1,45 @@ +"""Planar geometry primitives for freeroute. + +A port of the subset of FreeRouting's ``geometry/planar`` package the router +needs, with the exact-arithmetic types preserved. Arithmetic model per type: + +* :class:`IntPoint` / :class:`IntVector` / :class:`IntDirection` / :class:`Line` + / :class:`IntBox` — exact, unbounded Python ``int``. +* :class:`RationalPoint` / :class:`RationalVector` — exact projective ``(x,y,z)`` + ``int`` (affine ``x/z, y/z``; ``z == 0`` is a point at infinity). +* :class:`FloatPoint` — approximate ``float`` (distances, rounding, speed paths). + +The convex-shape hierarchy beyond :class:`IntBox` (``TileShape`` / ``Simplex`` / +``IntOctagon`` and polygon ``split_to_convex``) is a later phase. +""" + +from __future__ import annotations + +from .box import IntBox +from .direction import Direction, IntDirection +from .float_point import FloatPoint +from .limits import CRIT_DOUBLE, CRIT_INT +from .line import Line +from .point import IntPoint, Point, RationalPoint, point, rational_point +from .side import Side, Signum +from .vector import IntVector, RationalVector, Vector + +__all__ = [ + "Side", + "Signum", + "CRIT_INT", + "CRIT_DOUBLE", + "FloatPoint", + "Point", + "IntPoint", + "RationalPoint", + "point", + "rational_point", + "Vector", + "IntVector", + "RationalVector", + "Direction", + "IntDirection", + "Line", + "IntBox", +] diff --git a/src/freeroute/geometry/box.py b/src/freeroute/geometry/box.py new file mode 100644 index 0000000..70f00d4 --- /dev/null +++ b/src/freeroute/geometry/box.py @@ -0,0 +1,235 @@ +"""``IntBox`` — an axis-parallel rectangle with integer corners. + +Ports ``geometry/planar/IntBox.java``, the simplest concrete convex tile +(``RegularTileShape``) and the one the router leans on most for bounding-box +queries. The general convex machinery it also participates in — ``TileShape`` / +``Simplex`` / ``IntOctagon`` and ``split_to_convex`` — is deferred to the next +geometry phase; the operations here (contains, intersection, union, offset, +dimension, containment) are self-contained and exact. + +**Arithmetic model: exact, Python ``int`` corners.** +""" + +from __future__ import annotations + +import math + +from .limits import CRIT_INT +from .point import IntPoint, Point + +__all__ = ["IntBox"] + + +def _round_half_up(value: float) -> int: + return math.floor(value + 0.5) + + +class IntBox: + """The rectangle ``[ll.x, ur.x] x [ll.y, ur.y]`` with integer corners.""" + + __slots__ = ("ll", "ur") + + def __init__(self, *args) -> None: + if len(args) == 2: + ll, ur = args + self.ll: IntPoint = ll + self.ur: IntPoint = ur + elif len(args) == 4: + llx, lly, urx, ury = args + self.ll = IntPoint(llx, lly) + self.ur = IntPoint(urx, ury) + else: # pragma: no cover - misuse guard + raise TypeError("IntBox expects (ll, ur) or (llx, lly, urx, ury)") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, IntBox): + return NotImplemented + return self.ll == other.ll and self.ur == other.ur + + def __hash__(self) -> int: + return hash((self.ll, self.ur)) + + def __repr__(self) -> str: + return f"IntBox({self.ll.x}, {self.ll.y}, {self.ur.x}, {self.ur.y})" + + # --- basic measures ----------------------------------------------------- + + def is_empty(self) -> bool: + return self.ll.x > self.ur.x or self.ll.y > self.ur.y + + def width(self) -> int: + return self.ur.x - self.ll.x + + def height(self) -> int: + return self.ur.y - self.ll.y + + def max_width(self) -> int: + return max(self.ur.x - self.ll.x, self.ur.y - self.ll.y) + + def min_width(self) -> int: + return min(self.ur.x - self.ll.x, self.ur.y - self.ll.y) + + def area(self) -> float: + return float((self.ur.x - self.ll.x) * (self.ur.y - self.ll.y)) + + def circumference(self) -> int: + return 2 * ((self.ur.x - self.ll.x) + (self.ur.y - self.ll.y)) + + def border_line_count(self) -> int: + return 4 + + def corner(self, no: int) -> IntPoint: + """Corners counter-clockwise from the lower-left (``no`` in 0..3).""" + if no == 0: + return self.ll + if no == 1: + return IntPoint(self.ur.x, self.ll.y) + if no == 2: + return self.ur + if no == 3: + return IntPoint(self.ll.x, self.ur.y) + raise ValueError("IntBox.corner: no out of range") + + def corners(self) -> list[IntPoint]: + return [self.corner(i) for i in range(4)] + + def dimension(self) -> int: + """-1 empty, 0 a point, 1 a segment, 2 a proper rectangle.""" + if self.is_empty(): + return -1 + if self.ll == self.ur: + return 0 + if self.ur.x == self.ll.x or self.ll.y == self.ur.y: + return 1 + return 2 + + # --- containment -------------------------------------------------------- + + def contains(self, point: Point) -> bool: + """True if ``point`` is inside or on the border (exact for any Point).""" + return point.is_contained_in(self) + + def contains_inside(self, point: IntPoint) -> bool: + """True if ``point`` is strictly in the interior.""" + return self.ll.x < point.x < self.ur.x and self.ll.y < point.y < self.ur.y + + def is_contained_in(self, other: IntBox) -> bool: + if self.is_empty() or self is other: + return True + return ( + self.ll.x >= other.ll.x + and self.ll.y >= other.ll.y + and self.ur.x <= other.ur.x + and self.ur.y <= other.ur.y + ) + + def contains_box(self, other: IntBox) -> bool: + return other.is_contained_in(self) + + def contains_in_interior(self, other: IntBox) -> bool: + if other.is_empty(): + return True + return ( + other.ll.x > self.ll.x + and other.ll.y > self.ll.y + and other.ur.x < self.ur.x + and other.ur.y < self.ur.y + ) + + # --- set operations ----------------------------------------------------- + + def intersection(self, other: IntBox) -> IntBox: + if ( + other.ll.x > self.ur.x + or other.ll.y > self.ur.y + or self.ll.x > other.ur.x + or self.ll.y > other.ur.y + ): + return IntBox.empty() + return IntBox( + max(self.ll.x, other.ll.x), + max(self.ll.y, other.ll.y), + min(self.ur.x, other.ur.x), + min(self.ur.y, other.ur.y), + ) + + def union(self, other: IntBox) -> IntBox: + return IntBox( + min(self.ll.x, other.ll.x), + min(self.ll.y, other.ll.y), + max(self.ur.x, other.ur.x), + max(self.ur.y, other.ur.y), + ) + + def intersects(self, other: IntBox) -> bool: + return not ( + other.ll.x > self.ur.x + or other.ll.y > self.ur.y + or self.ll.x > other.ur.x + or self.ll.y > other.ur.y + ) + + def overlaps(self, other: IntBox) -> bool: + """True if the intersection is 2-dimensional (open-interval overlap).""" + return not ( + other.ll.x >= self.ur.x + or other.ll.y >= self.ur.y + or self.ll.x >= other.ur.x + or self.ll.y >= other.ur.y + ) + + # --- offset / shrink ---------------------------------------------------- + + def offset(self, dist: float) -> IntBox: + """Expand (``dist > 0``) or contract every side by ``dist``.""" + if dist == 0 or self.is_empty(): + return self + d = _round_half_up(dist) + return IntBox(self.ll.x - d, self.ll.y - d, self.ur.x + d, self.ur.y + d) + + def horizontal_offset(self, dist: float) -> IntBox: + if dist == 0 or self.is_empty(): + return self + d = _round_half_up(dist) + return IntBox(self.ll.x - d, self.ll.y, self.ur.x + d, self.ur.y) + + def vertical_offset(self, dist: float) -> IntBox: + if dist == 0 or self.is_empty(): + return self + d = _round_half_up(dist) + return IntBox(self.ll.x, self.ll.y - d, self.ur.x, self.ur.y + d) + + def shrink(self, width: int) -> IntBox: + """Shrink each side by ``width``; the box collapses to its center, not past.""" + if 2 * width <= self.ur.x - self.ll.x: + ll_x = self.ll.x + width + ur_x = self.ur.x - width + else: + ll_x = ur_x = (self.ll.x + self.ur.x) // 2 + if 2 * width <= self.ur.y - self.ll.y: + ll_y = self.ll.y + width + ur_y = self.ur.y - width + else: + ll_y = ur_y = (self.ll.y + self.ur.y) // 2 + return IntBox(ll_x, ll_y, ur_x, ur_y) + + # --- misc --------------------------------------------------------------- + + def bounding_box(self) -> IntBox: + return self + + def translate_by(self, vector) -> IntBox: + if vector.is_zero(): + return self + new_ll = self.ll.translate_by(vector) + new_ur = self.ur.translate_by(vector) + return IntBox(new_ll, new_ur) + + def to_float_corners(self): + from .float_point import FloatPoint + + return [FloatPoint(c.x, c.y) for c in self.corners()] + + @staticmethod + def empty() -> IntBox: + return IntBox(CRIT_INT, CRIT_INT, -CRIT_INT, -CRIT_INT) diff --git a/tests/geometry/test_box.py b/tests/geometry/test_box.py new file mode 100644 index 0000000..7dd777b --- /dev/null +++ b/tests/geometry/test_box.py @@ -0,0 +1,131 @@ +"""Tests for IntBox. + +Oracles hand-derived from ``geometry/planar/IntBox.java``. Emphasis on boundary +containment and degenerate (empty / point / segment) tiles. +""" + +from __future__ import annotations + +from freeroute.geometry import IntBox, IntPoint, IntVector, RationalPoint + + +def test_measures(): + b = IntBox(1, 2, 5, 8) + assert b.width() == 4 + assert b.height() == 6 + assert b.area() == 24 + assert b.circumference() == 20 + assert b.max_width() == 6 + assert b.min_width() == 4 + + +def test_corners_counter_clockwise(): + b = IntBox(0, 0, 4, 3) + assert b.corners() == [ + IntPoint(0, 0), + IntPoint(4, 0), + IntPoint(4, 3), + IntPoint(0, 3), + ] + + +def test_dimension_variants(): + assert IntBox(0, 0, 4, 3).dimension() == 2 + assert IntBox(0, 0, 0, 3).dimension() == 1 # vertical segment + assert IntBox(0, 0, 4, 0).dimension() == 1 # horizontal segment + assert IntBox(2, 2, 2, 2).dimension() == 0 # single point + assert IntBox.empty().dimension() == -1 + + +def test_boundary_containment_inclusive_vs_interior(): + b = IntBox(0, 0, 10, 10) + corner = IntPoint(0, 0) + edge = IntPoint(0, 5) + inside = IntPoint(5, 5) + outside = IntPoint(11, 5) + # border-inclusive contains + assert b.contains(corner) + assert b.contains(edge) + assert b.contains(inside) + assert not b.contains(outside) + # strict interior + assert not b.contains_inside(corner) + assert not b.contains_inside(edge) + assert b.contains_inside(inside) + + +def test_contains_rational_point_on_border(): + b = IntBox(0, 0, 3, 3) + # (3, 1.5) sits exactly on the right edge -> contained (inclusive) + assert b.contains(RationalPoint(6, 3, 2)) + # (3.5, 1.5) just outside + assert not b.contains(RationalPoint(7, 3, 2)) + + +def test_intersection_overlap_and_touch(): + a = IntBox(0, 0, 10, 10) + assert a.intersection(IntBox(5, 5, 20, 20)) == IntBox(5, 5, 10, 10) + # touching along an edge -> 1-dimensional intersection, still non-empty + touch = a.intersection(IntBox(10, 0, 20, 10)) + assert touch == IntBox(10, 0, 10, 10) + assert touch.dimension() == 1 + # disjoint -> empty + assert a.intersection(IntBox(20, 20, 30, 30)).is_empty() + + +def test_intersects_vs_overlaps_on_touching_edge(): + a = IntBox(0, 0, 10, 10) + edge_neighbor = IntBox(10, 0, 20, 10) + assert a.intersects(edge_neighbor) # closed-interval: touching counts + assert not a.overlaps(edge_neighbor) # open-interval: no 2D overlap + + +def test_union(): + assert IntBox(0, 0, 2, 2).union(IntBox(5, 5, 8, 8)) == IntBox(0, 0, 8, 8) + + +def test_offset_expands_and_contracts(): + b = IntBox(0, 0, 10, 10) + assert b.offset(2) == IntBox(-2, -2, 12, 12) + assert b.offset(-3) == IntBox(3, 3, 7, 7) + assert b.offset(0) is b + assert b.horizontal_offset(2) == IntBox(-2, 0, 12, 10) + assert b.vertical_offset(2) == IntBox(0, -2, 10, 12) + + +def test_offset_rounds_half_up(): + b = IntBox(0, 0, 10, 10) + assert b.offset(2.5) == IntBox(-3, -3, 13, 13) # 2.5 -> 3 (half up) + + +def test_shrink_collapses_to_center_not_past(): + b = IntBox(0, 0, 10, 10) + assert b.shrink(3) == IntBox(3, 3, 7, 7) + # over-shrink collapses to the center line rather than inverting + collapsed = b.shrink(20) + assert collapsed == IntBox(5, 5, 5, 5) + assert collapsed.dimension() == 0 + + +def test_containment_between_boxes(): + outer = IntBox(0, 0, 10, 10) + inner = IntBox(2, 2, 8, 8) + assert inner.is_contained_in(outer) + assert outer.contains_box(inner) + assert outer.contains_in_interior(inner) + edge = IntBox(0, 2, 8, 8) # touches left edge + assert edge.is_contained_in(outer) + assert not outer.contains_in_interior(edge) + + +def test_translate_by(): + b = IntBox(0, 0, 4, 4) + assert b.translate_by(IntVector(3, -2)) == IntBox(3, -2, 7, 2) + assert b.translate_by(IntVector(0, 0)) is b + + +def test_empty_box_predicates(): + e = IntBox.empty() + assert e.is_empty() + assert e.is_contained_in(IntBox(0, 0, 1, 1)) + assert e.offset(5) is e