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.
This commit is contained in:
2026-07-11 18:41:07 -06:00
parent 1599d181b5
commit 68ab16a7cf
6 changed files with 525 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
"""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