freeroute/tests/geometry/test_simplex.py
Ryan Malloy 1599d181b5 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.
2026-07-11 18:36:20 -06:00

189 lines
6.1 KiB
Python

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