From 5f91bd9c6c57a825cf5732092de3450aeec84988 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 9 Mar 2026 23:34:25 -0700 Subject: [PATCH 1/6] [BREAKING][Ref / Label / Pattern] Make rotate/mirror consistent intrinsic transfomations offset and repetition are extrinsic; use rotate_around() and flip() to alter both mirror() and rotate() only affect the object's intrinsic properties --- masque/label.py | 10 +- masque/pattern.py | 56 +++++++--- masque/ref.py | 6 +- masque/test/test_rotation_consistency.py | 133 +++++++++++++++++++++++ masque/traits/mirrorable.py | 6 +- masque/traits/rotatable.py | 18 ++- 6 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 masque/test/test_rotation_consistency.py diff --git a/masque/label.py b/masque/label.py index 8b67c65..b662035 100644 --- a/masque/label.py +++ b/masque/label.py @@ -98,18 +98,20 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl """ pivot = numpy.asarray(pivot, dtype=float) self.translate(-pivot) + if self.repetition is not None: + self.repetition.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: """ - Flip the label across a line in the pattern's coordinate system. - - This operation mirrors the label's offset relative to the pattern's origin. + Extrinsic transformation: Flip the label across a line in the pattern's + coordinate system. This affects both the label's offset and its + repetition grid. 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), 1: y-axis (flip x). x: Vertical line x=val to mirror across. y: Horizontal line y=val to mirror across. diff --git a/masque/pattern.py b/masque/pattern.py index e515614..24f6397 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -2,7 +2,7 @@ Object representing a one multi-layer lithographic layout. A single level of hierarchical references is included. """ -from typing import cast, Self, Any, TypeVar, TYPE_CHECKING +from typing import cast, Self, Any, TypeVar from collections.abc import Sequence, Mapping, MutableMapping, Iterable, Callable import copy import logging @@ -25,8 +25,6 @@ from .error import PatternError, PortError from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded from .ports import Port, PortList -if TYPE_CHECKING: - from .traits import Flippable logger = logging.getLogger(__name__) @@ -747,7 +745,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Rotate the Pattern around the a location. + Extrinsic transformation: Rotate the Pattern around the a location in the + container's coordinate system. This affects all elements' offsets and + their repetition grids. Args: pivot: (x, y) location to rotate around @@ -766,7 +766,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_element_centers(self, rotation: float) -> Self: """ - Rotate the offsets of all shapes, labels, refs, and ports around (0, 0) + Extrinsic transformation part: Rotate the offsets and repetition grids of all + shapes, labels, refs, and ports around (0, 0) in the container's + coordinate system. Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -777,11 +779,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): old_offset = cast('Positionable', entry).offset cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) + if isinstance(entry, Repeatable) and entry.repetition is not None: + entry.repetition.rotate(rotation) return self def rotate_elements(self, rotation: float) -> Self: """ - Rotate each shape, ref, and port around its origin (offset) + Intrinsic transformation part: Rotate each shape, ref, label, and port around its + origin (offset) in the container's coordinate system. This does NOT + affect their repetition grids. Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -789,13 +795,32 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Rotatable', entry).rotate(rotation) + for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): + if isinstance(entry, Rotatable): + entry.rotate(rotation) + return self + + def mirror_element_centers(self, axis: int = 0) -> Self: + """ + Extrinsic transformation part: Mirror the offsets and repetition grids of all + shapes, labels, refs, and ports relative to the container's origin. + + Args: + axis: Axis to mirror across (0: x-axis, 1: y-axis) + + Returns: + self + """ + for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): + cast('Positionable', entry).offset[1 - axis] *= -1 + if isinstance(entry, Repeatable) and entry.repetition is not None: + entry.repetition.mirror(axis) return self def mirror_elements(self, axis: int = 0) -> Self: """ - Mirror each shape, ref, and port relative to its offset. + Intrinsic transformation part: Mirror each shape, ref, label, and port relative + to its offset. This does NOT affect their repetition grids. Args: axis: Axis to mirror across @@ -805,14 +830,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Mirrorable', entry).mirror(axis=axis) + for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): + if isinstance(entry, Mirrorable): + entry.mirror(axis=axis) self._log_bulk_update(f"mirror_elements({axis})") return self def mirror(self, axis: int = 0) -> Self: """ - Mirror the Pattern across an axis through its origin. + Extrinsic transformation: Mirror the Pattern across an axis through its origin. + This affects all elements' offsets and their internal orientations. Args: axis: Axis to mirror across (0: x-axis, 1: y-axis). @@ -820,8 +847,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Flippable', entry).flip_across(axis=axis) + self.mirror_elements(axis=axis) + self.mirror_element_centers(axis=axis) self._log_bulk_update(f"mirror({axis})") return self @@ -1168,6 +1195,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for target, refs in pat.refs.items(): if target is None: continue + assert library is not None target_pat = library[target] for ref in refs: # Ref order of operations: mirror, rotate, scale, translate, repeat diff --git a/masque/ref.py b/masque/ref.py index 6423394..f3241e4 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -166,9 +166,11 @@ class Ref( return pattern def rotate(self, rotation: float) -> Self: + """ + Intrinsic transformation: Rotate the target pattern relative to this Ref's + origin. This does NOT affect the repetition grid. + """ self.rotation += rotation - if self.repetition is not None: - self.repetition.rotate(rotation) return self def mirror(self, axis: int = 0) -> Self: diff --git a/masque/test/test_rotation_consistency.py b/masque/test/test_rotation_consistency.py new file mode 100644 index 0000000..f574f52 --- /dev/null +++ b/masque/test/test_rotation_consistency.py @@ -0,0 +1,133 @@ + +from typing import cast +import numpy as np +from numpy.testing import assert_allclose +from ..pattern import Pattern +from ..ref import Ref +from ..label import Label +from ..repetition import Grid + +def test_ref_rotate_intrinsic() -> None: + # Intrinsic rotate() should NOT affect repetition + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + ref.rotate(np.pi/2) + + assert_allclose(ref.rotation, np.pi/2, atol=1e-10) + # Grid vector should still be (10, 0) + assert ref.repetition is not None + assert_allclose(cast('Grid', ref.repetition).a_vector, [10, 0], atol=1e-10) + +def test_ref_rotate_around_extrinsic() -> None: + # Extrinsic rotate_around() SHOULD affect repetition + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + ref.rotate_around((0, 0), np.pi/2) + + assert_allclose(ref.rotation, np.pi/2, atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert ref.repetition is not None + assert_allclose(cast('Grid', ref.repetition).a_vector, [0, 10], atol=1e-10) + +def test_pattern_rotate_around_extrinsic() -> None: + # Pattern.rotate_around() SHOULD affect repetition of its elements + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.rotate_around((0, 0), np.pi/2) + + # Check the ref inside the pattern + ref_in_pat = pat.refs['cell'][0] + assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) + +def test_label_rotate_around_extrinsic() -> None: + # Extrinsic rotate_around() SHOULD affect repetition of labels + rep = Grid(a_vector=(10, 0), a_count=2) + lbl = Label("test", repetition=rep, offset=(5, 0)) + + lbl.rotate_around((0, 0), np.pi/2) + + # Label offset should be (0, 5) + assert_allclose(lbl.offset, [0, 5], atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert lbl.repetition is not None + assert_allclose(cast('Grid', lbl.repetition).a_vector, [0, 10], atol=1e-10) + +def test_pattern_rotate_elements_intrinsic() -> None: + # rotate_elements() should NOT affect repetition + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.rotate_elements(np.pi/2) + + ref_in_pat = pat.refs['cell'][0] + assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) + # Grid vector should still be (10, 0) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 0], atol=1e-10) + +def test_pattern_rotate_element_centers_extrinsic() -> None: + # rotate_element_centers() SHOULD affect repetition and offset + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep, offset=(5, 0)) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.rotate_element_centers(np.pi/2) + + ref_in_pat = pat.refs['cell'][0] + # Offset should be (0, 5) + assert_allclose(ref_in_pat.offset, [0, 5], atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) + # Ref rotation should NOT be changed + assert_allclose(ref_in_pat.rotation, 0, atol=1e-10) + +def test_pattern_mirror_elements_intrinsic() -> None: + # mirror_elements() should NOT affect repetition or offset + rep = Grid(a_vector=(10, 5), a_count=2) + ref = Ref(repetition=rep, offset=(5, 2)) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.mirror_elements(axis=0) # Mirror across x (flip y) + + ref_in_pat = pat.refs['cell'][0] + assert ref_in_pat.mirrored is True + # Repetition and offset should be unchanged + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 5], atol=1e-10) + assert_allclose(ref_in_pat.offset, [5, 2], atol=1e-10) + +def test_pattern_mirror_element_centers_extrinsic() -> None: + # mirror_element_centers() SHOULD affect repetition and offset + rep = Grid(a_vector=(10, 5), a_count=2) + ref = Ref(repetition=rep, offset=(5, 2)) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.mirror_element_centers(axis=0) # Mirror across x (flip y) + + ref_in_pat = pat.refs['cell'][0] + # Offset should be (5, -2) + assert_allclose(ref_in_pat.offset, [5, -2], atol=1e-10) + # Grid vector should be (10, -5) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, -5], atol=1e-10) + # Ref mirrored state should NOT be changed + assert ref_in_pat.mirrored is False diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index ac00147..2a3a9fb 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -18,7 +18,8 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Mirror the entity across an axis through its origin. + Intrinsic transformation: Mirror the entity across an axis through its origin. + This does NOT affect the object's repetition grid. This operation is performed relative to the object's internal origin (ignoring its offset). For objects like `Polygon` and `Path` where the offset is forced @@ -75,7 +76,8 @@ class Flippable(Positionable, metaclass=ABCMeta): @abstractmethod def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: """ - Mirror the object across a line in the container's coordinate system. + Extrinsic transformation: Mirror the object across a line in the container's + coordinate system. This affects both the object's offset and its repetition grid. Unlike `mirror()`, this operation is performed relative to the container's origin (e.g. the `Pattern` origin, in the case of shapes) and takes the object's offset diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 2517e2e..436d0a2 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -25,7 +25,8 @@ class Rotatable(metaclass=ABCMeta): @abstractmethod def rotate(self, val: float) -> Self: """ - Rotate the shape around its origin (0, 0), ignoring its offset. + Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. + This does NOT affect the object's repetition grid. Args: val: Angle to rotate by (counterclockwise, radians) @@ -63,6 +64,10 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): # Methods # def rotate(self, rotation: float) -> Self: + """ + Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. + This does NOT affect the object's repetition grid. + """ self.rotation += rotation return self @@ -82,7 +87,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): class Pivotable(Positionable, metaclass=ABCMeta): """ - Trait class for entites which can be rotated around a point. + Trait class for entities which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. """ __slots__ = () @@ -90,7 +95,11 @@ class Pivotable(Positionable, metaclass=ABCMeta): @abstractmethod def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Rotate the object around a point. + Extrinsic transformation: Rotate the object around a point in the container's + coordinate system. This affects both the object's offset and its repetition grid. + + For objects that are also `Rotatable`, this also performs an intrinsic + rotation of the object. Args: pivot: Point (x, y) to rotate around @@ -110,9 +119,12 @@ class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): __slots__ = () def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: + from .repeatable import Repeatable #noqa: PLC0415 pivot = numpy.asarray(pivot, dtype=float) self.translate(-pivot) self.rotate(rotation) + if isinstance(self, Repeatable) and self.repetition is not None: + self.repetition.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self From feb5d87cf4bccbfb565b541efd5aa2fc53c11edd Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:29:10 -0700 Subject: [PATCH 2/6] [repetition.Arbitrary] fix zero-sized bounds --- masque/repetition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/masque/repetition.py b/masque/repetition.py index e1507b8..a8de94c 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -397,6 +397,8 @@ class Arbitrary(Repetition): Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` """ + if self.displacements.size == 0: + return None xy_min = numpy.min(self.displacements, axis=0) xy_max = numpy.max(self.displacements, axis=0) return numpy.array((xy_min, xy_max)) From 75dc391540d021e6d3d44453e33a24a577890c6b Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:29:51 -0700 Subject: [PATCH 3/6] [pack2d] don't place rejects --- masque/utils/pack2d.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index ce6b006..a99b01e 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -236,7 +236,9 @@ def pack_patterns( locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) pat = Pattern() - for pp, oo, loc in zip(patterns, offsets, locations, strict=True): + for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)): + if ii in reject_inds: + continue pat.ref(pp, offset=oo + loc) rejects = [patterns[ii] for ii in reject_inds] From fa3dfa1e7495cbe775bf94edbec7f2d2aff20963 Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:31:11 -0700 Subject: [PATCH 4/6] [Pattern] improve clarity of .copy()->.deepcopy() --- masque/pattern.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 24f6397..94068c9 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -172,7 +172,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return s def __copy__(self) -> 'Pattern': - logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!') + logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! ' + 'Consider using .deepcopy() if this was not intended.') new = Pattern( annotations=copy.deepcopy(self.annotations), ports=copy.deepcopy(self.ports), @@ -860,7 +861,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A deep copy of the current Pattern. """ - return copy.deepcopy(self) + return self.deepcopy() def deepcopy(self) -> Self: """ From 2275bf415a44e97de8829f617a52b72c5ef2cde9 Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:31:58 -0700 Subject: [PATCH 5/6] [Pattern] improve error message when attempting to reference a Pattern --- masque/pattern.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index 94068c9..46b564a 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1576,8 +1576,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self._log_port_removal(ki) map_out[vi] = None - if isinstance(other, Pattern): - assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)' + if isinstance(other, Pattern) and not (append or skip_geometry): + raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' + 'Use `append=True` if you intended to append the full geometry.') self.place( other, From cfec9e8c7651d40eac8dd1ad87bc16cf72fce1cd Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 10 Mar 2026 00:47:50 -0700 Subject: [PATCH 6/6] [euler_bend] speed up integration --- masque/utils/curves.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 8b3fcc4..2348678 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -69,14 +69,25 @@ def euler_bend( num_points_arc = num_points - 2 * num_points_spiral def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: - xx = [] - yy = [] - for ll in numpy.linspace(0, ll_max, num_points_spiral): - qq = numpy.linspace(0, ll, 1000) # integrate to current arclength - xx.append(trapezoid( numpy.cos(qq * qq / 2), qq)) - yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq)) - xy_part = numpy.stack((xx, yy), axis=1) - return xy_part + if ll_max == 0: + return numpy.zeros((num_points_spiral, 2)) + + resolution = 100000 + qq = numpy.linspace(0, ll_max, resolution) + dx = numpy.cos(qq * qq / 2) + dy = -numpy.sin(qq * qq / 2) + + dq = ll_max / (resolution - 1) + ix = numpy.zeros(resolution) + iy = numpy.zeros(resolution) + ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq + iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq + + ll_target = numpy.linspace(0, ll_max, num_points_spiral) + x_target = numpy.interp(ll_target, qq, ix) + y_target = numpy.interp(ll_target, qq, iy) + + return numpy.stack((x_target, y_target), axis=1) xy_spiral = gen_spiral(ll_max) xy_parts = [xy_spiral]