freeroute/tests/geometry/test_polygon.py
Ryan Malloy 68ab16a7cf Add PolygonShape.split_to_convex (polygon to convex tiles)
Ports geometry/planar/Polygon.java (corner de-duplication and collinear
removal, winding number) and PolygonShape.java, including the recursive
split_to_convex that decomposes a simple polygon into convex Simplex
tiles by dividing at concave corners along minimal axis-parallel lines.

Orientation and convexity tests are exact; the division-point search is
approximate (float line evaluation, split point rounded to an integer
corner) as upstream. The concave-corner search starts deterministically
at corner 0 rather than a seeded PRNG; this only affects which valid
decomposition is produced.

Supporting additions: Simplex.from_corners (convex polygon to simplex)
and Line.function_value_approx / function_in_y_value_approx.

Invariant tests over L, plus, staircase and square polygons: tile areas
sum to the polygon area (no gaps, no overlap), a point is in the polygon
iff in some tile, and no point is strictly inside more than one tile
(interiors disjoint). Also covers Polygon normalization and orientation.
2026-07-11 18:41:07 -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 freeroute.geometry import IntPoint, Side
from freeroute.geometry.polygon import Polygon
from freeroute.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