diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index e2fd7ea..b8d0538 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -15,6 +15,40 @@ def _poly_area(poly: Polygon) -> float: y = verts[:, 1] return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1))) + +@pytest.mark.parametrize('repeated_clip', [False, True]) +@pytest.mark.parametrize('nested', [False, True]) +def test_boolean_expands_repetitions(repeated_clip: bool, nested: bool) -> None: + from masque import boolean + from masque.repetition import Arbitrary + from masque.shapes import RectCollection + + repeated = RectCollection([[0, 0, 2, 2]], repetition=Arbitrary([[0, 0], [10, 0]])) + clip = Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]) + subject, other = (clip, repeated) if repeated_clip else (repeated, clip) + result = boolean([[subject]] if nested else subject, [other], operation='intersection') + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[10, 0], [12, 2]]) + assert _poly_area(result[0]) == 4 + assert result[0].repetition is None + assert_allclose(repeated.rects, [[0, 0, 2, 2]]) + assert_allclose(repeated.repetition.displacements, [[0, 0], [10, 0]]) + + +@pytest.mark.parametrize('operation', ['union', 'difference', 'xor']) +def test_boolean_single_set_normalizes_overlaps(operation: str) -> None: + from masque import boolean + + subject = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]], repetition=Grid(a_vector=(1, 0), a_count=2)) + for clips in (None, []): + result = boolean(subject, clips, operation=operation) + assert len(result) == 1 + assert _poly_area(result[0]) == 6 + if operation != 'difference': + result = boolean([], subject, operation=operation) + assert len(result) == 1 + assert _poly_area(result[0]) == 6 + def test_layer_as_polygons_basic() -> None: pat = Pattern() pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py index 5181fc5..9dd43bd 100644 --- a/masque/utils/boolean.py +++ b/masque/utils/boolean.py @@ -89,8 +89,8 @@ def boolean( Perform a boolean operation on two sets of polygons. Args: - subjects: List of subjects (Polygons or vertex arrays). - clips: List of clips (Polygons or vertex arrays). + subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded. + clips: Clips (shapes or vertex arrays). Shape repetitions are expanded. operation: The boolean operation to perform. scale: Scaling factor for integer conversion (pyclipper uses integers). @@ -115,44 +115,31 @@ def boolean( 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): + if isinstance(objs, numpy.ndarray): + return [objs] + if hasattr(objs, 'to_polygons'): + verts = [] + for poly in objs.to_polygons(): + if poly.repetition is None: + verts.append(poly.vertices) + else: + verts.extend(poly.vertices + dd for dd in poly.repetition.displacements) + return verts + if isinstance(objs, str | bytes) or not isinstance(objs, Iterable): raise PatternError(f"Unsupported type for boolean operation: {type(objs)}") - verts = [] - for obj in objs: - if hasattr(obj, 'to_polygons'): - for p in obj.to_polygons(): - verts.append(p.vertices) - elif isinstance(obj, numpy.ndarray): - verts.append(obj) - elif isinstance(obj, Polygon): - verts.append(obj.vertices) - else: - # Try to iterate if it's an iterable of shapes - try: - for sub in obj: - if hasattr(sub, 'to_polygons'): - for p in sub.to_polygons(): - verts.append(p.vertices) - elif isinstance(sub, Polygon): - verts.append(sub.vertices) - except TypeError: - raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None - return verts + return [vertices for obj in objs for vertices in to_vertices(obj)] + op = op_map[operation.lower()] 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': + if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts: return [] - return [Polygon(vertices) for vertices in subject_verts] + subject_verts, clip_verts = clip_verts, [] + + if not clip_verts and op == pyclipper.CT_INTERSECTION: + return [] pc = pyclipper.Pyclipper() pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) @@ -160,7 +147,13 @@ def boolean( pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) # Use GetPolyTree to distinguish between outers and holes - polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) + polytree = pc.Execute2(op, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) + return _polytree_to_polygons(polytree, scale) + + +def _polytree_to_polygons(polytree: Any, scale: float) -> list[Polygon]: + """Convert a Clipper result, bridging holes for masque's polygon representation.""" + import pyclipper # noqa: PLC0415 result_polygons = []