From d14d5438a46e58cffc5e9f6f31ed4ea0c5b294e9 Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 13 Apr 2023 17:54:52 -0700 Subject: [PATCH] renderpather, get_bounds includes repetitions, Boundable --- masque/__init__.py | 2 +- masque/builder/__init__.py | 2 +- masque/builder/renderpather.py | 52 +++--- masque/builder/tools.py | 285 +++++++++++++++++++++++++++------ masque/label.py | 2 +- masque/pattern.py | 36 +++-- masque/ref.py | 18 +-- masque/repetition.py | 6 +- masque/shapes/arc.py | 2 +- masque/shapes/circle.py | 2 +- masque/shapes/ellipse.py | 2 +- masque/shapes/path.py | 14 +- masque/shapes/polygon.py | 2 +- masque/shapes/shape.py | 8 +- masque/shapes/text.py | 19 ++- masque/traits/positionable.py | 44 ++--- masque/traits/repeatable.py | 33 +++- 17 files changed, 367 insertions(+), 162 deletions(-) diff --git a/masque/__init__.py b/masque/__init__.py index 2452f33..1cc4ba9 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -41,7 +41,7 @@ from .library import ( ) from .ports import Port, PortList from .abstract import Abstract -from .builder import Builder, Tool, Pather, RenderPather, render_step_t +from .builder import Builder, Tool, Pather, RenderPather, RenderStep from .utils import ports2data, oneshot diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index a02f5a3..1578a3f 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -2,4 +2,4 @@ from .builder import Builder from .pather import Pather from .renderpather import RenderPather from .utils import ell -from .tools import Tool, render_step_t +from .tools import Tool, RenderStep diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index ab0b43b..60219a4 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -1,4 +1,4 @@ -from typing import Self, Sequence, Mapping, Final +from typing import Self, Sequence, Mapping import copy import logging from collections import defaultdict @@ -15,7 +15,7 @@ from ..ports import PortList, Port from ..abstract import Abstract from ..utils import rotation_matrix_2d from ..utils import SupportsBool -from .tools import Tool, render_step_t +from .tools import Tool, RenderStep from .utils import ell from .builder import Builder @@ -35,8 +35,7 @@ class RenderPather(PortList): _dead: bool """ If True, plug()/place() are skipped (for debugging) """ - paths: defaultdict[str, list[render_step_t]] -# op, start_port, dx, dy, o_ptype tool + paths: defaultdict[str, list[RenderStep]] tools: dict[str | None, Tool] """ @@ -228,11 +227,10 @@ class RenderPather(PortList): # get rid of plugged ports for ki, vi in map_in.items(): + if ki in self.paths: + self.paths[ki].append(RenderStep('P', None, self.ports[ki].copy(), None)) del self.ports[ki] map_out[vi] = None - if ki in self.paths: - self.paths[ki].append(('P', None, 0.0, 0.0, 'unk', None)) - self.place(other, offset=translation, rotation=rotation, pivot=pivot, mirrored=mirrored, port_map=map_out, skip_port_check=True) return self @@ -270,7 +268,7 @@ class RenderPather(PortList): continue ports[new_name] = port if new_name in self.paths: - self.paths[new_name].append(('P', None, 0.0, 0.0, 'unk', None)) + self.paths[new_name].append(RenderStep('P', None, port.copy(), None)) for name, port in ports.items(): p = port.deepcopy() @@ -303,18 +301,9 @@ class RenderPather(PortList): tool = self.tools.get(portspec, self.tools[None]) # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype - bend_radius, out_ptype = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) + data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) - if ccw is None: - bend_run = 0.0 - elif bool(ccw): - bend_run = bend_radius - else: - bend_run = -bend_radius - - dx, dy = rotation_matrix_2d(port_rot + pi) @ [length, bend_run] - - step: Final = ('L', port.deepcopy(), dx, dy, out_ptype, tool) + step = RenderStep('L', tool, port.copy(), data) self.paths[portspec].append(step) # Update port @@ -415,19 +404,19 @@ class RenderPather(PortList): bb = Builder(lib) for portspec, steps in self.paths.items(): - batch: list[render_step_t] = [] + batch: list[RenderStep] = [] + tool0 = batch[0].tool + port0 = batch[0].start_port + assert tool0 is not None for step in steps: - opcode, _start_port, _dx, _dy, _out_ptype, tool = step - - appendable_op = opcode in ('L', 'S', 'U') - same_tool = batch and tool == batch[-1] + appendable_op = step.opcode in ('L', 'S', 'U') + same_tool = batch and step.tool == tool0 if batch and (not appendable_op or not same_tool): # If we can't continue a batch, render it - assert tool is not None - assert batch[0][1] is not None - name = lib << tool.render(batch, portnames=tool_port_names) - bb.ports[portspec] = batch[0][1] + assert batch[0].tool is not None + name = lib << batch[0].tool.render(batch, port_names=tool_port_names) + bb.ports[portspec] = port0.copy() bb.plug(name, {portspec: tool_port_names[0]}) batch = [] @@ -441,10 +430,9 @@ class RenderPather(PortList): if batch: # A batch didn't end yet - assert tool is not None - assert batch[0][1] is not None - name = lib << tool.render(batch, portnames=tool_port_names) - bb.ports[portspec] = batch[0][1] + assert batch[0].tool is not None + name = lib << batch[0].tool.render(batch, port_names=tool_port_names) + bb.ports[portspec] = batch[0].start_port.copy() bb.plug(name, {portspec: tool_port_names[0]}) bb.ports.clear() diff --git a/masque/builder/tools.py b/masque/builder/tools.py index f04bc5a..006f3d2 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,12 +1,14 @@ """ Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) """ -from typing import Sequence, Literal, Callable -from abc import ABCMeta, abstractmethod +from typing import Sequence, Literal, Callable, Any +from abc import ABCMeta, abstractmethod # TODO any way to make Tool ok with implementing only one method? +from dataclasses import dataclass import numpy +from numpy import pi -from ..utils import SupportsBool, rotation_matrix_2d +from ..utils import SupportsBool, rotation_matrix_2d, layer_t from ..ports import Port from ..pattern import Pattern from ..abstract import Abstract @@ -15,10 +17,16 @@ from ..error import BuildError from .builder import Builder -render_step_t = ( - tuple[Literal['L', 'S', 'U'], Port, float, float, str, 'Tool'] - | tuple[Literal['P'], None, float, float, str, None] - ) +@dataclass(frozen=True, slots=True) +class RenderStep: + opcode: Literal['L', 'S', 'U', 'P'] + tool: 'Tool' | None + start_port: Port + data: Any + + def __post_init__(self) -> None: + if self.opcode != 'P' and self.tool is None: + raise BuildError('Got tool=None but the opcode is not "P"') class Tool: @@ -42,7 +50,7 @@ class Tool: in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> tuple[float, str]: + ) -> Any: raise NotImplementedError(f'planL() not implemented for {type(self)}') def planS( @@ -54,26 +62,30 @@ class Tool: in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> str: # out_ptype only? + ) -> Any: raise NotImplementedError(f'planS() not implemented for {type(self)}') def render( self, - batch: Sequence[render_step_t], + batch: Sequence[RenderStep], *, in_ptype: str | None = None, out_ptype: str | None = None, port_names: Sequence[str] = ('A', 'B'), **kwargs, ) -> ILibrary: - assert batch[0][-1] == self + assert not batch or batch[0].tool == self raise NotImplementedError(f'render() not implemented for {type(self)}') +abstract_tuple_t = tuple[Abstract, str, str] + + class BasicTool(Tool, metaclass=ABCMeta): straight: tuple[Callable[[float], Pattern], str, str] - bend: tuple[Abstract, str, str] # Assumed to be clockwise - transitions: dict[str, tuple[Abstract, str, str]] + bend: abstract_tuple_t # Assumed to be clockwise + transitions: dict[str, abstract_tuple_t] + default_out_ptype: str def path( self, @@ -85,25 +97,62 @@ class BasicTool(Tool, metaclass=ABCMeta): port_names: tuple[str, str] = ('A', 'B'), **kwargs, ) -> Pattern: + straight_length, _ccw, in_transition, out_transition = self.planL( + ccw, + length, + in_ptype=in_ptype, + out_ptype=out_ptype, + ) - # TODO check all the math for L-shaped bends - straight_length = length - bend_run = 0 + gen_straight, sport_in, sport_out = self.straight + tree = Library() + bb = Builder(library=tree, name='_path').add_port_pair(names=port_names) + if in_transition: + ipat, iport_theirs, _iport_ours = in_transition + bb.plug(ipat, {port_names[1]: iport_theirs}) + if not numpy.isclose(straight_length, 0): + straight = tree << {'_straight': gen_straight(straight_length)} + bb.plug(straight, {port_names[1]: sport_in}) if ccw is not None: bend, bport_in, bport_out = self.bend - brot = bend.ports[bport_in].rotation - assert brot is not None - bend_dxy = numpy.abs( - rotation_matrix_2d(-brot) @ ( - bend.ports[bport_out].offset - - bend.ports[bport_in].offset - ) + bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw))) + if out_transition: + opat, oport_theirs, oport_ours = out_transition + bb.plug(opat, {port_names[1]: oport_ours}) + + return bb.pattern + + def planL( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[float, SupportsBool | None, abstract_tuple_t | None, abstract_tuple_t | None]: + # TODO check all the math for L-shaped bends + if ccw is not None: + bend, bport_in, bport_out = self.bend + + angle_in = bend.ports[bport_in].rotation + angle_out = bend.ports[bport_out].rotation + assert angle_in is not None + assert angle_out is not None + + bend_dxy = rotation_matrix_2d(-angle_in) @ ( + bend.ports[bport_out].offset + - bend.ports[bport_in].offset ) - straight_length -= bend_dxy[0] - bend_run += bend_dxy[1] + bend_angle = angle_out - angle_in + + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 else: bend_dxy = numpy.zeros(2) + bend_angle = 0 in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None) if in_transition is not None: @@ -114,9 +163,6 @@ class BasicTool(Tool, metaclass=ABCMeta): ipat.ports[iport_ours].offset - ipat.ports[iport_theirs].offset ) - - straight_length -= itrans_dxy[0] - bend_run += itrans_dxy[1] else: itrans_dxy = numpy.zeros(2) @@ -125,33 +171,176 @@ class BasicTool(Tool, metaclass=ABCMeta): opat, oport_theirs, oport_ours = out_transition orot = opat.ports[oport_ours].rotation assert orot is not None - otrans_dxy = rotation_matrix_2d(-orot) @ ( + + otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ ( opat.ports[oport_theirs].offset - opat.ports[oport_ours].offset ) - if ccw: - otrans_dxy[0] *= -1 - - straight_length -= otrans_dxy[1] - bend_run += otrans_dxy[0] else: otrans_dxy = numpy.zeros(2) + if out_transition is not None: + out_ptype_actual = opat.ports[oport_theirs].ptype + elif ccw is not None: + out_ptype_actual = bend.ports[bport_out].ptype + else: + out_ptype_actual = self.default_out_ptype + + straight_length = length - bend_dxy[0] - itrans_dxy[0] - otrans_dxy[0] + bend_run = bend_dxy[1] + itrans_dxy[1] + otrans_dxy + if straight_length < 0: - raise BuildError(f'Asked to draw path with total length {length:g}, shorter than required bends and tapers:\n' - f'bend: {bend_dxy[0]:g} in_taper: {abs(itrans_dxy[0])} out_taper: {otrans_dxy[1]}') + raise BuildError( + f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n' + f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g} out_trans: {otrans_dxy[0]:,g}' + ) + + return float(straight_length), ccw, in_transition, out_transition + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: Sequence[str] = ('A', 'B'), + append: bool = True, + **kwargs, + ) -> ILibrary: - gen_straight, sport_in, sport_out = self.straight tree = Library() - bb = Builder(library=tree, name='_path').add_port_pair(names=port_names) - if in_transition: - bb.plug(ipat, {port_names[1]: iport_theirs}) - if not numpy.isclose(straight_length, 0): - straight = tree << {'_straight': gen_straight(straight_length)} - bb.plug(straight, {port_names[1]: sport_in}) - if ccw is not None: - bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw))) - if out_transition: - bb.plug(opat, {port_names[1]: oport_ours}) + bb = Builder(library=tree, name='_path').add_port_pair(names=(port_names[0], port_names[1])) - return bb.pattern + gen_straight, sport_in, _sport_out = self.straight + for step in batch: + straight_length, ccw, in_transition, out_transition = step.data + assert step.tool == self + + if step.opcode == 'L': + if in_transition: + ipat, iport_theirs, _iport_ours = in_transition + bb.plug(ipat, {port_names[1]: iport_theirs}) + if not numpy.isclose(straight_length, 0): + straight_pat = gen_straight(straight_length) + if append: + bb.plug(straight_pat, {port_names[1]: sport_in}, append=True) + else: + straight = tree << {'_straight': straight_pat} + bb.plug(straight, {port_names[1]: sport_in}, append=True) + if ccw is not None: + bend, bport_in, bport_out = self.bend + bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw))) + if out_transition: + opat, oport_theirs, oport_ours = out_transition + bb.plug(opat, {port_names[1]: oport_ours}) + return tree + + + +class PathTool(Tool, metaclass=ABCMeta): + straight: tuple[Callable[[float], Pattern], str, str] + bend: abstract_tuple_t # Assumed to be clockwise + transitions: dict[str, abstract_tuple_t] + ptype: str + width: float + layer: layer_t + + def __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None: + Tool.__init__(self) + self.layer = layer + self.width = width + self.ptype: str + + def path( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, + ) -> Pattern: + dxy = self.planL( + ccw, + length, + in_ptype=in_ptype, + out_ptype=out_ptype, + ) + + pat = Pattern() + pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) + + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + + pat.ports = { + port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), + port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), + } + + return pat + + def planL( + self, + ccw: SupportsBool | None, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs, + ) -> tuple[float, float]: + # TODO check all the math for L-shaped bends + + if out_ptype and out_ptype != self.ptype: + raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') + + if ccw is not None: + bend_dxy = numpy.array([1, -1]) * self.width / 2 + bend_angle = pi / 2 + + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 + else: + bend_dxy = numpy.zeros(2) + bend_angle = 0 + + straight_length = length - bend_dxy[0] + bend_run = bend_dxy[1] + + if straight_length < 0: + raise BuildError( + f'Asked to draw path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' + ) + + return length, bend_run + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: Sequence[str] = ('A', 'B'), + **kwargs, + ) -> ILibrary: + + path_vertices = [batch[0].start_port.offset] + for step in batch: + assert step.tool == self + + port_rot = step.start_port.rotation + assert port_rot is not None + + if step.opcode == 'L': + length, bend_run = step.data + dxy = rotation_matrix_2d(port_rot + pi) @ (length, bend_run) + path_vertices.append(step.start_port.offset + dxy) + else: + raise BuildError(f'Unrecognized opcode "{step.opcode}"') + + tree, pat = Library.mktree('_path') + pat.path(layer=self.layer, width=self.width, vertices=path_vertices) + + return tree diff --git a/masque/label.py b/masque/label.py index a6d459c..e52e836 100644 --- a/masque/label.py +++ b/masque/label.py @@ -78,7 +78,7 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, self.translate(+pivot) return self - def get_bounds(self) -> NDArray[numpy.float64]: + 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 6019cf0..2fc66c7 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -8,7 +8,7 @@ from itertools import chain from collections import defaultdict import numpy -from numpy import inf, pi +from numpy import inf, pi, nan from numpy.typing import NDArray, ArrayLike # .visualize imports matplotlib and matplotlib.collections @@ -310,7 +310,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self, library: Mapping[str, 'Pattern'] | None = None, recurse: bool = True, - cache: MutableMapping[str, NDArray[numpy.float64]] | None = None, + cache: MutableMapping[str, NDArray[numpy.float64] | None] | None = None, ) -> NDArray[numpy.float64] | None: """ Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the @@ -326,19 +326,24 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if self.is_empty(): return None - cbounds = numpy.array([ - (+inf, +inf), - (-inf, -inf), - ]) + n_elems = sum(1 for _ in chain_elements(self.shapes, self.labels)) + ebounds = numpy.full((n_elems, 2, 2), nan) + for ee, entry in enumerate(chain_elements(self.shapes, self.labels)): + maybe_ebounds = cast(Bounded, entry).get_bounds() + if maybe_ebounds is not None: + ebounds[ee] = maybe_ebounds + mask = ~numpy.isnan(ebounds[:, 0, 0]) - for entry in chain_elements(self.shapes, self.labels): - bounds = cast(Bounded, entry).get_bounds() - if bounds is None: - continue - if entry.repetition is not None: - bounds += entry.repetition.get_bounds() - cbounds[0] = numpy.minimum(cbounds[0], bounds[0]) - cbounds[1] = numpy.maximum(cbounds[1], bounds[1]) + if mask.any(): + cbounds = numpy.vstack(( + numpy.min(ebounds[mask, 0, :]), + numpy.max(ebounds[mask, 1, :]), + )) + else: + cbounds = numpy.array(( + (+inf, +inf), + (-inf, -inf), + )) if recurse and self.has_refs(): if library is None: @@ -381,9 +386,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if bounds is None: continue - if ref.repetition is not None: - bounds += ref.repetition.get_bounds() - cbounds[0] = numpy.minimum(cbounds[0], bounds[0]) cbounds[1] = numpy.maximum(cbounds[1], bounds[1]) diff --git a/masque/ref.py b/masque/ref.py index 83b1e8b..68a3e77 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -144,7 +144,7 @@ class Ref( self.repetition.mirror(axis) return self - def get_bounds( + def get_bounds_single( self, pattern: 'Pattern', *, @@ -166,22 +166,6 @@ class Ref( return None return self.as_pattern(pattern=pattern).get_bounds(library) # TODO can just take pattern's bounds and then transform those! - def get_bounds_nonempty( - self, - pattern: 'Pattern', - *, - library: Mapping[str, 'Pattern'] | None = None, - ) -> NDArray[numpy.float64]: - """ - Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. - Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None). - - This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()` - """ - bounds = self.get_bounds(pattern, library=library) - assert bounds is not None - return bounds - def __repr__(self) -> str: rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' scale = f' d{self.scale:g}' if self.scale != 1 else '' diff --git a/masque/repetition.py b/masque/repetition.py index 6bad8a4..d77e47f 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -237,7 +237,11 @@ class Grid(Repetition): `[[x_min, y_min], [x_max, y_max]]` or `None` """ a_extent = self.a_vector * (self.a_count - 1) - b_extent = self.b_vector * ((self.b_count - 1) if (self.b_vector is not None) else 0) # type: NDArray[numpy.float64] | float + if self.b_count is None: + b_extent = numpy.zeros(2) + else: + assert self.b_vector is not None + b_extent = self.b_vector * (self.b_count - 1) corners = numpy.stack(((0, 0), a_extent, b_extent, a_extent + b_extent)) xy_min = numpy.min(corners, axis=0) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index ae1fc9a..56318a5 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -241,7 +241,7 @@ class Arc(Shape): poly = Polygon(xys, offset=self.offset, rotation=self.rotation) return [poly] - def get_bounds(self) -> NDArray[numpy.float64]: + def get_bounds_single(self) -> NDArray[numpy.float64]: ''' Equation for rotated ellipse is `x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)` diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index 5f3abf1..8176773 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -89,7 +89,7 @@ class Circle(Shape): return [Polygon(xys, offset=self.offset)] - def get_bounds(self) -> NDArray[numpy.float64]: + def get_bounds_single(self) -> NDArray[numpy.float64]: return numpy.vstack((self.offset - self.radius, self.offset + self.radius)) diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index da4db39..858e5d8 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -152,7 +152,7 @@ class Ellipse(Shape): poly = Polygon(xys, offset=self.offset, rotation=self.rotation) return [poly] - def get_bounds(self) -> NDArray[numpy.float64]: + def get_bounds_single(self) -> NDArray[numpy.float64]: rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii) return numpy.vstack((self.offset - rot_radii[0], self.offset + rot_radii[1])) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 141b6a7..a81318f 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -309,21 +309,23 @@ class Path(Shape): return polys - def get_bounds(self) -> NDArray[numpy.float64]: + 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)) - elif self.cap in (PathCap.Flush, - PathCap.Square, - PathCap.SquareCustom): + elif self.cap in ( + PathCap.Flush, + PathCap.Square, + PathCap.SquareCustom, + ): bounds = numpy.array([[+inf, +inf], [-inf, -inf]]) polys = self.to_polygons() for poly in polys: - poly_bounds = poly.get_bounds_nonempty() + poly_bounds = poly.get_bounds_single_nonempty() bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :]) bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :]) else: - raise PatternError(f'get_bounds() not implemented for endcaps: {self.cap}') + raise PatternError(f'get_bounds_single() not implemented for endcaps: {self.cap}') return bounds diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 5d4ccb5..fead3c7 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -327,7 +327,7 @@ class Polygon(Shape): ) -> list['Polygon']: return [copy.deepcopy(self)] - def get_bounds(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition + 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))) diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index c5237bb..1cf811a 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -5,7 +5,7 @@ import numpy from numpy.typing import NDArray, ArrayLike from ..traits import ( - Rotatable, Mirrorable, Copyable, Scalable, Bounded, + Rotatable, Mirrorable, Copyable, Scalable, PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) @@ -25,7 +25,7 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, Bounded, +class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. @@ -118,7 +118,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, Bounded polygon_contours = [] for polygon in self.to_polygons(): - bounds = polygon.get_bounds() + bounds = polygon.get_bounds_single() if bounds is None: continue @@ -250,7 +250,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, Bounded polygon_contours = [] for polygon in self.to_polygons(): # Get rid of unused gridlines (anything not within 2 lines of the polygon bounds) - bounds = polygon.get_bounds() + bounds = polygon.get_bounds_single() if bounds is None: continue diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 028cee6..2192d3c 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -2,7 +2,7 @@ from typing import Sequence, Any import copy import numpy -from numpy import pi, inf +from numpy import pi, nan from numpy.typing import NDArray, ArrayLike from . import Shape, Polygon, normalized_shape_tuple @@ -151,15 +151,20 @@ class Text(RotatableImpl, Shape): mirrored=(mirror_x, False), )) - def get_bounds(self) -> NDArray[numpy.float64]: + def get_bounds_single(self) -> NDArray[numpy.float64]: # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so # just convert to polygons instead - bounds = numpy.array([[+inf, +inf], [-inf, -inf]]) polys = self.to_polygons() - for poly in polys: - poly_bounds = poly.get_bounds() - bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :]) - bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :]) + pbounds = numpy.full((len(polys), 2, 2), nan) +# bounds = numpy.array([[+inf, +inf], [-inf, -inf]]) + for pp, poly in enumerate(polys): + pbounds[pp] = poly.get_bounds_nonempty() +# bounds[0] = numpy.minimum(bounds[0], poly_bounds[0]) +# bounds[1] = numpy.maximum(bounds[1], poly_bounds[1]) + bounds = numpy.vstack(( + numpy.min(pbounds[: 0, :], axis=0), + numpy.max(pbounds[: 1, :], axis=0), + )) return bounds diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index 07b2e67..3a66b27 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -61,27 +61,6 @@ class Positionable(metaclass=ABCMeta): pass -class Bounded(metaclass=ABCMeta): - @abstractmethod - def get_bounds(self) -> NDArray[numpy.float64] | None: - """ - Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. - Returns `None` for an empty entity. - """ - pass - - def get_bounds_nonempty(self) -> NDArray[numpy.float64]: - """ - Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. - Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None). - - This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()` - """ - bounds = self.get_bounds() - assert bounds is not None - return bounds - - class PositionableImpl(Positionable, metaclass=ABCMeta): """ Simple implementation of Positionable @@ -121,3 +100,26 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): def translate(self, offset: ArrayLike) -> Self: self._offset += offset # type: ignore # NDArray += ArrayLike should be fine?? return self + + +class Bounded(metaclass=ABCMeta): + @abstractmethod + def get_bounds(self, *args, **kwargs) -> NDArray[numpy.float64] | None: + """ + Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. + Returns `None` for an empty entity. + """ + pass + + def get_bounds_nonempty(self, *args, **kwargs) -> NDArray[numpy.float64]: + """ + Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. + Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None). + + This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()` + """ + bounds = self.get_bounds(*args, **kwargs) + assert bounds is not None + return bounds + + diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index ef22b63..838e12b 100644 --- a/masque/traits/repeatable.py +++ b/masque/traits/repeatable.py @@ -1,7 +1,11 @@ from typing import Self, TYPE_CHECKING from abc import ABCMeta, abstractmethod +import numpy +from numpy.typing import NDArray + from ..error import MasqueError +from .positionable import Bounded _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -50,15 +54,19 @@ class Repeatable(metaclass=ABCMeta): pass -class RepeatableImpl(Repeatable, metaclass=ABCMeta): +class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta): """ - Simple implementation of `Repeatable` + Simple implementation of `Repeatable` and extension of `Bounded` to include repetition bounds. """ __slots__ = _empty_slots _repetition: 'Repetition | None' """ Repetition object, or None (single instance only) """ + @abstractmethod + def get_bounds_single(self, *args, **kwargs) -> NDArray[numpy.float64] | None: + pass + # # Non-abstract properties # @@ -79,3 +87,24 @@ class RepeatableImpl(Repeatable, metaclass=ABCMeta): def set_repetition(self, repetition: 'Repetition | None') -> Self: self.repetition = repetition return self + + def get_bounds_single_nonempty(self, *args, **kwargs) -> NDArray[numpy.float64]: + """ + Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity. + Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None). + + This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()` + """ + bounds = self.get_bounds_single(*args, **kwargs) + assert bounds is not None + return bounds + + def get_bounds(self, *args, **kwargs) -> NDArray[numpy.float64] | None: + bounds = self.get_bounds_single(*args, **kwargs) + + if bounds is not None and self.repetition is not None: + rep_bounds = self.repetition.get_bounds() + if rep_bounds is None: + return None + bounds += rep_bounds + return bounds