From 1599d181b5f76d18e45788a82b93ddf0d0269a82 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 11 Jul 2026 18:36:20 -0600 Subject: [PATCH] Add TileShape and Simplex convex-shape core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports geometry/planar/TileShape.java (the border-line-based containment, area, and centre-of-gravity logic) and Simplex.java (a convex region as the intersection of directed half-planes). Corners are exact intersections of consecutive border lines; point containment uses exact side_of. The remove_redundant_lines normalization — dropping lines that do not contribute and detecting emptiness — is ported line-for-line. Supporting additions: Line.compare_to/__lt__ (angular sort order), Line.fast_equals, Line.side_of_intersection, Line.translate (perpendicular offset), IntDirection.determinant, and IntBox.to_simplex. offset is approximate (rounded translated lines, as upstream); enlarge clips to the enlarged bounding box pending the IntOctagon port. Since there is no JVM oracle, tests assert invariants: corners lie exactly on their border lines (exact side_of == 0), IntBox -> Simplex preserves the region over a sampled grid, intersection is contained in both operands and a point is in the result iff in both, and get_instance normalization drops redundant lines and detects empty half-plane pairs. --- src/freeroute/geometry/__init__.py | 9 +- src/freeroute/geometry/box.py | 20 ++ src/freeroute/geometry/direction.py | 4 + src/freeroute/geometry/line.py | 82 +++++++ src/freeroute/geometry/simplex.py | 338 ++++++++++++++++++++++++++++ src/freeroute/geometry/tile.py | 151 +++++++++++++ tests/geometry/test_simplex.py | 188 ++++++++++++++++ 7 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 src/freeroute/geometry/simplex.py create mode 100644 src/freeroute/geometry/tile.py create mode 100644 tests/geometry/test_simplex.py diff --git a/src/freeroute/geometry/__init__.py b/src/freeroute/geometry/__init__.py index 2dfd294..30944c1 100644 --- a/src/freeroute/geometry/__init__.py +++ b/src/freeroute/geometry/__init__.py @@ -9,8 +9,9 @@ needs, with the exact-arithmetic types preserved. Arithmetic model per type: ``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. +The convex-shape layer adds :class:`TileShape` (abstract) and :class:`Simplex` +(half-plane convex region). ``IntOctagon``, ``Polyline`` and polygon +``split_to_convex`` follow in later chunks. """ from __future__ import annotations @@ -22,6 +23,8 @@ 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 .simplex import Simplex +from .tile import TileShape from .vector import IntVector, RationalVector, Vector __all__ = [ @@ -42,4 +45,6 @@ __all__ = [ "IntDirection", "Line", "IntBox", + "TileShape", + "Simplex", ] diff --git a/src/freeroute/geometry/box.py b/src/freeroute/geometry/box.py index 70f00d4..fea215a 100644 --- a/src/freeroute/geometry/box.py +++ b/src/freeroute/geometry/box.py @@ -230,6 +230,26 @@ class IntBox: return [FloatPoint(c.x, c.y) for c in self.corners()] + def to_simplex(self): + """Return this box as a :class:`~freeroute.geometry.simplex.Simplex`. + + Four directed border lines whose common right side is the box interior + (ports ``IntBox.to_Simplex``). + """ + from .direction import IntDirection + from .line import Line + from .simplex import Simplex + + if self.is_empty(): + return Simplex(()) + lines = ( + Line.from_point_direction(self.ll, IntDirection(1, 0)), # bottom, +x + Line.from_point_direction(self.ur, IntDirection(0, 1)), # right, +y + Line.from_point_direction(self.ur, IntDirection(-1, 0)), # top, -x + Line.from_point_direction(self.ll, IntDirection(0, -1)), # left, -y + ) + return Simplex(lines) + @staticmethod def empty() -> IntBox: return IntBox(CRIT_INT, CRIT_INT, -CRIT_INT, -CRIT_INT) diff --git a/src/freeroute/geometry/direction.py b/src/freeroute/geometry/direction.py index 4698370..d04a61a 100644 --- a/src/freeroute/geometry/direction.py +++ b/src/freeroute/geometry/direction.py @@ -109,6 +109,10 @@ class IntDirection(Direction): nx, ny = table[n] return IntDirection(nx, ny) + def determinant(self, other: IntDirection) -> int: + """Exact determinant of the two direction vectors.""" + return self.x * other.y - self.y * other.x + def compare_to(self, other: Direction) -> int: """Angular comparison with the positive x-axis (exact). diff --git a/src/freeroute/geometry/line.py b/src/freeroute/geometry/line.py index dfe8ea5..61777a9 100644 --- a/src/freeroute/geometry/line.py +++ b/src/freeroute/geometry/line.py @@ -113,6 +113,16 @@ class Line: 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: @@ -120,6 +130,74 @@ class Line: 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 + # --- intersection (exact) ---------------------------------------------- def intersection(self, other: Line) -> Point: @@ -166,6 +244,10 @@ class Line: ) +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: diff --git a/src/freeroute/geometry/simplex.py b/src/freeroute/geometry/simplex.py new file mode 100644 index 0000000..6fba858 --- /dev/null +++ b/src/freeroute/geometry/simplex.py @@ -0,0 +1,338 @@ +"""``Simplex`` — a convex region as the intersection of directed half-planes. + +Ports ``geometry/planar/Simplex.java``. A simplex is defined by an array of +directed :class:`~freeroute.geometry.line.Line`s sorted by direction angle; the +region is the intersection of the *right* half-plane of each line. Corners are +the exact intersections of consecutive border lines +(:meth:`~freeroute.geometry.line.Line.intersection`). + +**Arithmetic model: exact where it matters.** Corners and all point-containment +tests are exact (exact line intersection + exact ``side_of``). ``offset`` is +approximate (it rounds translated lines, as upstream ``Line.translate`` does). +``bounding_box`` is computed from the approximate corners then floored/ceiled to +integers (matching the source). + +The normalization :meth:`_remove_redundant_lines` is the delicate heart — it +drops border lines that do not contribute to the shape and detects emptiness; it +is ported line-for-line from ``Simplex.remove_redundant_lines``. +""" + +from __future__ import annotations + +import math + +from .float_point import FloatPoint +from .line import Line +from .point import Point +from .side import Side +from .tile import TileShape + +__all__ = ["Simplex"] + + +class Simplex(TileShape): + """Convex tile defined by directed border lines (right half-plane each).""" + + __slots__ = ("_arr", "_corners", "_float_corners", "_bbox") + + def __init__(self, lines) -> None: + self._arr: tuple[Line, ...] = tuple(lines) + self._corners: list[Point | None] | None = None + self._float_corners: list[FloatPoint | None] | None = None + self._bbox = None + + # --- construction ------------------------------------------------------- + + @staticmethod + def get_instance(lines) -> Simplex: + """Build a normalized simplex from directed lines (sorted, deduped).""" + arr = list(lines) + if not arr: + return Simplex(()) + arr.sort() + return Simplex(arr)._remove_redundant_lines() + + @staticmethod + def empty() -> Simplex: + return Simplex(()) + + def __repr__(self) -> str: + return f"Simplex(<{len(self._arr)} lines>)" + + # --- accessors ---------------------------------------------------------- + + def is_empty(self) -> bool: + return len(self._arr) == 0 + + def border_line_count(self) -> int: + return len(self._arr) + + def border_line(self, no: int) -> Line: + return self._arr[_clamp(no, len(self._arr))] + + def corner(self, no: int) -> Point: + """Exact intersection of border line ``no-1`` and ``no``.""" + n = len(self._arr) + no = _clamp(no, n) + if self._corners is None: + self._corners = [None] * n + if self._corners[no] is None: + prev = self._arr[n - 1] if no == 0 else self._arr[no - 1] + self._corners[no] = self._arr[no].intersection(prev) + return self._corners[no] + + def corner_approx(self, no: int) -> FloatPoint: + n = len(self._arr) + if n == 0: + return None + no = _clamp(no, n) + if self._float_corners is None: + self._float_corners = [None] * n + if self._float_corners[no] is None: + prev = self._arr[n - 1] if no == 0 else self._arr[no - 1] + self._float_corners[no] = self._arr[no].intersection_approx(prev) + return self._float_corners[no] + + def corner_is_bounded(self, no: int) -> bool: + n = len(self._arr) + if n == 1: + return False + no = _clamp(no, n) + prev_no = n - 1 if no == 0 else no - 1 + prev_dir = self._arr[prev_no].direction().get_vector() + curr_dir = self._arr[no].direction().get_vector() + return prev_dir.determinant(curr_dir) > 0 + + def is_bounded(self) -> bool: + n = len(self._arr) + if n == 0: + return True + if n < 3: + return False + return all(self.corner_is_bounded(i) for i in range(n)) + + def dimension(self) -> int: + arr = self._arr + n = len(arr) + if n == 0: + return -1 + if n > 4: + return 2 + if n == 1: + return 2 # half-plane + if n == 2: + return 1 if arr[0].overlaps(arr[1]) else 2 + if n == 3: + if arr[0].overlaps(arr[1]) or arr[0].overlaps(arr[2]) or arr[1].overlaps(arr[2]): + return 1 + intersection = arr[1].intersection(arr[2]) + side = arr[0].side_of(intersection) + if side == Side.ON_THE_RIGHT: + return 2 + if side == Side.ON_THE_LEFT: + return -1 # empty, not normalized + return 0 # all three lines meet in a point + # n == 4: check opposing collinear pairs + collinear_0_2 = arr[0].overlaps(arr[2]) + collinear_1_3 = arr[1].overlaps(arr[3]) + if collinear_0_2 and collinear_1_3: + return 0 + if collinear_0_2 or collinear_1_3: + return 1 + return 2 + + # --- transforms --------------------------------------------------------- + + def translate_by(self, vector) -> Simplex: + if vector.is_zero(): + return self + return Simplex([line.translate_by(vector) for line in self._arr]) + + def offset(self, width: float) -> Simplex: + """Offset every border line outward (``width > 0``) or inward. + + Approximate (rounds translated lines), ported from ``Simplex.offset``. + """ + if width == 0: + return self + new_arr = [line.translate(-width) for line in self._arr] + result = Simplex(new_arr) + if width < 0: + result = result._remove_redundant_lines() + return result + + def enlarge(self, offset: float) -> Simplex: + """Enlarge, clipped to the enlarged bounding region to stay bounded. + + Upstream clips against the enlarged bounding *octagon*; pending the + IntOctagon port this uses the enlarged bounding *box* (a coarser but + valid bound), which is documented as a slight over-approximation. + """ + if offset == 0: + return self + offset_simplex = self.offset(offset) + bbox = self.bounding_box() + if bbox.is_empty(): + return Simplex(()) + clip = bbox.offset(offset).to_simplex() + return offset_simplex.intersection(clip) + + # --- intersection ------------------------------------------------------- + + def intersection(self, other) -> Simplex: + """Intersection with another :class:`Simplex` or an ``IntBox``.""" + from .box import IntBox + + if isinstance(other, IntBox): + other = other.to_simplex() + if self.is_empty() or other.is_empty(): + return Simplex(()) + merged = list(self._arr) + list(other._arr) + merged.sort() + return Simplex(merged)._remove_redundant_lines() + + def intersects(self, other) -> bool: + return not self.intersection(other).is_empty() + + # --- bounding ----------------------------------------------------------- + + def bounding_box(self): + from .box import IntBox + + if len(self._arr) == 0: + return IntBox.empty() + if self._bbox is None: + llx = lly = math.inf + urx = ury = -math.inf + for i in range(len(self._arr)): + c = self.corner_approx(i) + llx = min(llx, c.x) + lly = min(lly, c.y) + urx = max(urx, c.x) + ury = max(ury, c.y) + self._bbox = IntBox(math.floor(llx), math.floor(lly), math.ceil(urx), math.ceil(ury)) + return self._bbox + + # --- conversions -------------------------------------------------------- + + def is_int_box(self) -> bool: + for i, line in enumerate(self._arr): + if not line.is_orthogonal(): + return False + if not self.corner_is_bounded(i): + return False + return len(self._arr) > 0 + + def to_int_box(self): + return self.bounding_box() + + def simplify(self): + if self.is_empty(): + return Simplex(()) + if self.is_int_box(): + return self.bounding_box() + return self + + def to_simplex(self) -> Simplex: + return self + + # --- normalization (ported from remove_redundant_lines) ---------------- + + def _remove_redundant_lines(self) -> Simplex: # noqa: C901 - faithful port + arr = self._arr + if not arr: + return self + line_arr: list[Line] = [arr[0]] + prev = line_arr[0] + for i in range(1, len(arr)): + if not arr[i].fast_equals(prev): + line_arr.append(arr[i]) + prev = line_arr[-1] + new_length = len(line_arr) + # pad so index assignments below never run off the end + line_arr = line_arr + [None] * (len(arr) - new_length) + intersection_sides: list[Side | None] = [None] * len(arr) + + try_again = new_length > 2 + index_of_last_removed_line = new_length + while try_again: + try_again = False + prev_ind = new_length - 1 + prev_line = line_arr[prev_ind] + curr_line = line_arr[0] + ind = 0 + while ind < new_length: + next_ind = 0 if ind == new_length - 1 else ind + 1 + next_line = line_arr[next_ind] + + remove_line = False + prev_dir = prev_line.direction() + next_dir = next_line.direction() + det = prev_dir.determinant(next_dir) + if det != 0: + if intersection_sides[ind] is None: + intersection_sides[ind] = curr_line.side_of_intersection( + prev_line, next_line + ) + if det > 0: + remove_line = intersection_sides[ind] != Side.ON_THE_LEFT + else: + if intersection_sides[ind] == Side.ON_THE_LEFT: + curr_dir = curr_line.direction() + if prev_dir.determinant(curr_dir) > 0: + new_length = 0 + try_again = False + break + else: # parallel + if prev_line.side_of(next_line.a) == Side.ON_THE_LEFT: + new_length = 0 + try_again = False + break + + if remove_line: + try_again = True + new_length -= 1 + for i in range(ind, new_length): + line_arr[i] = line_arr[i + 1] + intersection_sides[i] = intersection_sides[i + 1] + if new_length < 3: + try_again = False + break + if ind == 0: + prev_ind = new_length - 1 + intersection_sides[prev_ind] = None + next_ind = 0 if ind >= new_length else ind + intersection_sides[next_ind] = None + ind -= 1 + index_of_last_removed_line = ind + else: + prev_line = curr_line + prev_ind = ind + curr_line = next_line + if not try_again and ind >= index_of_last_removed_line: + break + ind += 1 + + if new_length == 2 and line_arr[0].is_parallel(line_arr[1]): + if line_arr[0].direction().equals(line_arr[1].direction()): + # one of the two parallel lines is redundant + if line_arr[1].side_of(line_arr[0].a) == Side.ON_THE_LEFT: + line_arr[0] = line_arr[1] + new_length -= 1 + elif line_arr[1].side_of(line_arr[0].a) == Side.ON_THE_LEFT: + # opposite directions that do not overlap: empty + new_length = 0 + + if new_length == len(arr): + return self + if new_length == 0: + return Simplex(()) + return Simplex(line_arr[:new_length]) + + +def _clamp(no: int, count: int) -> int: + if no < 0: + return 0 + if no >= count: + return count - 1 + return no diff --git a/src/freeroute/geometry/tile.py b/src/freeroute/geometry/tile.py new file mode 100644 index 0000000..2448a3b --- /dev/null +++ b/src/freeroute/geometry/tile.py @@ -0,0 +1,151 @@ +"""``TileShape`` — abstract convex shape bounded by straight border lines. + +Ports the concrete (non-abstract) behaviour of ``geometry/planar/TileShape.java`` +that is defined purely in terms of the border lines and corners: point +containment, area, centre of gravity, and border tests. Concrete subclasses +(:class:`~freeroute.geometry.simplex.Simplex`) supply ``border_line_count``, +``border_line``, ``corner`` and ``corner_approx``. + +**Arithmetic model:** containment and ``contains_on_border`` are **exact** +(they use the exact :meth:`~freeroute.geometry.line.Line.side_of`); ``area`` and +``centre_of_gravity`` are approximate (``float``), as upstream. + +A convex tile is the intersection of the right half-planes of its directed +border lines: a point is inside-or-on-border iff it is not on the strict left of +any border line, and strictly inside iff it is on the strict right of every one. +""" + +from __future__ import annotations + +from .float_point import FloatPoint +from .point import Point +from .side import Side + +__all__ = ["TileShape"] + + +class TileShape: + """Abstract convex shape; subclasses provide the border-line accessors.""" + + # --- accessors subclasses must provide --------------------------------- + + def border_line_count(self) -> int: # pragma: no cover - abstract + raise NotImplementedError + + def border_line(self, no: int): # pragma: no cover - abstract + raise NotImplementedError + + def corner(self, no: int) -> Point: # pragma: no cover - abstract + raise NotImplementedError + + def corner_approx(self, no: int) -> FloatPoint: + return self.corner(no).to_float() + + def corner_is_bounded(self, no: int) -> bool: # pragma: no cover - abstract + raise NotImplementedError + + def is_empty(self) -> bool: # pragma: no cover - abstract + raise NotImplementedError + + def is_bounded(self) -> bool: # pragma: no cover - abstract + raise NotImplementedError + + def dimension(self) -> int: # pragma: no cover - abstract + raise NotImplementedError + + # --- point containment (exact) ----------------------------------------- + + def is_outside(self, point: Point) -> bool: + """True if ``point`` is neither inside nor on the border.""" + line_count = self.border_line_count() + if line_count == 0: + return True + for i in range(line_count): + if self.border_line(i).side_of(point) == Side.ON_THE_LEFT: + return True + return False + + def contains(self, point: Point) -> bool: + """True if ``point`` is inside or on the border (exact).""" + return not self.is_outside(point) + + def contains_inside(self, point: Point) -> bool: + """True if ``point`` is strictly interior (on the right of every line).""" + line_count = self.border_line_count() + if line_count == 0: + return False + for i in range(line_count): + if self.border_line(i).side_of(point) != Side.ON_THE_RIGHT: + return False + return True + + def contains_on_border_line_no(self, point: Point) -> int: + """Index of a border line containing ``point``, or -1 if not on border.""" + line_count = self.border_line_count() + if line_count == 0: + return -1 + containing = -1 + for i in range(line_count): + side = self.border_line(i).side_of(point) + if side == Side.ON_THE_LEFT: + return -1 + if side == Side.COLLINEAR: + containing = i + return containing + + def contains_on_border(self, point: Point) -> bool: + return self.contains_on_border_line_no(point) >= 0 + + def contains_tile(self, other: TileShape) -> bool: + """True if every corner of ``other`` is contained in this shape.""" + return all( + self.contains(other.corner(i)) for i in range(other.border_line_count()) + ) + + # --- measures (approximate) -------------------------------------------- + + def centre_of_gravity(self) -> FloatPoint: + """Arithmetic mean of the (approximate) corners.""" + n = self.border_line_count() + x = 0.0 + y = 0.0 + for i in range(n): + c = self.corner_approx(i) + x += c.x + y += c.y + return FloatPoint(x / n, y / n) + + def area(self) -> float: + """Absolute polygon area (0 if degenerate, ``inf`` if unbounded).""" + if not self.is_bounded(): + return float("inf") + if self.dimension() < 2: + return 0.0 + n = self.border_line_count() + result = 0.0 + prev_corner = self.corner_approx(n - 2) + curr_corner = self.corner_approx(n - 1) + for i in range(n): + next_corner = self.corner_approx(i) + result += curr_corner.x * (next_corner.y - prev_corner.y) + prev_corner = curr_corner + curr_corner = next_corner + return 0.5 * abs(result) + + def circumference(self) -> float: + if not self.is_bounded(): + return float("inf") + n = self.border_line_count() + result = 0.0 + prev_corner = self.corner_approx(n - 1) + for i in range(n): + curr_corner = self.corner_approx(i) + result += curr_corner.distance(prev_corner) + prev_corner = curr_corner + return result + + # --- convex decomposition (trivial for a tile) ------------------------- + + def split_to_convex(self) -> list[TileShape]: + """A convex tile is already convex — returns ``[self]``.""" + return [self] diff --git a/tests/geometry/test_simplex.py b/tests/geometry/test_simplex.py new file mode 100644 index 0000000..bc58ca1 --- /dev/null +++ b/tests/geometry/test_simplex.py @@ -0,0 +1,188 @@ +"""Invariant tests for TileShape / Simplex. + +There is no external oracle (no JVM; FreeRouting ships no unit tests for +``geometry.planar``), so these assert properties that must hold regardless of +implementation, per the phase-2 brief: + +* corners lie exactly on their two border lines (exact ``Line.side_of == 0``), +* ``IntBox -> Simplex -> region`` preserves the region, +* ``Simplex.intersection`` is contained in both operands and a point is in the + result iff it is in both, +* ``get_instance`` normalization drops redundant lines without changing the + region. + +Source: ``geometry/planar/{TileShape,Simplex}.java``. +""" + +from __future__ import annotations + +from freeroute.geometry import IntBox, IntPoint, Line, Side, Simplex + + +def _grid(lo: int, hi: int): + for x in range(lo, hi + 1): + for y in range(lo, hi + 1): + yield IntPoint(x, y) + + +def right_triangle() -> Simplex: + # legs of length 10 along the axes; hypotenuse x + y = 10 + return Simplex.get_instance( + [ + Line.from_coords(0, 0, 10, 0), + Line.from_coords(10, 0, 0, 10), + Line.from_coords(0, 10, 0, 0), + ] + ) + + +def unit_box_simplex() -> Simplex: + return IntBox(0, 0, 10, 10).to_simplex() + + +# --- corners lie exactly on their border lines ------------------------------ + + +def test_corners_are_exact_on_border_lines(): + for shape in (unit_box_simplex(), right_triangle()): + n = shape.border_line_count() + for i in range(n): + corner = shape.corner(i) + prev = shape.border_line(n - 1 if i == 0 else i - 1) + curr = shape.border_line(i) + # the corner is the exact intersection of these two lines + assert curr.side_of(corner) == Side.COLLINEAR + assert prev.side_of(corner) == Side.COLLINEAR + + +# --- IntBox -> Simplex -> region round trip --------------------------------- + + +def test_box_to_simplex_preserves_region(): + box = IntBox(-3, 2, 7, 11) + s = box.to_simplex() + assert s.bounding_box() == box + for p in _grid(-6, 14): + assert box.contains(p) == s.contains(p) + + +def test_box_to_simplex_is_a_box_and_simplifies_back(): + box = IntBox(0, 0, 10, 10) + s = box.to_simplex() + assert s.is_int_box() + assert s.simplify() == box + + +# --- containment semantics -------------------------------------------------- + + +def test_triangle_containment(): + tri = right_triangle() + assert tri.contains(IntPoint(0, 0)) # corner + assert tri.contains(IntPoint(5, 5)) # on hypotenuse + assert tri.contains_inside(IntPoint(2, 2)) + assert not tri.contains_inside(IntPoint(5, 5)) # on border, not inside + assert not tri.contains(IntPoint(6, 6)) # x+y=12 > 10 + assert tri.contains_on_border(IntPoint(3, 0)) # on the bottom edge + + +def test_triangle_area_and_bounds(): + tri = right_triangle() + assert tri.is_bounded() + assert tri.dimension() == 2 + assert tri.area() == 50.0 + assert tri.bounding_box() == IntBox(0, 0, 10, 10) + + +# --- intersection invariants ------------------------------------------------ + + +def test_intersection_contained_in_both_and_iff(): + a = IntBox(0, 0, 12, 8).to_simplex() + b = right_triangle() # x,y >= 0, x+y <= 10 + inter = a.intersection(b) + assert not inter.is_empty() + for p in _grid(-3, 15): + in_both = a.contains(p) and b.contains(p) + in_inter = inter.contains(p) + assert in_inter == in_both + # every corner of the intersection lies inside both operands + for i in range(inter.border_line_count()): + c = inter.corner(i) + assert a.contains(c) + assert b.contains(c) + + +def test_intersection_of_two_boxes_matches_box_intersection(): + a_box = IntBox(0, 0, 10, 10) + b_box = IntBox(4, -2, 20, 6) + inter = a_box.to_simplex().intersection(b_box.to_simplex()) + expected = a_box.intersection(b_box) + for p in _grid(-5, 22): + assert inter.contains(p) == expected.contains(p) + + +def test_disjoint_intersection_is_empty(): + a = IntBox(0, 0, 3, 3).to_simplex() + b = IntBox(10, 10, 13, 13).to_simplex() + assert a.intersection(b).is_empty() + assert not a.intersects(b) + + +def test_intersection_is_idempotent_with_self(): + tri = right_triangle() + inter = tri.intersection(tri) + for p in _grid(-3, 13): + assert inter.contains(p) == tri.contains(p) + + +# --- normalization (get_instance drops redundant lines) --------------------- + + +def test_get_instance_removes_redundant_line(): + # a unit box plus a far-away redundant half-plane that does not cut it. + # A DOWN-directed vertical line has its interior to the east, so this is + # x >= -100, which contains the whole box and is therefore redundant. + box_lines = list(IntBox(0, 0, 10, 10).to_simplex()._arr) + redundant = Line.from_coords(-100, 1, -100, 0) # x >= -100 + s = Simplex.get_instance(box_lines + [redundant]) + assert s.border_line_count() == 4 # redundant line dropped + for p in _grid(-5, 15): + assert s.contains(p) == IntBox(0, 0, 10, 10).contains(p) + + +def test_get_instance_detects_empty_from_opposing_halfplanes(): + # x >= 5 (DOWN line, interior east) and x <= 0 (UP line, interior west) + # cannot both hold. + ge5 = Line.from_coords(5, 1, 5, 0) # DOWN -> interior x >= 5 + le0 = Line.from_coords(0, 0, 0, 1) # UP -> interior x <= 0 + s = Simplex.get_instance([ge5, le0]) + assert s.is_empty() + + +def test_translate_preserves_shape(): + tri = right_triangle() + from freeroute.geometry import IntVector + + moved = tri.translate_by(IntVector(100, 50)) + for p in _grid(-3, 13): + assert tri.contains(p) == moved.contains(IntPoint(p.x + 100, p.y + 50)) + + +def test_offset_outward_enlarges_region(): + box = IntBox(0, 0, 10, 10).to_simplex() + bigger = box.offset(2) + # every point of the original box is still contained after outward offset + for p in _grid(0, 10): + assert bigger.contains(p) + # a point 2 outside the original right edge is now contained + assert bigger.contains(IntPoint(12, 5)) + assert not box.contains(IntPoint(12, 5)) + + +def test_empty_simplex_predicates(): + e = Simplex.empty() + assert e.is_empty() + assert e.dimension() == -1 + assert e.is_outside(IntPoint(0, 0)) + assert not e.contains(IntPoint(0, 0))