Compare commits

..

99 Commits

Author SHA1 Message Date
jan
e69ebc8070 [dxf] make sure layer tuple contents are ints 2025-12-10 21:22:56 -08:00
jan
13d013aaf3 [dxf] don't need to add polygon offset since it's zero 2025-12-10 21:22:38 -08:00
07fc8b2ad0 fixup! [PortPather] generalize to multi-port functions where possible 2025-12-01 11:58:55 -08:00
b12b406df4 fixup! [PortPather] generalize to multi-port functions where possible 2025-12-01 11:58:39 -08:00
6c6b1c16ff fixup! [PortPather] generalize to multi-port functions where possible 2025-11-21 20:35:17 -08:00
jan
24af43ff48 update TODO in readme 2025-11-21 01:09:38 -08:00
jan
1505844a0a [PortPather] generalize to multi-port functions where possible 2025-11-21 01:04:55 -08:00
jan
c064ee9d8f [Pattern] use 1-axis instead of axis-1 2025-11-20 13:05:30 -08:00
jan
22b53a930c [AutoTool] S-bend to L-bend fallback does not work yet, should throw an error 2025-11-20 13:01:51 -08:00
jan
355365c0dc [Pather / RenderPather] Fix handling of jog polarity 2025-11-20 13:00:57 -08:00
jan
2b835ec3a4 [Port] mirror() should not mirror port position, only orientation 2025-11-20 12:59:20 -08:00
jan
519e6ad618 minor readme cleanup 2025-11-19 23:19:29 -08:00
jan
e75a76e5a8 [RenderPather.plug] fix ok_connections param 2025-11-19 23:15:22 -08:00
jan
5e65dfafa1 cleanup 2025-11-19 23:14:54 -08:00
jan
04905153d3 [PortPather] add some more port-related convenience functions 2025-11-19 23:14:36 -08:00
jan
1eba387b6a [AutoTool / SimpleTool] allow choice between rotating or mirroring bends 2025-11-19 23:14:02 -08:00
jan
5fbbaa0648 [Library.flatten] add dangling_ok param 2025-11-19 23:08:17 -08:00
jan
4dc81bd9f7 fixup! [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends 2025-11-19 02:23:40 -08:00
jan
d3b83a7543 fixup! [PortPather] add rename_to and rename_from 2025-11-19 02:23:11 -08:00
jan
184168f623 [PatherMixin] add thru arg to path_into and rework portlist inheritance 2025-11-19 01:24:02 -08:00
jan
334bcade31 [PortPather] add rename_to and rename_from 2025-11-19 00:58:35 -08:00
jan
90b3157b00 [PatherMixin] add at() for generating PortPather 2025-11-19 00:32:14 -08:00
jan
7c5c1c26c8 add missing float_raster dep for manhattanize_slow 2025-11-19 00:17:38 -08:00
jan
fcd3d9663d fixup! [plug()] rename inherit_name arg to thru and allow passing a string 2025-11-19 00:17:10 -08:00
jan
2b7b1cd6e2 [PortPather] add PortPather 2025-11-19 00:16:34 -08:00
jan
dfd61b3a39 fixup! [plug()] rename inherit_name arg to thru and allow passing a string 2025-11-19 00:07:36 -08:00
jan
3a1a4b9126 [RenderPather] whitespace 2025-11-19 00:07:07 -08:00
jan
8a0c985e36 [plug()] rename inherit_name arg to thru and allow passing a string
Breaking change

Affects Pattern, Builder, Pather, RenderPather
2025-11-19 00:06:57 -08:00
jan
8d91fb4915 fixup! [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends 2025-11-18 23:06:03 -08:00
jan
146e6808ee add some whitespace 2025-11-18 23:01:51 -08:00
f831ccd873 fixup! [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends 2025-11-18 22:12:13 -08:00
982304bd10 fixup! [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends 2025-11-18 22:11:59 -08:00
049098ade5 [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends 2025-11-17 22:12:24 -08:00
dbaa6fc1f3 [Port] add Port.measure_travel() 2025-11-17 22:11:55 -08:00
1fe1334f34 [Tool / Pather] fix some doc typos 2025-11-17 22:11:04 -08:00
7389be9129 [Tool / AutoTool] clarify some docstings 2025-11-16 22:58:02 -08:00
fe49e1e25b [Pather] clarify a variable name 2025-11-16 22:57:20 -08:00
1faf5ccad5 [AutoTool] enable S-bends 2025-11-14 21:30:49 -08:00
3ba2ffd33f [AutoTool / SimpleTool] remove append arg 2025-11-14 21:30:49 -08:00
40e55a9067 [SimpleTool/AutoTool] clarify some error messages 2025-11-14 21:30:49 -08:00
jan
1b79cd6f45 fixup! [AutoTool] Add first pass for AutoTool 2025-11-13 12:58:05 -08:00
639850ab29 [AutoTool/SimpleTool/BasicTool] Rename BasicTool->SimpleTool and remove transition handling. Export AutoTool and SimpleTool at top level. 2025-11-13 00:39:41 -08:00
2bf44f334a fixup! [AutoTool] pass in kwargs to straight fn call 2025-11-13 00:08:32 -08:00
d37e6b873c [AutoTool] pass in kwargs to straight fn call 2025-11-13 00:01:12 -08:00
2a8879e3d4 [AutoTool] consolidate duplicate code for path() and render() 2025-11-12 23:53:53 -08:00
de534a755f fixup! [AutoTool] Use more dataclasses to clarify internal code 2025-11-12 23:34:23 -08:00
41bbfee80b fixup! [AutoTool] Use more dataclasses to clarify internal code 2025-11-12 23:34:11 -08:00
c7a8fac890 [AutoTool] add add_complementary_transitions() 2025-11-12 23:31:58 -08:00
fe440b0c53 [AutoTool] Use more dataclasses to clarify internal code 2025-11-12 18:50:57 -08:00
a62deb211c [gdsii_arrow] fix or suppress a bunch of linter messages 2025-11-12 18:50:23 -08:00
01f624cb6a [PolyCollection] rename setter arg to placate linter 2025-11-12 17:49:12 -08:00
8996d53479 [format_stacktrace] suppress linter 2025-11-12 17:47:07 -08:00
899d05217e [AutoTool] support min/max length for straight segments 2025-11-12 17:42:52 -08:00
f374651bc4 fixup! fixup! [Pather / RenderPather] move common functionality into PatherMixin; redo hierarchy 2025-11-12 17:42:16 -08:00
fd03e09ea1 [BasicTool/AutoTool] fix port orientation for straight segments when using RenderPather 2025-11-12 17:40:34 -08:00
ba7fab6db2 fixup! fixup! [Pather / RenderPather] move common functionality into PatherMixin; redo hierarchy 2025-11-12 17:38:46 -08:00
ace34aa7a3 [AutoTool] Add first pass for AutoTool 2025-11-12 01:08:58 -08:00
69e6b1bff1 fixup! [Pather / RenderPather] move common functionality into PatherMixin; redo hierarchy 2025-11-11 20:52:33 -08:00
701c297152 fixup! [Pather / RenderPather] move common functionality into PatherMixin; redo hierarchy 2025-11-11 20:44:06 -08:00
74f341db77 fixup! [pather] code style changes 2025-11-11 20:42:15 -08:00
4ce7525263 [RenderPather] add wrapped label/ref/polygon/rect functions 2025-11-11 20:40:13 -08:00
e3c1c46b10 [Pather/RenderPather/PatherMixin] clean up imports 2025-11-11 20:35:55 -08:00
da35019dc8 [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
2025-11-11 20:30:45 -08:00
d71ede927c [pather] code style changes 2025-11-11 18:30:23 -08:00
83850c1cbc [error] also exclude concurrent.futures.process from traces 2025-10-30 01:25:16 -07:00
ebd1fbdfbf [error] also exclude frames starting with '<frozen' 2025-10-30 01:22:46 -07:00
3e88ed9438 [file.svg] use logger.warning over warnings.warn (for flexibility) 2025-10-30 01:15:44 -07:00
006e7c428c [ports] make port mismatch deltas more obvious 2025-10-30 01:15:20 -07:00
dadaf48d35 [error, ports] Make stack traces more directly reflect teh location of the issue 2025-10-30 01:14:37 -07:00
240007eb7a misc cleanup: variable naming, typing, comments 2025-10-30 01:13:23 -07:00
ee4147ef99 [Path / PolyCollection / Polygon] fix order of rotation/offset 2025-10-26 21:36:29 -07:00
fe231e558a [Polygon / Path / PolyCollection] Force polygon/path offset to (0, 0)
And disallow setting it.

This offset was basically just a footgun.
2025-10-26 19:13:10 -07:00
ffc8dccbef [Polygon.rect] use floats more explicitly 2025-10-26 18:45:58 -07:00
debb27cdc8 Various type-checking improvements 2025-10-26 18:45:09 -07:00
5a4be88672 [file.gdsii] enable wider annotation key range (to 126 inclusive) 2025-10-26 18:43:45 -07:00
a2fa7648df [BasicTool] enable straight to handle trees (not just flat patterns) 2025-10-26 18:43:14 -07:00
aa175fbb75 [builder.tools] Handle in_ptype=None 2025-10-26 18:28:21 -07:00
00021c00e6 [gdsii_arrow] fix rep_valid 2025-09-14 12:37:49 -07:00
4e69273b5e [gdsii_arrow] fill some more nulls 2025-09-14 12:37:49 -07:00
923c00d72f [gdsii_arrow] fixup 2025-09-14 12:37:49 -07:00
7bd15ede88 [traits.annotatable] Don't break when setting annotations to None 2025-09-14 12:37:49 -07:00
4960c95637 [PolyCollection] many fixes 2025-09-14 12:37:49 -07:00
adbc86100b [gdsii_arrow] fixup annotation types 2025-09-14 12:37:49 -07:00
cb178bb694 [gdsii_arrow] make polycollections if able 2025-09-14 12:37:49 -07:00
18e5a4ac5a [gdsii_arrow] clean unused var in loop 2025-09-14 12:37:49 -07:00
215926269e [gdsii_arrow] fix var name typo 2025-09-14 12:37:48 -07:00
4487c3825b [shapes] Don't create empty dicts for annotations 2025-09-14 12:37:48 -07:00
5608a6717e [PolyCollection] rework PolyCollection into ndarrays of vertices and offsets 2025-09-14 12:37:48 -07:00
b0ec2a51f5 fix some polycollection syntax 2025-09-14 12:37:48 -07:00
jan
ded473c290 [wip] add poly_collection shape 2025-09-14 12:37:48 -07:00
d2f85c70ee fixup indexing 2025-09-14 12:37:48 -07:00
050f1b597c [gdsii_arrow] add some TODO notes 2025-09-14 12:37:48 -07:00
b4116a738d [gdsii_arrow] use direct access for all element types 2025-09-14 12:37:48 -07:00
aae467021b [utils.curves] ignore re-import of trapeziod 2025-09-14 12:37:48 -07:00
5ae990a83b allow annotations to be None
breaking change, but properties are seldom used by anyone afaik
2025-09-14 12:37:48 -07:00
3046e33742 gdsii_arrow wip 2025-09-14 12:37:48 -07:00
6d8efe82f2 split out _read_to_arrow
for ease of debugging
2025-09-14 12:37:48 -07:00
534002d4b5 actually make use of raw mode 2025-09-14 12:37:48 -07:00
jan
4364c809f3 add gdsii_arrow 2025-09-14 12:37:47 -07:00
34 changed files with 2447 additions and 888 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)
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,
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 return self
port_src = self.pattern[portspec_src] tname = self.library << tree
port_dst = self.pattern[portspec_dst] if plug_into is not None:
output = {plug_into: tool_port_names[1]}
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: else:
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') output = {}
self.plug(tname, {portspec: tool_port_names[0], **output})
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) """
else: A simple tool which relies on a single pre-rendered `bend` pattern, a function
straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat} for generating straight paths, and a table of pre-rendered `transitions` for converting
pat.plug(straight, {port_names[1]: sport_in}, append=True) from non-native ptypes.
if ccw is not None: """
bend, bport_in, bport_out = self.bend
pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw)) @dataclass(frozen=True, slots=True)
if out_transition: class Straight:
opat, oport_theirs, oport_ours = out_transition """ Description of a straight-path generator """
pat.plug(opat, {port_names[1]: oport_ours}) 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:
# Failed to break
raise BuildError(
f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n'
f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n'
f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}'
)
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():

453
masque/file/gdsii_arrow.py Normal file
View File

@ -0,0 +1,453 @@
# ruff: noqa: ARG001, F401
"""
GDSII file format readers and writers using the `TODO` library.
Note that GDSII references follow the same convention as `masque`,
with this order of operations:
1. Mirroring
2. Rotation
3. Scaling
4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)
Scaling, rotation, and mirroring apply to individual instances, not grid
vectors or offsets.
Notes:
* absolute positioning is not supported
* PLEX is not supported
* ELFLAGS are not supported
* GDS does not support library- or structure-level annotations
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
TODO writing
TODO warn on boxes, nodes
"""
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
import io
import mmap
import logging
import pathlib
import gzip
import string
from pprint import pformat
import numpy
from numpy.typing import ArrayLike, NDArray
from numpy.testing import assert_equal
import pyarrow
from pyarrow.cffi import ffi
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path, PolyCollection
from ..repetition import Grid
from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
logger = logging.getLogger(__name__)
clib = ffi.dlopen('/home/jan/projects/klamath-rs/target/release/libklamath_rs_ext.so')
ffi.cdef('void read_path(char* path, struct ArrowArray* array, struct ArrowSchema* schema);')
path_cap_map = {
0: Path.Cap.Flush,
1: Path.Cap.Circle,
2: Path.Cap.Square,
4: Path.Cap.SquareCustom,
}
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
return numpy.rint(val).astype(numpy.int32)
def _read_to_arrow(
filename: str | pathlib.Path,
*args,
**kwargs,
) -> pyarrow.Array:
path = pathlib.Path(filename)
path.resolve()
ptr_array = ffi.new('struct ArrowArray[]', 1)
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
clib.read_path(str(path).encode(), ptr_array, ptr_schema)
iptr_schema = int(ffi.cast('uintptr_t', ptr_schema))
iptr_array = int(ffi.cast('uintptr_t', ptr_array))
arrow_arr = pyarrow.Array._import_from_c(iptr_array, iptr_schema)
return arrow_arr
def readfile(
filename: str | pathlib.Path,
*args,
**kwargs,
) -> tuple[Library, dict[str, Any]]:
"""
Wrapper for `read()` that takes a filename or path instead of a stream.
Will automatically decompress gzipped files.
Args:
filename: Filename to save to.
*args: passed to `read()`
**kwargs: passed to `read()`
"""
arrow_arr = _read_to_arrow(filename)
assert len(arrow_arr) == 1
results = read_arrow(arrow_arr[0])
return results
def read_arrow(
libarr: pyarrow.Array,
raw_mode: bool = True,
) -> tuple[Library, dict[str, Any]]:
"""
# TODO check GDSII file for cycles!
Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
are translated into Ref objects.
Additional library info is returned in a dict, containing:
'name': name of the library
'meters_per_unit': number of meters per database unit (all values are in database units)
'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
per database unit
Args:
stream: Stream to read from.
raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True.
Returns:
- dict of pattern_name:Patterns generated from GDSII structures
- dict of GDSII library info
"""
library_info = _read_header(libarr)
layer_names_np = libarr['layers'].values.to_numpy().view('i2').reshape((-1, 2))
layer_tups = [tuple(pair) for pair in layer_names_np]
cell_ids = libarr['cells'].values.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]:
el = libarr['cells'].values.field(geom_type)
elem = dict(
offsets = el.offsets.to_numpy(),
xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)),
xy_off = el.values.field('xy').offsets.to_numpy() // 2,
layer_inds = el.values.field('layer').to_numpy(),
prop_off = el.values.field('properties').offsets.to_numpy(),
prop_key = el.values.field('properties').values.field('key').to_numpy(),
prop_val = el.values.field('properties').values.field('value').to_pylist(),
)
return elem
rf = libarr['cells'].values.field('refs')
refs = dict(
offsets = rf.offsets.to_numpy(),
targets = rf.values.field('target').to_numpy(),
xy = rf.values.field('xy').to_numpy().view('i4').reshape((-1, 2)),
invert_y = rf.values.field('invert_y').fill_null(False).to_numpy(zero_copy_only=False),
angle_rad = numpy.rad2deg(rf.values.field('angle_deg').fill_null(0).to_numpy()),
scale = rf.values.field('mag').fill_null(1).to_numpy(),
rep_valid = rf.values.field('repetition').is_valid().to_numpy(zero_copy_only=False),
rep_xy0 = rf.values.field('repetition').field('xy0').fill_null(0).to_numpy().view('i4').reshape((-1, 2)),
rep_xy1 = rf.values.field('repetition').field('xy1').fill_null(0).to_numpy().view('i4').reshape((-1, 2)),
rep_counts = rf.values.field('repetition').field('counts').fill_null(0).to_numpy().view('i2').reshape((-1, 2)),
prop_off = rf.values.field('properties').offsets.to_numpy(),
prop_key = rf.values.field('properties').values.field('key').to_numpy(),
prop_val = rf.values.field('properties').values.field('value').to_pylist(),
)
txt = libarr['cells'].values.field('texts')
texts = dict(
offsets = txt.offsets.to_numpy(),
layer_inds = txt.values.field('layer').to_numpy(),
xy = txt.values.field('xy').to_numpy().view('i4').reshape((-1, 2)),
string = txt.values.field('string').to_pylist(),
prop_off = txt.values.field('properties').offsets.to_numpy(),
prop_key = txt.values.field('properties').values.field('key').to_numpy(),
prop_val = txt.values.field('properties').values.field('value').to_pylist(),
)
elements = dict(
boundaries = get_geom(libarr, 'boundaries'),
paths = get_geom(libarr, 'paths'),
boxes = get_geom(libarr, 'boxes'),
nodes = get_geom(libarr, 'nodes'),
texts = texts,
refs = refs,
)
paths = libarr['cells'].values.field('paths')
elements['paths'].update(dict(
width = paths.values.field('width').fill_null(0).to_numpy(),
path_type = paths.values.field('path_type').fill_null(0).to_numpy(),
extensions = numpy.stack((
paths.values.field('extension_start').fill_null(0).to_numpy(),
paths.values.field('extension_end').fill_null(0).to_numpy(),
), axis=-1),
))
global_args = dict(
cell_names = cell_names,
layer_tups = layer_tups,
raw_mode = raw_mode,
)
mlib = Library()
for cc in range(len(libarr['cells'])):
name = cell_names[cell_ids[cc]]
pat = Pattern()
_boundaries_to_polygons(pat, global_args, elements['boundaries'], cc)
_gpaths_to_mpaths(pat, global_args, elements['paths'], cc)
_grefs_to_mrefs(pat, global_args, elements['refs'], cc)
_texts_to_labels(pat, global_args, elements['texts'], cc)
mlib[name] = pat
return mlib, library_info
def _read_header(libarr: pyarrow.Array) -> dict[str, Any]:
"""
Read the file header and create the library_info dict.
"""
library_info = dict(
name = libarr['lib_name'],
meters_per_unit = libarr['meters_per_db_unit'],
logical_units_per_unit = libarr['user_units_per_db_unit'],
)
return library_info
def _grefs_to_mrefs(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
cell_names = global_args['cell_names']
elem_off = elem['offsets'] # which elements belong to each cell
xy = elem['xy']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
targets = elem['targets']
elem_count = elem_off[cc + 1] - elem_off[cc]
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
elem_invert_y = elem['invert_y'][elem_slc][:elem_count]
elem_angle_rad = elem['angle_rad'][elem_slc][:elem_count]
elem_scale = elem['scale'][elem_slc][:elem_count]
elem_rep_xy0 = elem['rep_xy0'][elem_slc][:elem_count]
elem_rep_xy1 = elem['rep_xy1'][elem_slc][:elem_count]
elem_rep_counts = elem['rep_counts'][elem_slc][:elem_count]
rep_valid = elem['rep_valid'][elem_slc][:elem_count]
for ee in range(elem_count):
target = cell_names[targets[ee]]
offset = xy[ee]
mirr = elem_invert_y[ee]
rot = elem_angle_rad[ee]
mag = elem_scale[ee]
rep: None | Grid = None
if rep_valid[ee]:
a_vector = elem_rep_xy0[ee]
b_vector = elem_rep_xy1[ee]
a_count, b_count = elem_rep_counts[ee]
rep = Grid(a_vector=a_vector, b_vector=b_vector, a_count=a_count, b_count=b_count)
annotations: None | dict[str, list[int | float | str]] = None
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
if prop_ii < prop_ff:
annotations = {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}
ref = Ref(offset=offset, mirrored=mirr, rotation=rot, scale=mag, repetition=rep, annotations=annotations)
pat.refs[target].append(ref)
def _texts_to_labels(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets'] # which elements belong to each cell
xy = elem['xy']
layer_tups = global_args['layer_tups']
layer_inds = elem['layer_inds']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
elem_layer_inds = layer_inds[elem_slc][:elem_count]
elem_strings = elem['string'][elem_slc][:elem_count]
for ee in range(elem_count):
layer = layer_tups[elem_layer_inds[ee]]
offset = xy[ee]
string = elem_strings[ee]
annotations: None | dict[str, list[int | float | str]] = None
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
if prop_ii < prop_ff:
annotations = {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}
mlabel = Label(string=string, offset=offset, annotations=annotations)
pat.labels[layer].append(mlabel)
def _gpaths_to_mpaths(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets'] # which elements belong to each cell
xy_val = elem['xy_arr']
layer_tups = global_args['layer_tups']
layer_inds = elem['layer_inds']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
xy_offs = elem['xy_off'][elem_slc] # which xy coords belong to each element
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
elem_layer_inds = layer_inds[elem_slc][:elem_count]
elem_widths = elem['width'][elem_slc][:elem_count]
elem_path_types = elem['path_type'][elem_slc][:elem_count]
elem_extensions = elem['extensions'][elem_slc][:elem_count]
zeros = numpy.zeros((elem_count, 2))
raw_mode = global_args['raw_mode']
for ee in range(elem_count):
layer = layer_tups[elem_layer_inds[ee]]
vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]]
width = elem_widths[ee]
cap_int = elem_path_types[ee]
cap = path_cap_map[cap_int]
if cap_int == 4:
cap_extensions = elem_extensions[ee]
else:
cap_extensions = None
annotations: None | dict[str, list[int | float | str]] = None
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
if prop_ii < prop_ff:
annotations = {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}
path = Path(vertices=vertices, offset=zeros[ee], annotations=annotations, raw=raw_mode,
width=width, cap=cap,cap_extensions=cap_extensions)
pat.shapes[layer].append(path)
def _boundaries_to_polygons(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets'] # which elements belong to each cell
xy_val = elem['xy_arr']
layer_inds = elem['layer_inds']
layer_tups = global_args['layer_tups']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
xy_offs = elem['xy_off'][elem_slc] # which xy coords belong to each element
xy_counts = xy_offs[1:] - xy_offs[:-1]
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
prop_counts = prop_offs[1:] - prop_offs[:-1]
elem_layer_inds = layer_inds[elem_slc][:elem_count]
order = numpy.argsort(elem_layer_inds, stable=True)
unilayer_inds, unilayer_first, unilayer_count = numpy.unique(elem_layer_inds, return_index=True, return_counts=True)
zeros = numpy.zeros((elem_count, 2))
raw_mode = global_args['raw_mode']
for layer_ind, ff, nn in zip(unilayer_inds, unilayer_first, unilayer_count, strict=True):
ee_inds = order[ff:ff + nn]
layer = layer_tups[layer_ind]
propless_mask = prop_counts[ee_inds] == 0
poly_count_on_layer = propless_mask.sum()
if poly_count_on_layer == 1:
propless_mask[:] = 0 # Never make a 1-element collection
elif poly_count_on_layer > 1:
propless_vert_counts = xy_counts[ee_inds[propless_mask]] - 1 # -1 to drop closing point
vertex_lists = numpy.empty((propless_vert_counts.sum(), 2), dtype=numpy.float64)
vertex_offsets = numpy.cumsum(numpy.concatenate([[0], propless_vert_counts]))
for ii, ee in enumerate(ee_inds[propless_mask]):
vo = vertex_offsets[ii]
vertex_lists[vo:vo + propless_vert_counts[ii]] = xy_val[xy_offs[ee]:xy_offs[ee + 1] - 1]
polys = PolyCollection(vertex_lists=vertex_lists, vertex_offsets=vertex_offsets, offset=zeros[ee])
pat.shapes[layer].append(polys)
# Handle single polygons
for ee in ee_inds[~propless_mask]:
layer = layer_tups[elem_layer_inds[ee]]
vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1] - 1] # -1 to drop closing point
annotations: None | dict[str, list[int | float | str]] = None
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
if prop_ii < prop_ff:
annotations = {str(prop_key[off]): prop_val[off] for off in range(prop_ii, prop_ff)}
poly = Polygon(vertices=vertices, offset=zeros[ee], annotations=annotations, raw=raw_mode)
pat.shapes[layer].append(poly)
#def _properties_to_annotations(properties: pyarrow.Array) -> annotations_t:
# return {prop['key'].as_py(): prop['value'].as_py() for prop in properties}
def check_valid_names(
names: Iterable[str],
max_length: int = 32,
) -> None:
"""
Check all provided names to see if they're valid GDSII cell names.
Args:
names: Collection of names to check
max_length: Max allowed length
"""
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
bad_chars = [
name for name in names
if not set(name).issubset(allowed_chars)
]
bad_lengths = [
name for name in names
if len(name) > max_length
]
if bad_chars:
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
if bad_lengths:
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
if bad_chars or bad_lengths:
raise LibraryError('Library contains invalid names, see log above')

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,10 +354,13 @@ 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)
annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) if other_pattern.annotations is not None:
if annotation_conflicts: if self.annotations is None:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') self.annotations = {}
self.annotations.update(other_pattern.annotations) annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
self.annotations.update(other_pattern.annotations)
port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys()) port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys())
if port_conflicts: if port_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
self.rotate(rotation) if 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,8 +372,8 @@ 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,
PathCap.Square, PathCap.Square,
@ -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)
@ -248,11 +265,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 +295,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 +380,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 +401,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 +455,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

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