diff --git a/masque/abstract.py b/masque/abstract.py index 7135eba..1266ae6 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -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: diff --git a/masque/label.py b/masque/label.py index 89f0384..3f4f80e 100644 --- a/masque/label.py +++ b/masque/label.py @@ -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. diff --git a/masque/pattern.py b/masque/pattern.py index dc7d058..42f9ab4 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -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: diff --git a/masque/ports.py b/masque/ports.py index 0211723..c0f7ddc 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -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`. diff --git a/masque/ref.py b/masque/ref.py index b3a684c..75a27a8 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -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, ): """ diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 79411d6..9ae06ce 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -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 diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 6699e53..8e3fd49 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -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 diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 90bca2b..1cf2846 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -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. """ diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 78632f6..87293fc 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -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 diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py index 7c7360c..cca38f3 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -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, diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 6d4ec3c..fffa9c8 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -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