Booleans, decomposition, area, simplicity, hull, bounds, mitred offset and vertex cleaning. Booleans go to GEOS, which is the reason Shapely was chosen. The decomposition does not. BOSL2 region_parts counts by nesting parity, not connectivity: a path takes a level from how many others contain the midpoint of its first edge, even levels are outer boundaries, their odd children are holes. SECTION_PARTS == 1 is an exact assertion, and Shapely agreeing with that count is a coincidence that holds for well-formed input and not otherwise, so the decomposition is transcribed and both the part count and the area derive from it. is_region_simple is treated as a manifold precondition rather than a diagnostic. An outline that touches itself measures perfectly and cannot be tessellated, so it must fail here and not at export. Developed against Shapely 2.1.2 / GEOS 3.13.1, matching CT 100. Boolean results on near-degenerate geometry can shift between GEOS releases; if the oracle ever disagrees by one part after an upgrade, look there first. 39 tests, all arithmetic on rectangles. Mutation run found a real gap: nothing distinguished on-boundary from outside in the nesting probe until a shared-edge case was added. Eight mutations now caught. Oracle acceptance still skips; 236 unchanged.
321 lines
9.9 KiB
Python
321 lines
9.9 KiB
Python
"""
|
|
Unit tests for the region layer.
|
|
|
|
Every expected area and count here is arithmetic on rectangles, checkable by
|
|
hand. The nesting cases matter most: BOSL2 counts parts by nesting parity rather
|
|
than by connectivity, and ``SECTION_PARTS == 1`` is an exact assertion in the
|
|
oracle, so a decomposition that is merely usually-right is not good enough.
|
|
|
|
``is_region_simple`` is tested as a manifold precondition rather than as a
|
|
diagnostic, because that is what it is: an outline that touches itself measures
|
|
perfectly and will not extrude into a sealed solid.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from mechcomp.geom.region import (
|
|
area,
|
|
as_region,
|
|
clean_region,
|
|
difference,
|
|
from_shapely,
|
|
hull_region,
|
|
intersection,
|
|
is_path_simple,
|
|
is_region_simple,
|
|
nparts,
|
|
offset_path,
|
|
point_in_polygon,
|
|
pointlist_bounds,
|
|
region_area,
|
|
region_parts,
|
|
to_shapely,
|
|
union,
|
|
)
|
|
|
|
|
|
def rect(x0, y0, x1, y1):
|
|
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
|
|
|
|
SQ = rect(0, 0, 10, 10)
|
|
HOLE = rect(2, 2, 8, 8)
|
|
INNER = rect(4, 4, 6, 6)
|
|
FAR = rect(20, 0, 30, 10)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# point_in_polygon
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_point_in_polygon_three_way_answer():
|
|
assert point_in_polygon((5.0, 5.0), SQ) == 1
|
|
assert point_in_polygon((50.0, 5.0), SQ) == -1
|
|
assert point_in_polygon((0.0, 5.0), SQ) == 0
|
|
|
|
|
|
def test_boundary_is_its_own_answer_not_folded_into_inside():
|
|
"""
|
|
region_parts treats on-boundary as contained. Collapsing it into inside or
|
|
outside would shift nesting levels and change the part count.
|
|
"""
|
|
assert point_in_polygon((10.0, 10.0), SQ) == 0
|
|
assert point_in_polygon((5.0, 0.0), SQ) == 0
|
|
|
|
|
|
def test_point_in_polygon_is_winding_not_bounding_box():
|
|
ell = [(0, 0), (10, 0), (10, 4), (4, 4), (4, 10), (0, 10)]
|
|
assert point_in_polygon((8.0, 8.0), ell) == -1
|
|
assert point_in_polygon((2.0, 2.0), ell) == 1
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Decomposition and area
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_plain_square():
|
|
assert nparts([SQ]) == 1
|
|
assert area([SQ]) == pytest.approx(100.0)
|
|
|
|
|
|
def test_hole_subtracts_and_stays_one_part():
|
|
assert nparts([SQ, HOLE]) == 1
|
|
assert area([SQ, HOLE]) == pytest.approx(64.0)
|
|
|
|
|
|
def test_disjoint_paths_are_separate_parts():
|
|
assert nparts([SQ, FAR]) == 2
|
|
assert area([SQ, FAR]) == pytest.approx(200.0)
|
|
|
|
|
|
def test_nesting_parity_not_connectivity():
|
|
"""
|
|
Square, hole, and an island inside the hole. Levels 0, 1, 2 -- so two parts,
|
|
and the island's area is added back rather than subtracted.
|
|
"""
|
|
assert nparts([SQ, HOLE, INNER]) == 2
|
|
assert area([SQ, HOLE, INNER]) == pytest.approx(68.0)
|
|
|
|
|
|
def test_part_ordering_convention_outer_clockwise_holes_ccw():
|
|
"""region_area depends on this winding to make holes subtract."""
|
|
from mechcomp.geom.primitives import sb_signed_area
|
|
parts = region_parts([SQ, HOLE])
|
|
assert len(parts) == 1
|
|
outer, hole = parts[0]
|
|
assert sb_signed_area(outer) < 0
|
|
assert sb_signed_area(hole) > 0
|
|
|
|
|
|
def test_path_order_does_not_change_the_answer():
|
|
"""Nesting is derived, not assumed from input order."""
|
|
assert area([HOLE, SQ]) == pytest.approx(64.0)
|
|
assert nparts([HOLE, SQ]) == 1
|
|
|
|
|
|
def test_empty_region_reads_as_zero_not_an_error():
|
|
"""BOSL2's booleans return a bare [] for an empty result."""
|
|
assert nparts([]) == 0
|
|
assert area([]) == 0.0
|
|
assert region_parts([]) == []
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# as_region
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_as_region_wraps_a_bare_path():
|
|
assert as_region(SQ) == [SQ]
|
|
|
|
|
|
def test_as_region_passes_a_region_through():
|
|
assert as_region([SQ, HOLE]) == [SQ, HOLE]
|
|
|
|
|
|
def test_as_region_of_empty_is_empty():
|
|
assert as_region([]) == []
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Booleans
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_union_of_disjoint_squares_keeps_both():
|
|
u = union([[SQ], [FAR]])
|
|
assert nparts(u) == 2
|
|
assert area(u) == pytest.approx(200.0)
|
|
|
|
|
|
def test_union_of_overlapping_squares_merges_them():
|
|
u = union([[SQ], [rect(5, 0, 15, 10)]])
|
|
assert nparts(u) == 1
|
|
assert area(u) == pytest.approx(150.0)
|
|
|
|
|
|
def test_difference_makes_a_hole():
|
|
d = difference([SQ], [HOLE])
|
|
assert nparts(d) == 1
|
|
assert area(d) == pytest.approx(64.0)
|
|
assert len(d) == 2
|
|
|
|
|
|
def test_difference_can_split_one_solid_into_two():
|
|
"""A cut straight across is how SECTION_PARTS stops being 1."""
|
|
d = difference([SQ], [rect(4, -1, 6, 11)])
|
|
assert nparts(d) == 2
|
|
assert area(d) == pytest.approx(80.0)
|
|
|
|
|
|
def test_difference_with_nothing_returns_the_original():
|
|
assert area(difference([SQ], [])) == pytest.approx(100.0)
|
|
|
|
|
|
def test_intersection_of_overlapping_squares():
|
|
i = intersection([SQ], [rect(5, 5, 15, 15)])
|
|
assert area(i) == pytest.approx(25.0)
|
|
|
|
|
|
def test_union_ignores_empty_operands():
|
|
assert area(union([[SQ], []])) == pytest.approx(100.0)
|
|
|
|
|
|
def test_round_trip_through_shapely_preserves_area_and_parts():
|
|
for rgn in ([SQ], [SQ, HOLE], [SQ, HOLE, INNER], [SQ, FAR]):
|
|
back = from_shapely(to_shapely(rgn))
|
|
assert area(back) == pytest.approx(area(rgn))
|
|
assert nparts(back) == nparts(rgn)
|
|
|
|
|
|
def test_from_shapely_drops_the_repeated_closing_point():
|
|
"""Shapely closes rings explicitly; reference paths are implicitly closed."""
|
|
back = from_shapely(to_shapely([SQ]))
|
|
assert len(back[0]) == 4
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Simplicity -- manifold precondition
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_simple_region_is_simple():
|
|
assert is_region_simple([SQ, FAR])
|
|
|
|
|
|
def test_self_intersecting_path_is_not_simple():
|
|
bowtie = [(0, 0), (10, 10), (10, 0), (0, 10)]
|
|
assert not is_path_simple(bowtie)
|
|
assert not is_region_simple([bowtie])
|
|
|
|
|
|
def test_paths_that_merely_touch_are_not_simple():
|
|
"""
|
|
Touching at an edge or a point is the case that extrudes into something
|
|
untessellatable. It has to fail here, not at STL export.
|
|
"""
|
|
assert not is_region_simple([SQ, rect(10, 0, 20, 10)])
|
|
assert not is_region_simple([SQ, rect(10, 10, 20, 20)])
|
|
|
|
|
|
def test_a_hole_inside_an_outline_is_still_simple():
|
|
"""Nesting is the normal case and must not read as an intersection."""
|
|
assert is_region_simple([SQ, HOLE])
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Hull, bounds, offset
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_hull_of_a_convex_path_is_itself():
|
|
assert len(hull_region([SQ])) == 4
|
|
assert area([hull_region([SQ])]) == pytest.approx(100.0)
|
|
|
|
|
|
def test_hull_spans_disjoint_paths():
|
|
assert area([hull_region([SQ, FAR])]) == pytest.approx(300.0)
|
|
|
|
|
|
def test_hull_of_a_concave_path_fills_the_notch():
|
|
ell = [(0, 0), (10, 0), (10, 4), (4, 4), (4, 10), (0, 10)]
|
|
assert area([hull_region([ell])]) > area([ell])
|
|
|
|
|
|
def test_pointlist_bounds():
|
|
assert pointlist_bounds(SQ) == [(0.0, 0.0), (10.0, 10.0)]
|
|
|
|
|
|
def test_offset_grows_by_delta_on_every_side():
|
|
grown = offset_path(SQ, 2.0)
|
|
assert area([grown]) == pytest.approx(196.0)
|
|
assert pointlist_bounds(grown) == [(-2.0, -2.0), (12.0, 12.0)]
|
|
|
|
|
|
def test_offset_is_mitred_not_rounded():
|
|
"""
|
|
A rounded join would give 4 corner arcs and an area of 100 + 4*10*2 + pi*4.
|
|
Mitred keeps the corners sharp, so the envelope stays a 4-vertex square and
|
|
the explicit round_corners call afterwards is the only rounding applied.
|
|
"""
|
|
grown = offset_path(SQ, 2.0)
|
|
assert len(grown) == 4
|
|
assert area([grown]) != pytest.approx(100.0 + 80.0 + math.pi * 4.0)
|
|
|
|
|
|
def test_negative_offset_shrinks():
|
|
assert area([offset_path(SQ, -2.0)]) == pytest.approx(36.0)
|
|
|
|
|
|
def test_offset_preserves_a_triangle_shape():
|
|
tri = [(0.0, 0.0), (10.0, 0.0), (5.0, 8.0)]
|
|
grown = offset_path(tri, 1.0)
|
|
assert len(grown) == 3
|
|
assert area([grown]) > area([tri])
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# clean_region
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_clean_drops_collinear_vertices():
|
|
with_flat = [(0, 0), (5, 0), (10, 0), (10, 10), (0, 10)]
|
|
cleaned = clean_region([with_flat])
|
|
assert len(cleaned[0]) == 4
|
|
assert area(cleaned) == pytest.approx(100.0)
|
|
|
|
|
|
def test_clean_drops_duplicate_vertices():
|
|
dup = [(0, 0), (0, 0), (10, 0), (10, 10), (0, 10)]
|
|
assert len(clean_region([dup])[0]) == 4
|
|
|
|
|
|
def test_clean_discards_a_path_left_too_short():
|
|
"""A degenerate sliver collapses to fewer than three points and is dropped."""
|
|
sliver = [(0.0, 0.0), (5.0, 0.0), (10.0, 0.0)]
|
|
assert clean_region([sliver]) == []
|
|
|
|
|
|
def test_clean_preserves_a_genuine_outline():
|
|
assert area(clean_region([SQ, HOLE])) == pytest.approx(64.0)
|
|
|
|
|
|
def test_on_boundary_probe_counts_as_contained():
|
|
"""
|
|
Two squares sharing an edge. Each path's nesting is probed at the midpoint
|
|
of its first edge, and for the upper square that point lies exactly on the
|
|
lower square's top edge.
|
|
|
|
BOSL2 counts on-boundary as contained, so the upper square reads as nested
|
|
and becomes a hole: one part, and the areas cancel. Counting it as outside
|
|
would give two parts and 200. The result is geometrically odd either way --
|
|
which is exactly why is_region_simple rejects touching paths before any of
|
|
this is measured.
|
|
"""
|
|
upper = rect(0, 0, 10, 10)
|
|
lower = rect(0, -10, 10, 0)
|
|
assert point_in_polygon((5.0, 0.0), lower) == 0
|
|
assert nparts([upper, lower]) == 1
|
|
assert area([upper, lower]) == pytest.approx(0.0)
|
|
assert not is_region_simple([upper, lower])
|