diff --git a/masque/label.py b/masque/label.py index 0c250cc..7eb9068 100644 --- a/masque/label.py +++ b/masque/label.py @@ -1,15 +1,17 @@ -from typing import Self +from typing import Self, Any import copy +import functools import numpy from numpy.typing import ArrayLike, NDArray from .repetition import Repetition -from .utils import rotation_matrix_2d, annotations_t +from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded from .traits import AnnotatableImpl +@functools.total_ordering class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): """ A text annotation with a position (but no size; it is not drawn) @@ -64,6 +66,23 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl new._offset = self._offset.copy() return new + def __lt__(self, other: 'Label') -> bool: + if self.string != other.string: + return self.string < other.string + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + + def __eq__(self, other: Any) -> bool: + return ( + self.string == other.string + and numpy.array_equal(self.offset, other.offset) + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ Rotate the label around a point. diff --git a/masque/pattern.py b/masque/pattern.py index 0d98164..ff05c27 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -5,6 +5,7 @@ from typing import Callable, Sequence, cast, Mapping, Self, Any, Iterable, TypeVar, MutableMapping import copy import logging +import functools from itertools import chain from collections import defaultdict @@ -17,7 +18,8 @@ from .ref import Ref from .abstract import Abstract from .shapes import Shape, Polygon, Path, DEFAULT_POLY_NUM_VERTICES from .label import Label -from .utils import rotation_matrix_2d, annotations_t, layer_t +from .utils import rotation_matrix_2d, annotations_t, layer_t, annotations_eq, annotations_lt, layer2key +from .utils import ports_eq, ports_lt from .error import PatternError, PortError from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded from .ports import Port, PortList @@ -26,6 +28,7 @@ from .ports import Port, PortList logger = logging.getLogger(__name__) +@functools.total_ordering class Pattern(PortList, AnnotatableImpl, Mirrorable): """ 2D layout consisting of some set of shapes, labels, and references to other @@ -192,6 +195,108 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # ) # return new + def __lt__(self, other: 'Pattern') -> bool: + self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] + self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) + other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) + + if self_tgtkeys != other_tgtkeys: + return self_tgtkeys < other_tgtkeys + + for _, target in self_tgtkeys: + refs_ours = tuple(sorted(self.refs[target])) + refs_theirs = tuple(sorted(other.refs[target])) + if refs_ours != refs_theirs: + return refs_ours < refs_theirs + + self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] + self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) + other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) + + if self_layerkeys != other_layerkeys: + return self_layerkeys < other_layerkeys + + for _, _, layer in self_layerkeys: + shapes_ours = tuple(sorted(self.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) + if shapes_ours != shapes_theirs: + return shapes_ours < shapes_theirs + + self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] + self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) + other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) + + if self_txtlayerkeys != other_txtlayerkeys: + return self_txtlayerkeys < other_txtlayerkeys + + for _, _, layer in self_layerkeys: + labels_ours = tuple(sorted(self.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) + if labels_ours != labels_theirs: + return labels_ours < labels_theirs + + if not annotations_eq(self.annotations, other.annotations): + return annotations_lt(self.annotations, other.annotations) + + if not ports_eq(self.ports, other.ports): + return ports_lt(self.ports, other.ports) + + return False + + def __eq__(self, other: Any) -> bool: + self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] + self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) + other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) + + if self_tgtkeys != other_tgtkeys: + return False + + for _, target in self_tgtkeys: + refs_ours = tuple(sorted(self.refs[target])) + refs_theirs = tuple(sorted(other.refs[target])) + if refs_ours != refs_theirs: + return False + + self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] + self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) + other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) + + if self_layerkeys != other_layerkeys: + return False + + for _, _, layer in self_layerkeys: + shapes_ours = tuple(sorted(self.shapes[layer])) + shapes_theirs = tuple(sorted(self.shapes[layer])) + if shapes_ours != shapes_theirs: + return False + + self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] + self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) + other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) + + if self_txtlayerkeys != other_txtlayerkeys: + return False + + for _, _, layer in self_layerkeys: + labels_ours = tuple(sorted(self.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) + if labels_ours != labels_theirs: + return False + + if not annotations_eq(self.annotations, other.annotations): + return False + + if not ports_eq(self.ports, other.ports): + return False + + return True + def append(self, other_pattern: 'Pattern') -> Self: """ Appends all shapes, labels and refs from other_pattern to self's shapes, diff --git a/masque/ports.py b/masque/ports.py index 41e6418..9cc4e45 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -1,7 +1,8 @@ -from typing import Iterable, KeysView, ValuesView, overload, Self, Mapping, NoReturn +from typing import Iterable, KeysView, ValuesView, overload, Self, Mapping, NoReturn, Any import warnings import traceback import logging +import functools from collections import Counter from abc import ABCMeta, abstractmethod from itertools import chain @@ -18,6 +19,7 @@ from .error import PortError logger = logging.getLogger(__name__) +@functools.total_ordering class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): """ A point at which a `Device` can be snapped to another `Device`. @@ -118,6 +120,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): rot = str(numpy.rad2deg(self.rotation)) return f'<{self.offset}, {rot}, [{self.ptype}]>' + def __lt__(self, other: 'Port') -> bool: + if self.ptype != other.ptype: + return self.ptype < other.ptype + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.rotation != other.rotation: + if self.rotation is None: + return True + if other.rotation is None: + return False + return self.rotation < other.rotation + return False + + def __eq__(self, other: Any) -> bool: + return ( + type(self) == type(other) + and self.ptype == other.ptype + and numpy.array_equal(self.offset, other.offset) + and self.rotation == other.rotation + ) + class PortList(metaclass=ABCMeta): __slots__ = () # Allow subclasses to use __slots__ diff --git a/masque/ref.py b/masque/ref.py index d0b7dd4..9c9c519 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -2,14 +2,15 @@ Ref provides basic support for nesting Pattern objects within each other. It carries offset, rotation, mirroring, and scaling data for each individual instance. """ -from typing import Mapping, TYPE_CHECKING, Self +from typing import Mapping, TYPE_CHECKING, Self, Any import copy +import functools import numpy from numpy import pi from numpy.typing import NDArray, ArrayLike -from .utils import annotations_t, rotation_matrix_2d +from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, @@ -21,6 +22,7 @@ if TYPE_CHECKING: from . import Pattern +@functools.total_ordering class Ref( PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, @@ -99,6 +101,29 @@ class Ref( #new.annotations = copy.deepcopy(self.annotations, memo) return new + def __lt__(self, other: 'Ref') -> bool: + if (self.offset != other.offset).any(): + return tuple(self.offset) < tuple(other.offset) + if self.mirrored != other.mirrored: + return self.mirrored < other.mirrored + if self.rotation != other.rotation: + return self.rotation < other.rotation + if self.scale != other.scale: + return self.scale < other.scale + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + + def __eq__(self, other: Any) -> bool: + return ( + numpy.array_equal(self.offset, other.offset) + and self.mirrored == other.mirrored + and self.rotation == other.rotation + and self.scale == other.scale + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + def as_pattern( self, pattern: 'Pattern', diff --git a/masque/repetition.py b/masque/repetition.py index 685d815..30e27b0 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -2,8 +2,9 @@ Repetitions provide support for efficiently representing multiple identical instances of an object . """ -from typing import Any, Type, Self, TypeVar +from typing import Any, Type, Self, TypeVar, cast import copy +import functools from abc import ABCMeta, abstractmethod import numpy @@ -17,6 +18,7 @@ from .utils import rotation_matrix_2d GG = TypeVar('GG', bound='Grid') +@functools.total_ordering class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=ABCMeta): """ Interface common to all objects which specify repetitions @@ -31,6 +33,14 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A """ pass + @abstractmethod + def __le__(self, other: 'Repetition') -> bool: + pass + + @abstractmethod + def __eq__(self, other: Any) -> bool: + pass + class Grid(Repetition): """ @@ -270,7 +280,7 @@ class Grid(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if not isinstance(other, type(self)): + if type(other) != type(self): return False if self.a_count != other.a_count or self.b_count != other.b_count: return False @@ -284,6 +294,24 @@ class Grid(Repetition): return False return True + def __le__(self, other: Repetition) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Grid, other) + if self.a_count != other.a_count: + return self.a_count < other.a_count + if self.b_count != other.b_count: + return self.b_count < other.b_count + if not numpy.array_equal(self.a_vector, other.a_vector): + return tuple(self.a_vector) < tuple(other.a_vector) + if self.b_vector is None: + return other.b_vector is not None + if other.b_vector is None: + return False + if not numpy.array_equal(self.b_vector, other.b_vector): + return tuple(self.a_vector) < tuple(other.a_vector) + return False + class Arbitrary(Repetition): """ @@ -325,10 +353,23 @@ class Arbitrary(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if not isinstance(other, type(self)): + if not type(other) != type(self): return False return numpy.array_equal(self.displacements, other.displacements) + def __le__(self, other: Repetition) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Arbitrary, other) + if self.displacements.size != other.displacements.size: + return self.displacements.size < other.displacements.size + + neq = (self.displacements != other.displacements) + if neq.any(): + return self.displacements[neq][0] < other.displacements[neq][0] + + return False + def rotate(self, rotation: float) -> Self: """ Rotate dispacements (around (0, 0)) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index b69d9b5..60c077f 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -1,5 +1,6 @@ -from typing import Any +from typing import Any, cast import copy +import functools import numpy from numpy import pi @@ -8,9 +9,10 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition -from ..utils import is_scalar, annotations_t +from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key +@functools.total_ordering class Arc(Shape): """ An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its @@ -187,6 +189,36 @@ class Arc(Shape): new._annotations = copy.deepcopy(self._annotations) return new + def __eq__(self, other: Any) -> bool: + return ( + type(self) != type(other) + and numpy.array_equal(self.offset, other.offset) + and numpy.array_equal(self.radii, other.radii) + and numpy.array_equal(self.angles, other.angles) + and self.width == other.width + and self.rotation == other.rotation + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Arc, other) + if self.width != other.width: + return self.width < other.width + if not numpy.array_equal(self.radii, other.radii): + return tuple(self.radii) < tuple(other.radii) + if not numpy.array_equal(self.angles, other.angles): + return tuple(self.angles) < tuple(other.angles) + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.rotation != other.rotation: + return self.rotation < other.rotation + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + def to_polygons( self, num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES, diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 705c2d4..f03f9f3 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -1,4 +1,6 @@ +from typing import Any, cast import copy +import functools import numpy from numpy import pi @@ -7,9 +9,10 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition -from ..utils import is_scalar, annotations_t +from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key +@functools.total_ordering class Circle(Shape): """ A circle, which has a position and radius. @@ -67,6 +70,27 @@ class Circle(Shape): new._annotations = copy.deepcopy(self._annotations) return new + def __eq__(self, other: Any) -> bool: + return ( + type(self) != type(other) + and numpy.array_equal(self.offset, other.offset) + and self.radius == other.radius + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Circle, other) + if not self.radius == other.radius: + return self.radius < other.radius + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + def to_polygons( self, num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES, diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 1062061..a0c7a92 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -1,6 +1,7 @@ -from typing import Any, Self +from typing import Any, Self, cast import copy import math +import functools import numpy from numpy import pi @@ -9,9 +10,10 @@ from numpy.typing import ArrayLike, NDArray from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from ..error import PatternError from ..repetition import Repetition -from ..utils import is_scalar, rotation_matrix_2d, annotations_t +from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key +@functools.total_ordering class Ellipse(Shape): """ An ellipse, which has a position, two radii, and a rotation. @@ -117,6 +119,30 @@ class Ellipse(Shape): new._annotations = copy.deepcopy(self._annotations) return new + def __eq__(self, other: Any) -> bool: + return ( + type(self) != type(other) + and numpy.array_equal(self.offset, other.offset) + and numpy.array_equal(self.radii, other.radii) + and self.rotation == other.rotation + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Ellipse, other) + if not numpy.array_equal(self.radii, other.radii): + return tuple(self.radii) < tuple(other.radii) + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.rotation != other.rotation: + return self.rotation < other.rotation + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + def to_polygons( self, num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES, diff --git a/masque/shapes/path.py b/masque/shapes/path.py index abb4973..5fc5a2a 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -1,5 +1,6 @@ from typing import Sequence, Any, cast import copy +import functools from enum import Enum import numpy @@ -9,10 +10,11 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, normalized_shape_tuple, Polygon, Circle from ..error import PatternError from ..repetition import Repetition -from ..utils import is_scalar, rotation_matrix_2d +from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t +@functools.total_ordering class PathCap(Enum): Flush = 0 # Path ends at final vertices Circle = 1 # Path extends past final vertices with a semicircle of radius width/2 @@ -20,7 +22,11 @@ class PathCap(Enum): SquareCustom = 4 # Path extends past final vertices with a rectangle of length # # defined by path.cap_extensions + def __lt__(self, other: Any) -> bool: + return self.value == other.value + +@functools.total_ordering class Path(Shape): """ A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, @@ -201,6 +207,38 @@ class Path(Shape): new._annotations = copy.deepcopy(self._annotations) return new + def __eq__(self, other: Any) -> bool: + return ( + type(self) != type(other) + and numpy.array_equal(self.offset, other.offset) + and numpy.array_equal(self.vertices, other.vertices) + and self.width == other.width + and self.cap == other.cap + and numpy.array_equal(self.cap_extensions, other.cap_extensions) # type: ignore + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Path, other) + if self.width != other.width: + return self.width < other.width + if self.cap != other.cap: + return self.cap < other.cap + if not numpy.array_equal(self.cap_extensions, other.cap_extensions): # type: ignore + if other.cap_extensions is None: + return False + if self.cap_extensions is None: + return True + return tuple(self.cap_extensions) < tuple(other.cap_extensions) + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + @staticmethod def travel( travel_pairs: Sequence[tuple[float, float]], diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index ef1ba35..508f867 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,5 +1,6 @@ from typing import Sequence, Any, cast import copy +import functools import numpy from numpy import pi @@ -8,10 +9,11 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition -from ..utils import is_scalar, rotation_matrix_2d +from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t +@functools.total_ordering class Polygon(Shape): """ A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an @@ -113,6 +115,27 @@ class Polygon(Shape): new._annotations = copy.deepcopy(self._annotations) return new + def __eq__(self, other: Any) -> bool: + return ( + type(self) != type(other) + and numpy.array_equal(self.offset, other.offset) + and numpy.array_equal(self.vertices, other.vertices) + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Polygon, other) + if not numpy.array_equal(self.vertices, other.vertices): + return tuple(tuple(xy) for xy in self.vertices) < tuple(tuple(xy) for xy in other.vertices) + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + @staticmethod def square( side_length: float, diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 4263531..a1c4bcd 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -42,6 +42,14 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, # # Methods (abstract) # + @abstractmethod + def __eq__(self, other: Any) -> bool: + pass + + @abstractmethod + def __lt__(self, other: 'Shape') -> bool: + pass + @abstractmethod def to_polygons( self, diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 017bdc1..fc2e05c 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -1,5 +1,6 @@ -from typing import Self +from typing import Self, Any, cast import copy +import functools import numpy from numpy import pi, nan @@ -9,13 +10,14 @@ from . import Shape, Polygon, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition from ..traits import RotatableImpl -from ..utils import is_scalar, get_bit, annotations_t +from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key # Loaded on use: # from freetype import Face # from matplotlib.path import Path +@functools.total_ordering class Text(RotatableImpl, Shape): """ Text (to be printed e.g. as a set of polygons). @@ -96,6 +98,36 @@ class Text(RotatableImpl, Shape): new._annotations = copy.deepcopy(self._annotations) return new + def __eq__(self, other: Any) -> bool: + return ( + type(self) != type(other) + and numpy.array_equal(self.offset, other.offset) + and self.string == other.string + and self.height == other.height + and self.font_path == other.font_path + and self.rotation == other.rotation + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) != type(other): + return repr(type(self)) < repr(type(other)) + other = cast(Text, other) + if not self.height == other.height: + return self.height < other.height + if not self.string == other.string: + return self.string < other.string + if not self.font_path == other.font_path: + return self.font_path < other.font_path + if not numpy.array_equal(self.offset, other.offset): + return tuple(self.offset) < tuple(other.offset) + if self.rotation != other.rotation: + return self.rotation < other.rotation + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + def to_polygons( self, num_vertices: int | None = None, # unused diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index f4a7805..571c406 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -12,6 +12,7 @@ from .vertices import ( remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points ) from .transform import rotation_matrix_2d, normalize_mirror, rotate_offsets_around +from .comparisons import annotation2key, annotations_lt, annotations_eq, layer2key, ports_lt, ports_eq, rep2key from . import ports2data diff --git a/masque/utils/comparisons.py b/masque/utils/comparisons.py new file mode 100644 index 0000000..85910a4 --- /dev/null +++ b/masque/utils/comparisons.py @@ -0,0 +1,106 @@ +from typing import Any + +from .types import annotations_t, layer_t +from ..import Port +from ..repetition import Repetition + + +def annotation2key(aaa: int | float | str) -> tuple[bool, Any]: + return (isinstance(aaa, str), aaa) + + +def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool: + if aa is None: + return bb is not None + elif bb is None: + return False + + if len(aa) != len(bb): + return len(aa) < len(bb) + + keys_a = tuple(sorted(aa.keys())) + keys_b = tuple(sorted(bb.keys())) + if keys_a != keys_b: + return keys_a < keys_b + + for key in keys_a: + va = aa[key] + vb = bb[key] + if len(va) != len(vb): + return len(va) < len(vb) + + for aaa, bbb in zip(va, vb): + if aaa != bbb: + return annotation2key(aaa) < annotation2key(bbb) + return False + + +def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool: + if aa is None: + return bb is None + elif bb is None: + return False + + if len(aa) != len(bb): + return False + + keys_a = tuple(sorted(aa.keys())) + keys_b = tuple(sorted(bb.keys())) + if keys_a != keys_b: + return keys_a < keys_b + + for key in keys_a: + va = aa[key] + vb = bb[key] + if len(va) != len(vb): + return False + + for aaa, bbb in zip(va, vb): + if aaa != bbb: + return False + + return True + + +def layer2key(layer: layer_t) -> tuple[bool, bool, Any]: + is_int = isinstance(layer, int) + is_str = isinstance(layer, str) + layer_tup = (layer) if (is_str or is_int) else layer + tup = ( + is_str, + not is_int, + layer_tup, + ) + return tup + + +def rep2key(repetition: Repetition | None) -> tuple[bool, Repetition | None]: + return (repetition is None, repetition) + + +def ports_eq(aa: dict[str, Port], bb: dict[str, Port]) -> bool: + if len(aa) != len(bb): + return False + + keys = sorted(aa.keys()) + if keys != sorted(bb.keys()): + return False + + return all(aa[kk] == bb[kk] for kk in keys) + + +def ports_lt(aa: dict[str, Port], bb: dict[str, Port]) -> bool: + if len(aa) != len(bb): + return len(aa) < len(bb) + + aa_keys = tuple(sorted(aa.keys())) + bb_keys = tuple(sorted(bb.keys())) + if aa_keys != bb_keys: + return aa_keys < bb_keys + + for key in aa_keys: + pa = aa[key] + pb = bb[key] + if pa != pb: + return pa < pb + return False