diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 7eb310a..480835e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -10,10 +10,11 @@ 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, annotations_lt, annotations_eq, rep2key +from ..traits import PositionableImpl @functools.total_ordering -class Arc(Shape): +class Arc(PositionableImpl, Shape): """ An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its center. It has a position, two radii, a start and stop angle, a rotation, and a width. diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 0b71198..b20a681 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -10,10 +10,11 @@ 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, annotations_lt, annotations_eq, rep2key +from ..traits import PositionableImpl @functools.total_ordering -class Circle(Shape): +class Circle(PositionableImpl, Shape): """ A circle, which has a position and radius. """ diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 40d67d7..6029f2f 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -11,10 +11,11 @@ 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, annotations_lt, annotations_eq, rep2key +from ..traits import PositionableImpl @functools.total_ordering -class Ellipse(Shape): +class Ellipse(PositionableImpl, Shape): """ An ellipse, which has a position, two radii, and a rotation. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 48f3776..b9d2d4d 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -1,4 +1,4 @@ -from typing import Any, cast +from typing import Any, cast, Self from collections.abc import Sequence import copy import functools @@ -30,8 +30,7 @@ class PathCap(Enum): @functools.total_ordering class Path(Shape): """ - A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, - and an offset. + A path, consisting of a bunch of vertices (Nx2 ndarray), a width, and an end-cap shape. Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates. @@ -40,7 +39,7 @@ class Path(Shape): __slots__ = ( '_vertices', '_width', '_cap', '_cap_extensions', # Inherited - '_offset', '_repetition', '_annotations', + '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] _width: float @@ -160,6 +159,28 @@ class Path(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val + # Offset property for `Positionable` + @property + def offset(self) -> NDArray[numpy.float64]: + """ + [x, y] offset + """ + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + self._vertices += numpy.atleast_2d(offset) + return self + def __init__( self, vertices: ArrayLike, @@ -177,10 +198,8 @@ class Path(Shape): if raw: assert isinstance(vertices, numpy.ndarray) - assert isinstance(offset, numpy.ndarray) assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None self._vertices = vertices - self._offset = offset self._repetition = repetition self._annotations = annotations self._width = width @@ -188,18 +207,19 @@ class Path(Shape): self._cap_extensions = cap_extensions else: self.vertices = vertices - self.offset = offset self.repetition = repetition self.annotations = annotations self.width = width self.cap = cap self.cap_extensions = cap_extensions - self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) + if rotation: + self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> 'Path': memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() new._vertices = self._vertices.copy() new._cap = copy.deepcopy(self._cap, memo) new._cap_extensions = copy.deepcopy(self._cap_extensions, memo) @@ -209,7 +229,6 @@ class Path(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is 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 @@ -234,8 +253,6 @@ class Path(Shape): 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) @@ -292,7 +309,7 @@ class Path(Shape): if self.width == 0: verts = numpy.vstack((v, v[::-1])) - return [Polygon(offset=self.offset, vertices=verts)] + return [Polygon(vertices=verts)] perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2 @@ -343,7 +360,7 @@ class Path(Shape): o1.append(v[-1] - perp[-1]) verts = numpy.vstack((o0, o1[::-1])) - polys = [Polygon(offset=self.offset, vertices=verts)] + polys = [Polygon(vertices=verts)] if self.cap == PathCap.Circle: #for vert in v: # not sure if every vertex, or just ends? @@ -355,8 +372,8 @@ class Path(Shape): def get_bounds_single(self) -> NDArray[numpy.float64]: if self.cap == PathCap.Circle: - bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, - numpy.max(self.vertices, axis=0) + self.width / 2)) + bounds = numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, + numpy.max(self.vertices, axis=0) + self.width / 2)) elif self.cap in ( PathCap.Flush, PathCap.Square, @@ -390,7 +407,7 @@ class Path(Shape): def normalized_form(self, norm_value: float) -> normalized_shape_tuple: # Note: this function is going to be pretty slow for many-vertexed paths, relative to # other shapes - offset = self.vertices.mean(axis=0) + self.offset + offset = self.vertices.mean(axis=0) zeroed_vertices = self.vertices - offset scale = zeroed_vertices.std() @@ -460,5 +477,5 @@ class Path(Shape): return extensions def __repr__(self) -> str: - centroid = self.offset + self.vertices.mean(axis=0) + centroid = self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 0369fd4..e37d417 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -10,6 +10,7 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, normalized_shape_tuple from .polygon import Polygon +from ..error import PatternError from ..repetition import Repetition from ..utils import rotation_matrix_2d, annotations_lt, annotations_eq, rep2key, annotations_t @@ -27,7 +28,7 @@ class PolyCollection(Shape): '_vertex_lists', '_vertex_offsets', # Inherited - '_offset', '_repetition', '_annotations', + '_repetition', '_annotations', ) _vertex_lists: NDArray[numpy.float64] @@ -67,6 +68,27 @@ class PolyCollection(Shape): for slc in self.vertex_slices: yield self._vertex_lists[slc] + # Offset property for `Positionable` + @property + def offset(self) -> NDArray[numpy.float64]: + """ + [x, y] offset + """ + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + raise PatternError('PolyCollection offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + self._vertex_lists += numpy.atleast_2d(offset) + return self + def __init__( self, vertex_lists: ArrayLike, @@ -81,25 +103,23 @@ class PolyCollection(Shape): if raw: assert isinstance(vertex_lists, numpy.ndarray) assert isinstance(vertex_offsets, numpy.ndarray) - assert isinstance(offset, numpy.ndarray) self._vertex_lists = vertex_lists self._vertex_offsets = vertex_offsets - self._offset = offset self._repetition = repetition self._annotations = annotations else: self._vertex_lists = numpy.asarray(vertex_lists, dtype=float) self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) - self.offset = offset self.repetition = repetition self.annotations = annotations + if numpy.any(offset): + self.translate(offset) if rotation: self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() new._vertex_lists = self._vertex_lists.copy() new._vertex_offsets = self._vertex_offsets.copy() new._annotations = copy.deepcopy(self._annotations) @@ -108,7 +128,6 @@ class PolyCollection(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is type(other) - and numpy.array_equal(self.offset, other.offset) and numpy.array_equal(self._vertex_lists, other._vertex_lists) and numpy.array_equal(self._vertex_offsets, other._vertex_offsets) and self.repetition == other.repetition @@ -134,8 +153,6 @@ class PolyCollection(Shape): return vv.shape[0] < oo.shape[0] if len(self.vertex_lists) != len(other.vertex_lists): return len(self.vertex_lists) < len(other.vertex_lists) - 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) @@ -147,14 +164,13 @@ class PolyCollection(Shape): ) -> list['Polygon']: return [Polygon( vertices = vv, - offset = self.offset, repetition = copy.deepcopy(self.repetition), annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition - return numpy.vstack((self.offset + numpy.min(self._vertex_lists, axis=0), - self.offset + numpy.max(self._vertex_lists, axis=0))) + return numpy.vstack((numpy.min(self._vertex_lists, axis=0), + numpy.max(self._vertex_lists, axis=0))) def rotate(self, theta: float) -> Self: if theta != 0: @@ -175,7 +191,7 @@ class PolyCollection(Shape): # other shapes meanv = self._vertex_lists.mean(axis=0) zeroed_vertices = self._vertex_lists - [meanv] - offset = meanv + self.offset + offset = meanv scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale @@ -203,5 +219,5 @@ class PolyCollection(Shape): ) def __repr__(self) -> str: - centroid = self.offset + self.vertex_lists.mean(axis=0) + centroid = self.vertex_lists.mean(axis=0) return f'' diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 1f1183e..6b27606 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING +from typing import Any, cast, TYPE_CHECKING, Self import copy import functools @@ -20,7 +20,7 @@ if TYPE_CHECKING: class Polygon(Shape): """ A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an - implicitly-closed boundary, and an offset. + implicitly-closed boundary. Note that the setter for `Polygon.vertices` creates a copy of the passed vertex coordinates. @@ -30,7 +30,7 @@ class Polygon(Shape): __slots__ = ( '_vertices', # Inherited - '_offset', '_repetition', '_annotations', + '_repetition', '_annotations', ) _vertices: NDArray[numpy.float64] @@ -85,6 +85,28 @@ class Polygon(Shape): raise PatternError('Wrong number of vertices') self.vertices[:, 1] = val + # Offset property for `Positionable` + @property + def offset(self) -> NDArray[numpy.float64]: + """ + [x, y] offset + """ + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('Path offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + self._vertices += numpy.atleast_2d(offset) + return self + def __init__( self, vertices: ArrayLike, @@ -99,21 +121,20 @@ class Polygon(Shape): assert isinstance(vertices, numpy.ndarray) assert isinstance(offset, numpy.ndarray) self._vertices = vertices - self._offset = offset self._repetition = repetition self._annotations = annotations else: self.vertices = vertices - self.offset = offset self.repetition = repetition self.annotations = annotations + if numpy.any(offset): + self.translate(offset) if rotation: self.rotate(rotation) def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo new = copy.copy(self) - new._offset = self._offset.copy() new._vertices = self._vertices.copy() new._annotations = copy.deepcopy(self._annotations) return new @@ -121,7 +142,6 @@ class Polygon(Shape): def __eq__(self, other: Any) -> bool: return ( type(self) is 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) @@ -141,8 +161,6 @@ class Polygon(Shape): if eq_lt_masked.size > 0: return eq_lt_masked.flat[0] return self.vertices.shape[0] < other.vertices.shape[0] - 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) @@ -363,8 +381,8 @@ class Polygon(Shape): return [copy.deepcopy(self)] def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition - return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0), - self.offset + numpy.max(self.vertices, axis=0))) + return numpy.vstack((numpy.min(self.vertices, axis=0), + numpy.max(self.vertices, axis=0))) def rotate(self, theta: float) -> 'Polygon': if theta != 0: @@ -384,7 +402,7 @@ class Polygon(Shape): # other shapes meanv = self.vertices.mean(axis=0) zeroed_vertices = self.vertices - meanv - offset = meanv + self.offset + offset = meanv scale = zeroed_vertices.std() normed_vertices = zeroed_vertices / scale @@ -438,5 +456,5 @@ class Polygon(Shape): return self def __repr__(self) -> str: - centroid = self.offset + self.vertices.mean(axis=0) + centroid = self.vertices.mean(axis=0) return f'' diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 0a7c86d..90bca2b 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -7,7 +7,7 @@ from numpy.typing import NDArray, ArrayLike from ..traits import ( Rotatable, Mirrorable, Copyable, Scalable, - PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,7 +26,7 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, +class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. @@ -134,7 +134,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, mins, maxs = bounds vertex_lists = [] - p_verts = polygon.vertices + polygon.offset + p_verts = polygon.vertices for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True): dv = v_next - v @@ -282,7 +282,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, offset = (numpy.where(keep_x)[0][0], numpy.where(keep_y)[0][0]) - rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy) + rastered = float_raster.raster((polygon.vertices).T, gx, gy) binary_rastered = (numpy.abs(rastered) >= 0.5) supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index e30fe1c..78632f6 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -9,7 +9,7 @@ from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple from ..error import PatternError from ..repetition import Repetition -from ..traits import RotatableImpl +from ..traits import PositionableImpl, RotatableImpl from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool # Loaded on use: @@ -18,7 +18,7 @@ from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotatio @functools.total_ordering -class Text(RotatableImpl, Shape): +class Text(PositionableImpl, RotatableImpl, Shape): """ Text (to be printed e.g. as a set of polygons). This is distinct from non-printed Label objects.