renderpather, get_bounds includes repetitions, Boundable

This commit is contained in:
jan 2023-04-13 17:54:52 -07:00
parent 41dd123efe
commit d14d5438a4
17 changed files with 367 additions and 162 deletions

View File

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

View File

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

View File

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

View File

@ -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) @ (
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 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]}')
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 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)
bb = Builder(library=tree, name='_path').add_port_pair(names=(port_names[0], port_names[1]))
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 = tree << {'_straight': gen_straight(straight_length)}
bb.plug(straight, {port_names[1]: sport_in})
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
return bb.pattern
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

View File

@ -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.

View File

@ -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([
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])
if mask.any():
cbounds = numpy.vstack((
numpy.min(ebounds[mask, 0, :]),
numpy.max(ebounds[mask, 1, :]),
))
else:
cbounds = numpy.array((
(+inf, +inf),
(-inf, -inf),
])
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 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])

View File

@ -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 ''

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
elif self.cap in (
PathCap.Flush,
PathCap.Square,
PathCap.SquareCustom):
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

View File

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

View File

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

View File

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

View File

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

View File

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