renderpather, get_bounds includes repetitions, Boundable

This commit is contained in:
jan 2023-04-13 17:54:52 -07:00
parent 22e1c6ae1d
commit 9a28e1617c
17 changed files with 367 additions and 162 deletions

View File

@ -41,7 +41,7 @@ from .library import (
) )
from .ports import Port, PortList from .ports import Port, PortList
from .abstract import Abstract 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 from .utils import ports2data, oneshot

View File

@ -2,4 +2,4 @@ from .builder import Builder
from .pather import Pather from .pather import Pather
from .renderpather import RenderPather from .renderpather import RenderPather
from .utils import ell 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 copy
import logging import logging
from collections import defaultdict from collections import defaultdict
@ -15,7 +15,7 @@ from ..ports import PortList, Port
from ..abstract import Abstract from ..abstract import Abstract
from ..utils import rotation_matrix_2d from ..utils import rotation_matrix_2d
from ..utils import SupportsBool from ..utils import SupportsBool
from .tools import Tool, render_step_t from .tools import Tool, RenderStep
from .utils import ell from .utils import ell
from .builder import Builder from .builder import Builder
@ -35,8 +35,7 @@ class RenderPather(PortList):
_dead: bool _dead: bool
""" If True, plug()/place() are skipped (for debugging) """ """ If True, plug()/place() are skipped (for debugging) """
paths: defaultdict[str, list[render_step_t]] paths: defaultdict[str, list[RenderStep]]
# op, start_port, dx, dy, o_ptype tool
tools: dict[str | None, Tool] tools: dict[str | None, Tool]
""" """
@ -228,11 +227,10 @@ class RenderPather(PortList):
# get rid of plugged ports # get rid of plugged ports
for ki, vi in map_in.items(): 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] del self.ports[ki]
map_out[vi] = None 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, self.place(other, offset=translation, rotation=rotation, pivot=pivot,
mirrored=mirrored, port_map=map_out, skip_port_check=True) mirrored=mirrored, port_map=map_out, skip_port_check=True)
return self return self
@ -270,7 +268,7 @@ class RenderPather(PortList):
continue continue
ports[new_name] = port ports[new_name] = port
if new_name in self.paths: 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(): for name, port in ports.items():
p = port.deepcopy() p = port.deepcopy()
@ -303,18 +301,9 @@ class RenderPather(PortList):
tool = self.tools.get(portspec, self.tools[None]) 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 # 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: step = RenderStep('L', tool, port.copy(), data)
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)
self.paths[portspec].append(step) self.paths[portspec].append(step)
# Update port # Update port
@ -415,19 +404,19 @@ class RenderPather(PortList):
bb = Builder(lib) bb = Builder(lib)
for portspec, steps in self.paths.items(): 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: for step in steps:
opcode, _start_port, _dx, _dy, _out_ptype, tool = step appendable_op = step.opcode in ('L', 'S', 'U')
same_tool = batch and step.tool == tool0
appendable_op = opcode in ('L', 'S', 'U')
same_tool = batch and tool == batch[-1]
if batch and (not appendable_op or not same_tool): if batch and (not appendable_op or not same_tool):
# If we can't continue a batch, render it # If we can't continue a batch, render it
assert tool is not None assert batch[0].tool is not None
assert batch[0][1] is not None name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
name = lib << tool.render(batch, portnames=tool_port_names) bb.ports[portspec] = port0.copy()
bb.ports[portspec] = batch[0][1]
bb.plug(name, {portspec: tool_port_names[0]}) bb.plug(name, {portspec: tool_port_names[0]})
batch = [] batch = []
@ -441,10 +430,9 @@ class RenderPather(PortList):
if batch: if batch:
# A batch didn't end yet # A batch didn't end yet
assert tool is not None assert batch[0].tool is not None
assert batch[0][1] is not None name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
name = lib << tool.render(batch, portnames=tool_port_names) bb.ports[portspec] = batch[0].start_port.copy()
bb.ports[portspec] = batch[0][1]
bb.plug(name, {portspec: tool_port_names[0]}) bb.plug(name, {portspec: tool_port_names[0]})
bb.ports.clear() bb.ports.clear()

View File

@ -1,12 +1,14 @@
""" """
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
""" """
from typing import Sequence, Literal, Callable from typing import Sequence, Literal, Callable, Any
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod # TODO any way to make Tool ok with implementing only one method?
from dataclasses import dataclass
import numpy 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 ..ports import Port
from ..pattern import Pattern from ..pattern import Pattern
from ..abstract import Abstract from ..abstract import Abstract
@ -15,10 +17,16 @@ from ..error import BuildError
from .builder import Builder from .builder import Builder
render_step_t = ( @dataclass(frozen=True, slots=True)
tuple[Literal['L', 'S', 'U'], Port, float, float, str, 'Tool'] class RenderStep:
| tuple[Literal['P'], None, float, float, str, None] 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: class Tool:
@ -42,7 +50,7 @@ class Tool:
in_ptype: str | None = None, in_ptype: str | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
**kwargs, **kwargs,
) -> tuple[float, str]: ) -> Any:
raise NotImplementedError(f'planL() not implemented for {type(self)}') raise NotImplementedError(f'planL() not implemented for {type(self)}')
def planS( def planS(
@ -54,26 +62,30 @@ class Tool:
in_ptype: str | None = None, in_ptype: str | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
**kwargs, **kwargs,
) -> str: # out_ptype only? ) -> Any:
raise NotImplementedError(f'planS() not implemented for {type(self)}') raise NotImplementedError(f'planS() not implemented for {type(self)}')
def render( def render(
self, self,
batch: Sequence[render_step_t], batch: Sequence[RenderStep],
*, *,
in_ptype: str | None = None, in_ptype: str | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
port_names: Sequence[str] = ('A', 'B'), port_names: Sequence[str] = ('A', 'B'),
**kwargs, **kwargs,
) -> ILibrary: ) -> ILibrary:
assert batch[0][-1] == self assert not batch or batch[0].tool == self
raise NotImplementedError(f'render() not implemented for {type(self)}') raise NotImplementedError(f'render() not implemented for {type(self)}')
abstract_tuple_t = tuple[Abstract, str, str]
class BasicTool(Tool, metaclass=ABCMeta): class BasicTool(Tool, metaclass=ABCMeta):
straight: tuple[Callable[[float], Pattern], str, str] straight: tuple[Callable[[float], Pattern], str, str]
bend: tuple[Abstract, str, str] # Assumed to be clockwise bend: abstract_tuple_t # Assumed to be clockwise
transitions: dict[str, tuple[Abstract, str, str]] transitions: dict[str, abstract_tuple_t]
default_out_ptype: str
def path( def path(
self, self,
@ -85,25 +97,62 @@ class BasicTool(Tool, metaclass=ABCMeta):
port_names: tuple[str, str] = ('A', 'B'), port_names: tuple[str, str] = ('A', 'B'),
**kwargs, **kwargs,
) -> Pattern: ) -> 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 gen_straight, sport_in, sport_out = self.straight
straight_length = length tree = Library()
bend_run = 0 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: if ccw is not None:
bend, bport_in, bport_out = self.bend bend, bport_in, bport_out = self.bend
brot = bend.ports[bport_in].rotation bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw)))
assert brot is not None if out_transition:
bend_dxy = numpy.abs( opat, oport_theirs, oport_ours = out_transition
rotation_matrix_2d(-brot) @ ( 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_out].offset
- bend.ports[bport_in].offset - bend.ports[bport_in].offset
) )
)
straight_length -= bend_dxy[0] bend_angle = angle_out - angle_in
bend_run += bend_dxy[1]
if bool(ccw):
bend_dxy[1] *= -1
bend_angle *= -1
else: else:
bend_dxy = numpy.zeros(2) bend_dxy = numpy.zeros(2)
bend_angle = 0
in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None) in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None)
if in_transition is not None: if in_transition is not None:
@ -114,9 +163,6 @@ class BasicTool(Tool, metaclass=ABCMeta):
ipat.ports[iport_ours].offset ipat.ports[iport_ours].offset
- ipat.ports[iport_theirs].offset - ipat.ports[iport_theirs].offset
) )
straight_length -= itrans_dxy[0]
bend_run += itrans_dxy[1]
else: else:
itrans_dxy = numpy.zeros(2) itrans_dxy = numpy.zeros(2)
@ -125,33 +171,176 @@ class BasicTool(Tool, metaclass=ABCMeta):
opat, oport_theirs, oport_ours = out_transition opat, oport_theirs, oport_ours = out_transition
orot = opat.ports[oport_ours].rotation orot = opat.ports[oport_ours].rotation
assert orot is not None 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_theirs].offset
- opat.ports[oport_ours].offset - opat.ports[oport_ours].offset
) )
if ccw:
otrans_dxy[0] *= -1
straight_length -= otrans_dxy[1]
bend_run += otrans_dxy[0]
else: else:
otrans_dxy = numpy.zeros(2) otrans_dxy = numpy.zeros(2)
if straight_length < 0: if out_transition is not None:
raise BuildError(f'Asked to draw path with total length {length:g}, shorter than required bends and tapers:\n' out_ptype_actual = opat.ports[oport_theirs].ptype
f'bend: {bend_dxy[0]:g} in_taper: {abs(itrans_dxy[0])} out_taper: {otrans_dxy[1]}') 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() 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: if in_transition:
ipat, iport_theirs, _iport_ours = in_transition
bb.plug(ipat, {port_names[1]: iport_theirs}) bb.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(straight_length, 0): if not numpy.isclose(straight_length, 0):
straight = tree << {'_straight': gen_straight(straight_length)} straight_pat = gen_straight(straight_length)
bb.plug(straight, {port_names[1]: sport_in}) 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: if ccw is not None:
bend, bport_in, bport_out = self.bend
bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw))) bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw)))
if out_transition: if out_transition:
opat, oport_theirs, oport_ours = out_transition
bb.plug(opat, {port_names[1]: oport_ours}) 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) self.translate(+pivot)
return self return self
def get_bounds(self) -> NDArray[numpy.float64]: def get_bounds_single(self) -> NDArray[numpy.float64]:
""" """
Return the bounds of the label. Return the bounds of the label.

View File

@ -8,7 +8,7 @@ from itertools import chain
from collections import defaultdict from collections import defaultdict
import numpy import numpy
from numpy import inf, pi from numpy import inf, pi, nan
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
# .visualize imports matplotlib and matplotlib.collections # .visualize imports matplotlib and matplotlib.collections
@ -310,7 +310,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self, self,
library: Mapping[str, 'Pattern'] | None = None, library: Mapping[str, 'Pattern'] | None = None,
recurse: bool = True, recurse: bool = True,
cache: MutableMapping[str, NDArray[numpy.float64]] | None = None, cache: MutableMapping[str, NDArray[numpy.float64] | None] | None = None,
) -> NDArray[numpy.float64] | None: ) -> NDArray[numpy.float64] | None:
""" """
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the 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(): if self.is_empty():
return None 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),
(-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 recurse and self.has_refs():
if library is None: if library is None:
@ -381,9 +386,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if bounds is None: if bounds is None:
continue continue
if ref.repetition is not None:
bounds += ref.repetition.get_bounds()
cbounds[0] = numpy.minimum(cbounds[0], bounds[0]) cbounds[0] = numpy.minimum(cbounds[0], bounds[0])
cbounds[1] = numpy.maximum(cbounds[1], bounds[1]) cbounds[1] = numpy.maximum(cbounds[1], bounds[1])

View File

@ -144,7 +144,7 @@ class Ref(
self.repetition.mirror(axis) self.repetition.mirror(axis)
return self return self
def get_bounds( def get_bounds_single(
self, self,
pattern: 'Pattern', pattern: 'Pattern',
*, *,
@ -166,22 +166,6 @@ class Ref(
return None return None
return self.as_pattern(pattern=pattern).get_bounds(library) # TODO can just take pattern's bounds and then transform those! 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: def __repr__(self) -> str:
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
scale = f' d{self.scale:g}' if self.scale != 1 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` `[[x_min, y_min], [x_max, y_max]]` or `None`
""" """
a_extent = self.a_vector * (self.a_count - 1) 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)) corners = numpy.stack(((0, 0), a_extent, b_extent, a_extent + b_extent))
xy_min = numpy.min(corners, axis=0) 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) poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
return [poly] return [poly]
def get_bounds(self) -> NDArray[numpy.float64]: def get_bounds_single(self) -> NDArray[numpy.float64]:
''' '''
Equation for rotated ellipse is Equation for rotated ellipse is
`x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)` `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)] 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, return numpy.vstack((self.offset - self.radius,
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) poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
return [poly] 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) rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
return numpy.vstack((self.offset - rot_radii[0], return numpy.vstack((self.offset - rot_radii[0],
self.offset + rot_radii[1])) self.offset + rot_radii[1]))

View File

@ -309,21 +309,23 @@ class Path(Shape):
return polys return polys
def get_bounds(self) -> NDArray[numpy.float64]: def get_bounds_single(self) -> NDArray[numpy.float64]:
if self.cap == PathCap.Circle: if self.cap == PathCap.Circle:
bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2, bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2,
numpy.max(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.Square,
PathCap.SquareCustom): PathCap.SquareCustom,
):
bounds = numpy.array([[+inf, +inf], [-inf, -inf]]) bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons() polys = self.to_polygons()
for poly in polys: 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[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :]) bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
else: 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 return bounds

View File

@ -327,7 +327,7 @@ class Polygon(Shape):
) -> list['Polygon']: ) -> list['Polygon']:
return [copy.deepcopy(self)] 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), return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0),
self.offset + numpy.max(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 numpy.typing import NDArray, ArrayLike
from ..traits import ( from ..traits import (
Rotatable, Mirrorable, Copyable, Scalable, Bounded, Rotatable, Mirrorable, Copyable, Scalable,
PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
) )
@ -25,7 +25,7 @@ normalized_shape_tuple = tuple[
DEFAULT_POLY_NUM_VERTICES = 24 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): PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
""" """
Class specifying functions common to all shapes. Class specifying functions common to all shapes.
@ -118,7 +118,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, Bounded
polygon_contours = [] polygon_contours = []
for polygon in self.to_polygons(): for polygon in self.to_polygons():
bounds = polygon.get_bounds() bounds = polygon.get_bounds_single()
if bounds is None: if bounds is None:
continue continue
@ -250,7 +250,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, Bounded
polygon_contours = [] polygon_contours = []
for polygon in self.to_polygons(): for polygon in self.to_polygons():
# Get rid of unused gridlines (anything not within 2 lines of the polygon bounds) # 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: if bounds is None:
continue continue

View File

@ -2,7 +2,7 @@ from typing import Sequence, Any
import copy import copy
import numpy import numpy
from numpy import pi, inf from numpy import pi, nan
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple from . import Shape, Polygon, normalized_shape_tuple
@ -151,15 +151,20 @@ class Text(RotatableImpl, Shape):
mirrored=(mirror_x, False), 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 # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
# just convert to polygons instead # just convert to polygons instead
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons() polys = self.to_polygons()
for poly in polys: pbounds = numpy.full((len(polys), 2, 2), nan)
poly_bounds = poly.get_bounds() # bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :]) for pp, poly in enumerate(polys):
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :]) 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 return bounds

View File

@ -61,27 +61,6 @@ class Positionable(metaclass=ABCMeta):
pass 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): class PositionableImpl(Positionable, metaclass=ABCMeta):
""" """
Simple implementation of Positionable Simple implementation of Positionable
@ -121,3 +100,26 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
def translate(self, offset: ArrayLike) -> Self: def translate(self, offset: ArrayLike) -> Self:
self._offset += offset # type: ignore # NDArray += ArrayLike should be fine?? self._offset += offset # type: ignore # NDArray += ArrayLike should be fine??
return self 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 typing import Self, TYPE_CHECKING
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import NDArray
from ..error import MasqueError from ..error import MasqueError
from .positionable import Bounded
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
@ -50,15 +54,19 @@ class Repeatable(metaclass=ABCMeta):
pass 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 __slots__ = _empty_slots
_repetition: 'Repetition | None' _repetition: 'Repetition | None'
""" Repetition object, or None (single instance only) """ """ Repetition object, or None (single instance only) """
@abstractmethod
def get_bounds_single(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
pass
# #
# Non-abstract properties # Non-abstract properties
# #
@ -79,3 +87,24 @@ class RepeatableImpl(Repeatable, metaclass=ABCMeta):
def set_repetition(self, repetition: 'Repetition | None') -> Self: def set_repetition(self, repetition: 'Repetition | None') -> Self:
self.repetition = repetition self.repetition = repetition
return self 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