[RectCollection] add a RectCollection shape

This commit is contained in:
Jan Petykiewicz 2026-04-02 19:42:17 -07:00
commit ec78031565
5 changed files with 343 additions and 1 deletions

View file

@ -42,6 +42,7 @@ from .error import (
from .shapes import ( from .shapes import (
Shape as Shape, Shape as Shape,
Polygon as Polygon, Polygon as Polygon,
RectCollection as RectCollection,
Path as Path, Path as Path,
Circle as Circle, Circle as Circle,
Arc as Arc, Arc as Arc,

View file

@ -37,7 +37,7 @@ from klamath import records
from .utils import is_gzipped, tmpfile from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path from ..shapes import Polygon, Path, RectCollection
from ..repetition import Grid from ..repetition import Grid
from ..utils import layer_t, annotations_t from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView from ..library import LazyLibrary, Library, ILibrary, ILibraryView
@ -466,6 +466,20 @@ def _shapes_to_elements(
properties=properties, properties=properties,
) )
elements.append(path) 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): elif isinstance(shape, Polygon):
polygon = shape polygon = shape
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)

View file

@ -11,6 +11,7 @@ from .shape import (
from .polygon import Polygon as Polygon from .polygon import Polygon as Polygon
from .poly_collection import PolyCollection as PolyCollection from .poly_collection import PolyCollection as PolyCollection
from .rect_collection import RectCollection as RectCollection
from .circle import Circle as Circle from .circle import Circle as Circle
from .ellipse import Ellipse as Ellipse from .ellipse import Ellipse as Ellipse
from .arc import Arc as Arc from .arc import Arc as Arc

View file

@ -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 '<RectCollection r0>'
centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:])
centroid = centers.mean(axis=0)
return f'<RectCollection centroid {centroid} r{self._rects.shape[0]}>'

View file

@ -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]])