Add TileShape and Simplex convex-shape core

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.
This commit is contained in:
Ryan Malloy 2026-07-11 18:36:20 -06:00
parent d4aa4c729d
commit 1599d181b5
7 changed files with 790 additions and 2 deletions

View File

@ -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). ``int`` (affine ``x/z, y/z``; ``z == 0`` is a point at infinity).
* :class:`FloatPoint` approximate ``float`` (distances, rounding, speed paths). * :class:`FloatPoint` approximate ``float`` (distances, rounding, speed paths).
The convex-shape hierarchy beyond :class:`IntBox` (``TileShape`` / ``Simplex`` / The convex-shape layer adds :class:`TileShape` (abstract) and :class:`Simplex`
``IntOctagon`` and polygon ``split_to_convex``) is a later phase. (half-plane convex region). ``IntOctagon``, ``Polyline`` and polygon
``split_to_convex`` follow in later chunks.
""" """
from __future__ import annotations from __future__ import annotations
@ -22,6 +23,8 @@ from .limits import CRIT_DOUBLE, CRIT_INT
from .line import Line from .line import Line
from .point import IntPoint, Point, RationalPoint, point, rational_point from .point import IntPoint, Point, RationalPoint, point, rational_point
from .side import Side, Signum from .side import Side, Signum
from .simplex import Simplex
from .tile import TileShape
from .vector import IntVector, RationalVector, Vector from .vector import IntVector, RationalVector, Vector
__all__ = [ __all__ = [
@ -42,4 +45,6 @@ __all__ = [
"IntDirection", "IntDirection",
"Line", "Line",
"IntBox", "IntBox",
"TileShape",
"Simplex",
] ]

View File

@ -230,6 +230,26 @@ class IntBox:
return [FloatPoint(c.x, c.y) for c in self.corners()] 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 @staticmethod
def empty() -> IntBox: def empty() -> IntBox:
return IntBox(CRIT_INT, CRIT_INT, -CRIT_INT, -CRIT_INT) return IntBox(CRIT_INT, CRIT_INT, -CRIT_INT, -CRIT_INT)

View File

@ -109,6 +109,10 @@ class IntDirection(Direction):
nx, ny = table[n] nx, ny = table[n]
return IntDirection(nx, ny) 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: def compare_to(self, other: Direction) -> int:
"""Angular comparison with the positive x-axis (exact). """Angular comparison with the positive x-axis (exact).

View File

@ -113,6 +113,16 @@ class Line:
is_equal_or_opposite = overlaps 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 -------------------------------------------------------- # --- translation --------------------------------------------------------
def translate_by(self, vector: Vector) -> Line: def translate_by(self, vector: Vector) -> Line:
@ -120,6 +130,74 @@ class Line:
return self return self
return Line(self.a.translate_by(vector), self.b.translate_by(vector)) 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) ---------------------------------------------- # --- intersection (exact) ----------------------------------------------
def intersection(self, other: Line) -> Point: 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( def _fast_intersection(
a: IntPoint, oa: IntPoint, delta_1: IntVector, delta_2: IntVector a: IntPoint, oa: IntPoint, delta_1: IntVector, delta_2: IntVector
) -> Point | None: ) -> Point | None:

View File

@ -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

View File

@ -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]

View File

@ -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))