From ec7803156508b4ff1f3e9a869fa139f83b4d82e3 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 2 Apr 2026 19:42:17 -0700 Subject: [PATCH] [RectCollection] add a RectCollection shape --- masque/__init__.py | 1 + masque/file/gdsii.py | 16 +- masque/shapes/__init__.py | 1 + masque/shapes/rect_collection.py | 256 ++++++++++++++++++++++++++++ masque/test/test_rect_collection.py | 70 ++++++++ 5 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 masque/shapes/rect_collection.py create mode 100644 masque/test/test_rect_collection.py diff --git a/masque/__init__.py b/masque/__init__.py index 8e13bb9..b6cc5e9 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -42,6 +42,7 @@ from .error import ( from .shapes import ( Shape as Shape, Polygon as Polygon, + RectCollection as RectCollection, Path as Path, Circle as Circle, Arc as Arc, diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 116fa07..323dabd 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -37,7 +37,7 @@ from klamath import records from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape -from ..shapes import Polygon, Path +from ..shapes import Polygon, Path, RectCollection from ..repetition import Grid from ..utils import layer_t, annotations_t from ..library import LazyLibrary, Library, ILibrary, ILibraryView @@ -466,6 +466,20 @@ def _shapes_to_elements( properties=properties, ) elements.append(path) + elif isinstance(shape, RectCollection): + for rect in shape.rects: + xy_closed = numpy.empty((5, 2), dtype=numpy.int32) + xy_closed[0] = rint_cast((rect[0], rect[1])) + xy_closed[1] = rint_cast((rect[0], rect[3])) + xy_closed[2] = rint_cast((rect[2], rect[3])) + xy_closed[3] = rint_cast((rect[2], rect[1])) + xy_closed[4] = xy_closed[0] + boundary = klamath.elements.Boundary( + layer=(layer, data_type), + xy=xy_closed, + properties=properties, + ) + elements.append(boundary) elif isinstance(shape, Polygon): polygon = shape xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index fd66c59..ac3a14b 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -11,6 +11,7 @@ from .shape import ( from .polygon import Polygon as Polygon from .poly_collection import PolyCollection as PolyCollection +from .rect_collection import RectCollection as RectCollection from .circle import Circle as Circle from .ellipse import Ellipse as Ellipse from .arc import Arc as Arc diff --git a/masque/shapes/rect_collection.py b/masque/shapes/rect_collection.py new file mode 100644 index 0000000..96becfd --- /dev/null +++ b/masque/shapes/rect_collection.py @@ -0,0 +1,256 @@ +from typing import Any, cast, Self +from collections.abc import Iterator +import copy +import functools + +import numpy +from numpy import pi +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 annotations_lt, annotations_eq, rep2key, annotations_t + + +def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]: + arr = numpy.asarray(rects, dtype=float) + if arr.ndim != 2 or arr.shape[1] != 4: + raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]') + if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]): + raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax') + if arr.shape[0] <= 1: + return arr + order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0])) + return arr[order] + + +def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None: + x0 = numpy.minimum(rects[:, 0], rects[:, 2]) + x1 = numpy.maximum(rects[:, 0], rects[:, 2]) + y0 = numpy.minimum(rects[:, 1], rects[:, 3]) + y1 = numpy.maximum(rects[:, 1], rects[:, 3]) + rects[:, 0] = x0 + rects[:, 1] = y0 + rects[:, 2] = x1 + rects[:, 3] = y1 + + +@functools.total_ordering +class RectCollection(Shape): + """ + A collection of axis-aligned rectangles, stored as an Nx4 array of + `[xmin, ymin, xmax, ymax]` rows. + """ + __slots__ = ( + '_rects', + '_repetition', '_annotations', + ) + + _rects: NDArray[numpy.float64] + + @property + def rects(self) -> NDArray[numpy.float64]: + return self._rects + + @rects.setter + def rects(self, val: ArrayLike) -> None: + self._rects = _normalize_rects(val) + + @property + def offset(self) -> NDArray[numpy.float64]: + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + if numpy.any(val): + raise PatternError('RectCollection offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('RectCollection offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + delta = numpy.asarray(offset, dtype=float).reshape(2) + self._rects[:, [0, 2]] += delta[0] + self._rects[:, [1, 3]] += delta[1] + return self + + def __init__( + self, + rects: ArrayLike, + *, + offset: ArrayLike = (0.0, 0.0), + rotation: float = 0.0, + repetition: Repetition | None = None, + annotations: annotations_t = None, + raw: bool = False, + ) -> None: + if raw: + assert isinstance(rects, numpy.ndarray) + self._rects = rects + self._repetition = repetition + self._annotations = annotations + else: + self.rects = rects + self.repetition = repetition + self.annotations = annotations + if rotation: + self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) + + @classmethod + def _from_raw( + cls, + *, + rects: NDArray[numpy.float64], + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._rects = rects + new._repetition = repetition + new._annotations = annotations + return new + + @property + def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: + for rect in self._rects: + xmin, ymin, xmax, ymax = rect + yield numpy.array([ + [xmin, ymin], + [xmin, ymax], + [xmax, ymax], + [xmax, ymin], + ], dtype=float) + + def __deepcopy__(self, memo: dict | None = None) -> Self: + memo = {} if memo is None else memo + new = copy.copy(self) + new._rects = self._rects.copy() + new._repetition = copy.deepcopy(self._repetition, memo) + new._annotations = copy.deepcopy(self._annotations) + return new + + def _sorted_rects(self) -> NDArray[numpy.float64]: + if self._rects.shape[0] <= 1: + return self._rects + order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0])) + return self._rects[order] + + def __eq__(self, other: Any) -> bool: + return ( + type(self) is type(other) + and numpy.array_equal(self._sorted_rects(), other._sorted_rects()) + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) is not type(other): + if repr(type(self)) != repr(type(other)): + return repr(type(self)) < repr(type(other)) + return id(type(self)) < id(type(other)) + + other = cast('RectCollection', other) + self_rects = self._sorted_rects() + other_rects = other._sorted_rects() + if not numpy.array_equal(self_rects, other_rects): + min_len = min(self_rects.shape[0], other_rects.shape[0]) + eq_mask = self_rects[:min_len] != other_rects[:min_len] + eq_lt = self_rects[:min_len] < other_rects[:min_len] + eq_lt_masked = eq_lt[eq_mask] + if eq_lt_masked.size > 0: + return bool(eq_lt_masked.flat[0]) + return self_rects.shape[0] < other_rects.shape[0] + 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 # noqa: ARG002 + max_arclen: float | None = None, # unused # noqa: ARG002 + ) -> list[Polygon]: + return [ + Polygon( + vertices=vertices, + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), + ) + for vertices in self.polygon_vertices + ] + + def get_bounds_single(self) -> NDArray[numpy.float64] | None: + if self._rects.size == 0: + return None + mins = self._rects[:, :2].min(axis=0) + maxs = self._rects[:, 2:].max(axis=0) + return numpy.vstack((mins, maxs)) + + def rotate(self, theta: float) -> Self: + quarter_turns = int(numpy.rint(theta / (pi / 2))) + if not numpy.isclose(theta, quarter_turns * (pi / 2)): + raise PatternError('RectCollection only supports Manhattan rotations') + turns = quarter_turns % 4 + if turns == 0 or self._rects.size == 0: + return self + + corners = numpy.stack(( + self._rects[:, [0, 1]], + self._rects[:, [0, 3]], + self._rects[:, [2, 3]], + self._rects[:, [2, 1]], + ), axis=1) + flat = corners.reshape(-1, 2) + if turns == 1: + rotated = numpy.column_stack((-flat[:, 1], flat[:, 0])) + elif turns == 2: + rotated = -flat + else: + rotated = numpy.column_stack((flat[:, 1], -flat[:, 0])) + corners = rotated.reshape(corners.shape) + self._rects[:, 0] = corners[:, :, 0].min(axis=1) + self._rects[:, 1] = corners[:, :, 1].min(axis=1) + self._rects[:, 2] = corners[:, :, 0].max(axis=1) + self._rects[:, 3] = corners[:, :, 1].max(axis=1) + return self + + def mirror(self, axis: int = 0) -> Self: + if axis not in (0, 1): + raise PatternError('Axis must be 0 or 1') + if axis == 0: + self._rects[:, [1, 3]] *= -1 + else: + self._rects[:, [0, 2]] *= -1 + _renormalize_rects_in_place(self._rects) + return self + + def scale_by(self, c: float) -> Self: + self._rects *= c + _renormalize_rects_in_place(self._rects) + return self + + def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + rects = self._sorted_rects() + centers = 0.5 * (rects[:, :2] + rects[:, 2:]) + offset = centers.mean(axis=0) + zeroed = rects.copy() + zeroed[:, [0, 2]] -= offset[0] + zeroed[:, [1, 3]] -= offset[1] + normed = zeroed / norm_value + return ( + (type(self), normed.data.tobytes()), + (offset, 1.0, 0.0, False), + lambda: RectCollection(rects=normed * norm_value), + ) + + def __repr__(self) -> str: + if self._rects.size == 0: + return '' + centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:]) + centroid = centers.mean(axis=0) + return f'' diff --git a/masque/test/test_rect_collection.py b/masque/test/test_rect_collection.py new file mode 100644 index 0000000..449f4fa --- /dev/null +++ b/masque/test/test_rect_collection.py @@ -0,0 +1,70 @@ +import copy + +import numpy +import pytest +from numpy.testing import assert_allclose, assert_equal + +from ..error import PatternError +from ..shapes import Polygon, RectCollection + + +def test_rect_collection_init_and_to_polygons() -> None: + rects = RectCollection([[10, 10, 12, 12], [0, 0, 5, 5]]) + assert_equal(rects.rects, [[0, 0, 5, 5], [10, 10, 12, 12]]) + + polys = rects.to_polygons() + assert len(polys) == 2 + assert all(isinstance(poly, Polygon) for poly in polys) + assert_equal(polys[0].vertices, [[0, 0], [0, 5], [5, 5], [5, 0]]) + + +def test_rect_collection_rejects_invalid_rects() -> None: + with pytest.raises(PatternError): + RectCollection([[0, 0, 1]]) + with pytest.raises(PatternError): + RectCollection([[5, 0, 1, 2]]) + with pytest.raises(PatternError): + RectCollection([[0, 5, 1, 2]]) + + +def test_rect_collection_raw_constructor_matches_public() -> None: + raw = RectCollection._from_raw( + rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]), + annotations={'1': ['rects']}, + ) + public = RectCollection( + [[0, 0, 5, 5], [10, 10, 12, 12]], + annotations={'1': ['rects']}, + ) + assert raw == public + assert_equal(raw.get_bounds_single(), [[0, 0], [12, 12]]) + + +def test_rect_collection_manhattan_transforms() -> None: + rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]]) + + mirrored = copy.deepcopy(rects).mirror(1) + assert_equal(mirrored.rects, [[-2, 0, 0, 4], [-12, 20, -10, 22]]) + + scaled = copy.deepcopy(rects).scale_by(-2) + assert_equal(scaled.rects, [[-4, -8, 0, 0], [-24, -44, -20, -40]]) + + rotated = copy.deepcopy(rects).rotate(numpy.pi / 2) + assert_equal(rotated.rects, [[-4, 0, 0, 2], [-22, 10, -20, 12]]) + + +def test_rect_collection_non_manhattan_rotation_raises() -> None: + rects = RectCollection([[0, 0, 2, 4]]) + with pytest.raises(PatternError, match='Manhattan rotations'): + rects.rotate(numpy.pi / 4) + + +def test_rect_collection_normalized_form_rebuild_is_independent() -> None: + rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]]) + _intrinsic, extrinsic, rebuild = rects.normalized_form(2) + + clone = rebuild() + clone.rects[:] = [[1, 1, 2, 2], [3, 3, 4, 4]] + + assert_allclose(extrinsic[0], [6, 11.5]) + assert_equal(rects.rects, [[0, 0, 2, 4], [10, 20, 12, 22]])