[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
This commit is contained in:
jan 2026-03-09 23:34:25 -07:00
commit 5f91bd9c6c
6 changed files with 204 additions and 25 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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