Add IntBox convex tile and geometry package exports
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.
This commit is contained in:
parent
55a869ef7d
commit
d4aa4c729d
45
src/freeroute/geometry/__init__.py
Normal file
45
src/freeroute/geometry/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
235
src/freeroute/geometry/box.py
Normal file
235
src/freeroute/geometry/box.py
Normal file
@ -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)
|
||||
131
tests/geometry/test_box.py
Normal file
131
tests/geometry/test_box.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user