From 32744512e06c24a9235b8b934b1e1758f08cabe3 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 1 Apr 2026 21:28:33 -0700 Subject: [PATCH] [boolean] more work towards getting boolean ops working --- masque/test/test_boolean.py | 178 ++++++++++++++++++++++++++++++++++++ masque/utils/boolean.py | 24 ++++- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index bf5d33d..1e44e4d 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -6,6 +6,14 @@ from masque.pattern import Pattern from masque.shapes.polygon import Polygon from masque.repetition import Grid from masque.library import Library +from masque.error import PatternError + + +def _poly_area(poly: Polygon) -> float: + verts = poly.vertices + x = verts[:, 0] + y = verts[:, 1] + return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1))) def test_layer_as_polygons_basic() -> None: pat = Pattern() @@ -69,6 +77,176 @@ def test_polygon_boolean_shortcut() -> None: with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): poly.boolean(poly) + +def test_boolean_intersection_with_pyclipper() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + result = boolean( + [Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])], + [Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])], + operation='intersection', + ) + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + + +def test_polygon_boolean_shortcut_with_pyclipper() -> None: + pytest.importorskip("pyclipper") + + poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]) + result = poly.boolean( + Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), + operation='intersection', + ) + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + + +def test_boolean_union_difference_and_xor_with_pyclipper() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]) + rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]) + + union = boolean([rect_a], [rect_b], operation='union') + assert len(union) == 1 + assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10) + assert_allclose(_poly_area(union[0]), 7, atol=1e-10) + + difference = boolean([rect_a], [rect_b], operation='difference') + assert len(difference) == 1 + assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10) + assert_allclose(_poly_area(difference[0]), 3, atol=1e-10) + + xor = boolean([rect_a], [rect_b], operation='xor') + assert len(xor) == 2 + assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10) + xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor) + assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))] + + +def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + raw_result = boolean( + [numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])], + numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]), + operation='intersection', + ) + assert len(raw_result) == 1 + assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10) + + single_shape_result = boolean( + Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]), + Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), + operation='intersection', + ) + assert len(single_shape_result) == 1 + assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + + +def test_boolean_handles_multi_polygon_inputs() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + result = boolean( + [ + Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]), + Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]), + ], + [ + Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), + Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]), + ], + operation='intersection', + ) + assert len(result) == 2 + assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10) + result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result) + assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))] + + +def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) + hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]]) + result = boolean([outer], [hole], operation='difference') + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10) + assert_allclose(_poly_area(result[0]), 64, atol=1e-10) + + +def test_boolean_nested_hole_and_island_case() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) + hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]]) + island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]]) + + result = boolean([outer, island], [hole], operation='union') + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10) + assert_allclose(_poly_area(result[0]), 100, atol=1e-10) + + +def test_boolean_empty_inputs_follow_set_semantics() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]) + + union = boolean([], [rect], operation='union') + assert len(union) == 1 + assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + intersection = boolean([], [rect], operation='intersection') + assert intersection == [] + + difference = boolean([], [rect], operation='difference') + assert difference == [] + + xor = boolean([], [rect], operation='xor') + assert len(xor) == 1 + assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + clip_empty_union = boolean([rect], [], operation='union') + assert len(clip_empty_union) == 1 + assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + clip_empty_intersection = boolean([rect], [], operation='intersection') + assert clip_empty_intersection == [] + + clip_empty_difference = boolean([rect], [], operation='difference') + assert len(clip_empty_difference) == 1 + assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + clip_empty_xor = boolean([rect], [], operation='xor') + assert len(clip_empty_xor) == 1 + assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + +def test_boolean_invalid_inputs_raise_pattern_error() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) + + for bad in (123, object(), [123]): + with pytest.raises(PatternError, match='Unsupported type'): + boolean([rect], bad, operation='intersection') + + def test_bridge_holes() -> None: from masque.utils.boolean import _bridge_holes diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py index 78c24e2..5181fc5 100644 --- a/masque/utils/boolean.py +++ b/masque/utils/boolean.py @@ -106,15 +106,19 @@ def boolean( ) from None op_map = { - 'union': pyclipper.PT_UNION, - 'intersection': pyclipper.PT_INTERSECTION, - 'difference': pyclipper.PT_DIFFERENCE, - 'xor': pyclipper.PT_XOR, + 'union': pyclipper.CT_UNION, + 'intersection': pyclipper.CT_INTERSECTION, + 'difference': pyclipper.CT_DIFFERENCE, + 'xor': pyclipper.CT_XOR, } - def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]: + def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: if objs is None: return [] + if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon): + objs = (objs,) + elif not isinstance(objs, Iterable): + raise PatternError(f"Unsupported type for boolean operation: {type(objs)}") verts = [] for obj in objs: if hasattr(obj, 'to_polygons'): @@ -140,6 +144,16 @@ def boolean( subject_verts = to_vertices(subjects) clip_verts = to_vertices(clips) + if not subject_verts: + if operation in ('union', 'xor'): + return [Polygon(vertices) for vertices in clip_verts] + return [] + + if not clip_verts: + if operation == 'intersection': + return [] + return [Polygon(vertices) for vertices in subject_verts] + pc = pyclipper.Pyclipper() pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) if clip_verts: