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