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).
* :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",
]

View File

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

View File

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

View File

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

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