Files
rpmroute/tests/geometry/test_polygon.py
T
rsp2k a6aa1991a1 Rename to rpmroute
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.
2026-10-07 14:10:36 -06:00

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