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