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.
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 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
|