Compare commits

...

63 Commits

Author SHA1 Message Date
49e3917a6e [remove_duplicate_vertices] remove the last vertex rather than the first
to better match docs
2026-01-19 22:20:09 -08:00
48034b7a30 [Polygon.rect] raise a PatternError when given the wrong number of args
instead of assert
2026-01-19 22:20:09 -08:00
jan
28e2864ce1 [dxf] make sure layer tuple contents are ints 2026-01-19 22:20:09 -08:00
jan
ba2bc2b444 [dxf] don't need to add polygon offset since it's zero 2026-01-19 22:20:09 -08:00
jan
05b73066ea update TODO in readme 2026-01-19 22:20:09 -08:00
jan
f7138ee8e4 [PortPather] generalize to multi-port functions where possible 2026-01-19 22:20:09 -08:00
jan
dca0df940b [Pattern] use 1-axis instead of axis-1 2026-01-19 22:20:09 -08:00
jan
c18249c4d5 [AutoTool] S-bend to L-bend fallback does not work yet, should throw an error 2026-01-19 22:20:09 -08:00
jan
fc963cfbfc [Pather / RenderPather] Fix handling of jog polarity 2026-01-19 22:20:09 -08:00
jan
c366add952 [Port] mirror() should not mirror port position, only orientation 2026-01-19 22:20:09 -08:00
jan
84629ea614 minor readme cleanup 2026-01-19 22:20:09 -08:00
jan
fdd776f4d7 [RenderPather.plug] fix ok_connections param 2026-01-19 22:20:09 -08:00
jan
0f8078127d cleanup 2026-01-19 22:20:09 -08:00
jan
48ccf3e148 [PortPather] add some more port-related convenience functions 2026-01-19 22:20:09 -08:00
jan
88a3d261aa [AutoTool / SimpleTool] allow choice between rotating or mirroring bends 2026-01-19 22:20:09 -08:00
jan
c1c83afc98 [Library.flatten] add dangling_ok param 2026-01-19 22:20:09 -08:00
jan
f8a82336f6 [PatherMixin] add thru arg to path_into and rework portlist inheritance 2026-01-19 22:20:09 -08:00
jan
62a030dd14 [PortPather] add rename_to and rename_from 2026-01-19 22:20:09 -08:00
jan
7ca3dd5b09 [PatherMixin] add at() for generating PortPather 2026-01-19 22:20:09 -08:00
jan
d46be245c6 add missing float_raster dep for manhattanize_slow 2026-01-19 22:20:09 -08:00
jan
54cddaddd9 [PortPather] add PortPather 2026-01-19 22:20:09 -08:00
jan
1c7ee9bef4 [RenderPather] whitespace 2026-01-19 22:20:09 -08:00
jan
7c928a59fa [plug()] rename inherit_name arg to thru and allow passing a string
Breaking change

Affects Pattern, Builder, Pather, RenderPather
2026-01-19 22:20:09 -08:00
jan
bc8c0ee580 add some whitespace 2026-01-19 22:20:09 -08:00
d3216c680c [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends 2026-01-19 22:20:09 -08:00
3593b4aec7 [Port] add Port.measure_travel() 2026-01-19 22:20:09 -08:00
e8e630bb2f [Tool / Pather] fix some doc typos 2026-01-19 22:20:09 -08:00
feba7c699d [Tool / AutoTool] clarify some docstings 2026-01-19 22:20:09 -08:00
70559308a1 [Pather] clarify a variable name 2026-01-19 22:20:09 -08:00
021142533d [AutoTool] enable S-bends 2026-01-19 22:20:09 -08:00
b1c838c8fd [AutoTool / SimpleTool] remove append arg 2026-01-19 22:20:09 -08:00
7c033edc21 [SimpleTool/AutoTool] clarify some error messages 2026-01-19 22:20:09 -08:00
601773d17e [AutoTool/SimpleTool/BasicTool] Rename BasicTool->SimpleTool and remove transition handling. Export AutoTool and SimpleTool at top level. 2026-01-19 22:20:09 -08:00
cf92cc06b3 [AutoTool] pass in kwargs to straight fn call 2026-01-19 22:20:09 -08:00
fcb622441b [AutoTool] consolidate duplicate code for path() and render() 2026-01-19 22:20:09 -08:00
2e57724095 [AutoTool] add add_complementary_transitions() 2026-01-19 22:20:09 -08:00
a69860ad9c [AutoTool] Use more dataclasses to clarify internal code 2026-01-19 22:20:09 -08:00
82f3e7ab8f [PolyCollection] rename setter arg to placate linter 2026-01-19 22:20:09 -08:00
a308b1515a [format_stacktrace] suppress linter 2026-01-19 22:20:09 -08:00
38d4c4b6af [AutoTool] support min/max length for straight segments 2026-01-19 22:20:09 -08:00
4822ae8708 [BasicTool/AutoTool] fix port orientation for straight segments when using RenderPather 2026-01-19 22:20:09 -08:00
d00899bb39 [AutoTool] Add first pass for AutoTool 2026-01-19 22:20:09 -08:00
a908fadfc3 [RenderPather] add wrapped label/ref/polygon/rect functions 2026-01-19 22:20:09 -08:00
92875cfdb6 [Pather/RenderPather/PatherMixin] clean up imports 2026-01-19 22:20:09 -08:00
11306dbb56 [Pather / RenderPather] move common functionality into PatherMixin; redo hierarchy
- (BREAKING change) Pather.mpath no longer wraps the whole bus into a
container, since this has no equivalent in RenderPather. Possible this
functionality will return in the future
- Removed `tool_port_names` arg from Pather functions
- In general RenderPather should be much closer to Pather now
2026-01-19 22:20:09 -08:00
fc9d4c6ba2 [pather] code style changes 2026-01-19 22:20:09 -08:00
8fa1d0479c [error] also exclude concurrent.futures.process from traces 2026-01-19 22:20:09 -08:00
a4b93419b4 [error] also exclude frames starting with '<frozen' 2026-01-19 22:20:09 -08:00
549193534f [file.svg] use logger.warning over warnings.warn (for flexibility) 2026-01-19 22:20:09 -08:00
6a494b99a0 [ports] make port mismatch deltas more obvious 2026-01-19 22:20:09 -08:00
705a1cef78 [error, ports] Make stack traces more directly reflect teh location of the issue 2026-01-19 22:20:09 -08:00
b8ab3b91f5 misc cleanup: variable naming, typing, comments 2026-01-19 22:20:09 -08:00
34a43a707c [Path / PolyCollection / Polygon] fix order of rotation/offset 2026-01-19 22:20:09 -08:00
aca49dc7e3 [Polygon / Path / PolyCollection] Force polygon/path offset to (0, 0)
And disallow setting it.

This offset was basically just a footgun.
2026-01-19 22:20:09 -08:00
e231fa89cb [Polygon.rect] use floats more explicitly 2026-01-19 22:20:09 -08:00
0c04bf8ea3 Various type-checking improvements 2026-01-19 22:20:09 -08:00
e5f0c85560 [BasicTool] enable straight to handle trees (not just flat patterns) 2026-01-19 22:20:09 -08:00
2961ae5471 [builder.tools] Handle in_ptype=None 2026-01-19 22:20:09 -08:00
ba05e40f84 [traits.annotatable] Don't break when setting annotations to None 2026-01-19 22:20:09 -08:00
314910d363 [shapes] Don't create empty dicts for annotations 2026-01-19 22:20:09 -08:00
jan
fbe804750b [PolyCollection] add PolyCollection shape
based on ndarrays of vertices and offsets
2026-01-19 22:20:09 -08:00
aee0d5b619 [utils.curves] ignore re-import of trapeziod 2026-01-19 22:20:09 -08:00
e00d82bbc4 allow annotations to be None
breaking change, but properties are seldom used by anyone afaik
2026-01-19 22:20:09 -08:00
34 changed files with 2001 additions and 890 deletions

View File

@ -133,7 +133,7 @@ tree = make_tree(...)
# To reference this cell in our layout, we have to add all its children to our `library` first: # To reference this cell in our layout, we have to add all its children to our `library` first:
top_name = tree.top() # get the name of the topcell top_name = tree.top() # get the name of the topcell
name_mapping = library.add(tree) # add all patterns from `tree`, renaming elgible conflicting patterns name_mapping = library.add(tree) # add all patterns from `tree`, renaming eligible conflicting patterns
new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed) new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed)
my_pattern.ref(new_name, ...) # instantiate the cell my_pattern.ref(new_name, ...) # instantiate the cell
@ -176,7 +176,7 @@ my_pattern.place(library << make_tree(...), ...)
### Quickly add geometry, labels, or refs: ### Quickly add geometry, labels, or refs:
The long form for adding elements can be overly verbose: Adding elements can be overly verbose:
```python3 ```python3
my_pattern.shapes[layer].append(Polygon(vertices, ...)) my_pattern.shapes[layer].append(Polygon(vertices, ...))
my_pattern.labels[layer] += [Label('my text')] my_pattern.labels[layer] += [Label('my text')]
@ -228,9 +228,11 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
## TODO ## TODO
* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath)
* PolyCollection & arrow-based read/write
* pather and renderpather examples, including .at() (PortPather)
* Bus-to-bus connections?
* Tests tests tests
* Better interface for polygon operations (e.g. with `pyclipper`) * Better interface for polygon operations (e.g. with `pyclipper`)
- de-embedding - de-embedding
- boolean ops - boolean ops
* Tests tests tests
* check renderpather
* pather and renderpather examples

View File

@ -77,8 +77,10 @@ from .builder import (
Pather as Pather, Pather as Pather,
RenderPather as RenderPather, RenderPather as RenderPather,
RenderStep as RenderStep, RenderStep as RenderStep,
BasicTool as BasicTool, SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool, PathTool as PathTool,
PortPather as PortPather,
) )
from .utils import ( from .utils import (
ports2data as ports2data, ports2data as ports2data,

View File

@ -1,10 +1,12 @@
from .builder import Builder as Builder from .builder import Builder as Builder
from .pather import Pather as Pather from .pather import Pather as Pather
from .renderpather import RenderPather as RenderPather from .renderpather import RenderPather as RenderPather
from .pather_mixin import PortPather as PortPather
from .utils import ell as ell from .utils import ell as ell
from .tools import ( from .tools import (
Tool as Tool, Tool as Tool,
RenderStep as RenderStep, RenderStep as RenderStep,
BasicTool as BasicTool, SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool, PathTool as PathTool,
) )

View File

@ -67,7 +67,7 @@ class Builder(PortList):
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
argument is provided, and the `inherit_name` argument is not explicitly argument is provided, and the `thru` argument is not explicitly
set to `False`, the unconnected port of `wire` is automatically renamed to set to `False`, the unconnected port of `wire` is automatically renamed to
'myport'. This allows easy extension of existing ports without changing 'myport'. This allows easy extension of existing ports without changing
their names or having to provide `map_out` each time `plug` is called. their names or having to provide `map_out` each time `plug` is called.
@ -223,7 +223,7 @@ class Builder(PortList):
map_out: dict[str, str | None] | None = None, map_out: dict[str, str | None] | None = None,
*, *,
mirrored: bool = False, mirrored: bool = False,
inherit_name: bool = True, thru: bool | str = True,
set_rotation: bool | None = None, set_rotation: bool | None = None,
append: bool = False, append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (), ok_connections: Iterable[tuple[str, str]] = (),
@ -246,11 +246,15 @@ class Builder(PortList):
new names for ports in `other`. new names for ports in `other`.
mirrored: Enables mirroring `other` across the x axis prior to mirrored: Enables mirroring `other` across the x axis prior to
connecting any ports. connecting any ports.
inherit_name: If `True`, and `map_in` specifies only a single port, thru: If map_in specifies only a single port, `thru` provides a mechainsm
and `map_out` is `None`, and `other` has only two ports total, to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
then automatically renames the output port of `other` to the - If True (default), and `other` has only two ports total, and map_out
name of the port from `self` that appears in `map_in`. This doesn't specify a name for the other port, its name is set to the key
makes it easy to extend a device with simple 2-port devices in `map_in`, i.e. 'myport'.
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
An error is raised if that entry already exists.
This makes it easy to extend a pattern with simple 2-port devices
(e.g. wires) without providing `map_out` each time `plug` is (e.g. wires) without providing `map_out` each time `plug` is
called. See "Examples" above for more info. Default `True`. called. See "Examples" above for more info. Default `True`.
set_rotation: If the necessary rotation cannot be determined from set_rotation: If the necessary rotation cannot be determined from
@ -292,14 +296,14 @@ class Builder(PortList):
other = self.library[other.name] other = self.library[other.name]
self.pattern.plug( self.pattern.plug(
other=other, other = other,
map_in=map_in, map_in = map_in,
map_out=map_out, map_out = map_out,
mirrored=mirrored, mirrored = mirrored,
inherit_name=inherit_name, thru = thru,
set_rotation=set_rotation, set_rotation = set_rotation,
append=append, append = append,
ok_connections=ok_connections, ok_connections = ok_connections,
) )
return self return self
@ -365,14 +369,14 @@ class Builder(PortList):
other = self.library[other.name] other = self.library[other.name]
self.pattern.place( self.pattern.place(
other=other, other = other,
offset=offset, offset = offset,
rotation=rotation, rotation = rotation,
pivot=pivot, pivot = pivot,
mirrored=mirrored, mirrored = mirrored,
port_map=port_map, port_map = port_map,
skip_port_check=skip_port_check, skip_port_check = skip_port_check,
append=append, append = append,
) )
return self return self

View File

@ -2,31 +2,25 @@
Manual wire/waveguide routing (`Pather`) Manual wire/waveguide routing (`Pather`)
""" """
from typing import Self from typing import Self
from collections.abc import Sequence, MutableMapping, Mapping, Iterator from collections.abc import Sequence, Mapping, MutableMapping
import copy import copy
import logging import logging
from contextlib import contextmanager
from pprint import pformat from pprint import pformat
import numpy
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern from ..pattern import Pattern
from ..library import ILibrary, SINGLE_USE_PREFIX from ..library import ILibrary
from ..error import PortError, BuildError from ..error import BuildError
from ..ports import PortList, Port from ..ports import PortList, Port
from ..abstract import Abstract from ..utils import SupportsBool
from ..utils import SupportsBool, rotation_matrix_2d
from .tools import Tool from .tools import Tool
from .utils import ell from .pather_mixin import PatherMixin
from .builder import Builder from .builder import Builder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Pather(Builder): class Pather(Builder, PatherMixin):
""" """
An extension of `Builder` which provides functionality for routing and attaching An extension of `Builder` which provides functionality for routing and attaching
single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns.
@ -258,60 +252,6 @@ class Pather(Builder):
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>' s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
return s return s
def retool(
self,
tool: Tool,
keys: str | Sequence[str | None] | None = None,
) -> Self:
"""
Update the `Tool` which will be used when generating `Pattern`s for the ports
given by `keys`.
Args:
tool: The new `Tool` to use for the given ports.
keys: Which ports the tool should apply to. `None` indicates the default tool,
used when there is no matching entry in `self.tools` for the port in question.
Returns:
self
"""
if keys is None or isinstance(keys, str):
self.tools[keys] = tool
else:
for key in keys:
self.tools[key] = tool
return self
@contextmanager
def toolctx(
self,
tool: Tool,
keys: str | Sequence[str | None] | None = None,
) -> Iterator[Self]:
"""
Context manager for temporarily `retool`-ing and reverting the `retool`
upon exiting the context.
Args:
tool: The new `Tool` to use for the given ports.
keys: Which ports the tool should apply to. `None` indicates the default tool,
used when there is no matching entry in `self.tools` for the port in question.
Returns:
self
"""
if keys is None or isinstance(keys, str):
keys = [keys]
saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None`
try:
yield self.retool(tool=tool, keys=keys)
finally:
for kk, tt in saved_tools.items():
if tt is None:
# delete if present
self.tools.pop(kk, None)
else:
self.tools[kk] = tt
def path( def path(
self, self,
@ -319,7 +259,6 @@ class Pather(Builder):
ccw: SupportsBool | None, ccw: SupportsBool | None,
length: float, length: float,
*, *,
tool_port_names: tuple[str, str] = ('A', 'B'),
plug_into: str | None = None, plug_into: str | None = None,
**kwargs, **kwargs,
) -> Self: ) -> Self:
@ -327,7 +266,7 @@ class Pather(Builder):
Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
of traveling exactly `length` distance. of traveling exactly `length` distance.
The wire will travel `length` distance along the port's axis, an an unspecified The wire will travel `length` distance along the port's axis, and an unspecified
(tool-dependent) distance in the perpendicular direction. The output port will (tool-dependent) distance in the perpendicular direction. The output port will
be rotated (or not) based on the `ccw` parameter. be rotated (or not) based on the `ccw` parameter.
@ -338,9 +277,6 @@ class Pather(Builder):
and clockwise otherwise. and clockwise otherwise.
length: The total distance from input to output, along the input's axis only. length: The total distance from input to output, along the input's axis only.
(There may be a tool-dependent offset along the other axis.) (There may be a tool-dependent offset along the other axis.)
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
plug_into: If not None, attempts to plug the wire's output port into the provided plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`. port on `self`.
@ -355,54 +291,44 @@ class Pather(Builder):
logger.error('Skipping path() since device is dead') logger.error('Skipping path() since device is dead')
return self return self
tool_port_names = ('A', 'B')
tool = self.tools.get(portspec, self.tools[None]) tool = self.tools.get(portspec, self.tools[None])
in_ptype = self.pattern[portspec].ptype in_ptype = self.pattern[portspec].ptype
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
abstract = self.library << tree tname = self.library << tree
if plug_into is not None: if plug_into is not None:
output = {plug_into: tool_port_names[1]} output = {plug_into: tool_port_names[1]}
else: else:
output = {} output = {}
return self.plug(abstract, {portspec: tool_port_names[0], **output}) self.plug(tname, {portspec: tool_port_names[0], **output})
return self
def path_to( def pathS(
self, self,
portspec: str, portspec: str,
ccw: SupportsBool | None, length: float,
position: float | None = None, jog: float,
*, *,
x: float | None = None,
y: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'),
plug_into: str | None = None, plug_into: str | None = None,
**kwargs, **kwargs,
) -> Self: ) -> Self:
""" """
Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
of ending exactly at a target position. of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
left of direction of travel).
The wire will travel so that the output port will be placed at exactly the target The output port will have the same orientation as the source port (`portspec`).
position along the input port's axis. There can be an unspecified (tool-dependent)
offset in the perpendicular direction. The output port will be rotated (or not) This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
based on the `ccw` parameter. raises a NotImplementedError.
Args: Args:
portspec: The name of the port into which the wire will be plugged. portspec: The name of the port into which the wire will be plugged.
ccw: If `None`, the output should be along the same axis as the input. jog: Total manhattan distance perpendicular to the direction of travel.
Otherwise, cast to bool and turn counterclockwise if True Positive values are to the left of the direction of travel.
and clockwise otherwise. length: The total manhattan distance from input to output, along the input's axis only.
position: The final port position, along the input's axis only.
(There may be a tool-dependent offset along the other axis.) (There may be a tool-dependent offset along the other axis.)
Only one of `position`, `x`, and `y` may be specified.
x: The final port position along the x axis.
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
BuildError will be raised.
y: The final port position along the y axis.
`portspec` must refer to a vertical port if `y` is passed, otherwise a
BuildError will be raised.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
plug_into: If not None, attempts to plug the wire's output port into the provided plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`. port on `self`.
@ -410,317 +336,40 @@ class Pather(Builder):
self self
Raises: Raises:
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
is present). LibraryError if no valid name could be picked for the pattern.
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
BuildError if more than one of `x`, `y`, and `position` is specified.
""" """
if self._dead: if self._dead:
logger.error('Skipping path_to() since device is dead') logger.error('Skipping pathS() since device is dead')
return self return self
pos_count = sum(vv is not None for vv in (position, x, y)) tool_port_names = ('A', 'B')
if pos_count > 1:
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
if pos_count < 1:
raise BuildError('One of `position`, `x`, and `y` must be specified')
port = self.pattern[portspec] tool = self.tools.get(portspec, self.tools[None])
if port.rotation is None: in_ptype = self.pattern[portspec].ptype
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') try:
tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
except NotImplementedError:
# Fall back to drawing two L-bends
ccw0 = jog > 0
kwargs_no_out = kwargs | {'out_ptype': None}
t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out)
t_pat0 = t_tree0.top_pattern()
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs)
t_pat1 = t_tree1.top_pattern()
(_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]])
if not numpy.isclose(port.rotation % (pi / 2), 0): kwargs_plug = kwargs | {'plug_into': plug_into}
raise BuildError('path_to was asked to route from non-manhattan port') self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self
is_horizontal = numpy.isclose(port.rotation % pi, 0) tname = self.library << tree
if is_horizontal: if plug_into is not None:
if y is not None: output = {plug_into: tool_port_names[1]}
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
if position is None:
position = x
else: else:
if x is not None: output = {}
raise BuildError('Asked to path to x-coordinate, but port is vertical') self.plug(tname, {portspec: tool_port_names[0], **output})
if position is None:
position = y
x0, y0 = port.offset
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
length = numpy.abs(position - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0)
return self.path(
portspec,
ccw,
length,
tool_port_names=tool_port_names,
plug_into=plug_into,
**kwargs,
)
def path_into(
self,
portspec_src: str,
portspec_dst: str,
*,
tool_port_names: tuple[str, str] = ('A', 'B'),
out_ptype: str | None = None,
plug_destination: bool = True,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" and traveling between the ports `portspec_src` and
`portspec_dst`, and `plug` it into both (or just the source port).
Only unambiguous scenarios are allowed:
- Straight connector between facing ports
- Single 90 degree bend
- Jog between facing ports
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
By default, the destination's `pytpe` will be used as the `out_ptype` for the
wire, and the `portspec_dst` will be plugged (i.e. removed).
Args:
portspec_src: The name of the starting port into which the wire will be plugged.
portspec_dst: The name of the destination port.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
out_ptype: Passed to the pathing tool in order to specify the desired port type
to be generated at the destination end. If `None` (default), the destination
port's `ptype` will be used.
Returns:
self
Raises:
PortError if either port does not have a specified rotation.
BuildError if and invalid port config is encountered:
- Non-manhattan ports
- U-bend
- Destination too close to (or behind) source
"""
if self._dead:
logger.error('Skipping path_into() since device is dead')
return self
port_src = self.pattern[portspec_src]
port_dst = self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None:
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
if port_dst.rotation is None:
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route from non-manhattan port')
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route to non-manhattan port')
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
xs, ys = port_src.offset
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east
def get_jog(ccw: SupportsBool, length: float) -> float:
tool = self.tools.get(portspec_src, self.tools[None])
in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already...
tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs)
top2 = tree2.top_pattern()
jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset)
return jog[1] * [-1, 1][int(bool(ccw))]
dst_extra_args = {'out_ptype': out_ptype}
if plug_destination:
dst_extra_args['plug_into'] = portspec_dst
src_args = {**kwargs, 'tool_port_names': tool_port_names}
dst_args = {**src_args, **dst_extra_args}
if src_is_horizontal and not dst_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
self.path_to(portspec_src, None, y=yd, **dst_args)
elif dst_is_horizontal and not src_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
self.path_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
if src_is_horizontal and ys == yd:
# straight connector
self.path_to(portspec_src, None, x=xd, **dst_args)
elif not src_is_horizontal and xs == xd:
# straight connector
self.path_to(portspec_src, None, y=yd, **dst_args)
elif src_is_horizontal:
# figure out how much x our y-segment (2nd) takes up, then path based on that
y_len = numpy.abs(yd - ys)
ccw2 = src_ne != (yd > ys)
jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs)
self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args)
self.path_to(portspec_src, ccw2, y=yd, **dst_args)
else:
# figure out how much y our x-segment (2nd) takes up, then path based on that
x_len = numpy.abs(xd - xs)
ccw2 = src_ne != (xd < xs)
jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys)
self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args)
self.path_to(portspec_src, ccw2, x=xd, **dst_args)
elif numpy.isclose(angle, 0):
raise BuildError('Don\'t know how to route a U-bend at this time!')
else:
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
return self
def mpath(
self,
portspec: str | Sequence[str],
ccw: SupportsBool | None,
*,
spacing: float | ArrayLike | None = None,
set_rotation: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'),
force_container: bool = False,
base_name: str = SINGLE_USE_PREFIX + 'mpath',
**kwargs,
) -> Self:
"""
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
of "wires or "waveguides".
The wires will travel so that the output ports will be placed at well-defined
locations along the axis of their input ports, but may have arbitrary (tool-
dependent) offsets in the perpendicular direction.
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
which is required when `ccw` is not `None`. The final position of bundle as a
whole can be set in a number of ways:
=A>---------------------------V turn direction: `ccw=False`
=B>-------------V |
=C>-----------------------V |
=D=>----------------V |
|
x---x---x---x `spacing` (can be scalar or array)
<--------------> `emin=`
<------> `bound_type='min_past_furthest', bound=`
<--------------------------------> `emax=`
x `pmin=`
x `pmax=`
- `emin=`, equivalent to `bound_type='min_extension', bound=`
The total extension value for the furthest-out port (B in the diagram).
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
The total extension value for the closest-in port (C in the diagram).
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
The coordinate of the innermost bend (D's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
The coordinate of the outermost bend (A's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `bound_type='min_past_furthest', bound=`:
The distance between furthest out-port (B) and the innermost bend (D's bend).
If `ccw=None`, final output positions (along the input axis) of all wires will be
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
required. In this case, `emin=` and `emax=` are equivalent to each other, and
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
Args:
portspec: The names of the ports which are to be routed.
ccw: If `None`, the outputs should be along the same axis as the inputs.
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Must be provided if (and only if) `ccw` is not `None`.
set_rotation: If the provided ports have `rotation=None`, this can be used
to set a rotation for them.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
force_container: If `False` (default), and only a single port is provided, the
generated wire for that port will be referenced directly, rather than being
wrapped in an additonal `Pattern`.
base_name: Name to use for the generated `Pattern`. This will be passed through
`self.library.get_name()` to get a unique name for each new `Pattern`.
Returns:
self
Raises:
BuildError if the implied length for any wire is too close to fit the bend
(if a bend is requested).
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
match the axis of `portspec`.
BuildError if an incorrect bound type or spacing is specified.
"""
if self._dead:
logger.error('Skipping mpath() since device is dead')
return self
bound_types = set()
if 'bound_type' in kwargs:
bound_types.add(kwargs['bound_type'])
bound = kwargs['bound']
del kwargs['bound_type']
del kwargs['bound']
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in kwargs:
bound_types.add(bt)
bound = kwargs[bt]
del kwargs[bt]
if not bound_types:
raise BuildError('No bound type specified for mpath')
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
if isinstance(portspec, str):
portspec = [portspec]
ports = self.pattern[tuple(portspec)]
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
if len(ports) == 1 and not force_container:
# Not a bus, so having a container just adds noise to the layout
port_name = tuple(portspec)[0]
return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names, **kwargs)
bld = Pather.interface(source=ports, library=self.library, tools=self.tools)
for port_name, length in extensions.items():
bld.path(port_name, ccw, length, tool_port_names=tool_port_names, **kwargs)
name = self.library.get_name(base_name)
self.library[name] = bld.pattern
return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
# TODO def bus_join()?
def flatten(self) -> Self:
"""
Flatten the contained pattern, using the contained library to resolve references.
Returns:
self
"""
self.pattern.flatten(self.library)
return self return self

View File

@ -0,0 +1,677 @@
from typing import Self, overload
from collections.abc import Sequence, Iterator, Iterable
import logging
from contextlib import contextmanager
from abc import abstractmethod, ABCMeta
import numpy
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..library import ILibrary, TreeView
from ..error import PortError, BuildError
from ..utils import SupportsBool
from ..abstract import Abstract
from .tools import Tool
from .utils import ell
from ..ports import PortList
logger = logging.getLogger(__name__)
class PatherMixin(PortList, metaclass=ABCMeta):
pattern: Pattern
""" Layout of this device """
library: ILibrary
""" Library from which patterns should be referenced """
_dead: bool
""" If True, plug()/place() are skipped (for debugging) """
tools: dict[str | None, Tool]
"""
Tool objects are used to dynamically generate new single-use Devices
(e.g wires or waveguides) to be plugged into this device.
"""
@abstractmethod
def path(
self,
portspec: str,
ccw: SupportsBool | None,
length: float,
*,
plug_into: str | None = None,
**kwargs,
) -> Self:
pass
@abstractmethod
def pathS(
self,
portspec: str,
length: float,
jog: float,
*,
plug_into: str | None = None,
**kwargs,
) -> Self:
pass
@abstractmethod
def plug(
self,
other: Abstract | str | Pattern | TreeView,
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: bool = False,
thru: bool | str = True,
set_rotation: bool | None = None,
append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
) -> Self:
pass
def retool(
self,
tool: Tool,
keys: str | Sequence[str | None] | None = None,
) -> Self:
"""
Update the `Tool` which will be used when generating `Pattern`s for the ports
given by `keys`.
Args:
tool: The new `Tool` to use for the given ports.
keys: Which ports the tool should apply to. `None` indicates the default tool,
used when there is no matching entry in `self.tools` for the port in question.
Returns:
self
"""
if keys is None or isinstance(keys, str):
self.tools[keys] = tool
else:
for key in keys:
self.tools[key] = tool
return self
@contextmanager
def toolctx(
self,
tool: Tool,
keys: str | Sequence[str | None] | None = None,
) -> Iterator[Self]:
"""
Context manager for temporarily `retool`-ing and reverting the `retool`
upon exiting the context.
Args:
tool: The new `Tool` to use for the given ports.
keys: Which ports the tool should apply to. `None` indicates the default tool,
used when there is no matching entry in `self.tools` for the port in question.
Returns:
self
"""
if keys is None or isinstance(keys, str):
keys = [keys]
saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None`
try:
yield self.retool(tool=tool, keys=keys)
finally:
for kk, tt in saved_tools.items():
if tt is None:
# delete if present
self.tools.pop(kk, None)
else:
self.tools[kk] = tt
def path_to(
self,
portspec: str,
ccw: SupportsBool | None,
position: float | None = None,
*,
x: float | None = None,
y: float | None = None,
plug_into: str | None = None,
**kwargs,
) -> Self:
"""
Build a "wire"/"waveguide" extending from the port `portspec`, with the aim
of ending exactly at a target position.
The wire will travel so that the output port will be placed at exactly the target
position along the input port's axis. There can be an unspecified (tool-dependent)
offset in the perpendicular direction. The output port will be rotated (or not)
based on the `ccw` parameter.
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec: The name of the port into which the wire will be plugged.
ccw: If `None`, the output should be along the same axis as the input.
Otherwise, cast to bool and turn counterclockwise if True
and clockwise otherwise.
position: The final port position, along the input's axis only.
(There may be a tool-dependent offset along the other axis.)
Only one of `position`, `x`, and `y` may be specified.
x: The final port position along the x axis.
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
BuildError will be raised.
y: The final port position along the y axis.
`portspec` must refer to a vertical port if `y` is passed, otherwise a
BuildError will be raised.
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns:
self
Raises:
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend
is present).
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
BuildError if more than one of `x`, `y`, and `position` is specified.
"""
if self._dead:
logger.error('Skipping path_to() since device is dead')
return self
pos_count = sum(vv is not None for vv in (position, x, y))
if pos_count > 1:
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
if pos_count < 1:
raise BuildError('One of `position`, `x`, and `y` must be specified')
port = self.pattern[portspec]
if port.rotation is None:
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
if not numpy.isclose(port.rotation % (pi / 2), 0):
raise BuildError('path_to was asked to route from non-manhattan port')
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if y is not None:
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
if position is None:
position = x
else:
if x is not None:
raise BuildError('Asked to path to x-coordinate, but port is vertical')
if position is None:
position = y
x0, y0 = port.offset
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
length = numpy.abs(position - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0)
return self.path(
portspec,
ccw,
length,
plug_into = plug_into,
**kwargs,
)
def path_into(
self,
portspec_src: str,
portspec_dst: str,
*,
out_ptype: str | None = None,
plug_destination: bool = True,
thru: str | None = None,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" traveling between the ports `portspec_src` and
`portspec_dst`, and `plug` it into both (or just the source port).
Only unambiguous scenarios are allowed:
- Straight connector between facing ports
- Single 90 degree bend
- Jog between facing ports
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
By default, the destination's `pytpe` will be used as the `out_ptype` for the
wire, and the `portspec_dst` will be plugged (i.e. removed).
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec_src: The name of the starting port into which the wire will be plugged.
portspec_dst: The name of the destination port.
out_ptype: Passed to the pathing tool in order to specify the desired port type
to be generated at the destination end. If `None` (default), the destination
port's `ptype` will be used.
thru: If not `None`, the port by this name will be rename to `portspec_src`.
This can be used when routing a signal through a pre-placed 2-port device.
Returns:
self
Raises:
PortError if either port does not have a specified rotation.
BuildError if and invalid port config is encountered:
- Non-manhattan ports
- U-bend
- Destination too close to (or behind) source
"""
if self._dead:
logger.error('Skipping path_into() since device is dead')
return self
port_src = self.pattern[portspec_src]
port_dst = self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None:
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
if port_dst.rotation is None:
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route from non-manhattan port')
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route to non-manhattan port')
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
xs, ys = port_src.offset
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
dst_extra_args = {'out_ptype': out_ptype}
if plug_destination:
dst_extra_args['plug_into'] = portspec_dst
src_args = {**kwargs}
dst_args = {**src_args, **dst_extra_args}
if src_is_horizontal and not dst_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
self.path_to(portspec_src, None, y=yd, **dst_args)
elif dst_is_horizontal and not src_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
self.path_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
if src_is_horizontal and ys == yd:
# straight connector
self.path_to(portspec_src, None, x=xd, **dst_args)
elif not src_is_horizontal and xs == xd:
# straight connector
self.path_to(portspec_src, None, y=yd, **dst_args)
else:
# S-bend, delegate to implementations
(travel, jog), _ = port_src.measure_travel(port_dst)
self.pathS(portspec_src, -travel, -jog, **dst_args)
elif numpy.isclose(angle, 0):
raise BuildError('Don\'t know how to route a U-bend yet (TODO)!')
else:
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
if thru is not None:
self.rename_ports({thru: portspec_src})
return self
def mpath(
self,
portspec: str | Sequence[str],
ccw: SupportsBool | None,
*,
spacing: float | ArrayLike | None = None,
set_rotation: float | None = None,
**kwargs,
) -> Self:
"""
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
of "wires or "waveguides".
The wires will travel so that the output ports will be placed at well-defined
locations along the axis of their input ports, but may have arbitrary (tool-
dependent) offsets in the perpendicular direction.
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
which is required when `ccw` is not `None`. The final position of bundle as a
whole can be set in a number of ways:
=A>---------------------------V turn direction: `ccw=False`
=B>-------------V |
=C>-----------------------V |
=D=>----------------V |
|
x---x---x---x `spacing` (can be scalar or array)
<--------------> `emin=`
<------> `bound_type='min_past_furthest', bound=`
<--------------------------------> `emax=`
x `pmin=`
x `pmax=`
- `emin=`, equivalent to `bound_type='min_extension', bound=`
The total extension value for the furthest-out port (B in the diagram).
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
The total extension value for the closest-in port (C in the diagram).
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
The coordinate of the innermost bend (D's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
The coordinate of the outermost bend (A's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `bound_type='min_past_furthest', bound=`:
The distance between furthest out-port (B) and the innermost bend (D's bend).
If `ccw=None`, final output positions (along the input axis) of all wires will be
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
required. In this case, `emin=` and `emax=` are equivalent to each other, and
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec: The names of the ports which are to be routed.
ccw: If `None`, the outputs should be along the same axis as the inputs.
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Must be provided if (and only if) `ccw` is not `None`.
set_rotation: If the provided ports have `rotation=None`, this can be used
to set a rotation for them.
Returns:
self
Raises:
BuildError if the implied length for any wire is too close to fit the bend
(if a bend is requested).
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
match the axis of `portspec`.
BuildError if an incorrect bound type or spacing is specified.
"""
if self._dead:
logger.error('Skipping mpath() since device is dead')
return self
bound_types = set()
if 'bound_type' in kwargs:
bound_types.add(kwargs.pop('bound_type'))
bound = kwargs.pop('bound')
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in kwargs:
bound_types.add(bt)
bound = kwargs.pop(bt)
if not bound_types:
raise BuildError('No bound type specified for mpath')
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
if isinstance(portspec, str):
portspec = [portspec]
ports = self.pattern[tuple(portspec)]
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
#if container:
# assert not getattr(self, 'render'), 'Containers not implemented for RenderPather'
# bld = self.interface(source=ports, library=self.library, tools=self.tools)
# for port_name, length in extensions.items():
# bld.path(port_name, ccw, length, **kwargs)
# self.library[container] = bld.pattern
# self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
#else:
for port_name, length in extensions.items():
self.path(port_name, ccw, length, **kwargs)
return self
# TODO def bus_join()?
def flatten(self) -> Self:
"""
Flatten the contained pattern, using the contained library to resolve references.
Returns:
self
"""
self.pattern.flatten(self.library)
return self
def at(self, portspec: str | Iterable[str]) -> 'PortPather':
return PortPather(portspec, self)
class PortPather:
"""
Port state manager
This class provides a convenient way to perform multiple pathing operations on a
set of ports without needing to repeatedly pass their names.
"""
ports: list[str]
pather: PatherMixin
def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None:
self.ports = [ports] if isinstance(ports, str) else list(ports)
self.pather = pather
#
# Delegate to pather
#
def retool(self, tool: Tool) -> Self:
self.pather.retool(tool, keys=self.ports)
return self
@contextmanager
def toolctx(self, tool: Tool) -> Iterator[Self]:
with self.pather.toolctx(tool, keys=self.ports):
yield self
def path(self, *args, **kwargs) -> Self:
if len(self.ports) > 1:
logger.warning('Use path_each() when pathing multiple ports independently')
for port in self.ports:
self.pather.path(port, *args, **kwargs)
return self
def path_each(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather.path(port, *args, **kwargs)
return self
def pathS(self, *args, **kwargs) -> Self:
if len(self.ports) > 1:
logger.warning('Use pathS_each() when pathing multiple ports independently')
for port in self.ports:
self.pather.pathS(port, *args, **kwargs)
return self
def pathS_each(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather.pathS(port, *args, **kwargs)
return self
def path_to(self, *args, **kwargs) -> Self:
if len(self.ports) > 1:
logger.warning('Use path_each_to() when pathing multiple ports independently')
for port in self.ports:
self.pather.path_to(port, *args, **kwargs)
return self
def path_each_to(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather.path_to(port, *args, **kwargs)
return self
def mpath(self, *args, **kwargs) -> Self:
self.pather.mpath(self.ports, *args, **kwargs)
return self
def path_into(self, *args, **kwargs) -> Self:
""" Path_into, using the current port as the source """
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.')
self.pather.path_into(self.ports[0], *args, **kwargs)
return self
def path_from(self, *args, **kwargs) -> Self:
""" Path_into, using the current port as the destination """
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.')
thru = kwargs.pop('thru', None)
self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs)
if thru is not None:
self.rename_from(thru)
return self
def plug(
self,
other: Abstract | str,
other_port: str,
*args,
**kwargs,
) -> Self:
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.'
'Use the pather or pattern directly to plug multiple ports.')
self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs)
return self
def plugged(self, other_port: str) -> Self:
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.')
self.pather.plugged({self.ports[0]: other_port})
return self
#
# Delegate to port
#
def set_ptype(self, ptype: str) -> Self:
for port in self.ports:
self.pather[port].set_ptype(ptype)
return self
def translate(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather[port].translate(*args, **kwargs)
return self
def mirror(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather[port].mirror(*args, **kwargs)
return self
def rotate(self, rotation: float) -> Self:
for port in self.ports:
self.pather[port].rotate(rotation)
return self
def set_rotation(self, rotation: float | None) -> Self:
for port in self.ports:
self.pather[port].set_rotation(rotation)
return self
def rename_to(self, new_name: str) -> Self:
if len(self.ports) > 1:
BuildError('Use rename_ports() for >1 port')
self.pather.rename_ports({self.ports[0]: new_name})
self.ports[0] = new_name
return self
def rename_from(self, old_name: str) -> Self:
if len(self.ports) > 1:
BuildError('Use rename_ports() for >1 port')
self.pather.rename_ports({old_name: self.ports[0]})
return self
def rename_ports(self, name_map: dict[str, str | None]) -> Self:
self.pather.rename_ports(name_map)
self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None]
return self
def add_ports(self, ports: Iterable[str]) -> Self:
ports = list(ports)
conflicts = set(ports) & set(self.ports)
if conflicts:
raise BuildError(f'ports {conflicts} already selected')
self.ports += ports
return self
def add_port(self, port: str, index: int | None = None) -> Self:
if port in self.ports:
raise BuildError(f'{port=} already selected')
if index is not None:
self.ports.insert(index, port)
else:
self.ports.append(port)
return self
def drop_port(self, port: str) -> Self:
if port not in self.ports:
raise BuildError(f'{port=} already not selected')
self.ports = [pp for pp in self.ports if pp != port]
return self
def into_copy(self, new_name: str, src: str | None = None) -> Self:
""" Copy a port and replace it with the copy """
if not self.ports:
raise BuildError('Have no ports to copy')
if len(self.ports) == 1:
src = self.ports[0]
elif src is None:
raise BuildError('Must specify src when >1 port is available')
if src not in self.ports:
raise BuildError(f'{src=} not available')
self.pather.ports[new_name] = self.pather[src].copy()
self.ports = [(new_name if pp == src else pp) for pp in self.ports]
return self
def save_copy(self, new_name: str, src: str | None = None) -> Self:
""" Copy a port and but keep using the original """
if not self.ports:
raise BuildError('Have no ports to copy')
if len(self.ports) == 1:
src = self.ports[0]
elif src is None:
raise BuildError('Must specify src when >1 port is available')
if src not in self.ports:
raise BuildError(f'{src=} not available')
self.pather.ports[new_name] = self.pather[src].copy()
return self
@overload
def delete(self, name: None) -> None: ...
@overload
def delete(self, name: str) -> Self: ...
def delete(self, name: str | None = None) -> Self | None:
if name is None:
for pp in self.ports:
del self.pather.ports[pp]
return None
del self.pather.ports[name]
self.ports = [pp for pp in self.ports if pp != name]
return self

View File

@ -2,30 +2,30 @@
Pather with batched (multi-step) rendering Pather with batched (multi-step) rendering
""" """
from typing import Self from typing import Self
from collections.abc import Sequence, Mapping, MutableMapping from collections.abc import Sequence, Mapping, MutableMapping, Iterable
import copy import copy
import logging import logging
from collections import defaultdict from collections import defaultdict
from functools import wraps
from pprint import pformat from pprint import pformat
import numpy
from numpy import pi from numpy import pi
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ..pattern import Pattern from ..pattern import Pattern
from ..library import ILibrary from ..library import ILibrary, TreeView
from ..error import PortError, BuildError from ..error import BuildError
from ..ports import PortList, Port from ..ports import PortList, Port
from ..abstract import Abstract from ..abstract import Abstract
from ..utils import SupportsBool from ..utils import SupportsBool
from .tools import Tool, RenderStep from .tools import Tool, RenderStep
from .utils import ell from .pather_mixin import PatherMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RenderPather(PortList): class RenderPather(PatherMixin):
""" """
`RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath`
functions to plan out wire paths without incrementally generating the layout. Instead, functions to plan out wire paths without incrementally generating the layout. Instead,
@ -108,15 +108,11 @@ class RenderPather(PortList):
if self.pattern.ports: if self.pattern.ports:
raise BuildError('Ports supplied for pattern with pre-existing ports!') raise BuildError('Ports supplied for pattern with pre-existing ports!')
if isinstance(ports, str): if isinstance(ports, str):
if library is None:
raise BuildError('Ports given as a string, but `library` was `None`!')
ports = library.abstract(ports).ports ports = library.abstract(ports).ports
self.pattern.ports.update(copy.deepcopy(dict(ports))) self.pattern.ports.update(copy.deepcopy(dict(ports)))
if name is not None: if name is not None:
if library is None:
raise BuildError('Name was supplied, but no library was given!')
library[name] = self.pattern library[name] = self.pattern
if tools is None: if tools is None:
@ -186,16 +182,21 @@ class RenderPather(PortList):
new = RenderPather(library=library, pattern=pat, name=name, tools=tools) new = RenderPather(library=library, pattern=pat, name=name, tools=tools)
return new return new
def __repr__(self) -> str:
s = f'<RenderPather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
return s
def plug( def plug(
self, self,
other: Abstract | str, other: Abstract | str | Pattern | TreeView,
map_in: dict[str, str], map_in: dict[str, str],
map_out: dict[str, str | None] | None = None, map_out: dict[str, str | None] | None = None,
*, *,
mirrored: bool = False, mirrored: bool = False,
inherit_name: bool = True, thru: bool | str = True,
set_rotation: bool | None = None, set_rotation: bool | None = None,
append: bool = False, append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
) -> Self: ) -> Self:
""" """
Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P'
@ -210,11 +211,15 @@ class RenderPather(PortList):
new names for ports in `other`. new names for ports in `other`.
mirrored: Enables mirroring `other` across the x axis prior to mirrored: Enables mirroring `other` across the x axis prior to
connecting any ports. connecting any ports.
inherit_name: If `True`, and `map_in` specifies only a single port, thru: If map_in specifies only a single port, `thru` provides a mechainsm
and `map_out` is `None`, and `other` has only two ports total, to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
then automatically renames the output port of `other` to the - If True (default), and `other` has only two ports total, and map_out
name of the port from `self` that appears in `map_in`. This doesn't specify a name for the other port, its name is set to the key
makes it easy to extend a device with simple 2-port devices in `map_in`, i.e. 'myport'.
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
An error is raised if that entry already exists.
This makes it easy to extend a pattern with simple 2-port devices
(e.g. wires) without providing `map_out` each time `plug` is (e.g. wires) without providing `map_out` each time `plug` is
called. See "Examples" above for more info. Default `True`. called. See "Examples" above for more info. Default `True`.
set_rotation: If the necessary rotation cannot be determined from set_rotation: If the necessary rotation cannot be determined from
@ -225,6 +230,12 @@ class RenderPather(PortList):
append: If `True`, `other` is appended instead of being referenced. append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`). be refs (now inside `self`).
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
Returns: Returns:
self self
@ -261,13 +272,14 @@ class RenderPather(PortList):
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
self.pattern.plug( self.pattern.plug(
other=other_tgt, other = other_tgt,
map_in=map_in, map_in = map_in,
map_out=map_out, map_out = map_out,
mirrored=mirrored, mirrored = mirrored,
inherit_name=inherit_name, thru = thru,
set_rotation=set_rotation, set_rotation = set_rotation,
append=append, append = append,
ok_connections = ok_connections,
) )
return self return self
@ -333,40 +345,28 @@ class RenderPather(PortList):
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
self.pattern.place( self.pattern.place(
other=other_tgt, other = other_tgt,
offset=offset, offset = offset,
rotation=rotation, rotation = rotation,
pivot=pivot, pivot = pivot,
mirrored=mirrored, mirrored = mirrored,
port_map=port_map, port_map = port_map,
skip_port_check=skip_port_check, skip_port_check = skip_port_check,
append=append, append = append,
) )
return self return self
def retool( def plugged(
self, self,
tool: Tool, connections: dict[str, str],
keys: str | Sequence[str | None] | None = None,
) -> Self: ) -> Self:
""" for aa, bb in connections.items():
Update the `Tool` which will be used when generating `Pattern`s for the ports porta = self.ports[aa]
given by `keys`. portb = self.ports[bb]
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
Args: self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
tool: The new `Tool` to use for the given ports. PortList.plugged(self, connections)
keys: Which ports the tool should apply to. `None` indicates the default tool,
used when there is no matching entry in `self.tools` for the port in question.
Returns:
self
"""
if keys is None or isinstance(keys, str):
self.tools[keys] = tool
else:
for key in keys:
self.tools[key] = tool
return self return self
def path( def path(
@ -374,6 +374,8 @@ class RenderPather(PortList):
portspec: str, portspec: str,
ccw: SupportsBool | None, ccw: SupportsBool | None,
length: float, length: float,
*,
plug_into: str | None = None,
**kwargs, **kwargs,
) -> Self: ) -> Self:
""" """
@ -393,6 +395,8 @@ class RenderPather(PortList):
and clockwise otherwise. and clockwise otherwise.
length: The total distance from input to output, along the input's axis only. length: The total distance from input to output, along the input's axis only.
(There may be a tool-dependent offset along the other axis.) (There may be a tool-dependent offset along the other axis.)
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns: Returns:
self self
@ -423,163 +427,87 @@ class RenderPather(PortList):
self.pattern.ports[portspec] = out_port.copy() self.pattern.ports[portspec] = out_port.copy()
if plug_into is not None:
self.plugged({portspec: plug_into})
return self return self
def path_to( def pathS(
self, self,
portspec: str, portspec: str,
ccw: SupportsBool | None, length: float,
position: float | None = None, jog: float,
*, *,
x: float | None = None, plug_into: str | None = None,
y: float | None = None,
**kwargs, **kwargs,
) -> Self: ) -> Self:
""" """
Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
of ending exactly at a target position. of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
left of direction of travel).
The wire will travel so that the output port will be placed at exactly the target The output port will have the same orientation as the source port (`portspec`).
position along the input port's axis. There can be an unspecified (tool-dependent)
offset in the perpendicular direction. The output port will be rotated (or not)
based on the `ccw` parameter.
`RenderPather.render` must be called after all paths have been fully planned. `RenderPather.render` must be called after all paths have been fully planned.
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
raises a NotImplementedError.
Args: Args:
portspec: The name of the port into which the wire will be plugged. portspec: The name of the port into which the wire will be plugged.
ccw: If `None`, the output should be along the same axis as the input. jog: Total manhattan distance perpendicular to the direction of travel.
Otherwise, cast to bool and turn counterclockwise if True Positive values are to the left of the direction of travel.
and clockwise otherwise. length: The total manhattan distance from input to output, along the input's axis only.
position: The final port position, along the input's axis only.
(There may be a tool-dependent offset along the other axis.) (There may be a tool-dependent offset along the other axis.)
Only one of `position`, `x`, and `y` may be specified. plug_into: If not None, attempts to plug the wire's output port into the provided
x: The final port position along the x axis. port on `self`.
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
BuildError will be raised.
y: The final port position along the y axis.
`portspec` must refer to a vertical port if `y` is passed, otherwise a
BuildError will be raised.
Returns: Returns:
self self
Raises: Raises:
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
is present). LibraryError if no valid name could be picked for the pattern.
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
BuildError if more than one of `x`, `y`, and `position` is specified.
""" """
if self._dead: if self._dead:
logger.error('Skipping path_to() since device is dead') logger.error('Skipping pathS() since device is dead')
return self return self
pos_count = sum(vv is not None for vv in (position, x, y))
if pos_count > 1:
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
if pos_count < 1:
raise BuildError('One of `position`, `x`, and `y` must be specified')
port = self.pattern[portspec] port = self.pattern[portspec]
if port.rotation is None: in_ptype = port.ptype
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') port_rot = port.rotation
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
if not numpy.isclose(port.rotation % (pi / 2), 0): tool = self.tools.get(portspec, self.tools[None])
raise BuildError('path_to was asked to route from non-manhattan port')
is_horizontal = numpy.isclose(port.rotation % pi, 0) # check feasibility, get output port and data
if is_horizontal: try:
if y is not None: out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
raise BuildError('Asked to path to y-coordinate, but port is horizontal') except NotImplementedError:
if position is None: # Fall back to drawing two L-bends
position = x ccw0 = jog > 0
else: kwargs_no_out = (kwargs | {'out_ptype': None})
if x is not None: t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out)
raise BuildError('Asked to path to x-coordinate, but port is vertical') jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
if position is None: t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
position = y jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
x0, y0 = port.offset kwargs_plug = kwargs | {'plug_into': plug_into}
if is_horizontal: self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
length = numpy.abs(position - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0)
return self.path(portspec, ccw, length, **kwargs)
def mpath(
self,
portspec: str | Sequence[str],
ccw: SupportsBool | None,
*,
spacing: float | ArrayLike | None = None,
set_rotation: float | None = None,
**kwargs,
) -> Self:
"""
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
of "wires or "waveguides".
See `Pather.mpath` for details.
Args:
portspec: The names of the ports which are to be routed.
ccw: If `None`, the outputs should be along the same axis as the inputs.
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Must be provided if (and only if) `ccw` is not `None`.
set_rotation: If the provided ports have `rotation=None`, this can be used
to set a rotation for them.
Returns:
self
Raises:
BuildError if the implied length for any wire is too close to fit the bend
(if a bend is requested).
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
match the axis of `portspec`.
BuildError if an incorrect bound type or spacing is specified.
"""
if self._dead:
logger.error('Skipping mpath() since device is dead')
return self return self
bound_types = set() out_port.rotate_around((0, 0), pi + port_rot)
if 'bound_type' in kwargs: out_port.translate(port.offset)
bound_types.add(kwargs['bound_type']) step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
bound = kwargs['bound'] self.paths[portspec].append(step)
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): self.pattern.ports[portspec] = out_port.copy()
if bt in kwargs:
bound_types.add(bt)
bound = kwargs[bt]
if not bound_types: if plug_into is not None:
raise BuildError('No bound type specified for mpath') self.plugged({portspec: plug_into})
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
if isinstance(portspec, str):
portspec = [portspec]
ports = self.pattern[tuple(portspec)]
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
if len(ports) == 1:
# Not a bus, so having a container just adds noise to the layout
port_name = tuple(portspec)[0]
self.path(port_name, ccw, extensions[port_name])
else:
for port_name, length in extensions.items():
self.path(port_name, ccw, length)
return self return self
def render( def render(
self, self,
append: bool = True, append: bool = True,
@ -696,8 +624,23 @@ class RenderPather(PortList):
self._dead = True self._dead = True
return self return self
def __repr__(self) -> str: @wraps(Pattern.label)
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>' def label(self, *args, **kwargs) -> Self:
return s self.pattern.label(*args, **kwargs)
return self
@wraps(Pattern.ref)
def ref(self, *args, **kwargs) -> Self:
self.pattern.ref(*args, **kwargs)
return self
@wraps(Pattern.polygon)
def polygon(self, *args, **kwargs) -> Self:
self.pattern.polygon(*args, **kwargs)
return self
@wraps(Pattern.rect)
def rect(self, *args, **kwargs) -> Self:
self.pattern.rect(*args, **kwargs)
return self

View File

@ -3,7 +3,7 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
# TODO document all tools # TODO document all tools
""" """
from typing import Literal, Any from typing import Literal, Any, Self
from collections.abc import Sequence, Callable from collections.abc import Sequence, Callable
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
from dataclasses import dataclass from dataclasses import dataclass
@ -70,7 +70,7 @@ class Tool:
Create a wire or waveguide that travels exactly `length` distance along the axis Create a wire or waveguide that travels exactly `length` distance along the axis
of its input port. of its input port.
Used by `Pather`. Used by `Pather` and `RenderPather`.
The output port must be exactly `length` away along the input port's axis, but The output port must be exactly `length` away along the input port's axis, but
may be placed an additional (unspecified) distance away along the perpendicular may be placed an additional (unspecified) distance away along the perpendicular
@ -101,6 +101,48 @@ class Tool:
""" """
raise NotImplementedError(f'path() not implemented for {type(self)}') raise NotImplementedError(f'path() not implemented for {type(self)}')
def pathS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
"""
Create a wire or waveguide that travels exactly `length` distance along the axis
of its input port, and `jog` distance on the perpendicular axis.
`jog` is positive when moving left of the direction of travel (from input to ouput port).
Used by `Pather` and `RenderPather`.
The output port should be rotated to face the input port (i.e. plugging the device
into a port will move that port but keep its orientation).
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively. They should also be named `port_names[0]` and
`port_names[1]`, respectively.
Args:
length: The total distance from input to output, along the input's axis only.
jog: The total distance from input to output, along the second axis. Positive indicates
a leftward shift when moving from input to output port.
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
port_names: The output pattern will have its input port named `port_names[0]` and
its output named `port_names[1]`.
kwargs: Custom tool-specific parameters.
Returns:
A pattern tree containing the requested S-shaped (or straight) wire or waveguide
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'path() not implemented for {type(self)}')
def planL( def planL(
self, self,
ccw: SupportsBool | None, ccw: SupportsBool | None,
@ -135,7 +177,7 @@ class Tool:
kwargs: Custom tool-specific parameters. kwargs: Custom tool-specific parameters.
Returns: Returns:
The calculated output `Port` for the wire. The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises: Raises:
@ -173,7 +215,7 @@ class Tool:
kwargs: Custom tool-specific parameters. kwargs: Custom tool-specific parameters.
Returns: Returns:
The calculated output `Port` for the wire. The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises: Raises:
@ -204,14 +246,14 @@ class Tool:
Args: Args:
jog: The total offset from the input to output, along the perpendicular axis. jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a rightwards shift (i.e. clockwise bend followed A positive number implies a leftwards shift (i.e. counterclockwise bend
by a counterclockwise bend) followed by a clockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
kwargs: Custom tool-specific parameters. kwargs: Custom tool-specific parameters.
Returns: Returns:
The calculated output `Port` for the wire. The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises: Raises:
@ -223,7 +265,7 @@ class Tool:
self, self,
batch: Sequence[RenderStep], batch: Sequence[RenderStep],
*, *,
port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused) port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused)
**kwargs, # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused)
) -> ILibrary: ) -> ILibrary:
""" """
@ -245,77 +287,40 @@ abstract_tuple_t = tuple[Abstract, str, str]
@dataclass @dataclass
class BasicTool(Tool, metaclass=ABCMeta): class SimpleTool(Tool, metaclass=ABCMeta):
""" """
A simple tool which relies on a single pre-rendered `bend` pattern, a function A simple tool which relies on a single pre-rendered `bend` pattern, a function
for generating straight paths, and a table of pre-rendered `transitions` for converting for generating straight paths, and a table of pre-rendered `transitions` for converting
from non-native ptypes. from non-native ptypes.
""" """
straight: tuple[Callable[[float], Pattern], str, str] straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str]
""" `create_straight(length: float), in_port_name, out_port_name` """ """ `create_straight(length: float), in_port_name, out_port_name` """
bend: abstract_tuple_t # Assumed to be clockwise bend: abstract_tuple_t # Assumed to be clockwise
""" `clockwise_bend_abstract, in_port_name, out_port_name` """ """ `clockwise_bend_abstract, in_port_name, out_port_name` """
transitions: dict[str, abstract_tuple_t]
""" `{ptype: (transition_abstract`, ptype_port_name, other_port_name), ...}` """
default_out_ptype: str default_out_ptype: str
""" Default value for out_ptype """ """ Default value for out_ptype """
mirror_bend: bool = True
""" Whether a clockwise bend should be mirrored (vs rotated) to get a ccw bend """
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LData: class LData:
""" Data for planL """ """ Data for planL """
straight_length: float straight_length: float
straight_kwargs: dict[str, Any]
ccw: SupportsBool | None ccw: SupportsBool | None
in_transition: abstract_tuple_t | None
out_transition: abstract_tuple_t | None
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,
) -> Library:
_out_port, data = self.planL(
ccw,
length,
in_ptype=in_ptype,
out_ptype=out_ptype,
)
gen_straight, sport_in, sport_out = self.straight
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=port_names, ptype=in_ptype)
if data.in_transition:
ipat, iport_theirs, _iport_ours = data.in_transition
pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(data.straight_length, 0):
straight = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length, **kwargs)}
pat.plug(straight, {port_names[1]: sport_in})
if data.ccw is not None:
bend, bport_in, bport_out = self.bend
pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw))
if data.out_transition:
opat, oport_theirs, oport_ours = data.out_transition
pat.plug(opat, {port_names[1]: oport_ours})
return tree
def planL( def planL(
self, self,
ccw: SupportsBool | None, ccw: SupportsBool | None,
length: float, length: float,
*, *,
in_ptype: str | None = None, in_ptype: str | None = None, # noqa: ARG002 (unused)
out_ptype: str | None = None, out_ptype: str | None = None, # noqa: ARG002 (unused)
**kwargs, # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused)
) -> tuple[Port, LData]: ) -> tuple[Port, LData]:
# TODO check all the math for L-shaped bends
if ccw is not None: if ccw is not None:
bend, bport_in, bport_out = self.bend bend, bport_in, bport_out = self.bend
@ -336,87 +341,532 @@ class BasicTool(Tool, metaclass=ABCMeta):
bend_angle *= -1 bend_angle *= -1
else: else:
bend_dxy = numpy.zeros(2) bend_dxy = numpy.zeros(2)
bend_angle = 0 bend_angle = pi
in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None) if ccw is not None:
if in_transition is not None:
ipat, iport_theirs, iport_ours = in_transition
irot = ipat.ports[iport_theirs].rotation
assert irot is not None
itrans_dxy = rotation_matrix_2d(-irot) @ (
ipat.ports[iport_ours].offset
- ipat.ports[iport_theirs].offset
)
else:
itrans_dxy = numpy.zeros(2)
out_transition = self.transitions.get('unk' if out_ptype is None else out_ptype, None)
if out_transition is not None:
opat, oport_theirs, oport_ours = out_transition
orot = opat.ports[oport_ours].rotation
assert orot is not None
otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ (
opat.ports[oport_theirs].offset
- opat.ports[oport_ours].offset
)
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 out_ptype_actual = bend.ports[bport_out].ptype
else: else:
out_ptype_actual = self.default_out_ptype out_ptype_actual = self.default_out_ptype
straight_length = length - bend_dxy[0] - itrans_dxy[0] - otrans_dxy[0] straight_length = length - bend_dxy[0]
bend_run = bend_dxy[1] + itrans_dxy[1] + otrans_dxy[1] bend_run = bend_dxy[1]
if straight_length < 0: if straight_length < 0:
raise BuildError( raise BuildError(
f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n' f'Asked to draw L-path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})'
f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g} out_trans: {otrans_dxy[0]:,g}'
) )
data = self.LData(straight_length, ccw, in_transition, out_transition) data = self.LData(straight_length, kwargs, ccw)
out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual)
return out_port, data return out_port, data
def _renderL(
self,
data: LData,
tree: ILibrary,
port_names: tuple[str, str],
straight_kwargs: dict[str, Any],
) -> ILibrary:
"""
Render an L step into a preexisting tree
"""
pat = tree.top_pattern()
gen_straight, sport_in, _sport_out = self.straight
if not numpy.isclose(data.straight_length, 0):
straight_pat_or_tree = gen_straight(data.straight_length, **(straight_kwargs | data.straight_kwargs))
pmap = {port_names[1]: sport_in}
if isinstance(straight_pat_or_tree, Pattern):
straight_pat = straight_pat_or_tree
pat.plug(straight_pat, pmap, append=True)
else:
straight_tree = straight_pat_or_tree
top = straight_tree.top()
straight_tree.flatten(top, dangling_ok=True)
pat.plug(straight_tree[top], pmap, append=True)
if data.ccw is not None:
bend, bport_in, bport_out = self.bend
mirrored = self.mirror_bend and bool(data.ccw)
inport = bport_in if (self.mirror_bend or not data.ccw) else bport_out
pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored)
return tree
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,
) -> Library:
_out_port, data = self.planL(
ccw,
length,
in_ptype = in_ptype,
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
return tree
def render( def render(
self, self,
batch: Sequence[RenderStep], batch: Sequence[RenderStep],
*, *,
port_names: Sequence[str] = ('A', 'B'), port_names: tuple[str, str] = ('A', 'B'),
append: bool = True,
**kwargs, **kwargs,
) -> ILibrary: ) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=(port_names[0], port_names[1])) pat.add_port_pair(names=(port_names[0], port_names[1]))
gen_straight, sport_in, _sport_out = self.straight
for step in batch: for step in batch:
straight_length, ccw, in_transition, out_transition = step.data
assert step.tool == self assert step.tool == self
if step.opcode == 'L': if step.opcode == 'L':
if in_transition: self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
ipat, iport_theirs, _iport_ours = in_transition return tree
pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(straight_length, 0):
straight_pat = gen_straight(straight_length, **kwargs) @dataclass
if append: class AutoTool(Tool, metaclass=ABCMeta):
pat.plug(straight_pat, {port_names[1]: sport_in}, append=True) """
A simple tool which relies on a single pre-rendered `bend` pattern, a function
for generating straight paths, and a table of pre-rendered `transitions` for converting
from non-native ptypes.
"""
@dataclass(frozen=True, slots=True)
class Straight:
""" Description of a straight-path generator """
ptype: str
fn: Callable[[float], Pattern] | Callable[[float], Library]
in_port_name: str
out_port_name: str
length_range: tuple[float, float] = (0, numpy.inf)
@dataclass(frozen=True, slots=True)
class SBend:
""" Description of an s-bend generator """
ptype: str
fn: Callable[[float], Pattern] | Callable[[float], Library]
"""
Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel
and may be negative for a jog in the opposite direction. Won't be called if jog=0.
"""
in_port_name: str
out_port_name: str
jog_range: tuple[float, float] = (0, numpy.inf)
@dataclass(frozen=True, slots=True)
class Bend:
""" Description of a pre-rendered bend """
abstract: Abstract
in_port_name: str
out_port_name: str
clockwise: bool = True # Is in-to-out clockwise?
mirror: bool = True # Should we mirror to get the other rotation?
@property
def in_port(self) -> Port:
return self.abstract.ports[self.in_port_name]
@property
def out_port(self) -> Port:
return self.abstract.ports[self.out_port_name]
@dataclass(frozen=True, slots=True)
class Transition:
""" Description of a pre-rendered transition """
abstract: Abstract
their_port_name: str
our_port_name: str
@property
def our_port(self) -> Port:
return self.abstract.ports[self.our_port_name]
@property
def their_port(self) -> Port:
return self.abstract.ports[self.their_port_name]
def reversed(self) -> Self:
return type(self)(self.abstract, self.our_port_name, self.their_port_name)
@dataclass(frozen=True, slots=True)
class LData:
""" Data for planL """
straight_length: float
straight: 'AutoTool.Straight'
straight_kwargs: dict[str, Any]
ccw: SupportsBool | None
bend: 'AutoTool.Bend | None'
in_transition: 'AutoTool.Transition | None'
b_transition: 'AutoTool.Transition | None'
out_transition: 'AutoTool.Transition | None'
@dataclass(frozen=True, slots=True)
class SData:
""" Data for planS """
straight_length: float
straight: 'AutoTool.Straight'
gen_kwargs: dict[str, Any]
jog_remaining: float
sbend: 'AutoTool.SBend'
in_transition: 'AutoTool.Transition | None'
b_transition: 'AutoTool.Transition | None'
out_transition: 'AutoTool.Transition | None'
straights: list[Straight]
""" List of straight-generators to choose from, in order of priority """
bends: list[Bend]
""" List of bends to choose from, in order of priority """
sbends: list[SBend]
""" List of S-bend generators to choose from, in order of priority """
transitions: dict[tuple[str, str], Transition]
""" `{(external_ptype, internal_ptype): Transition, ...}` """
default_out_ptype: str
""" Default value for out_ptype """
def add_complementary_transitions(self) -> Self:
for iioo in list(self.transitions.keys()):
ooii = (iioo[1], iioo[0])
self.transitions.setdefault(ooii, self.transitions[iioo].reversed())
return self
@staticmethod
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
if ccw is None:
return numpy.zeros(2), pi
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
assert bend_angle is not None
if bool(ccw):
bend_dxy[1] *= -1
bend_angle *= -1
return bend_dxy, bend_angle
@staticmethod
def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]:
if numpy.isclose(jog, 0):
return numpy.zeros(2)
sbend_pat_or_tree = sbend.fn(abs(jog))
sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern()
dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name])
return dxy
@staticmethod
def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]:
if in_transition is None:
return numpy.zeros(2)
dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port)
return dxy
@staticmethod
def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]:
if out_transition is None:
return numpy.zeros(2)
orot = out_transition.our_port.rotation
assert orot is not None
otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset)
return otrans_dxy
def planL(
self,
ccw: SupportsBool | None,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs,
) -> tuple[Port, LData]:
success = False
for straight in self.straights:
for bend in self.bends:
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
in_transition = self.transitions.get(in_ptype_pair, None)
itrans_dxy = self._itransition2dxy(in_transition)
out_ptype_pair = (
'unk' if out_ptype is None else out_ptype,
straight.ptype if ccw is None else bend.out_port.ptype
)
out_transition = self.transitions.get(out_ptype_pair, None)
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
b_transition = None
if ccw is not None and bend.in_port.ptype != straight.ptype:
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
btrans_dxy = self._itransition2dxy(b_transition)
straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0]
bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1]
success = straight.length_range[0] <= straight_length < straight.length_range[1]
if success:
break
if success:
break
else: else:
straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} # Failed to break
pat.plug(straight, {port_names[1]: sport_in}, append=True) raise BuildError(
if ccw is not None: f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n'
bend, bport_in, bport_out = self.bend f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n'
pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}'
if out_transition: )
opat, oport_theirs, oport_ours = out_transition
pat.plug(opat, {port_names[1]: oport_ours}) if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype
elif ccw is not None:
out_ptype_actual = bend.out_port.ptype
elif not numpy.isclose(straight_length, 0):
out_ptype_actual = straight.ptype
else:
out_ptype_actual = self.default_out_ptype
data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition)
out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual)
return out_port, data
def _renderL(
self,
data: LData,
tree: ILibrary,
port_names: tuple[str, str],
straight_kwargs: dict[str, Any],
) -> ILibrary:
"""
Render an L step into a preexisting tree
"""
pat = tree.top_pattern()
if data.in_transition:
pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name})
if not numpy.isclose(data.straight_length, 0):
straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs))
pmap = {port_names[1]: data.straight.in_port_name}
if isinstance(straight_pat_or_tree, Pattern):
pat.plug(straight_pat_or_tree, pmap, append=True)
else:
straight_tree = straight_pat_or_tree
top = straight_tree.top()
straight_tree.flatten(top, dangling_ok=True)
pat.plug(straight_tree[top], pmap, append=True)
if data.b_transition:
pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name})
if data.ccw is not None:
bend = data.bend
assert bend is not None
mirrored = bend.mirror and (bool(data.ccw) == bend.clockwise)
inport = bend.in_port_name if (bend.mirror or bool(data.ccw) != bend.clockwise) else bend.out_port_name
pat.plug(bend.abstract, {port_names[1]: inport}, mirrored=mirrored)
if data.out_transition:
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
return tree
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,
) -> Library:
_out_port, data = self.planL(
ccw,
length,
in_ptype = in_ptype,
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
return tree
def planS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs,
) -> tuple[Port, Any]:
success = False
for straight in self.straights:
for sbend in self.sbends:
out_ptype_pair = (
'unk' if out_ptype is None else out_ptype,
straight.ptype if numpy.isclose(jog, 0) else sbend.ptype
)
out_transition = self.transitions.get(out_ptype_pair, None)
otrans_dxy = self._otransition2dxy(out_transition, pi)
# Assume we'll need a straight segment with transitions, then discard them if they don't fit
# We do this before generating the s-bend because the transitions might have some dy component
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
in_transition = self.transitions.get(in_ptype_pair, None)
itrans_dxy = self._itransition2dxy(in_transition)
b_transition = None
if not numpy.isclose(jog, 0) and sbend.ptype != straight.ptype:
b_transition = self.transitions.get((sbend.ptype, straight.ptype), None)
btrans_dxy = self._itransition2dxy(b_transition)
if length > itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]:
# `if` guard to avoid unnecessary calls to `_sbend2dxy()`, which calls `sbend.fn()`
# note some S-bends may have 0 length, so we can't be more restrictive
jog_remaining = jog - itrans_dxy[1] - btrans_dxy[1] - otrans_dxy[1]
sbend_dxy = self._sbend2dxy(sbend, jog_remaining)
straight_length = length - sbend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0]
success = straight.length_range[0] <= straight_length < straight.length_range[1]
if success:
break
# Straight didn't work, see if just the s-bend is enough
if sbend.ptype != straight.ptype:
# Need to use a different in-transition for sbend (vs straight)
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, sbend.ptype)
in_transition = self.transitions.get(in_ptype_pair, None)
itrans_dxy = self._itransition2dxy(in_transition)
jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1]
if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]:
sbend_dxy = self._sbend2dxy(sbend, jog_remaining)
success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1])
if success:
b_transition = None
straight_length = 0
break
if success:
break
if not success:
try:
ccw0 = jog > 0
p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype)
p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype)
dx = p_test1.x - length / 2
p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype)
p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype)
success = True
except BuildError as err:
l2_err: BuildError | None = err
else:
l2_err = None
raise NotImplementedError('TODO need to handle ldata below')
if not success:
# Failed to break
raise BuildError(
f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}'
) from l2_err
if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype
elif not numpy.isclose(jog_remaining, 0):
out_ptype_actual = sbend.ptype
elif not numpy.isclose(straight_length, 0):
out_ptype_actual = straight.ptype
else:
out_ptype_actual = self.default_out_ptype
data = self.SData(straight_length, straight, kwargs, jog_remaining, sbend, in_transition, b_transition, out_transition)
out_port = Port((length, jog), rotation=pi, ptype=out_ptype_actual)
return out_port, data
def _renderS(
self,
data: SData,
tree: ILibrary,
port_names: tuple[str, str],
gen_kwargs: dict[str, Any],
) -> ILibrary:
"""
Render an L step into a preexisting tree
"""
pat = tree.top_pattern()
if data.in_transition:
pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name})
if not numpy.isclose(data.straight_length, 0):
straight_pat_or_tree = data.straight.fn(data.straight_length, **(gen_kwargs | data.gen_kwargs))
pmap = {port_names[1]: data.straight.in_port_name}
if isinstance(straight_pat_or_tree, Pattern):
straight_pat = straight_pat_or_tree
pat.plug(straight_pat, pmap, append=True)
else:
straight_tree = straight_pat_or_tree
top = straight_tree.top()
straight_tree.flatten(top, dangling_ok=True)
pat.plug(straight_tree[top], pmap, append=True)
if data.b_transition:
pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name})
if not numpy.isclose(data.jog_remaining, 0):
sbend_pat_or_tree = data.sbend.fn(abs(data.jog_remaining), **(gen_kwargs | data.gen_kwargs))
pmap = {port_names[1]: data.sbend.in_port_name}
if isinstance(sbend_pat_or_tree, Pattern):
pat.plug(sbend_pat_or_tree, pmap, append=True, mirrored=data.jog_remaining < 0)
else:
sbend_tree = sbend_pat_or_tree
top = sbend_tree.top()
sbend_tree.flatten(top, dangling_ok=True)
pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0)
if data.out_transition:
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
return tree
def pathS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
_out_port, data = self.planS(
length,
jog,
in_ptype = in_ptype,
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
return tree
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=(port_names[0], port_names[1]))
for step in batch:
assert step.tool == self
if step.opcode == 'L':
self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
elif step.opcode == 'S':
self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
return tree return tree
@ -511,7 +961,7 @@ class PathTool(Tool, metaclass=ABCMeta):
if straight_length < 0: if straight_length < 0:
raise BuildError( raise BuildError(
f'Asked to draw path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}'
) )
data = numpy.array((length, bend_run)) data = numpy.array((length, bend_run))
out_port = Port(data, rotation=bend_angle, ptype=self.ptype) out_port = Port(data, rotation=bend_angle, ptype=self.ptype)
@ -521,7 +971,7 @@ class PathTool(Tool, metaclass=ABCMeta):
self, self,
batch: Sequence[RenderStep], batch: Sequence[RenderStep],
*, *,
port_names: Sequence[str] = ('A', 'B'), port_names: tuple[str, str] = ('A', 'B'),
**kwargs, # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused)
) -> ILibrary: ) -> ILibrary:

View File

@ -1,3 +1,10 @@
import traceback
import pathlib
MASQUE_DIR = str(pathlib.Path(__file__).parent)
class MasqueError(Exception): class MasqueError(Exception):
""" """
Parent exception for all Masque-related Exceptions Parent exception for all Masque-related Exceptions
@ -25,15 +32,64 @@ class BuildError(MasqueError):
""" """
pass pass
class PortError(MasqueError): class PortError(MasqueError):
""" """
Exception raised by builder-related functions Exception raised by port-related functions
""" """
pass pass
class OneShotError(MasqueError): class OneShotError(MasqueError):
""" """
Exception raised when a function decorated with `@oneshot` is called more than once Exception raised when a function decorated with `@oneshot` is called more than once
""" """
def __init__(self, func_name: str) -> None: def __init__(self, func_name: str) -> None:
Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once') Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once')
def format_stacktrace(
stacklevel: int = 1,
*,
skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,),
low_file_prefixes: tuple[str, ...] = ('<frozen', '<runpy', '<string>'),
low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', 'concurrent/futures/process.py'),
) -> str:
"""
Utility function for making nicer stack traces (e.g. excluding <frozen runpy> and similar)
Args:
stacklevel: Number of frames to remove from near this function (default is to
show caller but not ourselves). Similar to `warnings.warn` and `logging.warning`.
skip_file_prefixes: Indicates frames to ignore after counting stack levels; similar
to `warnings.warn` *TODO check if this is actually the same effect re:stacklevel*.
Forces stacklevel to max(2, stacklevel).
Default is to exclude anything within `masque`.
low_file_prefixes: Indicates frames to ignore on the other (entry-point) end of the stack,
based on prefixes on their filenames.
low_file_suffixes: Indicates frames to ignore on the other (entry-point) end of the stack,
based on suffixes on their filenames.
Returns:
Formatted trimmed stack trace
"""
if skip_file_prefixes:
stacklevel = max(2, stacklevel)
stack = traceback.extract_stack()
bad_inds = [ii + 1 for ii, frame in enumerate(stack)
if frame.filename.startswith(low_file_prefixes) or frame.filename.endswith(low_file_suffixes)]
first_ok = max([0] + bad_inds)
last_ok = -stacklevel - 1
while last_ok >= -len(stack) and stack[last_ok].filename.startswith(skip_file_prefixes):
last_ok -= 1
if selected := stack[first_ok:last_ok + 1]:
pass
elif selected := stack[:-stacklevel]:
pass # noqa: SIM114 # separate elif for clarity
else:
selected = stack
return ''.join(traceback.format_list(selected))

View File

@ -351,7 +351,7 @@ def _shapes_to_elements(
) )
for polygon in shape.to_polygons(): for polygon in shape.to_polygons():
xy_open = polygon.vertices + polygon.offset xy_open = polygon.vertices
xy_closed = numpy.vstack((xy_open, xy_open[0, :])) xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
block.add_lwpolyline(xy_closed, dxfattribs=attribs) block.add_lwpolyline(xy_closed, dxfattribs=attribs)
@ -376,5 +376,5 @@ def _mlayer2dxf(layer: layer_t) -> str:
if isinstance(layer, int): if isinstance(layer, int):
return str(layer) return str(layer)
if isinstance(layer, tuple): if isinstance(layer, tuple):
return f'{layer[0]}.{layer[1]}' return f'{layer[0]:d}.{layer[1]:d}'
raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') raise PatternError(f'Unknown layer type: {layer} ({type(layer)})')

View File

@ -21,6 +21,7 @@ Notes:
""" """
from typing import IO, cast, Any from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable from collections.abc import Iterable, Mapping, Callable
from types import MappingProxyType
import io import io
import mmap import mmap
import logging import logging
@ -52,6 +53,8 @@ path_cap_map = {
4: Path.Cap.SquareCustom, 4: Path.Cap.SquareCustom,
} }
RO_EMPTY_DICT: Mapping[int, bytes] = MappingProxyType({})
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
return numpy.rint(val).astype(numpy.int32) return numpy.rint(val).astype(numpy.int32)
@ -399,11 +402,15 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
return grefs return grefs
def _properties_to_annotations(properties: dict[int, bytes]) -> annotations_t: def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t:
if not properties:
return None
return {str(k): [v.decode()] for k, v in properties.items()} return {str(k): [v.decode()] for k, v in properties.items()}
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> dict[int, bytes]: def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]:
if annotations is None:
return RO_EMPTY_DICT
cum_len = 0 cum_len = 0
props = {} props = {}
for key, vals in annotations.items(): for key, vals in annotations.items():

View File

@ -661,7 +661,7 @@ def repetition_masq2fata(
diffs = numpy.diff(rep.displacements, axis=0) diffs = numpy.diff(rep.displacements, axis=0)
diff_ints = rint_cast(diffs) diff_ints = rint_cast(diffs)
frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore
offset = rep.displacements[0, :] offset = tuple(rep.displacements[0, :])
else: else:
assert rep is None assert rep is None
frep = None frep = None
@ -671,6 +671,8 @@ def repetition_masq2fata(
def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Property]: def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Property]:
#TODO determine is_standard based on key? #TODO determine is_standard based on key?
if annotations is None:
return []
properties = [] properties = []
for key, values in annotations.items(): for key, values in annotations.items():
vals = [AString(v) if isinstance(v, str) else v vals = [AString(v) if isinstance(v, str) else v

View File

@ -2,7 +2,7 @@
SVG file format readers and writers SVG file format readers and writers
""" """
from collections.abc import Mapping from collections.abc import Mapping
import warnings import logging
import numpy import numpy
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
@ -12,6 +12,9 @@ from .utils import mangle_name
from .. import Pattern from .. import Pattern
logger = logging.getLogger(__name__)
def writefile( def writefile(
library: Mapping[str, Pattern], library: Mapping[str, Pattern],
top: str, top: str,
@ -50,7 +53,7 @@ def writefile(
bounds = pattern.get_bounds(library=library) bounds = pattern.get_bounds(library=library)
if bounds is None: if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
else: else:
bounds_min, bounds_max = bounds bounds_min, bounds_max = bounds
@ -117,7 +120,7 @@ def writefile_inverted(
bounds = pattern.get_bounds(library=library) bounds = pattern.get_bounds(library=library)
if bounds is None: if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
else: else:
bounds_min, bounds_max = bounds bounds_min, bounds_max = bounds

View File

@ -264,6 +264,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
self, self,
tops: str | Sequence[str], tops: str | Sequence[str],
flatten_ports: bool = False, flatten_ports: bool = False,
dangling_ok: bool = False,
) -> dict[str, 'Pattern']: ) -> dict[str, 'Pattern']:
""" """
Returns copies of all `tops` patterns with all refs Returns copies of all `tops` patterns with all refs
@ -276,6 +277,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
tops: The pattern(s) to flattern. tops: The pattern(s) to flattern.
flatten_ports: If `True`, keep ports from any referenced flatten_ports: If `True`, keep ports from any referenced
patterns; otherwise discard them. patterns; otherwise discard them.
dangling_ok: If `True`, no error will be thrown if any
ref points to a name which is not present in the library.
Default False.
Returns: Returns:
{name: flat_pattern} mapping for all flattened patterns. {name: flat_pattern} mapping for all flattened patterns.
@ -292,6 +296,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
for target in pat.refs: for target in pat.refs:
if target is None: if target is None:
continue continue
if dangling_ok and target not in self:
continue
if target not in flattened: if target not in flattened:
flatten_single(target) flatten_single(target)
@ -307,7 +313,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
p.ports.clear() p.ports.clear()
pat.append(p) pat.append(p)
pat.refs.clear() for target in set(pat.refs.keys()) & set(self.keys()):
del pat.refs[target]
flattened[name] = pat flattened[name] = pat
for top in tops: for top in tops:

View File

@ -332,7 +332,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
)) ))
self.ports = dict(sorted(self.ports.items())) self.ports = dict(sorted(self.ports.items()))
self.annotations = dict(sorted(self.annotations.items())) self.annotations = dict(sorted(self.annotations.items())) if self.annotations is not None else None
return self return self
@ -354,6 +354,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for layer, lseq in other_pattern.labels.items(): for layer, lseq in other_pattern.labels.items():
self.labels[layer].extend(lseq) self.labels[layer].extend(lseq)
if other_pattern.annotations is not None:
if self.annotations is None:
self.annotations = {}
annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys())
if annotation_conflicts: if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
@ -415,7 +418,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
elif default_keep: elif default_keep:
pat.refs = copy.copy(self.refs) pat.refs = copy.copy(self.refs)
if annotations is not None: if annotations is not None and self.annotations is not None:
pat.annotations = {k: v for k, v in self.annotations.items() if annotations(k, v)} pat.annotations = {k: v for k, v in self.annotations.items() if annotations(k, v)}
elif default_keep: elif default_keep:
pat.annotations = copy.copy(self.annotations) pat.annotations = copy.copy(self.annotations)
@ -581,7 +584,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
bounds = numpy.vstack((numpy.min(corners, axis=0), bounds = numpy.vstack((numpy.min(corners, axis=0),
numpy.max(corners, axis=0))) * ref.scale + [ref.offset] numpy.max(corners, axis=0))) * ref.scale + [ref.offset]
if ref.repetition is not None: if ref.repetition is not None:
bounds += ref.repetition.get_bounds() bounds += ref.repetition.get_bounds_nonempty()
else: else:
# Non-manhattan rotation, have to figure out bounds by rotating the pattern # Non-manhattan rotation, have to figure out bounds by rotating the pattern
@ -742,7 +745,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
cast('Positionable', entry).offset[across_axis - 1] *= -1 cast('Positionable', entry).offset[1 - across_axis] *= -1
return self return self
def mirror_elements(self, across_axis: int = 0) -> Self: def mirror_elements(self, across_axis: int = 0) -> Self:
@ -1166,12 +1169,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
ports[new_name] = port ports[new_name] = port
for name, port in ports.items(): for name, port in ports.items():
p = port.deepcopy() pp = port.deepcopy()
if mirrored: if mirrored:
p.mirror() pp.mirror()
p.rotate_around(pivot, rotation) pp.offset[1] *= -1
p.translate(offset) pp.rotate_around(pivot, rotation)
self.ports[name] = p pp.translate(offset)
self.ports[name] = pp
if append: if append:
if isinstance(other, Abstract): if isinstance(other, Abstract):
@ -1199,7 +1203,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
# map_out: dict[str, str | None] | None, # map_out: dict[str, str | None] | None,
# *, # *,
# mirrored: bool, # mirrored: bool,
# inherit_name: bool, # thru: bool | str,
# set_rotation: bool | None, # set_rotation: bool | None,
# append: Literal[False], # append: Literal[False],
# ) -> Self: # ) -> Self:
@ -1213,7 +1217,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
# map_out: dict[str, str | None] | None, # map_out: dict[str, str | None] | None,
# *, # *,
# mirrored: bool, # mirrored: bool,
# inherit_name: bool, # thru: bool | str,
# set_rotation: bool | None, # set_rotation: bool | None,
# append: bool, # append: bool,
# ) -> Self: # ) -> Self:
@ -1226,7 +1230,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out: dict[str, str | None] | None = None, map_out: dict[str, str | None] | None = None,
*, *,
mirrored: bool = False, mirrored: bool = False,
inherit_name: bool = True, thru: bool | str = True,
set_rotation: bool | None = None, set_rotation: bool | None = None,
append: bool = False, append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (), ok_connections: Iterable[tuple[str, str]] = (),
@ -1247,7 +1251,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
- `my_pat.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' - `my_pat.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
of `my_pat`. of `my_pat`.
If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out` argument is If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out` argument is
provided, and the `inherit_name` argument is not explicitly set to `False`, provided, and the `thru` argument is not explicitly set to `False`,
the unconnected port of `wire` is automatically renamed to 'myport'. This the unconnected port of `wire` is automatically renamed to 'myport'. This
allows easy extension of existing ports without changing their names or allows easy extension of existing ports without changing their names or
having to provide `map_out` each time `plug` is called. having to provide `map_out` each time `plug` is called.
@ -1260,11 +1264,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
new names for ports in `other`. new names for ports in `other`.
mirrored: Enables mirroring `other` across the x axis prior to connecting mirrored: Enables mirroring `other` across the x axis prior to connecting
any ports. any ports.
inherit_name: If `True`, and `map_in` specifies only a single port, thru: If map_in specifies only a single port, `thru` provides a mechainsm
and `map_out` is `None`, and `other` has only two ports total, to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
then automatically renames the output port of `other` to the - If True (default), and `other` has only two ports total, and map_out
name of the port from `self` that appears in `map_in`. This doesn't specify a name for the other port, its name is set to the key
makes it easy to extend a pattern with simple 2-port devices in `map_in`, i.e. 'myport'.
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
An error is raised if that entry already exists.
This makes it easy to extend a pattern with simple 2-port devices
(e.g. wires) without providing `map_out` each time `plug` is (e.g. wires) without providing `map_out` each time `plug` is
called. See "Examples" above for more info. Default `True`. called. See "Examples" above for more info. Default `True`.
set_rotation: If the necessary rotation cannot be determined from set_rotation: If the necessary rotation cannot be determined from
@ -1292,25 +1300,32 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
`PortError` if the specified port mapping is not achieveable (the ports `PortError` if the specified port mapping is not achieveable (the ports
do not line up) do not line up)
""" """
# If asked to inherit a name, check that all conditions are met
if (inherit_name
and not map_out
and len(map_in) == 1
and len(other.ports) == 2):
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
map_out = {out_port_name: next(iter(map_in.keys()))}
if map_out is None: if map_out is None:
map_out = {} map_out = {}
map_out = copy.deepcopy(map_out) map_out = copy.deepcopy(map_out)
# If asked to inherit a name, check that all conditions are met
if isinstance(thru, str):
if not len(map_in) == 1:
raise PatternError(f'Got {thru=} but have multiple map_in entries; don\'t know which one to use')
if thru in map_out:
raise PatternError(f'Got {thru=} but tha port already exists in map_out')
map_out[thru] = next(iter(map_in.keys()))
elif (bool(thru)
and len(map_in) == 1
and not map_out
and len(other.ports) == 2
):
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
map_out = {out_port_name: next(iter(map_in.keys()))}
self.check_ports(other.ports.keys(), map_in, map_out) self.check_ports(other.ports.keys(), map_in, map_out)
translation, rotation, pivot = self.find_transform( translation, rotation, pivot = self.find_transform(
other, other,
map_in, map_in,
mirrored=mirrored, mirrored = mirrored,
set_rotation=set_rotation, set_rotation = set_rotation,
ok_connections=ok_connections, ok_connections = ok_connections,
) )
# get rid of plugged ports # get rid of plugged ports
@ -1323,13 +1338,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.place( self.place(
other, other,
offset=translation, offset = translation,
rotation=rotation, rotation = rotation,
pivot=pivot, pivot = pivot,
mirrored=mirrored, mirrored = mirrored,
port_map=map_out, port_map = map_out,
skip_port_check=True, skip_port_check = True,
append=append, append = append,
) )
return self return self

View File

@ -1,7 +1,5 @@
from typing import overload, Self, NoReturn, Any from typing import overload, Self, NoReturn, Any
from collections.abc import Iterable, KeysView, ValuesView, Mapping from collections.abc import Iterable, KeysView, ValuesView, Mapping
import warnings
import traceback
import logging import logging
import functools import functools
from collections import Counter from collections import Counter
@ -13,8 +11,8 @@ from numpy import pi
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
from .utils import rotate_offsets_around from .utils import rotate_offsets_around, rotation_matrix_2d
from .error import PortError from .error import PortError, format_stacktrace
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,7 +62,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
return self._rotation return self._rotation
@rotation.setter @rotation.setter
def rotation(self, val: float) -> None: def rotation(self, val: float | None) -> None:
if val is None: if val is None:
self._rotation = None self._rotation = None
else: else:
@ -102,7 +100,6 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
return self return self
def mirror(self, axis: int = 0) -> Self: def mirror(self, axis: int = 0) -> Self:
self.offset[1 - axis] *= -1
if self.rotation is not None: if self.rotation is not None:
self.rotation *= -1 self.rotation *= -1
self.rotation += axis * pi self.rotation += axis * pi
@ -145,6 +142,28 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
and self.rotation == other.rotation and self.rotation == other.rotation
) )
def measure_travel(self, destination: 'Port') -> tuple[NDArray[numpy.float64], float | None]:
"""
Find the (travel, jog) distances and rotation angle from the current port to the provided
`destination` port.
Travel is along the source port's axis (into the device interior), and jog is perpendicular,
with left of the travel direction corresponding to a positive jog.
Args:
(self): Source `Port`
destination: Destination `Port`
Returns
[travel, jog], rotation
"""
angle_in = self.rotation
angle_out = destination.rotation
assert angle_in is not None
dxy = rotation_matrix_2d(-angle_in) @ (destination.offset - self.offset)
angle = ((angle_out - angle_in) % (2 * pi)) if angle_out is not None else None
return dxy, angle
class PortList(metaclass=ABCMeta): class PortList(metaclass=ABCMeta):
__slots__ = () # Allow subclasses to use __slots__ __slots__ = () # Allow subclasses to use __slots__
@ -305,11 +324,11 @@ class PortList(metaclass=ABCMeta):
if type_conflicts.any(): if type_conflicts.any():
msg = 'Ports have conflicting types:\n' msg = 'Ports have conflicting types:\n'
for nn, (k, v) in enumerate(connections.items()): for nn, (kk, vv) in enumerate(connections.items()):
if type_conflicts[nn]: if type_conflicts[nn]:
msg += f'{k} | {a_types[nn]}:{b_types[nn]} | {v}\n' msg += f'{kk} | {a_types[nn]}:{b_types[nn]} | {vv}\n'
msg = ''.join(traceback.format_stack()) + '\n' + msg msg += '\nStack trace:\n' + format_stacktrace()
warnings.warn(msg, stacklevel=2) logger.warning(msg)
a_offsets = numpy.array([pp.offset for pp in a_ports]) a_offsets = numpy.array([pp.offset for pp in a_ports])
b_offsets = numpy.array([pp.offset for pp in b_ports]) b_offsets = numpy.array([pp.offset for pp in b_ports])
@ -326,17 +345,17 @@ class PortList(metaclass=ABCMeta):
if not numpy.allclose(rotations, 0): if not numpy.allclose(rotations, 0):
rot_deg = numpy.rad2deg(rotations) rot_deg = numpy.rad2deg(rotations)
msg = 'Port orientations do not match:\n' msg = 'Port orientations do not match:\n'
for nn, (k, v) in enumerate(connections.items()): for nn, (kk, vv) in enumerate(connections.items()):
if not numpy.isclose(rot_deg[nn], 0): if not numpy.isclose(rot_deg[nn], 0):
msg += f'{k} | {rot_deg[nn]:g} | {v}\n' msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n'
raise PortError(msg) raise PortError(msg)
translations = a_offsets - b_offsets translations = a_offsets - b_offsets
if not numpy.allclose(translations, 0): if not numpy.allclose(translations, 0):
msg = 'Port translations do not match:\n' msg = 'Port translations do not match:\n'
for nn, (k, v) in enumerate(connections.items()): for nn, (kk, vv) in enumerate(connections.items()):
if not numpy.allclose(translations[nn], 0): if not numpy.allclose(translations[nn], 0):
msg += f'{k} | {translations[nn]} | {v}\n' msg += f'{kk} | {translations[nn]} | {vv}\n'
raise PortError(msg) raise PortError(msg)
for pp in chain(a_names, b_names): for pp in chain(a_names, b_names):
@ -406,7 +425,7 @@ class PortList(metaclass=ABCMeta):
map_out_counts = Counter(map_out.values()) map_out_counts = Counter(map_out.values())
map_out_counts[None] = 0 map_out_counts[None] = 0
conflicts_out = {k for k, v in map_out_counts.items() if v > 1} conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1}
if conflicts_out: if conflicts_out:
raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}') raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')
@ -438,7 +457,7 @@ class PortList(metaclass=ABCMeta):
`set_rotation` must remain `None`. `set_rotation` must remain `None`.
ok_connections: Set of "allowed" ptype combinations. Identical ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will emit a any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`. `(b, a)`.
@ -452,12 +471,12 @@ class PortList(metaclass=ABCMeta):
s_ports = self[map_in.keys()] s_ports = self[map_in.keys()]
o_ports = other[map_in.values()] o_ports = other[map_in.values()]
return self.find_port_transform( return self.find_port_transform(
s_ports=s_ports, s_ports = s_ports,
o_ports=o_ports, o_ports = o_ports,
map_in=map_in, map_in = map_in,
mirrored=mirrored, mirrored = mirrored,
set_rotation=set_rotation, set_rotation = set_rotation,
ok_connections=ok_connections, ok_connections = ok_connections,
) )
@staticmethod @staticmethod
@ -489,7 +508,7 @@ class PortList(metaclass=ABCMeta):
`set_rotation` must remain `None`. `set_rotation` must remain `None`.
ok_connections: Set of "allowed" ptype combinations. Identical ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will emit a any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`. `(b, a)`.
@ -520,11 +539,11 @@ class PortList(metaclass=ABCMeta):
for st, ot in zip(s_types, o_types, strict=True)]) for st, ot in zip(s_types, o_types, strict=True)])
if type_conflicts.any(): if type_conflicts.any():
msg = 'Ports have conflicting types:\n' msg = 'Ports have conflicting types:\n'
for nn, (k, v) in enumerate(map_in.items()): for nn, (kk, vv) in enumerate(map_in.items()):
if type_conflicts[nn]: if type_conflicts[nn]:
msg += f'{k} | {s_types[nn]}:{o_types[nn]} | {v}\n' msg += f'{kk} | {s_types[nn]}:{o_types[nn]} | {vv}\n'
msg = ''.join(traceback.format_stack()) + '\n' + msg msg += '\nStack trace:\n' + format_stacktrace()
warnings.warn(msg, stacklevel=2) logger.warning(msg)
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
if not has_rot.any(): if not has_rot.any():
@ -546,8 +565,12 @@ class PortList(metaclass=ABCMeta):
translations = s_offsets - o_offsets translations = s_offsets - o_offsets
if not numpy.allclose(translations[:1], translations): if not numpy.allclose(translations[:1], translations):
msg = 'Port translations do not match:\n' msg = 'Port translations do not match:\n'
common_translation = numpy.min(translations, axis=0)
msg += f'Common: {common_translation} \n'
msg += 'Deltas:\n'
for nn, (kk, vv) in enumerate(map_in.items()): for nn, (kk, vv) in enumerate(map_in.items()):
msg += f'{kk} | {translations[nn]} | {vv}\n' msg += f'{kk} | {translations[nn] - common_translation} | {vv}\n'
raise PortError(msg) raise PortError(msg)
return translations[0], rotations[0], o_offsets[0] return translations[0], rotations[0], o_offsets[0]

View File

@ -11,7 +11,7 @@ import numpy
from numpy import pi from numpy import pi
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key, SupportsBool
from .repetition import Repetition from .repetition import Repetition
from .traits import ( from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl, PositionableImpl, RotatableImpl, ScalableImpl,
@ -50,11 +50,11 @@ class Ref(
# Mirrored property # Mirrored property
@property @property
def mirrored(self) -> bool: # mypy#3004, setter should be SupportsBool def mirrored(self) -> bool:
return self._mirrored return self._mirrored
@mirrored.setter @mirrored.setter
def mirrored(self, val: bool) -> None: def mirrored(self, val: SupportsBool) -> None:
self._mirrored = bool(val) self._mirrored = bool(val)
def __init__( def __init__(

View File

@ -327,7 +327,7 @@ class Arbitrary(Repetition):
""" """
@property @property
def displacements(self) -> Any: # mypy#3004 NDArray[numpy.float64]: def displacements(self) -> NDArray[numpy.float64]:
return self._displacements return self._displacements
@displacements.setter @displacements.setter

View File

@ -10,6 +10,7 @@ from .shape import (
) )
from .polygon import Polygon as Polygon from .polygon import Polygon as Polygon
from .poly_collection import PolyCollection as PolyCollection
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

@ -10,10 +10,11 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
from ..error import PatternError from ..error import PatternError
from ..repetition import Repetition from ..repetition import Repetition
from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
from ..traits import PositionableImpl
@functools.total_ordering @functools.total_ordering
class Arc(Shape): class Arc(PositionableImpl, Shape):
""" """
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
center. It has a position, two radii, a start and stop angle, a rotation, and a width. center. It has a position, two radii, a start and stop angle, a rotation, and a width.
@ -42,7 +43,7 @@ class Arc(Shape):
# radius properties # radius properties
@property @property
def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: def radii(self) -> NDArray[numpy.float64]:
""" """
Return the radii `[rx, ry]` Return the radii `[rx, ry]`
""" """
@ -79,7 +80,7 @@ class Arc(Shape):
# arc start/stop angle properties # arc start/stop angle properties
@property @property
def angles(self) -> Any: # mypy#3004 NDArray[numpy.float64]: def angles(self) -> NDArray[numpy.float64]:
""" """
Return the start and stop angles `[a_start, a_stop]`. Return the start and stop angles `[a_start, a_stop]`.
Angles are measured from x-axis after rotation Angles are measured from x-axis after rotation
@ -157,7 +158,7 @@ class Arc(Shape):
offset: ArrayLike = (0.0, 0.0), offset: ArrayLike = (0.0, 0.0),
rotation: float = 0, rotation: float = 0,
repetition: Repetition | None = None, repetition: Repetition | None = None,
annotations: annotations_t | None = None, annotations: annotations_t = None,
raw: bool = False, raw: bool = False,
) -> None: ) -> None:
if raw: if raw:
@ -170,7 +171,7 @@ class Arc(Shape):
self._offset = offset self._offset = offset
self._rotation = rotation self._rotation = rotation
self._repetition = repetition self._repetition = repetition
self._annotations = annotations if annotations is not None else {} self._annotations = annotations
else: else:
self.radii = radii self.radii = radii
self.angles = angles self.angles = angles
@ -178,7 +179,7 @@ class Arc(Shape):
self.offset = offset self.offset = offset
self.rotation = rotation self.rotation = rotation
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations
def __deepcopy__(self, memo: dict | None = None) -> 'Arc': def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
memo = {} if memo is None else memo memo = {} if memo is None else memo
@ -412,15 +413,15 @@ class Arc(Shape):
start_angle -= pi start_angle -= pi
rotation += pi rotation += pi
angles = (start_angle, start_angle + delta_angle) norm_angles = (start_angle, start_angle + delta_angle)
rotation %= 2 * pi rotation %= 2 * pi
width = self.width width = self.width
return ((type(self), radii, angles, width / norm_value), return ((type(self), radii, norm_angles, width / norm_value),
(self.offset, scale / norm_value, rotation, False), (self.offset, scale / norm_value, rotation, False),
lambda: Arc( lambda: Arc(
radii=radii * norm_value, radii=radii * norm_value,
angles=angles, angles=norm_angles,
width=width * norm_value, width=width * norm_value,
)) ))

View File

@ -10,10 +10,11 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
from ..error import PatternError from ..error import PatternError
from ..repetition import Repetition from ..repetition import Repetition
from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
from ..traits import PositionableImpl
@functools.total_ordering @functools.total_ordering
class Circle(Shape): class Circle(PositionableImpl, Shape):
""" """
A circle, which has a position and radius. A circle, which has a position and radius.
""" """
@ -48,7 +49,7 @@ class Circle(Shape):
*, *,
offset: ArrayLike = (0.0, 0.0), offset: ArrayLike = (0.0, 0.0),
repetition: Repetition | None = None, repetition: Repetition | None = None,
annotations: annotations_t | None = None, annotations: annotations_t = None,
raw: bool = False, raw: bool = False,
) -> None: ) -> None:
if raw: if raw:
@ -56,12 +57,12 @@ class Circle(Shape):
self._radius = radius self._radius = radius
self._offset = offset self._offset = offset
self._repetition = repetition self._repetition = repetition
self._annotations = annotations if annotations is not None else {} self._annotations = annotations
else: else:
self.radius = radius self.radius = radius
self.offset = offset self.offset = offset
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations
def __deepcopy__(self, memo: dict | None = None) -> 'Circle': def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
memo = {} if memo is None else memo memo = {} if memo is None else memo

View File

@ -11,10 +11,11 @@ from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
from ..error import PatternError from ..error import PatternError
from ..repetition import Repetition from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key
from ..traits import PositionableImpl
@functools.total_ordering @functools.total_ordering
class Ellipse(Shape): class Ellipse(PositionableImpl, Shape):
""" """
An ellipse, which has a position, two radii, and a rotation. An ellipse, which has a position, two radii, and a rotation.
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
@ -33,7 +34,7 @@ class Ellipse(Shape):
# radius properties # radius properties
@property @property
def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]: def radii(self) -> NDArray[numpy.float64]:
""" """
Return the radii `[rx, ry]` Return the radii `[rx, ry]`
""" """
@ -93,7 +94,7 @@ class Ellipse(Shape):
offset: ArrayLike = (0.0, 0.0), offset: ArrayLike = (0.0, 0.0),
rotation: float = 0, rotation: float = 0,
repetition: Repetition | None = None, repetition: Repetition | None = None,
annotations: annotations_t | None = None, annotations: annotations_t = None,
raw: bool = False, raw: bool = False,
) -> None: ) -> None:
if raw: if raw:
@ -103,13 +104,13 @@ class Ellipse(Shape):
self._offset = offset self._offset = offset
self._rotation = rotation self._rotation = rotation
self._repetition = repetition self._repetition = repetition
self._annotations = annotations if annotations is not None else {} self._annotations = annotations
else: else:
self.radii = radii self.radii = radii
self.offset = offset self.offset = offset
self.rotation = rotation self.rotation = rotation
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations
def __deepcopy__(self, memo: dict | None = None) -> Self: def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo memo = {} if memo is None else memo

View File

@ -1,4 +1,4 @@
from typing import Any, cast from typing import Any, cast, Self
from collections.abc import Sequence from collections.abc import Sequence
import copy import copy
import functools import functools
@ -30,8 +30,7 @@ class PathCap(Enum):
@functools.total_ordering @functools.total_ordering
class Path(Shape): class Path(Shape):
""" """
A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape, A path, consisting of a bunch of vertices (Nx2 ndarray), a width, and an end-cap shape.
and an offset.
Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates. Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates.
@ -40,7 +39,7 @@ class Path(Shape):
__slots__ = ( __slots__ = (
'_vertices', '_width', '_cap', '_cap_extensions', '_vertices', '_width', '_cap', '_cap_extensions',
# Inherited # Inherited
'_offset', '_repetition', '_annotations', '_repetition', '_annotations',
) )
_vertices: NDArray[numpy.float64] _vertices: NDArray[numpy.float64]
_width: float _width: float
@ -87,7 +86,7 @@ class Path(Shape):
# cap_extensions property # cap_extensions property
@property @property
def cap_extensions(self) -> Any | None: # mypy#3004 NDArray[numpy.float64]]: def cap_extensions(self) -> NDArray[numpy.float64] | None:
""" """
Path end-cap extension Path end-cap extension
@ -113,7 +112,7 @@ class Path(Shape):
# vertices property # vertices property
@property @property
def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]]: def vertices(self) -> NDArray[numpy.float64]:
""" """
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]` Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`
@ -160,6 +159,28 @@ class Path(Shape):
raise PatternError('Wrong number of vertices') raise PatternError('Wrong number of vertices')
self.vertices[:, 1] = val self.vertices[:, 1] = val
# Offset property for `Positionable`
@property
def offset(self) -> NDArray[numpy.float64]:
"""
[x, y] offset
"""
return numpy.zeros(2)
@offset.setter
def offset(self, val: ArrayLike) -> None:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
self._vertices += numpy.atleast_2d(offset)
return self
def __init__( def __init__(
self, self,
vertices: ArrayLike, vertices: ArrayLike,
@ -170,36 +191,35 @@ class Path(Shape):
offset: ArrayLike = (0.0, 0.0), offset: ArrayLike = (0.0, 0.0),
rotation: float = 0, rotation: float = 0,
repetition: Repetition | None = None, repetition: Repetition | None = None,
annotations: annotations_t | None = None, annotations: annotations_t = None,
raw: bool = False, raw: bool = False,
) -> None: ) -> None:
self._cap_extensions = None # Since .cap setter might access it self._cap_extensions = None # Since .cap setter might access it
if raw: if raw:
assert isinstance(vertices, numpy.ndarray) assert isinstance(vertices, numpy.ndarray)
assert isinstance(offset, numpy.ndarray)
assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None
self._vertices = vertices self._vertices = vertices
self._offset = offset
self._repetition = repetition self._repetition = repetition
self._annotations = annotations if annotations is not None else {} self._annotations = annotations
self._width = width self._width = width
self._cap = cap self._cap = cap
self._cap_extensions = cap_extensions self._cap_extensions = cap_extensions
else: else:
self.vertices = vertices self.vertices = vertices
self.offset = offset
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations
self.width = width self.width = width
self.cap = cap self.cap = cap
self.cap_extensions = cap_extensions self.cap_extensions = cap_extensions
if rotation:
self.rotate(rotation) self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
def __deepcopy__(self, memo: dict | None = None) -> 'Path': def __deepcopy__(self, memo: dict | None = None) -> 'Path':
memo = {} if memo is None else memo memo = {} if memo is None else memo
new = copy.copy(self) new = copy.copy(self)
new._offset = self._offset.copy()
new._vertices = self._vertices.copy() new._vertices = self._vertices.copy()
new._cap = copy.deepcopy(self._cap, memo) new._cap = copy.deepcopy(self._cap, memo)
new._cap_extensions = copy.deepcopy(self._cap_extensions, memo) new._cap_extensions = copy.deepcopy(self._cap_extensions, memo)
@ -209,7 +229,6 @@ class Path(Shape):
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
return ( return (
type(self) is type(other) type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.vertices, other.vertices) and numpy.array_equal(self.vertices, other.vertices)
and self.width == other.width and self.width == other.width
and self.cap == other.cap and self.cap == other.cap
@ -234,8 +253,6 @@ class Path(Shape):
if self.cap_extensions is None: if self.cap_extensions is None:
return True return True
return tuple(self.cap_extensions) < tuple(other.cap_extensions) return tuple(self.cap_extensions) < tuple(other.cap_extensions)
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition: if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition) return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations) return annotations_lt(self.annotations, other.annotations)
@ -292,7 +309,7 @@ class Path(Shape):
if self.width == 0: if self.width == 0:
verts = numpy.vstack((v, v[::-1])) verts = numpy.vstack((v, v[::-1]))
return [Polygon(offset=self.offset, vertices=verts)] return [Polygon(vertices=verts)]
perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2 perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2
@ -343,7 +360,7 @@ class Path(Shape):
o1.append(v[-1] - perp[-1]) o1.append(v[-1] - perp[-1])
verts = numpy.vstack((o0, o1[::-1])) verts = numpy.vstack((o0, o1[::-1]))
polys = [Polygon(offset=self.offset, vertices=verts)] polys = [Polygon(vertices=verts)]
if self.cap == PathCap.Circle: if self.cap == PathCap.Circle:
#for vert in v: # not sure if every vertex, or just ends? #for vert in v: # not sure if every vertex, or just ends?
@ -355,7 +372,7 @@ class Path(Shape):
def get_bounds_single(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 = 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 ( elif self.cap in (
PathCap.Flush, PathCap.Flush,
@ -390,7 +407,7 @@ class Path(Shape):
def normalized_form(self, norm_value: float) -> normalized_shape_tuple: def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
# Note: this function is going to be pretty slow for many-vertexed paths, relative to # Note: this function is going to be pretty slow for many-vertexed paths, relative to
# other shapes # other shapes
offset = self.vertices.mean(axis=0) + self.offset offset = self.vertices.mean(axis=0)
zeroed_vertices = self.vertices - offset zeroed_vertices = self.vertices - offset
scale = zeroed_vertices.std() scale = zeroed_vertices.std()
@ -460,5 +477,5 @@ class Path(Shape):
return extensions return extensions
def __repr__(self) -> str: def __repr__(self) -> str:
centroid = self.offset + self.vertices.mean(axis=0) centroid = self.vertices.mean(axis=0)
return f'<Path centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>' return f'<Path centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'

View File

@ -0,0 +1,223 @@
from typing import Any, cast, Self
from collections.abc import Iterator
import copy
import functools
from itertools import chain
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 rotation_matrix_2d, annotations_lt, annotations_eq, rep2key, annotations_t
@functools.total_ordering
class PolyCollection(Shape):
"""
A collection of polygons, consisting of concatenated vertex arrays (N_m x 2 ndarray) which specify
implicitly-closed boundaries, and an array of offets specifying the first vertex of each
successive polygon.
A `normalized_form(...)` is available, but is untested and probably fairly slow.
"""
__slots__ = (
'_vertex_lists',
'_vertex_offsets',
# Inherited
'_repetition', '_annotations',
)
_vertex_lists: NDArray[numpy.float64]
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
_vertex_offsets: NDArray[numpy.intp]
""" 1D NDArray specifying the starting offset for each polygon """
@property
def vertex_lists(self) -> NDArray[numpy.float64]:
"""
Vertices of the polygons, ((N+M+...) x 2). Use with `vertex_offsets`.
"""
return self._vertex_lists
@property
def vertex_offsets(self) -> NDArray[numpy.intp]:
"""
Starting offset (in `vertex_lists`) for each polygon
"""
return self._vertex_offsets
@property
def vertex_slices(self) -> Iterator[slice]:
"""
Iterator which provides slices which index vertex_lists
"""
for ii, ff in zip(
self._vertex_offsets,
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
strict=True,
):
yield slice(ii, ff)
@property
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
for slc in self.vertex_slices:
yield self._vertex_lists[slc]
# Offset property for `Positionable`
@property
def offset(self) -> NDArray[numpy.float64]:
"""
[x, y] offset
"""
return numpy.zeros(2)
@offset.setter
def offset(self, _val: ArrayLike) -> None:
raise PatternError('PolyCollection offset is forced to (0, 0)')
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
self._vertex_lists += numpy.atleast_2d(offset)
return self
def __init__(
self,
vertex_lists: ArrayLike,
vertex_offsets: 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(vertex_lists, numpy.ndarray)
assert isinstance(vertex_offsets, numpy.ndarray)
self._vertex_lists = vertex_lists
self._vertex_offsets = vertex_offsets
self._repetition = repetition
self._annotations = annotations
else:
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
self.repetition = repetition
self.annotations = annotations
if rotation:
self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._vertex_lists = self._vertex_lists.copy()
new._vertex_offsets = self._vertex_offsets.copy()
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
and numpy.array_equal(self._vertex_offsets, other._vertex_offsets)
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('PolyCollection', other)
for vv, oo in zip(self.polygon_vertices, other.polygon_vertices, strict=False):
if not numpy.array_equal(vv, oo):
min_len = min(vv.shape[0], oo.shape[0])
eq_mask = vv[:min_len] != oo[:min_len]
eq_lt = vv[:min_len] < oo[:min_len]
eq_lt_masked = eq_lt[eq_mask]
if eq_lt_masked.size > 0:
return eq_lt_masked.flat[0]
return vv.shape[0] < oo.shape[0]
if len(self.vertex_lists) != len(other.vertex_lists):
return len(self.vertex_lists) < len(other.vertex_lists)
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 = vv,
repetition = copy.deepcopy(self.repetition),
annotations = copy.deepcopy(self.annotations),
) for vv in self.polygon_vertices]
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
numpy.max(self._vertex_lists, axis=0)))
def rotate(self, theta: float) -> Self:
if theta != 0:
rot = rotation_matrix_2d(theta)
self._vertex_lists = numpy.einsum('ij,kj->ki', rot, self._vertex_lists)
return self
def mirror(self, axis: int = 0) -> Self:
self._vertex_lists[:, axis - 1] *= -1
return self
def scale_by(self, c: float) -> Self:
self._vertex_lists *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
# Note: this function is going to be pretty slow for many-vertexed polygons, relative to
# other shapes
meanv = self._vertex_lists.mean(axis=0)
zeroed_vertices = self._vertex_lists - [meanv]
offset = meanv
scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale
_, _, vertex_axis = numpy.linalg.svd(zeroed_vertices)
rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi)
rotated_vertices = numpy.einsum('ij,kj->ki', rotation_matrix_2d(-rotation), normed_vertices)
# TODO consider how to reorder vertices for polycollection
## Reorder the vertices so that the one with lowest x, then y, comes first.
#x_min = rotated_vertices[:, 0].argmin()
#if not is_scalar(x_min):
# y_min = rotated_vertices[x_min, 1].argmin()
# x_min = cast('Sequence', x_min)[y_min]
#reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
# TODO: normalize mirroring?
return ((type(self), rotated_vertices.data.tobytes() + self._vertex_offsets.tobytes()),
(offset, scale / norm_value, rotation, False),
lambda: PolyCollection(
vertex_lists=rotated_vertices * norm_value,
vertex_offsets=self._vertex_offsets,
),
)
def __repr__(self) -> str:
centroid = self.vertex_lists.mean(axis=0)
return f'<PolyCollection centroid {centroid} p{len(self.vertex_offsets)}>'

View File

@ -1,4 +1,4 @@
from typing import Any, cast, TYPE_CHECKING from typing import Any, cast, TYPE_CHECKING, Self
import copy import copy
import functools import functools
@ -20,7 +20,7 @@ if TYPE_CHECKING:
class Polygon(Shape): class Polygon(Shape):
""" """
A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an
implicitly-closed boundary, and an offset. implicitly-closed boundary.
Note that the setter for `Polygon.vertices` creates a copy of the Note that the setter for `Polygon.vertices` creates a copy of the
passed vertex coordinates. passed vertex coordinates.
@ -30,7 +30,7 @@ class Polygon(Shape):
__slots__ = ( __slots__ = (
'_vertices', '_vertices',
# Inherited # Inherited
'_offset', '_repetition', '_annotations', '_repetition', '_annotations',
) )
_vertices: NDArray[numpy.float64] _vertices: NDArray[numpy.float64]
@ -38,7 +38,7 @@ class Polygon(Shape):
# vertices property # vertices property
@property @property
def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]: def vertices(self) -> NDArray[numpy.float64]:
""" """
Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`) Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
@ -85,6 +85,28 @@ class Polygon(Shape):
raise PatternError('Wrong number of vertices') raise PatternError('Wrong number of vertices')
self.vertices[:, 1] = val self.vertices[:, 1] = val
# Offset property for `Positionable`
@property
def offset(self) -> NDArray[numpy.float64]:
"""
[x, y] offset
"""
return numpy.zeros(2)
@offset.setter
def offset(self, val: ArrayLike) -> None:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
self._vertices += numpy.atleast_2d(offset)
return self
def __init__( def __init__(
self, self,
vertices: ArrayLike, vertices: ArrayLike,
@ -92,28 +114,26 @@ class Polygon(Shape):
offset: ArrayLike = (0.0, 0.0), offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0, rotation: float = 0.0,
repetition: Repetition | None = None, repetition: Repetition | None = None,
annotations: annotations_t | None = None, annotations: annotations_t = None,
raw: bool = False, raw: bool = False,
) -> None: ) -> None:
if raw: if raw:
assert isinstance(vertices, numpy.ndarray) assert isinstance(vertices, numpy.ndarray)
assert isinstance(offset, numpy.ndarray)
self._vertices = vertices self._vertices = vertices
self._offset = offset
self._repetition = repetition self._repetition = repetition
self._annotations = annotations if annotations is not None else {} self._annotations = annotations
else: else:
self.vertices = vertices self.vertices = vertices
self.offset = offset
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations
if rotation: if rotation:
self.rotate(rotation) self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': def __deepcopy__(self, memo: dict | None = None) -> 'Polygon':
memo = {} if memo is None else memo memo = {} if memo is None else memo
new = copy.copy(self) new = copy.copy(self)
new._offset = self._offset.copy()
new._vertices = self._vertices.copy() new._vertices = self._vertices.copy()
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
@ -121,7 +141,6 @@ class Polygon(Shape):
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
return ( return (
type(self) is type(other) type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.vertices, other.vertices) and numpy.array_equal(self.vertices, other.vertices)
and self.repetition == other.repetition and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations) and annotations_eq(self.annotations, other.annotations)
@ -141,8 +160,6 @@ class Polygon(Shape):
if eq_lt_masked.size > 0: if eq_lt_masked.size > 0:
return eq_lt_masked.flat[0] return eq_lt_masked.flat[0]
return self.vertices.shape[0] < other.vertices.shape[0] return self.vertices.shape[0] < other.vertices.shape[0]
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition: if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition) return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations) return annotations_lt(self.annotations, other.annotations)
@ -239,6 +256,11 @@ class Polygon(Shape):
Returns: Returns:
A Polygon object containing the requested rectangle A Polygon object containing the requested rectangle
""" """
if sum(int(pp is None) for pp in (xmin, xmax, xctr, lx)) != 2:
raise PatternError('Exactly two of xmin, xctr, xmax, lx must be provided!')
if sum(int(pp is None) for pp in (ymin, ymax, yctr, ly)) != 2:
raise PatternError('Exactly two of ymin, yctr, ymax, ly must be provided!')
if lx is None: if lx is None:
if xctr is None: if xctr is None:
assert xmin is not None assert xmin is not None
@ -248,11 +270,11 @@ class Polygon(Shape):
elif xmax is None: elif xmax is None:
assert xmin is not None assert xmin is not None
assert xctr is not None assert xctr is not None
lx = 2 * (xctr - xmin) lx = 2.0 * (xctr - xmin)
elif xmin is None: elif xmin is None:
assert xctr is not None assert xctr is not None
assert xmax is not None assert xmax is not None
lx = 2 * (xmax - xctr) lx = 2.0 * (xmax - xctr)
else: else:
raise PatternError('Two of xmin, xctr, xmax, lx must be None!') raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
else: # noqa: PLR5501 else: # noqa: PLR5501
@ -278,11 +300,11 @@ class Polygon(Shape):
elif ymax is None: elif ymax is None:
assert ymin is not None assert ymin is not None
assert yctr is not None assert yctr is not None
ly = 2 * (yctr - ymin) ly = 2.0 * (yctr - ymin)
elif ymin is None: elif ymin is None:
assert yctr is not None assert yctr is not None
assert ymax is not None assert ymax is not None
ly = 2 * (ymax - yctr) ly = 2.0 * (ymax - yctr)
else: else:
raise PatternError('Two of ymin, yctr, ymax, ly must be None!') raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
else: # noqa: PLR5501 else: # noqa: PLR5501
@ -363,8 +385,8 @@ class Polygon(Shape):
return [copy.deepcopy(self)] return [copy.deepcopy(self)]
def get_bounds_single(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((numpy.min(self.vertices, axis=0),
self.offset + numpy.max(self.vertices, axis=0))) numpy.max(self.vertices, axis=0)))
def rotate(self, theta: float) -> 'Polygon': def rotate(self, theta: float) -> 'Polygon':
if theta != 0: if theta != 0:
@ -384,7 +406,7 @@ class Polygon(Shape):
# other shapes # other shapes
meanv = self.vertices.mean(axis=0) meanv = self.vertices.mean(axis=0)
zeroed_vertices = self.vertices - meanv zeroed_vertices = self.vertices - meanv
offset = meanv + self.offset offset = meanv
scale = zeroed_vertices.std() scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale normed_vertices = zeroed_vertices / scale
@ -438,5 +460,5 @@ class Polygon(Shape):
return self return self
def __repr__(self) -> str: def __repr__(self) -> str:
centroid = self.offset + self.vertices.mean(axis=0) centroid = self.vertices.mean(axis=0)
return f'<Polygon centroid {centroid} v{len(self.vertices)}>' return f'<Polygon centroid {centroid} v{len(self.vertices)}>'

View File

@ -7,7 +7,7 @@ from numpy.typing import NDArray, ArrayLike
from ..traits import ( from ..traits import (
Rotatable, Mirrorable, Copyable, Scalable, Rotatable, Mirrorable, Copyable, Scalable,
PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -26,7 +26,7 @@ normalized_shape_tuple = tuple[
DEFAULT_POLY_NUM_VERTICES = 24 DEFAULT_POLY_NUM_VERTICES = 24
class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable, class Shape(Positionable, 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.
@ -134,7 +134,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
mins, maxs = bounds mins, maxs = bounds
vertex_lists = [] vertex_lists = []
p_verts = polygon.vertices + polygon.offset p_verts = polygon.vertices
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True): for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
dv = v_next - v dv = v_next - v
@ -282,7 +282,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
offset = (numpy.where(keep_x)[0][0], offset = (numpy.where(keep_x)[0][0],
numpy.where(keep_y)[0][0]) numpy.where(keep_y)[0][0])
rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy) rastered = float_raster.raster((polygon.vertices).T, gx, gy)
binary_rastered = (numpy.abs(rastered) >= 0.5) binary_rastered = (numpy.abs(rastered) >= 0.5)
supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1) supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1)

View File

@ -9,8 +9,8 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple from . import Shape, Polygon, normalized_shape_tuple
from ..error import PatternError from ..error import PatternError
from ..repetition import Repetition from ..repetition import Repetition
from ..traits import RotatableImpl from ..traits import PositionableImpl, RotatableImpl
from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool
# Loaded on use: # Loaded on use:
# from freetype import Face # from freetype import Face
@ -18,7 +18,7 @@ from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotatio
@functools.total_ordering @functools.total_ordering
class Text(RotatableImpl, Shape): class Text(PositionableImpl, RotatableImpl, Shape):
""" """
Text (to be printed e.g. as a set of polygons). Text (to be printed e.g. as a set of polygons).
This is distinct from non-printed Label objects. This is distinct from non-printed Label objects.
@ -55,11 +55,11 @@ class Text(RotatableImpl, Shape):
self._height = val self._height = val
@property @property
def mirrored(self) -> bool: # mypy#3004, should be bool def mirrored(self) -> bool:
return self._mirrored return self._mirrored
@mirrored.setter @mirrored.setter
def mirrored(self, val: bool) -> None: def mirrored(self, val: SupportsBool) -> None:
self._mirrored = bool(val) self._mirrored = bool(val)
def __init__( def __init__(
@ -71,7 +71,7 @@ class Text(RotatableImpl, Shape):
offset: ArrayLike = (0.0, 0.0), offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0, rotation: float = 0.0,
repetition: Repetition | None = None, repetition: Repetition | None = None,
annotations: annotations_t | None = None, annotations: annotations_t = None,
raw: bool = False, raw: bool = False,
) -> None: ) -> None:
if raw: if raw:
@ -81,14 +81,14 @@ class Text(RotatableImpl, Shape):
self._height = height self._height = height
self._rotation = rotation self._rotation = rotation
self._repetition = repetition self._repetition = repetition
self._annotations = annotations if annotations is not None else {} self._annotations = annotations
else: else:
self.offset = offset self.offset = offset
self.string = string self.string = string
self.height = height self.height = height
self.rotation = rotation self.rotation = rotation
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations
self.font_path = font_path self.font_path = font_path
def __deepcopy__(self, memo: dict | None = None) -> Self: def __deepcopy__(self, memo: dict | None = None) -> Self:
@ -201,7 +201,7 @@ def get_char_as_polygons(
font_path: str, font_path: str,
char: str, char: str,
resolution: float = 48 * 64, resolution: float = 48 * 64,
) -> tuple[list[list[list[float]]], float]: ) -> tuple[list[NDArray[numpy.float64]], float]:
from freetype import Face # type: ignore from freetype import Face # type: ignore
from matplotlib.path import Path # type: ignore from matplotlib.path import Path # type: ignore
@ -276,11 +276,12 @@ def get_char_as_polygons(
advance = slot.advance.x / resolution advance = slot.advance.x / resolution
polygons: list[NDArray[numpy.float64]]
if len(all_verts) == 0: if len(all_verts) == 0:
polygons = [] polygons = []
else: else:
path = Path(all_verts, all_codes) path = Path(all_verts, all_codes)
path.should_simplify = False path.should_simplify = False
polygons = path.to_polygons() polygons = [numpy.asarray(poly) for poly in path.to_polygons()]
return polygons, advance return polygons, advance

View File

@ -45,6 +45,6 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta):
@annotations.setter @annotations.setter
def annotations(self, annotations: annotations_t) -> None: def annotations(self, annotations: annotations_t) -> None:
if not isinstance(annotations, dict): if not isinstance(annotations, dict) and annotations is not None:
raise MasqueError(f'annotations expected dict, got {type(annotations)}') raise MasqueError(f'annotations expected dict or None, got {type(annotations)}')
self._annotations = annotations self._annotations = annotations

View File

@ -1,4 +1,4 @@
from typing import Self, Any from typing import Self
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import numpy import numpy
@ -73,7 +73,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
# #
# offset property # offset property
@property @property
def offset(self) -> Any: # mypy#3004 NDArray[numpy.float64]: def offset(self) -> NDArray[numpy.float64]:
""" """
[x, y] offset [x, y] offset
""" """
@ -95,7 +95,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
return self return self
def translate(self, offset: ArrayLike) -> Self: def translate(self, offset: ArrayLike) -> Self:
self._offset += offset # type: ignore # NDArray += ArrayLike should be fine?? self._offset += numpy.asarray(offset)
return self return self

View File

@ -116,7 +116,7 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta):
pivot = numpy.asarray(pivot, dtype=float) pivot = numpy.asarray(pivot, dtype=float)
cast('Positionable', self).translate(-pivot) cast('Positionable', self).translate(-pivot)
cast('Rotatable', self).rotate(rotation) cast('Rotatable', self).rotate(rotation)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # mypy#3004 self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
cast('Positionable', self).translate(+pivot) cast('Positionable', self).translate(+pivot)
return self return self

View File

@ -5,7 +5,7 @@ from numpy import pi
try: try:
from numpy import trapezoid from numpy import trapezoid
except ImportError: except ImportError:
from numpy import trapz as trapezoid from numpy import trapz as trapezoid # type:ignore
def bezier( def bezier(

View File

@ -5,7 +5,7 @@ from typing import Protocol
layer_t = int | tuple[int, int] | str layer_t = int | tuple[int, int] | str
annotations_t = dict[str, list[int | float | str]] annotations_t = dict[str, list[int | float | str]] | None
class SupportsBool(Protocol): class SupportsBool(Protocol):

View File

@ -18,9 +18,9 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) ->
`vertices` with no consecutive duplicates. This may be a view into the original array. `vertices` with no consecutive duplicates. This may be a view into the original array.
""" """
vertices = numpy.asarray(vertices) vertices = numpy.asarray(vertices)
duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1)
if not closed_path: if not closed_path:
duplicates[0] = False duplicates[-1] = False
return vertices[~duplicates] return vertices[~duplicates]

View File

@ -56,6 +56,7 @@ dxf = ["ezdxf~=1.0.2"]
svg = ["svgwrite"] svg = ["svgwrite"]
visualize = ["matplotlib"] visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"] text = ["matplotlib", "freetype-py"]
manhatanize_slow = ["float_raster"]
[tool.ruff] [tool.ruff]