The router began as a port of FreeRouting and has grown its own engine (negotiated congestion, tree nets, exact checks, axis escapes), and 'freeroute' was one letter from the project it no longer follows. The package, module and command are now rpmroute; the freeroute command stays as an alias, and the RPMROUTE / RPMROUTE_ORACLE_JAVA variables fall back to the old FREEROUTE names. Version 2026.10.07. Routing is unchanged: a PCBench board routes to a byte-identical session either way.
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
"""Invariant tests for Polygon / PolygonShape.split_to_convex.
|
|
|
|
No external oracle exists, so these assert the decomposition invariants from the
|
|
phase-2 brief for several concave polygons: the convex tiles' areas sum to the
|
|
polygon area (no gaps, no overlap), a point is contained in the polygon iff it
|
|
is contained in some tile, and no point is strictly inside more than one tile
|
|
(interiors disjoint).
|
|
|
|
Source: ``geometry/planar/{Polygon,PolygonShape}.java``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from rpmroute.geometry import IntPoint, Side
|
|
from rpmroute.geometry.polygon import Polygon
|
|
from rpmroute.geometry.polygon_shape import PolygonShape
|
|
|
|
|
|
def pts(*coords):
|
|
return [IntPoint(x, y) for x, y in coords]
|
|
|
|
|
|
# --- Polygon normalization --------------------------------------------------
|
|
|
|
|
|
def test_polygon_removes_consecutive_duplicates():
|
|
poly = Polygon(pts((0, 0), (0, 0), (10, 0), (10, 10), (10, 10), (0, 10)))
|
|
assert len(poly.corners) == 4
|
|
|
|
|
|
def test_polygon_removes_middle_collinear_corner():
|
|
# (5, 0) is collinear with its neighbours (0,0) and (10,0) -> dropped.
|
|
# Raw Polygon only removes *middle* collinear corners (endpoint-collinear
|
|
# removal is PolygonShape's job), so the trailing (0,5) is kept.
|
|
poly = Polygon(pts((0, 0), (5, 0), (10, 0), (10, 10), (0, 10), (0, 5)))
|
|
coords = [(c.x, c.y) for c in poly.corners]
|
|
assert (5, 0) not in coords
|
|
assert coords == [(0, 0), (10, 0), (10, 10), (0, 10), (0, 5)]
|
|
|
|
|
|
def test_polygon_shape_removes_endpoint_collinear_corner():
|
|
# PolygonShape additionally drops the endpoint-collinear (0,5).
|
|
ps = PolygonShape(pts((0, 0), (5, 0), (10, 0), (10, 10), (0, 10), (0, 5)))
|
|
coords = {(c.x, c.y) for c in ps.corners}
|
|
assert coords == {(0, 0), (10, 0), (10, 10), (0, 10)}
|
|
|
|
|
|
def test_winding_number_sign():
|
|
ccw = Polygon(pts((0, 0), (10, 0), (10, 10), (0, 10)))
|
|
cw = Polygon(pts((0, 0), (0, 10), (10, 10), (10, 0)))
|
|
assert ccw.winding_number_after_closing() > 0
|
|
assert cw.winding_number_after_closing() < 0
|
|
|
|
|
|
# --- convexity --------------------------------------------------------------
|
|
|
|
|
|
def test_is_convex():
|
|
square = PolygonShape(pts((0, 0), (10, 0), (10, 10), (0, 10)))
|
|
assert square.is_convex()
|
|
ell = PolygonShape(pts((0, 0), (60, 0), (60, 20), (20, 20), (20, 60), (0, 60)))
|
|
assert not ell.is_convex()
|
|
|
|
|
|
# --- split_to_convex invariants ---------------------------------------------
|
|
|
|
SHAPES = {
|
|
"L": pts((0, 0), (60, 0), (60, 20), (20, 20), (20, 60), (0, 60)),
|
|
"plus": pts(
|
|
(20, 0),
|
|
(40, 0),
|
|
(40, 20),
|
|
(60, 20),
|
|
(60, 40),
|
|
(40, 40),
|
|
(40, 60),
|
|
(20, 60),
|
|
(20, 40),
|
|
(0, 40),
|
|
(0, 20),
|
|
(20, 20),
|
|
),
|
|
"staircase": pts((0, 0), (30, 0), (30, 10), (20, 10), (20, 20), (10, 20), (10, 30), (0, 30)),
|
|
"square": pts((0, 0), (40, 0), (40, 40), (0, 40)),
|
|
}
|
|
|
|
|
|
def _bounds(corners):
|
|
xs = [c.x for c in corners]
|
|
ys = [c.y for c in corners]
|
|
return min(xs) - 3, max(xs) + 3, min(ys) - 3, max(ys) + 3
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(SHAPES))
|
|
def test_split_area_conserved(name):
|
|
ps = PolygonShape(SHAPES[name])
|
|
tiles = ps.split_to_convex()
|
|
assert tiles, "expected at least one convex tile"
|
|
assert sum(t.area() for t in tiles) == pytest.approx(ps.area())
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(SHAPES))
|
|
def test_split_union_equals_polygon(name):
|
|
ps = PolygonShape(SHAPES[name])
|
|
tiles = ps.split_to_convex()
|
|
lo_x, hi_x, lo_y, hi_y = _bounds(ps.corners)
|
|
for x in range(lo_x, hi_x + 1):
|
|
for y in range(lo_y, hi_y + 1):
|
|
p = IntPoint(x, y)
|
|
in_poly = ps.contains(p)
|
|
in_some_tile = any(t.contains(p) for t in tiles)
|
|
assert in_poly == in_some_tile, (x, y)
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(SHAPES))
|
|
def test_split_interiors_are_disjoint(name):
|
|
ps = PolygonShape(SHAPES[name])
|
|
tiles = ps.split_to_convex()
|
|
lo_x, hi_x, lo_y, hi_y = _bounds(ps.corners)
|
|
for x in range(lo_x, hi_x + 1):
|
|
for y in range(lo_y, hi_y + 1):
|
|
p = IntPoint(x, y)
|
|
strict = sum(1 for t in tiles if t.contains_inside(p))
|
|
assert strict <= 1, (x, y, strict)
|
|
|
|
|
|
def test_convex_polygon_is_single_tile():
|
|
ps = PolygonShape(SHAPES["square"])
|
|
tiles = ps.split_to_convex()
|
|
assert len(tiles) == 1
|
|
assert tiles[0].dimension() == 2
|
|
|
|
|
|
def test_concave_corner_detected_by_side_of():
|
|
# the reflex corner of the L is (20, 20): its next corner is on the right
|
|
ell = PolygonShape(SHAPES["L"])
|
|
corners = ell.corners
|
|
reflex_found = False
|
|
n = len(corners)
|
|
for i in range(n):
|
|
prev_c = corners[i - 1]
|
|
curr = corners[i]
|
|
nxt = corners[(i + 1) % n]
|
|
if nxt.side_of(prev_c, curr) == Side.ON_THE_RIGHT:
|
|
reflex_found = True
|
|
assert reflex_found
|