Compare commits

...

6 commits

28 changed files with 557 additions and 35 deletions

View file

@ -2,7 +2,7 @@
import numpy import numpy
from numpy import pi from numpy import pi
from masque import layer_t, Pattern, Circle, Arc, Polygon, Ref from masque import layer_t, Pattern, Circle, Arc, Ref
from masque.repetition import Grid from masque.repetition import Grid
import masque.file.gdsii import masque.file.gdsii

View file

@ -55,6 +55,7 @@ from .pattern import (
map_targets as map_targets, map_targets as map_targets,
chain_elements as chain_elements, chain_elements as chain_elements,
) )
from .utils.boolean import boolean as boolean
from .library import ( from .library import (
ILibraryView as ILibraryView, ILibraryView as ILibraryView,

View file

@ -311,6 +311,7 @@ class Pather(Builder, PatherMixin):
# Fallback for dead pather: manually update the port instead of plugging # Fallback for dead pather: manually update the port instead of plugging
port = self.pattern[portspec] port = self.pattern[portspec]
port_rot = port.rotation port_rot = port.rotation
assert port_rot is not None
if ccw is None: if ccw is None:
out_rot = pi out_rot = pi
elif bool(ccw): elif bool(ccw):
@ -414,6 +415,7 @@ class Pather(Builder, PatherMixin):
# Fallback for dead pather: manually update the port instead of plugging # Fallback for dead pather: manually update the port instead of plugging
port = self.pattern[portspec] port = self.pattern[portspec]
port_rot = port.rotation port_rot = port.rotation
assert port_rot is not None
out_port = Port((length, jog), rotation=pi, ptype=in_ptype) out_port = Port((length, jog), rotation=pi, ptype=in_ptype)
out_port.rotate_around((0, 0), pi + port_rot) out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset) out_port.translate(port.offset)

View file

@ -520,7 +520,7 @@ class RenderPather(PatherMixin):
ccw0 = jog > 0 ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None}) kwargs_no_out = (kwargs | {'out_ptype': None})
try: try:
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail w/asymmetric ptypes
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]

View file

@ -643,6 +643,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
if out_transition is not None: if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype out_ptype_actual = out_transition.their_port.ptype
elif ccw is not None: elif ccw is not None:
assert bend is not None
out_ptype_actual = bend.out_port.ptype out_ptype_actual = bend.out_port.ptype
elif not numpy.isclose(straight_length, 0): elif not numpy.isclose(straight_length, 0):
out_ptype_actual = straight.ptype out_ptype_actual = straight.ptype

View file

@ -502,6 +502,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
] ]
return polys 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]: def referenced_patterns(self) -> set[str | None]:
""" """
Get all pattern namers referenced by this pattern. Non-recursive. Get all pattern namers referenced by this pattern. Non-recursive.
@ -638,7 +693,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
""" """
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast('Positionable', entry).translate(offset) cast('Positionable', entry).translate(offset)
self._log_bulk_update(f"translate({offset})") self._log_bulk_update(f"translate({offset!r})")
return self return self
def scale_elements(self, c: float) -> Self: def scale_elements(self, c: float) -> Self:
@ -738,18 +793,22 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
cast('Rotatable', entry).rotate(rotation) cast('Rotatable', entry).rotate(rotation)
return self return self
def mirror_elements(self, across_axis: int = 0) -> Self: def mirror_elements(self, axis: int = 0) -> Self:
""" """
Mirror each shape, ref, and port relative to (0,0). Mirror each shape, ref, and port relative to its offset.
Args: Args:
across_axis: Axis to mirror across axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis) 0: mirror across x axis (flip y),
1: mirror across y axis (flip x)
Returns: Returns:
self self
""" """
return self.flip_across(axis=across_axis) for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Mirrorable', entry).mirror(axis=axis)
self._log_bulk_update(f"mirror_elements({axis})")
return self
def mirror(self, axis: int = 0) -> Self: def mirror(self, axis: int = 0) -> Self:
""" """

View file

@ -302,9 +302,7 @@ class PortList(metaclass=ABCMeta):
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
for kk, vv in mapping.items(): for kk, vv in mapping.items():
if vv is None: if vv is None or vv != kk:
self._log_port_removal(kk)
elif vv != kk:
self._log_port_removal(kk) self._log_port_removal(kk)
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}

View file

@ -1,4 +1,4 @@
from typing import Any, cast, TYPE_CHECKING, Self from typing import Any, cast, TYPE_CHECKING, Self, Literal
import copy import copy
import functools import functools
@ -462,3 +462,23 @@ class Polygon(Shape):
def __repr__(self) -> str: def __repr__(self) -> str:
centroid = self.vertices.mean(axis=0) centroid = self.vertices.mean(axis=0)
return f'<Polygon centroid {centroid} v{len(self.vertices)}>' return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
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)

View file

@ -20,6 +20,7 @@ def test_abstract_transform() -> None:
abs_obj.rotate_around((0, 0), pi / 2) abs_obj.rotate_around((0, 0), pi / 2)
# (10, 0) rot 0 -> (0, 10) rot pi/2 # (10, 0) rot 0 -> (0, 10) rot pi/2
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10) assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
# Mirror across x axis (axis 0): flips y-offset # Mirror across x axis (axis 0): flips y-offset
@ -27,6 +28,7 @@ def test_abstract_transform() -> None:
# (0, 10) mirrored(0) -> (0, -10) # (0, 10) mirrored(0) -> (0, -10)
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2 # rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10) assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
@ -48,6 +50,7 @@ def test_abstract_ref_transform() -> None:
# (0, 10) -> (100, 110) # (0, 10) -> (100, 110)
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
@ -57,4 +60,5 @@ def test_abstract_undo_transform() -> None:
abs_obj.undo_ref_transform(ref) abs_obj.undo_ref_transform(ref)
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)

119
masque/test/test_boolean.py Normal file
View file

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

View file

@ -50,6 +50,7 @@ def test_builder_plug() -> None:
assert "start" in b.ports assert "start" in b.ports
assert_equal(b.ports["start"].offset, [90, 100]) assert_equal(b.ports["start"].offset, [90, 100])
assert b.ports["start"].rotation is not None
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10) assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
@ -126,4 +127,5 @@ def test_dead_plug_best_effort() -> None:
# 3. Translate by s_port.offset (0,0): (-10,-10) # 3. Translate by s_port.offset (0,0): (-10,-10)
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10) assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
# P2 rot pi + transform rot -pi = 0 # P2 rot pi + transform rot -pi = 0
assert b.ports['B'].rotation is not None
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)

View file

@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import cast
import pytest import pytest
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
@ -77,17 +78,18 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
# Order might be different depending on how they were written, # Order might be different depending on how they were written,
# but here they should match the order they were added if dict order is preserved. # but here they should match the order they were added if dict order is preserved.
# Actually, they are grouped by layer. # Actually, they are grouped by layer.
p_flush = read_paths.shapes[(2, 0)][0] p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
assert p_flush.cap == MPath.Cap.Flush assert p_flush.cap == MPath.Cap.Flush
p_square = read_paths.shapes[(2, 1)][0] p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
assert p_square.cap == MPath.Cap.Square assert p_square.cap == MPath.Cap.Square
p_circle = read_paths.shapes[(2, 2)][0] p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
assert p_circle.cap == MPath.Cap.Circle assert p_circle.cap == MPath.Cap.Circle
p_custom = read_paths.shapes[(2, 3)][0] p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
assert p_custom.cap == MPath.Cap.SquareCustom assert p_custom.cap == MPath.Cap.SquareCustom
assert p_custom.cap_extensions is not None
assert_allclose(p_custom.cap_extensions, (1, 5)) assert_allclose(p_custom.cap_extensions, (1, 5))
# Check Refs with repetitions # Check Refs with repetitions
@ -125,8 +127,8 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None:
# Check Path caps # Check Path caps
read_paths = read_lib["paths"] read_paths = read_lib["paths"]
assert read_paths.shapes[(2, 0)][0].cap == MPath.Cap.Flush assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
assert read_paths.shapes[(2, 1)][0].cap == MPath.Cap.Square assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
# OASIS HalfWidth is Square. masque's Square is also HalfWidth extension. # OASIS HalfWidth is Square. masque's Square is also HalfWidth extension.
# Wait, Circle cap in OASIS? # Wait, Circle cap in OASIS?
# masque/file/oasis.py: # masque/file/oasis.py:

View file

@ -1,11 +1,12 @@
from pathlib import Path from pathlib import Path
from typing import cast
import numpy import numpy
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from ..pattern import Pattern from ..pattern import Pattern
from ..library import Library from ..library import Library
from ..file import gdsii from ..file import gdsii
from ..shapes import Path as MPath from ..shapes import Path as MPath, Polygon
def test_gdsii_roundtrip(tmp_path: Path) -> None: def test_gdsii_roundtrip(tmp_path: Path) -> None:
@ -36,14 +37,14 @@ def test_gdsii_roundtrip(tmp_path: Path) -> None:
assert "ref_cell" in read_lib assert "ref_cell" in read_lib
# Check polygon # Check polygon
read_poly = read_lib["poly_cell"].shapes[(1, 0)][0] read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0])
# GDSII closes polygons, so it might have an extra vertex or different order # GDSII closes polygons, so it might have an extra vertex or different order
assert len(read_poly.vertices) >= 4 assert len(read_poly.vertices) >= 4
# Check bounds as a proxy for geometry correctness # Check bounds as a proxy for geometry correctness
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]]) assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
# Check path # Check path
read_path = read_lib["path_cell"].shapes[(2, 5)][0] read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0])
assert isinstance(read_path, MPath) assert isinstance(read_path, MPath)
assert read_path.width == 10 assert read_path.width == 10
assert_equal(read_path.vertices, [[0, 0], [100, 0]]) assert_equal(read_path.vertices, [[0, 0], [100, 0]])
@ -66,4 +67,5 @@ def test_gdsii_annotations(tmp_path: Path) -> None:
read_lib, _ = gdsii.readfile(gds_file) read_lib, _ = gdsii.readfile(gds_file)
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
assert read_ann is not None
assert read_ann["1"] == ["hello"] assert read_ann["1"] == ["hello"]

View file

@ -1,8 +1,12 @@
import pytest import pytest
from typing import cast, TYPE_CHECKING
from ..library import Library, LazyLibrary from ..library import Library, LazyLibrary
from ..pattern import Pattern from ..pattern import Pattern
from ..error import LibraryError from ..error import LibraryError
if TYPE_CHECKING:
from ..shapes import Polygon
def test_library_basic() -> None: def test_library_basic() -> None:
lib = Library() lib = Library()
@ -51,7 +55,7 @@ def test_library_flatten() -> None:
assert not flat_parent.has_refs() assert not flat_parent.has_refs()
assert len(flat_parent.shapes[(1, 0)]) == 1 assert len(flat_parent.shapes[(1, 0)]) == 1
# Transformations are baked into vertices for Polygon # Transformations are baked into vertices for Polygon
assert_vertices = flat_parent.shapes[(1, 0)][0].vertices assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices
assert tuple(assert_vertices[0]) == (10.0, 10.0) assert tuple(assert_vertices[0]) == (10.0, 10.0)

View file

@ -28,6 +28,7 @@ def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None
# port rot pi/2 (North). Travel +pi relative to port -> South. # port rot pi/2 (North). Travel +pi relative to port -> South.
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
@ -46,6 +47,7 @@ def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
# North (pi/2) + CW (90 deg) -> West (pi)? # North (pi/2) + CW (90 deg) -> West (pi)?
# Actual behavior results in 0 (East) - apparently rotation is flipped. # Actual behavior results in 0 (East) - apparently rotation is flipped.
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
@ -80,6 +82,7 @@ def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> N
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
# pi/2 (North) + CCW (90 deg) -> 0 (East)? # pi/2 (North) + CCW (90 deg) -> 0 (East)?
# Actual behavior results in pi (West). # Actual behavior results in pi (West).
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)

View file

@ -1,3 +1,4 @@
from typing import cast
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
@ -56,7 +57,7 @@ def test_pattern_translate() -> None:
pat.translate_elements((10, 20)) pat.translate_elements((10, 20))
# Polygon.translate adds to vertices, and offset is always (0,0) # Polygon.translate adds to vertices, and offset is always (0,0)
assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, 20]) assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20])
assert_equal(pat.ports["P1"].offset, [15, 25]) assert_equal(pat.ports["P1"].offset, [15, 25])
@ -67,7 +68,7 @@ def test_pattern_scale() -> None:
pat.scale_by(2) pat.scale_by(2)
# Vertices should be scaled # Vertices should be scaled
assert_equal(pat.shapes[(1, 0)][0].vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
def test_pattern_rotate() -> None: def test_pattern_rotate() -> None:
@ -77,7 +78,7 @@ def test_pattern_rotate() -> None:
pat.rotate_around((0, 0), pi / 2) pat.rotate_around((0, 0), pi / 2)
# [10, 0] rotated 90 deg around (0,0) is [0, 10] # [10, 0] rotated 90 deg around (0,0) is [0, 10]
assert_allclose(pat.shapes[(1, 0)][0].vertices[0], [0, 10], atol=1e-10) assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10)
def test_pattern_mirror() -> None: def test_pattern_mirror() -> None:
@ -86,7 +87,7 @@ def test_pattern_mirror() -> None:
# Mirror across X axis (y -> -y) # Mirror across X axis (y -> -y)
pat.mirror(0) pat.mirror(0)
assert_equal(pat.shapes[(1, 0)][0].vertices[0], [10, -5]) assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5])
def test_pattern_get_bounds() -> None: def test_pattern_get_bounds() -> None:
@ -106,7 +107,9 @@ def test_pattern_interface() -> None:
assert "in_A" in iface.ports assert "in_A" in iface.ports
assert "out_A" in iface.ports assert "out_A" in iface.ports
assert iface.ports["in_A"].rotation is not None
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
assert iface.ports["out_A"].rotation is not None
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10) assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
assert iface.ports["in_A"].ptype == "test" assert iface.ports["in_A"].ptype == "test"
assert iface.ports["out_A"].ptype == "test" assert iface.ports["out_A"].ptype == "test"

View file

@ -17,11 +17,13 @@ def test_port_transform() -> None:
p = Port(offset=(10, 0), rotation=0) p = Port(offset=(10, 0), rotation=0)
p.rotate_around((0, 0), pi / 2) p.rotate_around((0, 0), pi / 2)
assert_allclose(p.offset, [0, 10], atol=1e-10) assert_allclose(p.offset, [0, 10], atol=1e-10)
assert p.rotation is not None
assert_allclose(p.rotation, pi / 2, atol=1e-10) assert_allclose(p.rotation, pi / 2, atol=1e-10)
p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
assert_allclose(p.offset, [0, 10], atol=1e-10) assert_allclose(p.offset, [0, 10], atol=1e-10)
# rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2 # rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2
assert p.rotation is not None
assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10)
@ -30,6 +32,7 @@ def test_port_flip_across() -> None:
p.flip_across(axis=1) # Mirror across x=0: flips x-offset p.flip_across(axis=1) # Mirror across x=0: flips x-offset
assert_equal(p.offset, [-10, 0]) assert_equal(p.offset, [-10, 0])
# rotation was 0, mirrored(1) -> pi # rotation was 0, mirrored(1) -> pi
assert p.rotation is not None
assert_allclose(p.rotation, pi, atol=1e-10) assert_allclose(p.rotation, pi, atol=1e-10)

View file

@ -25,6 +25,7 @@ def test_ports2data_roundtrip() -> None:
assert "P1" in pat2.ports assert "P1" in pat2.ports
assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10) assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10)
assert pat2.ports["P1"].rotation is not None
assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10)
assert pat2.ports["P1"].ptype == "test" assert pat2.ports["P1"].ptype == "test"
@ -52,4 +53,5 @@ def test_data_to_ports_hierarchical() -> None:
# rot 0 + pi/2 = pi/2 # rot 0 + pi/2 = pi/2
assert "A" in parent.ports assert "A" in parent.ports
assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10)
assert parent.ports["A"].rotation is not None
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)

View file

@ -1,3 +1,4 @@
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
@ -5,6 +6,9 @@ from ..pattern import Pattern
from ..ref import Ref from ..ref import Ref
from ..repetition import Grid from ..repetition import Grid
if TYPE_CHECKING:
from ..shapes import Polygon
def test_ref_init() -> None: def test_ref_init() -> None:
ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0) ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0)
@ -22,7 +26,7 @@ def test_ref_as_pattern() -> None:
transformed_pat = ref.as_pattern(sub_pat) transformed_pat = ref.as_pattern(sub_pat)
# Check transformed shape # Check transformed shape
shape = transformed_pat.shapes[(1, 0)][0] shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0])
# ref.as_pattern deepcopies sub_pat then applies transformations: # ref.as_pattern deepcopies sub_pat then applies transformations:
# 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]] # 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]]
# 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]] # 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]]
@ -42,7 +46,7 @@ def test_ref_with_repetition() -> None:
# Should have 4 shapes # Should have 4 shapes
assert len(repeated_pat.shapes[(1, 0)]) == 4 assert len(repeated_pat.shapes[(1, 0)]) == 4
first_verts = sorted([tuple(s.vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]])
assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)] assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)]

View file

@ -1,4 +1,5 @@
import pytest import pytest
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
from numpy import pi from numpy import pi
@ -7,6 +8,9 @@ from ..builder.tools import PathTool
from ..library import Library from ..library import Library
from ..ports import Port from ..ports import Port
if TYPE_CHECKING:
from ..shapes import Path
@pytest.fixture @pytest.fixture
def rpather_setup() -> tuple[RenderPather, PathTool, Library]: def rpather_setup() -> tuple[RenderPather, PathTool, Library]:
@ -37,7 +41,7 @@ def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library
# start_port rot pi/2. pi/2 + pi = 3pi/2. # start_port rot pi/2. pi/2 + pi = 3pi/2.
# (10, 0) rotated 3pi/2 -> (0, -10) # (10, 0) rotated 3pi/2 -> (0, -10)
# So vertices: (0,0), (0,-10), (0,-20) # So vertices: (0,0), (0,-10), (0,-20)
path_shape = rp.pattern.shapes[(1, 0)][0] path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert len(path_shape.vertices) == 3 assert len(path_shape.vertices) == 3
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
@ -48,7 +52,7 @@ def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]
rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10) rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10)
rp.render() rp.render()
path_shape = rp.pattern.shapes[(1, 0)][0] path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
# Path vertices: # Path vertices:
# 1. Start (0,0) # 1. Start (0,0)
# 2. Straight end: (0, -10) # 2. Straight end: (0, -10)

View file

@ -25,8 +25,9 @@ class Mirrorable(metaclass=ABCMeta):
to (0, 0), this is equivalent to mirroring in the container's coordinate system. to (0, 0), this is equivalent to mirroring in the container's coordinate system.
Args: Args:
axis: Axis to mirror across (0: x-axis, 1: y-axis). axis: Axis to mirror across:
0: X-axis (flip y coords),
1: Y-axis (flip x coords)
Returns: Returns:
self self
""" """
@ -37,8 +38,8 @@ class Mirrorable(metaclass=ABCMeta):
Optionally mirror the entity across both axes through its origin. Optionally mirror the entity across both axes through its origin.
Args: Args:
across_x: Mirror across x axis (flip y) across_x: Mirror across the horizontal X-axis (flip Y coordinates).
across_y: Mirror across y axis (flip x) across_y: Mirror across the vertical Y-axis (flip X coordinates).
Returns: Returns:
self self
@ -81,7 +82,7 @@ class Flippable(Positionable, metaclass=ABCMeta):
into account. into account.
Args: Args:
axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0. axis: Axis to mirror across. 0: x-axis (flip y coord), 1: y-axis (flip x coord).
x: Vertical line x=val to mirror across. x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across. y: Horizontal line y=val to mirror across.

180
masque/utils/boolean.py Normal file
View file

@ -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

View file

@ -56,6 +56,8 @@ dev = [
"masque[text]", "masque[text]",
"masque[manhattanize]", "masque[manhattanize]",
"masque[manhattanize_slow]", "masque[manhattanize_slow]",
"ruff>=0.15.1",
"mypy>=1.19.1",
] ]
[tool.hatch.version] [tool.hatch.version]
@ -69,6 +71,7 @@ visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"] text = ["matplotlib", "freetype-py"]
manhattanize = ["scikit-image"] manhattanize = ["scikit-image"]
manhattanize_slow = ["float_raster"] manhattanize_slow = ["float_raster"]
boolean = ["pyclipper"]
[tool.ruff] [tool.ruff]
@ -106,3 +109,9 @@ lint.ignore = [
addopts = "-rsXx" addopts = "-rsXx"
testpaths = ["masque"] testpaths = ["masque"]
[tool.mypy]
mypy_path = "stubs"
python_version = "3.11"
strict = false
check_untyped_defs = true

12
stubs/ezdxf/__init__.pyi Normal file
View file

@ -0,0 +1,12 @@
from typing import Any, TextIO, Iterable
from .layouts import Modelspace, BlockRecords
class Drawing:
blocks: BlockRecords
@property
def layers(self) -> Iterable[Any]: ...
def modelspace(self) -> Modelspace: ...
def write(self, stream: TextIO) -> None: ...
def new(version: str = ..., setup: bool = ...) -> Drawing: ...
def read(stream: TextIO) -> Drawing: ...

17
stubs/ezdxf/entities.pyi Normal file
View file

@ -0,0 +1,17 @@
from typing import Any, Iterable, Tuple, Sequence
class DXFEntity:
def dxfattribs(self) -> dict[str, Any]: ...
def dxftype(self) -> str: ...
class LWPolyline(DXFEntity):
def get_points(self) -> Iterable[Tuple[float, ...]]: ...
class Polyline(DXFEntity):
def points(self) -> Iterable[Any]: ... # has .xyz
class Text(DXFEntity):
def get_placement(self) -> Tuple[int, Tuple[float, float, float]]: ...
def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ...
class Insert(DXFEntity): ...

4
stubs/ezdxf/enums.pyi Normal file
View file

@ -0,0 +1,4 @@
from enum import IntEnum
class TextEntityAlignment(IntEnum):
BOTTOM_LEFT = ...

20
stubs/ezdxf/layouts.pyi Normal file
View file

@ -0,0 +1,20 @@
from typing import Any, Iterator, Sequence, Union, Iterable
from .entities import DXFEntity
class BaseLayout:
def __iter__(self) -> Iterator[DXFEntity]: ...
def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ...
def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ...
def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ...
class Modelspace(BaseLayout):
@property
def name(self) -> str: ...
class BlockLayout(BaseLayout):
@property
def name(self) -> str: ...
class BlockRecords:
def new(self, name: str) -> BlockLayout: ...
def __iter__(self) -> Iterator[BlockLayout]: ...

View file

@ -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: ...