[Mirrorable / Flippable] Bifurcate mirror into flip (relative to line) vs mirror (relative to own offset/origin)

This commit is contained in:
Jan Petykiewicz 2026-02-15 00:05:53 -08:00
commit 44986bac67
11 changed files with 115 additions and 111 deletions

View file

@ -8,16 +8,13 @@ from numpy.typing import ArrayLike
from .ref import Ref
from .ports import PortList, Port
from .utils import rotation_matrix_2d
#if TYPE_CHECKING:
# from .builder import Builder, Tool
# from .library import ILibrary
from .traits import Flippable
logger = logging.getLogger(__name__)
class Abstract(PortList):
class Abstract(PortList, Flippable):
"""
An `Abstract` is a container for a name and associated ports.
@ -131,50 +128,18 @@ class Abstract(PortList):
port.rotate(rotation)
return self
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
Mirror the Abstract across an axis through its origin.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across (0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
for port in self.ports.values():
port.offset[across_axis - 1] *= -1
return self
def mirror_ports(self, across_axis: int = 0) -> Self:
"""
Mirror each port's rotation across an axis, relative to its
offset
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
for port in self.ports.values():
port.mirror(across_axis)
return self
def mirror(self, across_axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis
Args:
axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
self.mirror_ports(across_axis)
self.mirror_port_offsets(across_axis)
port.flip_across(axis=axis)
return self
def apply_ref_transform(self, ref: Ref) -> Self:

View file

@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray
from .repetition import Repetition
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable
from .traits import AnnotatableImpl
@functools.total_ordering
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
"""
A text annotation with a position (but no size; it is not drawn)
"""
@ -102,6 +102,22 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
self.translate(+pivot)
return self
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
"""
Mirror the object across a line.
Args:
axis: Axis to mirror across. 0 mirrors across x=0. 1 mirrors across y=0.
x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across.
Returns:
self
"""
if self.repetition is not None:
self.repetition.flip_across(axis=axis, x=x, y=y)
return self
def get_bounds_single(self) -> NDArray[numpy.float64]:
"""
Return the bounds of the label.

View file

@ -733,50 +733,31 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
cast('Rotatable', entry).rotate(rotation)
return self
def mirror_element_centers(self, across_axis: int = 0) -> Self:
def mirror_elements(self, across_axis: int = 0) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
Mirror each shape, ref, and port relative to (0,0).
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
return self.flip_across(axis=across_axis)
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis through its 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 - across_axis] *= -1
return self
def mirror_elements(self, across_axis: int = 0) -> Self:
"""
Mirror each shape, ref, and pattern across an axis, relative
to its offset
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Mirrorable', entry).mirror(across_axis)
return self
def mirror(self, across_axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
self.mirror_elements(across_axis)
self.mirror_element_centers(across_axis)
cast('Flippable', entry).flip_across(axis=axis)
return self
def copy(self) -> Self:

View file

@ -10,7 +10,7 @@ import numpy
from numpy import pi
from numpy.typing import ArrayLike, NDArray
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, Flippable, FlippableImpl
from .utils import rotate_offsets_around, rotation_matrix_2d
from .error import PortError, format_stacktrace
@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
@functools.total_ordering
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, FlippableImpl):
"""
A point at which a `Device` can be snapped to another `Device`.

View file

@ -16,6 +16,7 @@ from .repetition import Repetition
from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl,
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
Flippable, FlippableImpl,
)
@ -25,7 +26,7 @@ if TYPE_CHECKING:
@functools.total_ordering
class Ref(
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
FlippableImpl, PositionableImpl, RotatableImpl, ScalableImpl,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
):
"""

View file

@ -384,7 +384,6 @@ class Arc(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> 'Arc':
self.offset[1 - axis] *= -1
self.rotation *= -1
self.rotation += axis * pi
self.angles *= -1

View file

@ -189,7 +189,6 @@ class Ellipse(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> Self:
self.offset[1 - axis] *= -1
self.rotation *= -1
self.rotation += axis * pi
return self

View file

@ -6,8 +6,8 @@ import numpy
from numpy.typing import NDArray, ArrayLike
from ..traits import (
Rotatable, Mirrorable, Copyable, Scalable,
Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
Rotatable, Mirrorable, Copyable, Scalable, FlippableImpl,
Positionable, Pivotable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
)
if TYPE_CHECKING:
@ -26,8 +26,9 @@ normalized_shape_tuple = tuple[
DEFAULT_POLY_NUM_VERTICES = 24
class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
class Shape(Positionable, Rotatable, FlippableImpl, Copyable, Scalable,
AnnotatableImpl, RepeatableImpl, PivotableImpl, Pivotable,
metaclass=ABCMeta):
"""
Class specifying functions common to all shapes.
"""

View file

@ -70,6 +70,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: bool = False,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
@ -80,6 +81,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
self._string = string
self._height = height
self._rotation = rotation
self._mirrored = mirrored
self._repetition = repetition
self._annotations = annotations
else:
@ -87,6 +89,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
self.string = string
self.height = height
self.rotation = rotation
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations
self.font_path = font_path

View file

@ -26,7 +26,11 @@ from .scalable import (
Scalable as Scalable,
ScalableImpl as ScalableImpl,
)
from .mirrorable import Mirrorable as Mirrorable
from .mirrorable import (
Mirrorable as Mirrorable,
Flippable as Flippable,
FlippableImpl as FlippableImpl,
)
from .copyable import Copyable as Copyable
from .annotatable import (
Annotatable as Annotatable,

View file

@ -1,6 +1,14 @@
from typing import Self
from typing import Self, Any, TYPE_CHECKING, cast
from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import ArrayLike, NDArray
from ..error import MasqueError
if TYPE_CHECKING:
from .positionable import Positionable
class Mirrorable(metaclass=ABCMeta):
"""
@ -11,10 +19,10 @@ class Mirrorable(metaclass=ABCMeta):
@abstractmethod
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the entity across an axis.
Mirror the entity across an axis through its origin, ignoring its offset.
Args:
axis: Axis to mirror across.
axis: Axis to mirror across (0: x-axis, 1: y-axis).
Returns:
self
@ -23,10 +31,11 @@ class Mirrorable(metaclass=ABCMeta):
def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
"""
Optionally mirror the entity across both axes
Optionally mirror the entity across both axes through its origin.
Args:
axes: (mirror_across_x, mirror_across_y)
across_x: Mirror across x axis (flip y)
across_y: Mirror across y axis (flip x)
Returns:
self
@ -38,30 +47,56 @@ class Mirrorable(metaclass=ABCMeta):
return self
#class MirrorableImpl(Mirrorable, metaclass=ABCMeta):
# """
# Simple implementation of `Mirrorable`
# """
# __slots__ = ()
#
# _mirrored: NDArray[numpy.bool]
# """ Whether to mirror the instance across the x and/or y axes. """
#
# #
# # Properties
# #
# # Mirrored property
# @property
# def mirrored(self) -> NDArray[numpy.bool]:
# """ Whether to mirror across the [x, y] axes, respectively """
# return self._mirrored
#
# @mirrored.setter
# def mirrored(self, val: Sequence[bool]) -> None:
# if is_scalar(val):
# raise MasqueError('Mirrored must be a 2-element list of booleans')
# self._mirrored = numpy.array(val, dtype=bool)
#
# #
# # Methods
# #
class Flippable(metaclass=ABCMeta):
"""
Trait class for entities which can be mirrored relative to an external line.
"""
__slots__ = ()
@staticmethod
def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, float]:
pivot = numpy.zeros(2)
if axis is not None:
if x is not None or y is not None:
raise MasqueError('Cannot specify both axis and x or y')
return axis, pivot
if x is not None:
if y is not None:
raise MasqueError('Cannot specify both x and y')
return 0, pivot + (x, 0)
if y is not None:
return 1, pivot + (0, y)
raise MasqueError('Must specify one of axis, x, or y')
@abstractmethod
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
"""
Mirror the object across a line.
Args:
axis: Axis to mirror across. 0 mirrors across x=0. 1 mirrors across y=0.
x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across.
Returns:
self
"""
pass
class FlippableImpl(Flippable, metaclass=ABCMeta):
"""
Implementation of `Flippable` for objects which are `Mirrorable` and `Positionable`.
"""
__slots__ = ()
offset: NDArray[numpy.float64]
""" `[x_offset, y_offset]` """
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
cast('Positionable', self).translate(-pivot)
cast('Mirrorable', self).mirror(axis)
self.offset[1 - axis] *= -1
cast('Positionable', self).translate(+pivot)
return self