From 7ad59d6b891b2c51dbc5461968475162051c8978 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 17:41:58 -0800 Subject: [PATCH] [boolean] Add basic boolean functionality (boolean() and Polygon.boolean()) --- masque/__init__.py | 1 + masque/pattern.py | 55 +++++++++++ masque/ports.py | 4 +- masque/shapes/polygon.py | 22 ++++- masque/test/test_boolean.py | 119 +++++++++++++++++++++++ masque/utils/boolean.py | 180 +++++++++++++++++++++++++++++++++++ pyproject.toml | 7 ++ stubs/pyclipper/__init__.pyi | 46 +++++++++ 8 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 masque/test/test_boolean.py create mode 100644 masque/utils/boolean.py create mode 100644 stubs/pyclipper/__init__.pyi diff --git a/masque/__init__.py b/masque/__init__.py index 4ad7e69..e435fac 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -55,6 +55,7 @@ from .pattern import ( map_targets as map_targets, chain_elements as chain_elements, ) +from .utils.boolean import boolean as boolean from .library import ( ILibraryView as ILibraryView, diff --git a/masque/pattern.py b/masque/pattern.py index d7bbc01..acebf62 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -502,6 +502,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ] return polys + def layer_as_polygons( + self, + layer: layer_t, + flatten: bool = True, + library: Mapping[str, 'Pattern'] | None = None, + ) -> list[Polygon]: + """ + Collect all geometry effectively on a given layer as a list of polygons. + + If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`. + `Repetition` objects are expanded, and non-polygon shapes are converted + to `Polygon` approximations. + + Args: + layer: The layer to collect geometry from. + flatten: If `True`, include geometry from referenced patterns. + library: Required if `flatten=True` to resolve references. + + Returns: + A list of `Polygon` objects. + """ + if flatten and self.has_refs() and library is None: + raise PatternError("Must provide a library to layer_as_polygons() when flatten=True") + + polys: list[Polygon] = [] + + # Local shapes + for shape in self.shapes.get(layer, []): + for p in shape.to_polygons(): + # expand repetitions + if p.repetition is not None: + for offset in p.repetition.displacements: + polys.append(p.deepcopy().translate(offset).set_repetition(None)) + else: + polys.append(p.deepcopy()) + + if flatten and self.has_refs(): + assert library is not None + for target, refs in self.refs.items(): + if target is None: + continue + target_pat = library[target] + for ref in refs: + # Get polygons from target pattern on the same layer + ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library) + # Apply ref transformations + for p in ref_polys: + p_pat = ref.as_pattern(Pattern(shapes={layer: [p]})) + # as_pattern expands repetition of the ref itself + # but we need to pull the polygons back out + for p_transformed in p_pat.shapes[layer]: + polys.append(cast('Polygon', p_transformed)) + + return polys + def referenced_patterns(self) -> set[str | None]: """ Get all pattern namers referenced by this pattern. Non-recursive. diff --git a/masque/ports.py b/masque/ports.py index 5260b19..04ab061 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -302,9 +302,7 @@ class PortList(metaclass=ABCMeta): raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') for kk, vv in mapping.items(): - if vv is None: - self._log_port_removal(kk) - elif vv != kk: + if vv is None or vv != kk: self._log_port_removal(kk) renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 6440144..a243901 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self +from typing import Any, cast, TYPE_CHECKING, Self, Literal import copy import functools @@ -462,3 +462,23 @@ class Polygon(Shape): def __repr__(self) -> str: centroid = self.vertices.mean(axis=0) return f'' + + def boolean( + self, + other: Any, + operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', + scale: float = 1e6, + ) -> list['Polygon']: + """ + Perform a boolean operation using this polygon as the subject. + + Args: + other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP. + operation: 'union', 'intersection', 'difference', 'xor'. + scale: Scaling factor for integer conversion. + + Returns: + A list of resulting Polygons. + """ + from ..utils.boolean import boolean + return boolean([self], other, operation=operation, scale=scale) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py new file mode 100644 index 0000000..c1a2d7b --- /dev/null +++ b/masque/test/test_boolean.py @@ -0,0 +1,119 @@ +import pytest +import numpy +from numpy.testing import assert_allclose +from masque.pattern import Pattern +from masque.shapes.polygon import Polygon +from masque.repetition import Grid +from masque.library import Library + +def test_layer_as_polygons_basic() -> None: + pat = Pattern() + pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) + + polys = pat.layer_as_polygons((1, 0), flatten=False) + assert len(polys) == 1 + assert isinstance(polys[0], Polygon) + assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + +def test_layer_as_polygons_repetition() -> None: + pat = Pattern() + rep = Grid(a_vector=(2, 0), a_count=2) + pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep) + + polys = pat.layer_as_polygons((1, 0), flatten=False) + assert len(polys) == 2 + # First polygon at (0,0) + assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + # Second polygon at (2,0) + assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]]) + +def test_layer_as_polygons_flatten() -> None: + lib = Library() + + child = Pattern() + child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]]) + lib['child'] = child + + parent = Pattern() + parent.ref('child', offset=(10, 10), rotation=numpy.pi/2) + + polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib) + assert len(polys) == 1 + # Original child at (0,0) with rot pi/2 is still at (0,0) in its own space? + # No, ref.as_pattern(child) will apply the transform. + # Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1) + # Then offset by (10,10) -> (10,10), (10,11), (9,11) + + # Let's verify the vertices + expected = numpy.array([[10, 10], [10, 11], [9, 11]]) + assert_allclose(polys[0].vertices, expected, atol=1e-10) + +def test_boolean_import_error() -> None: + from masque import boolean + # If pyclipper is not installed, this should raise ImportError + try: + import pyclipper # noqa: F401 + pytest.skip("pyclipper is installed, cannot test ImportError") + except ImportError: + with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): + boolean([], [], operation='union') + +def test_polygon_boolean_shortcut() -> None: + poly = Polygon([[0, 0], [1, 0], [1, 1]]) + # This should also raise ImportError if pyclipper is missing + try: + import pyclipper # noqa: F401 + pytest.skip("pyclipper is installed") + except ImportError: + with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): + poly.boolean(poly) + +def test_bridge_holes() -> None: + from masque.utils.boolean import _bridge_holes + + # Outer: 10x10 square + outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) + # Hole: 2x2 square in the middle + hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]]) + + bridged = _bridge_holes(outer, [hole]) + + # We expect more vertices than outer + hole + # Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop? + # Our implementation: + # 1. outer up to bridge edge (best_edge_idx) + # 2. bridge point on outer + # 3. hole reordered starting at max X + # 4. close hole loop (repeat max X) + # 5. bridge point on outer again + # 6. rest of outer + + # max X of hole is 6 at (6,4) or (6,6). argmax will pick first one. + # hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4) + # roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4] + + # intersection of ray from (6,4) to right: + # edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0) + # edge (10,0)-(10,10) spans y=4. + # intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2) + + # vertices added: + # outer[0:2]: (0,0), (10,0) + # bridge pt: (10,4) + # hole: (6,4), (6,6), (4,6), (4,4) + # hole close: (6,4) + # bridge pt back: (10,4) + # outer[2:]: (10,10), (0,10) + + expected_len = 11 + assert len(bridged) == expected_len + + # verify it wraps around the hole and back + # index 2 is bridge_pt + assert_allclose(bridged[2], [10, 4]) + # index 3 is hole reordered max X + assert_allclose(bridged[3], [6, 4]) + # index 7 is hole closed at max X + assert_allclose(bridged[7], [6, 4]) + # index 8 is bridge_pt back + assert_allclose(bridged[8], [10, 4]) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py new file mode 100644 index 0000000..9b9514e --- /dev/null +++ b/masque/utils/boolean.py @@ -0,0 +1,180 @@ +from typing import Any, Literal +from collections.abc import Iterable +import logging + +import numpy +from numpy.typing import NDArray + +from ..shapes.polygon import Polygon +from ..error import PatternError + +logger = logging.getLogger(__name__) + +def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]: + """ + Bridge multiple holes into an outer boundary using zero-width slits. + """ + current_outer = outer_path + + # Sort holes by max X to potentially minimize bridge lengths or complexity + # (though not strictly necessary for correctness) + holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True) + + for hole in holes: + # Find max X vertex of hole + max_idx = numpy.argmax(hole[:, 0]) + m = hole[max_idx] + + # Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges + best_t = numpy.inf + best_pt = None + best_edge_idx = -1 + + n = len(current_outer) + for i in range(n): + p1 = current_outer[i] + p2 = current_outer[(i + 1) % n] + + # Check if edge (p1, p2) spans m.y + if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]): + # Intersection x: + # x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y) + t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0] + if 0 <= t < best_t: + best_t = t + best_pt = numpy.array([m[0] + t, m[1]]) + best_edge_idx = i + + if best_edge_idx == -1: + # Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole) + dists = numpy.linalg.norm(current_outer - m, axis=1) + best_edge_idx = int(numpy.argmin(dists)) + best_pt = current_outer[best_edge_idx] + # Adjust best_edge_idx to insert AFTER this vertex + # (treating it as a degenerate edge) + + assert best_pt is not None + + # Reorder hole vertices to start at m + hole_reordered = numpy.roll(hole, -max_idx, axis=0) + + # Construct new outer: + # 1. Start of outer up to best_edge_idx + # 2. Intersection point + # 3. Hole vertices (starting and ending at m) + # 4. Intersection point (to close slit) + # 5. Rest of outer + + new_outer: list[NDArray[numpy.float64]] = [] + new_outer.extend(current_outer[:best_edge_idx + 1]) + new_outer.append(best_pt) + new_outer.extend(hole_reordered) + new_outer.append(hole_reordered[0]) # close hole loop at m + new_outer.append(best_pt) # back to outer + new_outer.extend(current_outer[best_edge_idx + 1:]) + + current_outer = numpy.array(new_outer) + + return current_outer + +def boolean( + subjects: Iterable[Any], + clips: Iterable[Any] | None = None, + operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', + scale: float = 1e6, + ) -> list[Polygon]: + """ + 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). + operation: The boolean operation to perform. + scale: Scaling factor for integer conversion (pyclipper uses integers). + + Returns: + A list of result Polygons. + """ + try: + import pyclipper + except ImportError: + raise ImportError( + "Boolean operations require 'pyclipper'. " + "Install it with 'pip install pyclipper' or 'pip install masque[boolean]'." + ) from None + + op_map = { + 'union': pyclipper.PT_UNION, + 'intersection': pyclipper.PT_INTERSECTION, + 'difference': pyclipper.PT_DIFFERENCE, + 'xor': pyclipper.PT_XOR, + } + + def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]: + if objs is None: + return [] + 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 + + subject_verts = to_vertices(subjects) + clip_verts = to_vertices(clips) + + pc = pyclipper.Pyclipper() + pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) + if clip_verts: + 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) + + result_polygons = [] + + def process_node(node: Any) -> None: + if not node.IsHole: + # This is an outer boundary + outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale)) + + # Find immediate holes + holes = [] + for child in node.Childs: + if child.IsHole: + holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale))) + + if holes: + combined_vertices = _bridge_holes(outer_path, holes) + result_polygons.append(Polygon(combined_vertices)) + else: + result_polygons.append(Polygon(outer_path)) + + # Recursively process children of holes (which are nested outers) + for child in node.Childs: + if child.IsHole: + for grandchild in child.Childs: + process_node(grandchild) + else: + # Holes are processed as children of outers + pass + + for top_node in polytree.Childs: + process_node(top_node) + + return result_polygons diff --git a/pyproject.toml b/pyproject.toml index d6605fa..764eee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] manhattanize = ["scikit-image"] manhattanize_slow = ["float_raster"] +boolean = ["pyclipper"] [tool.ruff] @@ -106,3 +107,9 @@ lint.ignore = [ addopts = "-rsXx" testpaths = ["masque"] +[tool.mypy] +mypy_path = "stubs" +python_version = "3.11" +strict = false +check_untyped_defs = true + diff --git a/stubs/pyclipper/__init__.pyi b/stubs/pyclipper/__init__.pyi new file mode 100644 index 0000000..08d77c8 --- /dev/null +++ b/stubs/pyclipper/__init__.pyi @@ -0,0 +1,46 @@ +from typing import Any +from collections.abc import Iterable, Sequence +import numpy +from numpy.typing import NDArray + + +# Basic types for Clipper integer coordinates +Path = Sequence[tuple[int, int]] +Paths = Sequence[Path] + +# Types for input/output floating point coordinates +FloatPoint = tuple[float, float] | NDArray[numpy.floating] +FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating] +FloatPaths = Iterable[FloatPath] + +# Constants +PT_SUBJECT: int +PT_CLIP: int + +PT_UNION: int +PT_INTERSECTION: int +PT_DIFFERENCE: int +PT_XOR: int + +PFT_EVENODD: int +PFT_NONZERO: int +PFT_POSITIVE: int +PFT_NEGATIVE: int + +# Scaling functions +def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ... +def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ... + +class PolyNode: + Contour: Path + Childs: list[PolyNode] + Parent: PolyNode + IsHole: bool + +class Pyclipper: + def __init__(self) -> None: ... + def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ... + def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ... + def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ... + def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ... + def Clear(self) -> None: ...