Compare commits

..

103 Commits
v3.0 ... master

Author SHA1 Message Date
jan
c071b33732 Bump version to v3.4 2025-08-17 21:05:15 +02:00
jan
42a7df3055 add pytest config 2025-08-17 21:03:23 +02:00
5e0eef7c59 [dxf] match ezdxf syntax changes 2025-05-28 00:40:24 -07:00
ad00ade097 [Tool] correctly set input ptype 2025-05-28 00:39:44 -07:00
385a37e0a2 [Pather] fix path_into for some 2-bend cases 2025-05-28 00:39:25 -07:00
35e28acb89 [polygon] Only call rotate if necessary 2025-04-21 19:07:21 -07:00
jan
c1bfee1ddd [library] minor stylistic cleanup 2025-04-15 17:34:05 -07:00
jan
560c165f2e remove deprecated rule from ignore list 2025-04-15 17:26:33 -07:00
jan
284c7e4fd0 Use quoted first arg for cast()
ruff rule TC006
2025-04-15 17:25:56 -07:00
jan
1eac3baf6a [pattern] add arg to , useful for whole-library scaling 2025-04-15 17:21:49 -07:00
5a4b9609bd close code block 2025-03-12 23:14:45 -07:00
30cfa0da31 Bump version to v3.3 2025-03-12 23:11:35 -07:00
d11c910dfd [utils.curves] improve type annotations 2025-03-12 23:10:49 -07:00
9b2f8599e6 [utils.curves] use numpy.trapezoid for 2.0 compatibility
fall back to trapz if import fails
2025-03-12 23:09:45 -07:00
6c76e1f5cf Add R90 and R180 constants for rotation shorthand 2025-03-12 23:04:51 -07:00
6567394fbf [utils.curves.bezier] Fix and clarify bezier() code
- Accuracy fix (incorrect +1 term)
- Explicitly index last dim of `nodes`
- Suppress warnings about div by zero
- simplify `umul` and `udiv` calculation
2025-03-04 23:00:52 -08:00
858ef4a114 [utils.curves.euler_bend] add num_point arg and improve naming 2025-03-03 00:53:34 -08:00
b27b1d93d8 [utils.curves.bezier] improve handling of non-ndarray inputs 2025-03-03 00:52:51 -08:00
c74573e7dd [Arc] improve some variable names 2025-03-03 00:52:24 -08:00
0e34242ba5 misc type hint fixes 2025-03-03 00:52:04 -08:00
c3534beb3f [utils.curves.bezier] be more explicit about broadcast axes 2025-02-25 21:27:16 -08:00
cae6de69c1 [path] Fix calls to numpy.linalg.solve for numpy 2 2025-02-25 21:15:32 -08:00
f14528654b [utils.curves] add masque.utils.curves with Bezier and Euler curves 2025-02-25 21:09:04 -08:00
6631c5e558 clear out some old TODOs 2025-02-25 21:09:04 -08:00
cd60dcc765 add the toolctx() context manager to simplify temporary retool() calls 2025-02-25 21:09:04 -08:00
fcb470a02c use cast() to clarify some type checker complaints 2025-02-25 21:09:04 -08:00
93471a221c add ok_connections arg to allow plugging mismatched ports without warnings 2025-02-25 21:09:04 -08:00
4e862d6853 cleanup 2024-12-12 23:47:46 -08:00
9917355bb0 speed up prune_empty() on large patterns 2024-12-12 23:47:28 -08:00
jan
94a1b3d793 cleanup comment 2024-10-14 17:25:01 -07:00
jan
7c7a7e916c Fix offset handling in polygon normalized_form() 2024-10-14 17:24:49 -07:00
73193473df Fixup arclength calculation for wedges (or other thick arcs) 2024-10-05 11:24:40 -07:00
febaaeff0b add Library functions for finding instances and extracting hierarchy
added child_graph, parent_graph, child_order, find_refs_local and find_refs_global
2024-10-04 17:21:31 -07:00
a54ee5a26c bump klamath req 2024-08-01 00:41:01 -07:00
8d671ed709 bump version to v3.2
Highlights:
- Pather.path_into() for connecting into existing ports
- Pattern.plugged() for removing ports which were manually pathed into
  each other.
- Defined ordering/comparsions to enable sorting patterns and shapes
- numpy 2.0 compatibility
- Fix bounds calculation for arrays with manhattan rotations
- Bugfixes for DXF and OASIS
- Speed improvement for default Library.get_name() and GDS writing
2024-07-29 21:00:40 -07:00
a816a7db8e allow numpy v2.0 2024-07-29 18:24:31 -07:00
a8a42bba1d speed up b64suffix by using a simple array lookup instead of base64.b64encode 2024-07-29 11:30:31 -07:00
da7118f521 misc cleanup 2024-07-29 03:13:36 -07:00
ef6c5df386 be more consistent about when copies are made 2024-07-29 03:13:23 -07:00
ad0adec8e8 numpy.array(..., copy=False) -> numpy.asarray(...)
For numpy 2.0
2024-07-29 02:37:48 -07:00
8fd6896a71 set stacklevel=1 2024-07-28 20:41:17 -07:00
1ae3ffb9a2 linter cleanup 2024-07-28 20:35:37 -07:00
810a09f18b simplify comparison 2024-07-28 20:34:25 -07:00
97688ffae1 don't want to use context manager here 2024-07-28 20:32:55 -07:00
445c5690e1 use path.open 2024-07-28 20:32:55 -07:00
7e1f617274 fix bug where use_mmap was ignored 2024-07-28 20:32:53 -07:00
b10803efe9 pass along string arg 2024-07-28 20:31:41 -07:00
5f0a450ffa no need for string annotation 2024-07-28 20:31:41 -07:00
aa3636ebc6 flatten indent 2024-07-28 20:31:41 -07:00
48ffc9709e double quotes for docstrings 2024-07-28 20:31:41 -07:00
5cdafd580f don't need ABCMeta here 2024-07-28 20:31:41 -07:00
2cf187fdb8 def-in-loop needs assigments for vars 2024-07-28 20:31:41 -07:00
99e55f931c refactor to single-line conditional assignments 2024-07-28 20:31:41 -07:00
c48b427c77 mark some missing annotations as intentional 2024-07-28 20:31:41 -07:00
62fc64c344 iteration and collection simplifications 2024-07-28 20:31:41 -07:00
f304217d76 format is ok 2024-07-28 20:31:41 -07:00
ae21a2132e handle int-based cell references 2024-07-28 20:31:41 -07:00
e159c80b0c improve error generation and handling 2024-07-28 20:31:41 -07:00
38e9d5c250 use strict zip 2024-07-28 20:31:41 -07:00
5614eea3b4 Update DXF reading 2024-07-28 20:31:41 -07:00
8035daee7e mark intentionally unused args 2024-07-28 20:31:41 -07:00
4c69e773fd pass kwargs down into gen_straight() 2024-07-28 20:31:41 -07:00
39d9b88fa4 flatten indentation where it makes sense 2024-07-28 20:31:29 -07:00
9d5b1ef5e6 type annotation updates 2024-07-28 19:44:04 -07:00
3d50ff0070 add ruff config 2024-07-28 19:37:57 -07:00
01fe53dc79 fix final assignment and clarify what's going 2024-07-28 19:37:20 -07:00
d5adf57bc6 fix repr outside of class 2024-07-28 19:35:44 -07:00
4c721feaec re-exports: import x as x 2024-07-28 19:34:17 -07:00
6ec94fb3c3 import Sequence et al from collections.abc not typing 2024-07-28 19:33:16 -07:00
b1d78b9acb mkdir examples/layouts/ 2024-07-28 19:28:26 -07:00
dca918e63f notes for more todos 2024-07-28 19:28:05 -07:00
cda895a7d3 remove Builder.path() to avoid confusion with Pather.path() 2024-06-03 17:09:43 -07:00
jan
6db4bb96db Create an ordering for everything
In order to make layouts more reproducible
Also add pattern.sort() and file.utils.preflight_check()

optionally don't sort elements
elements aren't re-ordered that often, sorting them is slow, and the
sort criteria are arbitrary, so we might want to only sort stuff by name
2024-06-03 17:00:20 -07:00
jan
94aa853a49 add plugged() for manually-aligned ports 2024-06-03 16:57:07 -07:00
bb054b9eee port .copy() should deepcopy 2024-06-03 16:54:25 -07:00
5fb736eb74 add a more descriptive error message 2024-06-03 16:54:15 -07:00
4334d0d50b fix bounds calculation for arrays with manhattan rotation 2024-06-03 16:54:02 -07:00
31863c9799 reduce compression level to improve speed 2024-06-03 16:53:14 -07:00
30982d742b make sure kwargs get passed into gen_straight() 2024-06-03 16:53:03 -07:00
447d4ba35b improve path_into docs and error messages 2024-06-03 16:52:34 -07:00
70a51ed8ef path_into should use destination port's ptype by default 2024-06-03 16:26:12 -07:00
jan
b33c632569 cache base64encode calls since it's actually fairly slow 2024-03-09 18:38:29 -08:00
c115780bc7 bump version to v3.1 2024-03-30 18:02:40 -07:00
66d9a4eff8 add note about github mirror 2024-03-30 18:01:14 -07:00
3a0c49174b improve variable naming 2024-03-30 18:01:14 -07:00
8d122cbd2e add path_into() 2024-03-30 18:01:08 -07:00
383b5a0bef add plug_into arg 2023-11-24 23:55:39 -08:00
jan
24c77fd3c3 remove custom __copy__
no longer necessary now that we're not locking anything
2023-11-18 12:29:36 -08:00
jan
33529f5ed3 pattern shouldn't have an offset 2023-11-18 12:28:51 -08:00
jan
2516f06e40 add missing returns 2023-11-18 12:28:33 -08:00
1f6d78386c pass kwargs down into tool's path() calls 2023-11-12 02:30:11 -08:00
41d670eef3 Add missing f for f-strings 2023-11-12 02:29:52 -08:00
7f927c46b3 another arc fix 2023-10-27 23:31:22 -07:00
55e3066485 Wrap Pattern functions for label, ref, polygon, etc. 2023-10-27 21:59:48 -07:00
c7736a18c3 add missing arc endpoints 2023-10-27 21:55:17 -07:00
aefd79fb5d Pattern should be a forward reference 2023-10-23 10:24:49 -07:00
jan
7353617878 add .x and .y aliases for .offset 2023-10-20 23:19:28 -07:00
jan
f28c31fe29 = should have been + 2023-10-20 23:16:39 -07:00
jan
8ef5e2e852 improve docs 2023-10-20 23:16:02 -07:00
jan
ed433861e3 make sure transform is float-typed 2023-10-20 23:15:38 -07:00
jan
e710fa44b5 improve type annotations 2023-10-20 23:15:13 -07:00
jan
9a7a5583ed Add Tree/TreeView and allow Builder to ingest them 2023-10-20 23:14:47 -07:00
jan
b4d31903c1 update required python version 2023-10-15 23:55:41 -07:00
54 changed files with 1952 additions and 447 deletions

View File

@ -8,6 +8,7 @@ to output to multiple formats.
- [Source repository](https://mpxd.net/code/jan/masque) - [Source repository](https://mpxd.net/code/jan/masque)
- [PyPI](https://pypi.org/project/masque) - [PyPI](https://pypi.org/project/masque)
- [Github mirror](https://github.com/anewusername/masque)
## Installation ## Installation
@ -101,7 +102,7 @@ References are accomplished by listing the target's name, not its `Pattern` obje
## Glossary ## Glossary
- `Library`: A collection of named cells. OASIS or GDS "library" or file. - `Library`: A collection of named cells. OASIS or GDS "library" or file.
- "tree": Any Library which has only one topcell. - `Tree`: Any `{name: pattern}` mapping which has only one topcell.
- `Pattern`: A collection of geometry, text labels, and reference to other patterns. - `Pattern`: A collection of geometry, text labels, and reference to other patterns.
OASIS or GDS "Cell", DXF "Block". OASIS or GDS "Cell", DXF "Block".
- `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement". - `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement".
@ -142,6 +143,11 @@ my_pattern.ref(new_name, ...) # instantiate the cell
# In practice, you may do lots of # In practice, you may do lots of
my_pattern.ref(lib << make_tree(...), ...) my_pattern.ref(lib << make_tree(...), ...)
# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit:
my_builder = Builder(library=lib, ...)
...
my_builder.place(make_tree(...))
``` ```
We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern: We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern:
@ -166,6 +172,7 @@ my_pattern.place(abstract, ...)
# or # or
my_pattern.place(library << make_tree(...), ...) my_pattern.place(library << make_tree(...), ...)
```
### Quickly add geometry, labels, or refs: ### Quickly add geometry, labels, or refs:

View File

@ -99,6 +99,7 @@ def main():
print('\nAdded aref_test') print('\nAdded aref_test')
folder = Path('./layouts/') folder = Path('./layouts/')
folder.mkdir(exist_ok=True)
print(f'...writing files to {folder}...') print(f'...writing files to {folder}...')
gds1 = folder / 'rep.gds.gz' gds1 = folder / 'rep.gds.gz'

View File

@ -1,4 +1,4 @@
from typing import Sequence from collections.abc import Sequence
import numpy import numpy
from numpy import pi from numpy import pi

View File

@ -1,4 +1,4 @@
from typing import Sequence, Mapping from collections.abc import Sequence, Mapping
import numpy import numpy
from numpy import pi from numpy import pi

View File

@ -1,4 +1,5 @@
from typing import Sequence, Callable, Any from typing import Any
from collections.abc import Sequence, Callable
from pprint import pformat from pprint import pformat
import numpy import numpy

View File

@ -1,7 +1,7 @@
""" """
Manual wire routing tutorial: Pather and BasicTool Manual wire routing tutorial: Pather and BasicTool
""" """
from typing import Callable from collections.abc import Callable
from numpy import pi from numpy import pi
from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque.builder.tools import BasicTool, PathTool from masque.builder.tools import BasicTool, PathTool
@ -265,6 +265,12 @@ def main() -> None:
# when using pather.retool(). # when using pather.retool().
pather.path_to('VCC', None, -50_000, out_ptype='m1wire') pather.path_to('VCC', None, -50_000, out_ptype='m1wire')
# Now extend GND out to x=-50_000, using M2 for a portion of the path.
# We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice.
with pather.toolctx(M2_tool, keys=['GND']):
pather.path_to('GND', None, -40_000)
pather.path_to('GND', None, -50_000)
# Save the pather's pattern into our library # Save the pather's pattern into our library
library['Pather_and_BasicTool'] = pather.pattern library['Pather_and_BasicTool'] = pather.pattern

View File

@ -2,7 +2,7 @@
Routines for creating normalized 2D lattices and common photonic crystal Routines for creating normalized 2D lattices and common photonic crystal
cavity designs. cavity designs.
""" """
from typing import Sequence from collection.abc import Sequence
import numpy import numpy
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
@ -233,8 +233,8 @@ def ln_shift_defect(
# Shift holes # Shift holes
# Expand shifts as necessary # Expand shifts as necessary
tmp_a = numpy.array(shifts_a) tmp_a = numpy.asarray(shifts_a)
tmp_r = numpy.array(shifts_r) tmp_r = numpy.asarray(shifts_r)
n_shifted = max(tmp_a.size, tmp_r.size) n_shifted = max(tmp_a.size, tmp_r.size)
shifts_a = numpy.ones(n_shifted) shifts_a = numpy.ones(n_shifted)

View File

@ -1,7 +1,7 @@
""" """
Manual wire routing tutorial: RenderPather an PathTool Manual wire routing tutorial: RenderPather an PathTool
""" """
from typing import Callable from collections.abc import Callable
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque.builder.tools import PathTool from masque.builder.tools import PathTool
from masque.file.gdsii import writefile from masque.file.gdsii import writefile

View File

@ -28,25 +28,67 @@
can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally. can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally.
""" """
from .utils import layer_t, annotations_t, SupportsBool from .utils import (
from .error import MasqueError, PatternError, LibraryError, BuildError layer_t as layer_t,
from .shapes import Shape, Polygon, Path, Circle, Arc, Ellipse annotations_t as annotations_t,
from .label import Label SupportsBool as SupportsBool,
from .ref import Ref )
from .pattern import Pattern, map_layers, map_targets, chain_elements from .error import (
MasqueError as MasqueError,
PatternError as PatternError,
LibraryError as LibraryError,
BuildError as BuildError,
)
from .shapes import (
Shape as Shape,
Polygon as Polygon,
Path as Path,
Circle as Circle,
Arc as Arc,
Ellipse as Ellipse,
)
from .label import Label as Label
from .ref import Ref as Ref
from .pattern import (
Pattern as Pattern,
map_layers as map_layers,
map_targets as map_targets,
chain_elements as chain_elements,
)
from .library import ( from .library import (
ILibraryView, ILibrary, ILibraryView as ILibraryView,
LibraryView, Library, LazyLibrary, ILibrary as ILibrary,
AbstractView, LibraryView as LibraryView,
Library as Library,
LazyLibrary as LazyLibrary,
AbstractView as AbstractView,
TreeView as TreeView,
Tree as Tree,
)
from .ports import (
Port as Port,
PortList as PortList,
)
from .abstract import Abstract as Abstract
from .builder import (
Builder as Builder,
Tool as Tool,
Pather as Pather,
RenderPather as RenderPather,
RenderStep as RenderStep,
BasicTool as BasicTool,
PathTool as PathTool,
)
from .utils import (
ports2data as ports2data,
oneshot as oneshot,
R90 as R90,
R180 as R180,
) )
from .ports import Port, PortList
from .abstract import Abstract
from .builder import Builder, Tool, Pather, RenderPather, RenderStep, BasicTool, PathTool
from .utils import ports2data, oneshot
__author__ = 'Jan Petykiewicz' __author__ = 'Jan Petykiewicz'
__version__ = '3.0' __version__ = '3.4'
version = __version__ # legacy version = __version__ # legacy

View File

@ -97,7 +97,7 @@ class Abstract(PortList):
Returns: Returns:
self self
""" """
pivot = numpy.array(pivot) pivot = numpy.asarray(pivot, dtype=float)
self.translate_ports(-pivot) self.translate_ports(-pivot)
self.rotate_ports(rotation) self.rotate_ports(rotation)
self.rotate_port_offsets(rotation) self.rotate_port_offsets(rotation)

View File

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

View File

@ -1,14 +1,16 @@
""" """
Simplified Pattern assembly (`Builder`) Simplified Pattern assembly (`Builder`)
""" """
from typing import Self, Sequence, Mapping from typing import Self
from collections.abc import Iterable, Sequence, Mapping
import copy import copy
import logging import logging
from functools import wraps
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 BuildError from ..error import BuildError
from ..ports import PortList, Port from ..ports import PortList, Port
from ..abstract import Abstract from ..abstract import Abstract
@ -136,7 +138,7 @@ class Builder(PortList):
@classmethod @classmethod
def interface( def interface(
cls, cls: type['Builder'],
source: PortList | Mapping[str, Port] | str, source: PortList | Mapping[str, Port] | str,
*, *,
library: ILibrary | None = None, library: ILibrary | None = None,
@ -188,9 +190,35 @@ class Builder(PortList):
new = Builder(library=library, pattern=pat, name=name) new = Builder(library=library, pattern=pat, name=name)
return new return new
@wraps(Pattern.label)
def label(self, *args, **kwargs) -> Self:
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
# Note: We're a superclass of `Pather`, where path() means something different...
#@wraps(Pattern.path)
#def path(self, *args, **kwargs) -> Self:
# self.pattern.path(*args, **kwargs)
# return self
def plug( def plug(
self, self,
other: Abstract | str | Pattern, 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,
*, *,
@ -198,14 +226,20 @@ class Builder(PortList):
inherit_name: bool = True, inherit_name: bool = 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 around `Pattern.plug` which allows a string for `other`. Wrapper around `Pattern.plug` which allows a string for `other`.
The `Builder`'s library is used to dereference the string (or `Abstract`, if The `Builder`'s library is used to dereference the string (or `Abstract`, if
one is passed with `append=True`). one is passed with `append=True`). If a `TreeView` is passed, it is first
added into `self.library`.
Args: Args:
other: An `Abstract`, string, or `Pattern` describing the device to be instatiated. other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
device to be instatiated. If it is a `TreeView`, it is first
added into `self.library`, after which the topcell is plugged;
an equivalent statement is `self.plug(self.library << other, ...)`.
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
port connections between the two devices. port connections between the two devices.
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
@ -227,6 +261,11 @@ class Builder(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
@ -243,6 +282,10 @@ class Builder(PortList):
logger.error('Skipping plug() since device is dead') logger.error('Skipping plug() since device is dead')
return self return self
if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it
other = self.library << other
if isinstance(other, str): if isinstance(other, str):
other = self.library.abstract(other) other = self.library.abstract(other)
if append and isinstance(other, Abstract): if append and isinstance(other, Abstract):
@ -256,12 +299,13 @@ class Builder(PortList):
inherit_name=inherit_name, inherit_name=inherit_name,
set_rotation=set_rotation, set_rotation=set_rotation,
append=append, append=append,
ok_connections=ok_connections,
) )
return self return self
def place( def place(
self, self,
other: Abstract | str | Pattern, other: Abstract | str | Pattern | TreeView,
*, *,
offset: ArrayLike = (0, 0), offset: ArrayLike = (0, 0),
rotation: float = 0, rotation: float = 0,
@ -272,12 +316,17 @@ class Builder(PortList):
append: bool = False, append: bool = False,
) -> Self: ) -> Self:
""" """
Wrapper around `Pattern.place` which allows a string for `other`. Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.
The `Builder`'s library is used to dereference the string (or `Abstract`, if The `Builder`'s library is used to dereference the string (or `Abstract`, if
one is passed with `append=True`). one is passed with `append=True`). If a `TreeView` is passed, it is first
added into `self.library`.
Args: Args:
other: An `Abstract`, string, or `Pattern` describing the device to be instatiated. other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
device to be instatiated. If it is a `TreeView`, it is first
added into `self.library`, after which the topcell is plugged;
an equivalent statement is `self.plug(self.library << other, ...)`.
offset: Offset at which to place the instance. Default (0, 0). offset: Offset at which to place the instance. Default (0, 0).
rotation: Rotation applied to the instance before placement. Default 0. rotation: Rotation applied to the instance before placement. Default 0.
pivot: Rotation is applied around this pivot point (default (0, 0)). pivot: Rotation is applied around this pivot point (default (0, 0)).
@ -306,6 +355,10 @@ class Builder(PortList):
logger.error('Skipping place() since device is dead') logger.error('Skipping place() since device is dead')
return self return self
if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it
other = self.library << other
if isinstance(other, str): if isinstance(other, str):
other = self.library.abstract(other) other = self.library.abstract(other)
if append and isinstance(other, Abstract): if append and isinstance(other, Abstract):

View File

@ -1,9 +1,11 @@
""" """
Manual wire/waveguide routing (`Pather`) Manual wire/waveguide routing (`Pather`)
""" """
from typing import Self, Sequence, MutableMapping, Mapping from typing import Self
from collections.abc import Sequence, MutableMapping, Mapping, Iterator
import copy import copy
import logging import logging
from contextlib import contextmanager
from pprint import pformat from pprint import pformat
import numpy import numpy
@ -15,7 +17,7 @@ from ..library import ILibrary, SINGLE_USE_PREFIX
from ..error import PortError, BuildError from ..error import PortError, 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, rotation_matrix_2d
from .tools import Tool from .tools import Tool
from .utils import ell from .utils import ell
from .builder import Builder from .builder import Builder
@ -174,7 +176,7 @@ class Pather(Builder):
@classmethod @classmethod
def from_builder( def from_builder(
cls, cls: type['Pather'],
builder: Builder, builder: Builder,
*, *,
tools: Tool | MutableMapping[str | None, Tool] | None = None, tools: Tool | MutableMapping[str | None, Tool] | None = None,
@ -194,7 +196,7 @@ class Pather(Builder):
@classmethod @classmethod
def interface( def interface(
cls, cls: type['Pather'],
source: PortList | Mapping[str, Port] | str, source: PortList | Mapping[str, Port] | str,
*, *,
library: ILibrary | None = None, library: ILibrary | None = None,
@ -280,6 +282,37 @@ class Pather(Builder):
self.tools[key] = tool self.tools[key] = tool
return self 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,
portspec: str, portspec: str,
@ -287,6 +320,7 @@ class Pather(Builder):
length: float, length: float,
*, *,
tool_port_names: tuple[str, str] = ('A', 'B'), tool_port_names: tuple[str, str] = ('A', 'B'),
plug_into: str | None = None,
**kwargs, **kwargs,
) -> Self: ) -> Self:
""" """
@ -307,6 +341,8 @@ class Pather(Builder):
tool_port_names: The names of the ports on the generated pattern. It is unlikely 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 that you will need to change these. The first port is the input (to be
connected to `portspec`). connected to `portspec`).
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns: Returns:
self self
@ -323,7 +359,11 @@ class Pather(Builder):
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 abstract = self.library << tree
return self.plug(abstract, {portspec: tool_port_names[0]}) if plug_into is not None:
output = {plug_into: tool_port_names[1]}
else:
output = {}
return self.plug(abstract, {portspec: tool_port_names[0], **output})
def path_to( def path_to(
self, self,
@ -334,6 +374,7 @@ class Pather(Builder):
x: float | None = None, x: float | None = None,
y: float | None = None, y: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'), tool_port_names: tuple[str, str] = ('A', 'B'),
plug_into: str | None = None,
**kwargs, **kwargs,
) -> Self: ) -> Self:
""" """
@ -362,6 +403,8 @@ class Pather(Builder):
tool_port_names: The names of the ports on the generated pattern. It is unlikely 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 that you will need to change these. The first port is the input (to be
connected to `portspec`). connected to `portspec`).
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns: Returns:
self self
@ -411,7 +454,136 @@ class Pather(Builder):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0) length = numpy.abs(position - y0)
return self.path(portspec, ccw, length, tool_port_names=tool_port_names, **kwargs) return self.path(
portspec,
ccw,
length,
tool_port_names=tool_port_names,
plug_into=plug_into,
**kwargs,
)
def path_into(
self,
portspec_src: str,
portspec_dst: str,
*,
tool_port_names: tuple[str, str] = ('A', 'B'),
out_ptype: str | None = None,
plug_destination: bool = True,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" and traveling between the ports `portspec_src` and
`portspec_dst`, and `plug` it into both (or just the source port).
Only unambiguous scenarios are allowed:
- Straight connector between facing ports
- Single 90 degree bend
- Jog between facing ports
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
By default, the destination's `pytpe` will be used as the `out_ptype` for the
wire, and the `portspec_dst` will be plugged (i.e. removed).
Args:
portspec_src: The name of the starting port into which the wire will be plugged.
portspec_dst: The name of the destination port.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
out_ptype: Passed to the pathing tool in order to specify the desired port type
to be generated at the destination end. If `None` (default), the destination
port's `ptype` will be used.
Returns:
self
Raises:
PortError if either port does not have a specified rotation.
BuildError if and invalid port config is encountered:
- Non-manhattan ports
- U-bend
- Destination too close to (or behind) source
"""
if self._dead:
logger.error('Skipping path_into() since device is dead')
return self
port_src = self.pattern[portspec_src]
port_dst = self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None:
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
if port_dst.rotation is None:
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route from non-manhattan port')
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route to non-manhattan port')
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
xs, ys = port_src.offset
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east
def get_jog(ccw: SupportsBool, length: float) -> float:
tool = self.tools.get(portspec_src, self.tools[None])
in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already...
tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs)
top2 = tree2.top_pattern()
jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset)
return jog[1] * [-1, 1][int(bool(ccw))]
dst_extra_args = {'out_ptype': out_ptype}
if plug_destination:
dst_extra_args['plug_into'] = portspec_dst
src_args = {**kwargs, 'tool_port_names': tool_port_names}
dst_args = {**src_args, **dst_extra_args}
if src_is_horizontal and not dst_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
self.path_to(portspec_src, None, y=yd, **dst_args)
elif dst_is_horizontal and not src_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
self.path_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
if src_is_horizontal and ys == yd:
# straight connector
self.path_to(portspec_src, None, x=xd, **dst_args)
elif not src_is_horizontal and xs == xd:
# straight connector
self.path_to(portspec_src, None, y=yd, **dst_args)
elif src_is_horizontal:
# figure out how much x our y-segment (2nd) takes up, then path based on that
y_len = numpy.abs(yd - ys)
ccw2 = src_ne != (yd > ys)
jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs)
self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args)
self.path_to(portspec_src, ccw2, y=yd, **dst_args)
else:
# figure out how much y our x-segment (2nd) takes up, then path based on that
x_len = numpy.abs(xd - xs)
ccw2 = src_ne != (xd < xs)
jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys)
self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args)
self.path_to(portspec_src, ccw2, x=xd, **dst_args)
elif numpy.isclose(angle, 0):
raise BuildError('Don\'t know how to route a U-bend at this time!')
else:
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
return self
def mpath( def mpath(
self, self,
@ -508,14 +680,17 @@ class Pather(Builder):
if 'bound_type' in kwargs: if 'bound_type' in kwargs:
bound_types.add(kwargs['bound_type']) bound_types.add(kwargs['bound_type'])
bound = kwargs['bound'] bound = kwargs['bound']
del kwargs['bound_type']
del kwargs['bound']
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in kwargs: if bt in kwargs:
bound_types.add(bt) bound_types.add(bt)
bound = kwargs[bt] bound = kwargs[bt]
del kwargs[bt]
if not bound_types: if not bound_types:
raise BuildError('No bound type specified for mpath') raise BuildError('No bound type specified for mpath')
elif len(bound_types) > 1: if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}') raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0] bound_type = tuple(bound_types)[0]
@ -528,16 +703,16 @@ class Pather(Builder):
if len(ports) == 1 and not force_container: if len(ports) == 1 and not force_container:
# Not a bus, so having a container just adds noise to the layout # Not a bus, so having a container just adds noise to the layout
port_name = tuple(portspec)[0] port_name = tuple(portspec)[0]
return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names) return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names, **kwargs)
else:
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)
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.keys()}) # TODO safe to use 'in_'?
# TODO def path_join() and def bus_join()? 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: def flatten(self) -> Self:
""" """

View File

@ -1,7 +1,8 @@
""" """
Pather with batched (multi-step) rendering Pather with batched (multi-step) rendering
""" """
from typing import Self, Sequence, Mapping, MutableMapping from typing import Self
from collections.abc import Sequence, Mapping, MutableMapping
import copy import copy
import logging import logging
from collections import defaultdict from collections import defaultdict
@ -127,7 +128,7 @@ class RenderPather(PortList):
@classmethod @classmethod
def interface( def interface(
cls, cls: type['RenderPather'],
source: PortList | Mapping[str, Port] | str, source: PortList | Mapping[str, Port] | str,
*, *,
library: ILibrary | None = None, library: ILibrary | None = None,
@ -247,7 +248,7 @@ class RenderPather(PortList):
other_tgt = self.library[other.name] other_tgt = self.library[other.name]
# get rid of plugged ports # get rid of plugged ports
for kk in map_in.keys(): for kk in map_in:
if kk in self.paths: if kk in self.paths:
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None)) self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
@ -560,7 +561,7 @@ class RenderPather(PortList):
if not bound_types: if not bound_types:
raise BuildError('No bound type specified for mpath') raise BuildError('No bound type specified for mpath')
elif len(bound_types) > 1: if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}') raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0] bound_type = tuple(bound_types)[0]

View File

@ -3,7 +3,8 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
# TODO document all tools # TODO document all tools
""" """
from typing import Sequence, Literal, Callable, Any from typing import Literal, Any
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
@ -222,8 +223,8 @@ class Tool:
self, self,
batch: Sequence[RenderStep], batch: Sequence[RenderStep],
*, *,
port_names: Sequence[str] = ('A', 'B'), port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused)
**kwargs, **kwargs, # noqa: ARG002 (unused)
) -> ILibrary: ) -> ILibrary:
""" """
Render the provided `batch` of `RenderStep`s into geometry, returning a tree Render the provided `batch` of `RenderStep`s into geometry, returning a tree
@ -289,12 +290,12 @@ class BasicTool(Tool, metaclass=ABCMeta):
gen_straight, sport_in, sport_out = self.straight gen_straight, sport_in, sport_out = self.straight
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=port_names) pat.add_port_pair(names=port_names, ptype=in_ptype)
if data.in_transition: if data.in_transition:
ipat, iport_theirs, _iport_ours = data.in_transition ipat, iport_theirs, _iport_ours = data.in_transition
pat.plug(ipat, {port_names[1]: iport_theirs}) pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(data.straight_length, 0): if not numpy.isclose(data.straight_length, 0):
straight = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length)} straight = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length, **kwargs)}
pat.plug(straight, {port_names[1]: sport_in}) pat.plug(straight, {port_names[1]: sport_in})
if data.ccw is not None: if data.ccw is not None:
bend, bport_in, bport_out = self.bend bend, bport_in, bport_out = self.bend
@ -312,7 +313,7 @@ class BasicTool(Tool, metaclass=ABCMeta):
*, *,
in_ptype: str | None = None, in_ptype: str | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
**kwargs, **kwargs, # noqa: ARG002 (unused)
) -> tuple[Port, LData]: ) -> tuple[Port, LData]:
# TODO check all the math for L-shaped bends # TODO check all the math for L-shaped bends
if ccw is not None: if ccw is not None:
@ -404,7 +405,7 @@ class BasicTool(Tool, metaclass=ABCMeta):
ipat, iport_theirs, _iport_ours = in_transition ipat, iport_theirs, _iport_ours = in_transition
pat.plug(ipat, {port_names[1]: iport_theirs}) pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(straight_length, 0): if not numpy.isclose(straight_length, 0):
straight_pat = gen_straight(straight_length) straight_pat = gen_straight(straight_length, **kwargs)
if append: if append:
pat.plug(straight_pat, {port_names[1]: sport_in}, append=True) pat.plug(straight_pat, {port_names[1]: sport_in}, append=True)
else: else:
@ -454,7 +455,7 @@ class PathTool(Tool, metaclass=ABCMeta):
in_ptype: str | None = None, in_ptype: str | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'), port_names: tuple[str, str] = ('A', 'B'),
**kwargs, **kwargs, # noqa: ARG002 (unused)
) -> Library: ) -> Library:
out_port, dxy = self.planL( out_port, dxy = self.planL(
ccw, ccw,
@ -485,9 +486,9 @@ class PathTool(Tool, metaclass=ABCMeta):
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,
**kwargs, **kwargs, # noqa: ARG002 (unused)
) -> tuple[Port, NDArray[numpy.float64]]: ) -> tuple[Port, NDArray[numpy.float64]]:
# TODO check all the math for L-shaped bends # TODO check all the math for L-shaped bends
@ -521,7 +522,7 @@ class PathTool(Tool, metaclass=ABCMeta):
batch: Sequence[RenderStep], batch: Sequence[RenderStep],
*, *,
port_names: Sequence[str] = ('A', 'B'), port_names: Sequence[str] = ('A', 'B'),
**kwargs, **kwargs, # noqa: ARG002 (unused)
) -> ILibrary: ) -> ILibrary:
path_vertices = [batch[0].start_port.offset] path_vertices = [batch[0].start_port.offset]

View File

@ -1,4 +1,5 @@
from typing import Mapping, Sequence, SupportsFloat, cast, TYPE_CHECKING from typing import SupportsFloat, cast, TYPE_CHECKING
from collections.abc import Mapping, Sequence
from pprint import pformat from pprint import pformat
import numpy import numpy
@ -20,7 +21,7 @@ def ell(
*, *,
spacing: float | ArrayLike | None = None, spacing: float | ArrayLike | None = None,
set_rotation: float | None = None, set_rotation: float | None = None,
) -> dict[str, float]: ) -> dict[str, numpy.float64]:
""" """
Calculate extension for each port in order to build a 90-degree bend with the provided Calculate extension for each port in order to build a 90-degree bend with the provided
channel spacing: channel spacing:
@ -111,9 +112,9 @@ def ell(
is_horizontal = numpy.isclose(rotations[0] % pi, 0) is_horizontal = numpy.isclose(rotations[0] % pi, 0)
if bound_type in ('ymin', 'ymax') and is_horizontal: if bound_type in ('ymin', 'ymax') and is_horizontal:
raise BuildError('Asked for {bound_type} position but ports are pointing along the x-axis!') raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!')
elif bound_type in ('xmin', 'xmax') and not is_horizontal: if bound_type in ('xmin', 'xmax') and not is_horizontal:
raise BuildError('Asked for {bound_type} position but ports are pointing along the y-axis!') raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!')
direction = rotations[0] + pi # direction we want to travel in (+pi relative to port) direction = rotations[0] + pi # direction we want to travel in (+pi relative to port)
rot_matrix = rotation_matrix_2d(-direction) rot_matrix = rotation_matrix_2d(-direction)
@ -168,11 +169,11 @@ def ell(
'emax', 'max_extension', 'emax', 'max_extension',
'min_past_furthest',): 'min_past_furthest',):
if numpy.size(bound) == 2: if numpy.size(bound) == 2:
bound = cast(Sequence[float], bound) bound = cast('Sequence[float]', bound)
rot_bound = (rot_matrix @ ((bound[0], 0), rot_bound = (rot_matrix @ ((bound[0], 0),
(0, bound[1])))[0, :] (0, bound[1])))[0, :]
else: else:
bound = cast(float, bound) bound = cast('float', bound)
rot_bound = numpy.array(bound) rot_bound = numpy.array(bound)
if rot_bound < 0: if rot_bound < 0:
@ -184,10 +185,10 @@ def ell(
offsets += rot_bound.min() - offsets.max() offsets += rot_bound.min() - offsets.max()
else: else:
if numpy.size(bound) == 2: if numpy.size(bound) == 2:
bound = cast(Sequence[float], bound) bound = cast('Sequence[float]', bound)
rot_bound = (rot_matrix @ bound)[0] rot_bound = (rot_matrix @ bound)[0]
else: else:
bound = cast(float, bound) bound = cast('float', bound)
neg = (direction + pi / 4) % (2 * pi) > pi neg = (direction + pi / 4) % (2 * pi) > pi
rot_bound = -bound if neg else bound rot_bound = -bound if neg else bound
@ -201,7 +202,7 @@ def ell(
if extension < 0: if extension < 0:
ext_floor = -numpy.floor(extension) ext_floor = -numpy.floor(extension)
raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t' raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t'
+ '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets))) + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets, strict=True)))
result = dict(zip(ports.keys(), offsets)) result = dict(zip(ports.keys(), offsets, strict=True))
return result return result

View File

@ -6,7 +6,8 @@ Notes:
* ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID * ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
to unique values, so byte-for-byte reproducibility is not achievable for now to unique values, so byte-for-byte reproducibility is not achievable for now
""" """
from typing import Any, Callable, Mapping, cast, TextIO, IO from typing import Any, cast, TextIO, IO
from collections.abc import Mapping, Callable
import io import io
import logging import logging
import pathlib import pathlib
@ -15,6 +16,7 @@ import gzip
import numpy import numpy
import ezdxf import ezdxf
from ezdxf.enums import TextEntityAlignment from ezdxf.enums import TextEntityAlignment
from ezdxf.entities import LWPolyline, Polyline, Text, Insert
from .utils import is_gzipped, tmpfile from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label from .. import Pattern, Ref, PatternError, Label
@ -38,7 +40,7 @@ def write(
top_name: str, top_name: str,
stream: TextIO, stream: TextIO,
*, *,
dxf_version='AC1024', dxf_version: str = 'AC1024',
) -> None: ) -> None:
""" """
Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes
@ -130,7 +132,7 @@ def writefile(
with tmpfile(path) as base_stream: with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,) streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz': if path.suffix == '.gz':
gz_stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) gz_stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb'))
streams = (gz_stream,) + streams streams = (gz_stream,) + streams
else: else:
gz_stream = base_stream gz_stream = base_stream
@ -204,26 +206,25 @@ def read(
return mlib, library_info return mlib, library_info
def _read_block(block) -> tuple[str, Pattern]: def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]:
name = block.name name = block.name
pat = Pattern() pat = Pattern()
for element in block: for element in block:
eltype = element.dxftype() if isinstance(element, LWPolyline | Polyline):
if eltype in ('POLYLINE', 'LWPOLYLINE'): if isinstance(element, LWPolyline):
if eltype == 'LWPOLYLINE': points = numpy.asarray(element.get_points())
points = numpy.array(tuple(element.lwpoints)) elif isinstance(element, Polyline):
else: points = numpy.asarray([pp.xyz for pp in element.points()])
points = numpy.array(tuple(element.points()))
attr = element.dxfattribs() attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER) layer = attr.get('layer', DEFAULT_LAYER)
if points.shape[1] == 2: if points.shape[1] == 2:
raise PatternError('Invalid or unimplemented polygon?') raise PatternError('Invalid or unimplemented polygon?')
#shape = Polygon()
elif points.shape[1] > 2: if points.shape[1] > 2:
if (points[0, 2] != points[:, 2]).any(): if (points[0, 2] != points[:, 2]).any():
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
elif points.shape[1] == 4 and (points[:, 3] != 0).any(): if points.shape[1] == 4 and (points[:, 3] != 0).any():
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
width = points[0, 2] width = points[0, 2]
@ -238,9 +239,9 @@ def _read_block(block) -> tuple[str, Pattern]:
pat.shapes[layer].append(shape) pat.shapes[layer].append(shape)
elif eltype in ('TEXT',): elif isinstance(element, Text):
args = dict( args = dict(
offset=numpy.array(element.get_pos()[1])[:2], offset=numpy.asarray(element.get_placement()[1])[:2],
layer=element.dxfattribs().get('layer', DEFAULT_LAYER), layer=element.dxfattribs().get('layer', DEFAULT_LAYER),
) )
string = element.dxfattribs().get('text', '') string = element.dxfattribs().get('text', '')
@ -251,7 +252,7 @@ def _read_block(block) -> tuple[str, Pattern]:
pat.label(string=string, **args) pat.label(string=string, **args)
# else: # else:
# pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????)) # pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????))
elif eltype in ('INSERT',): elif isinstance(element, Insert):
attr = element.dxfattribs() attr = element.dxfattribs()
xscale = attr.get('xscale', 1) xscale = attr.get('xscale', 1)
yscale = attr.get('yscale', 1) yscale = attr.get('yscale', 1)
@ -261,7 +262,7 @@ def _read_block(block) -> tuple[str, Pattern]:
mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0)) mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0))
rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle
offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2] offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]
args = dict( args = dict(
target=attr.get('name', None), target=attr.get('name', None),
@ -336,10 +337,10 @@ def _mrefs_to_drefs(
def _shapes_to_elements( def _shapes_to_elements(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
shapes: dict[layer_t, list[Shape]], shapes: dict[layer_t, list[Shape]],
polygonize_paths: bool = False,
) -> None: ) -> None:
# Add `LWPolyline`s for each shape. # Add `LWPolyline`s for each shape.
# Could set do paths with width setting, but need to consider endcaps. # Could set do paths with width setting, but need to consider endcaps.
# TODO: can DXF do paths?
for layer, sseq in shapes.items(): for layer, sseq in shapes.items():
attribs = dict(layer=_mlayer2dxf(layer)) attribs = dict(layer=_mlayer2dxf(layer))
for shape in sseq: for shape in sseq:

View File

@ -19,7 +19,8 @@ Notes:
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility. * 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) * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
""" """
from typing import Callable, Iterable, Mapping, IO, cast, Any from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
import io import io
import mmap import mmap
import logging import logging
@ -144,7 +145,7 @@ def writefile(
with tmpfile(path) as base_stream: with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,) streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz': if path.suffix == '.gz':
stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams streams = (stream,) + streams
else: else:
stream = base_stream stream = base_stream
@ -356,7 +357,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
if isinstance(rep, Grid): if isinstance(rep, Grid):
b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
b_count = rep.b_count if rep.b_count is not None else 1 b_count = rep.b_count if rep.b_count is not None else 1
xy = numpy.array(ref.offset) + numpy.array([ xy = numpy.asarray(ref.offset) + numpy.array([
[0.0, 0.0], [0.0, 0.0],
rep.a_vector * rep.a_count, rep.a_vector * rep.a_count,
b_vector * b_count, b_vector * b_count,
@ -408,8 +409,8 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
for key, vals in annotations.items(): for key, vals in annotations.items():
try: try:
i = int(key) i = int(key)
except ValueError: except ValueError as err:
raise PatternError(f'Annotation key {key} is not convertable to an integer') raise PatternError(f'Annotation key {key} is not convertable to an integer') from err
if not (0 < i < 126): if not (0 < i < 126):
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])')
@ -596,19 +597,19 @@ def load_libraryfile(
path = pathlib.Path(filename) path = pathlib.Path(filename)
stream: IO[bytes] stream: IO[bytes]
if is_gzipped(path): if is_gzipped(path):
if mmap: if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...') logger.info('Asked to mmap a gzipped file, reading into memory instead...')
gz_stream = gzip.open(path, mode='rb') gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BytesIO(gz_stream.read()) # type: ignore stream = io.BytesIO(gz_stream.read()) # type: ignore
else: else:
gz_stream = gzip.open(path, mode='rb') gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BufferedReader(gz_stream) # type: ignore stream = io.BufferedReader(gz_stream) # type: ignore
else: else: # noqa: PLR5501
if mmap: if use_mmap:
base_stream = open(path, mode='rb', buffering=0) base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
else: else:
stream = open(path, mode='rb') stream = path.open(mode='rb') # noqa: SIM115
return load_library(stream, full_load=full_load, postprocess=postprocess) return load_library(stream, full_load=full_load, postprocess=postprocess)

View File

@ -14,7 +14,8 @@ Note that OASIS references follow the same convention as `masque`,
Notes: Notes:
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
""" """
from typing import Any, Callable, Iterable, IO, Mapping, cast, Sequence from typing import Any, IO, cast
from collections.abc import Sequence, Iterable, Mapping, Callable
import logging import logging
import pathlib import pathlib
import gzip import gzip
@ -189,7 +190,7 @@ def writefile(
with tmpfile(path) as base_stream: with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,) streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz': if path.suffix == '.gz':
stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb'))
streams += (stream,) streams += (stream,)
else: else:
stream = base_stream stream = base_stream
@ -297,7 +298,7 @@ def read(
cap_start = path_cap_map[element.get_extension_start()[0]] cap_start = path_cap_map[element.get_extension_start()[0]]
cap_end = path_cap_map[element.get_extension_end()[0]] cap_end = path_cap_map[element.get_extension_end()[0]]
if cap_start != cap_end: if cap_start != cap_end:
raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types raise PatternError('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
cap = cap_start cap = cap_start
path_args: dict[str, Any] = {} path_args: dict[str, Any] = {}
@ -452,6 +453,8 @@ def read(
for placement in cell.placements: for placement in cell.placements:
target, ref = _placement_to_ref(placement, lib) target, ref = _placement_to_ref(placement, lib)
if isinstance(target, int):
target = lib.cellnames[target].nstring.string
pat.refs[target].append(ref) pat.refs[target].append(ref)
mlib[cell_name] = pat mlib[cell_name] = pat
@ -548,7 +551,7 @@ def _shapes_to_elements(
circle = fatrec.Circle( circle = fatrec.Circle(
layer=layer, layer=layer,
datatype=datatype, datatype=datatype,
radius=cast(int, radius), radius=cast('int', radius),
x=offset[0], x=offset[0],
y=offset[1], y=offset[1],
properties=properties, properties=properties,
@ -565,8 +568,8 @@ def _shapes_to_elements(
path = fatrec.Path( path = fatrec.Path(
layer=layer, layer=layer,
datatype=datatype, datatype=datatype,
point_list=cast(Sequence[Sequence[int]], deltas), point_list=cast('Sequence[Sequence[int]]', deltas),
half_width=cast(int, half_width), half_width=cast('int', half_width),
x=xy[0], x=xy[0],
y=xy[1], y=xy[1],
extension_start=extension_start, # TODO implement multiple cap types? extension_start=extension_start, # TODO implement multiple cap types?
@ -584,7 +587,7 @@ def _shapes_to_elements(
datatype=datatype, datatype=datatype,
x=xy[0], x=xy[0],
y=xy[1], y=xy[1],
point_list=cast(list[list[int]], points), point_list=cast('list[list[int]]', points),
properties=properties, properties=properties,
repetition=repetition, repetition=repetition,
)) ))
@ -648,10 +651,10 @@ def repetition_masq2fata(
a_count = rint_cast(rep.a_count) a_count = rint_cast(rep.a_count)
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
frep = fatamorgana.GridRepetition( frep = fatamorgana.GridRepetition(
a_vector=cast(list[int], a_vector), a_vector=cast('list[int]', a_vector),
b_vector=cast(list[int] | None, b_vector), b_vector=cast('list[int] | None', b_vector),
a_count=cast(int, a_count), a_count=cast('int', a_count),
b_count=cast(int | None, b_count), b_count=cast('int | None', b_count),
) )
offset = (0, 0) offset = (0, 0)
elif isinstance(rep, Arbitrary): elif isinstance(rep, Arbitrary):
@ -692,9 +695,9 @@ def properties_to_annotations(
assert proprec.values is not None assert proprec.values is not None
for value in proprec.values: for value in proprec.values:
if isinstance(value, (float, int)): if isinstance(value, float | int):
values.append(value) values.append(value)
elif isinstance(value, (NString, AString)): elif isinstance(value, NString | AString):
values.append(value.string) values.append(value.string)
elif isinstance(value, PropStringReference): elif isinstance(value, PropStringReference):
values.append(propstrings[value.ref].string) # dereference values.append(propstrings[value.ref].string) # dereference

View File

@ -1,7 +1,7 @@
""" """
SVG file format readers and writers SVG file format readers and writers
""" """
from typing import Mapping from collections.abc import Mapping
import warnings import warnings
import numpy import numpy
@ -50,7 +50,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') warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
else: else:
bounds_min, bounds_max = bounds bounds_min, bounds_max = bounds
@ -117,7 +117,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') warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
else: else:
bounds_min, bounds_max = bounds bounds_min, bounds_max = bounds
@ -154,9 +154,9 @@ def poly2path(vertices: ArrayLike) -> str:
Returns: Returns:
SVG path-string. SVG path-string.
""" """
verts = numpy.array(vertices, copy=False) verts = numpy.asarray(vertices)
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032
for vertex in verts[1:]: for vertex in verts[1:]:
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032
commands += ' Z ' commands += ' Z '
return commands return commands

View File

@ -1,21 +1,93 @@
""" """
Helper functions for file reading and writing Helper functions for file reading and writing
""" """
from typing import IO, Iterator from typing import IO
from collections.abc import Iterator, Mapping
import re import re
import pathlib import pathlib
import logging import logging
import tempfile import tempfile
import shutil import shutil
from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from pprint import pformat
from itertools import chain
from .. import Pattern, PatternError from .. import Pattern, PatternError, Library, LibraryError
from ..shapes import Polygon, Path from ..shapes import Polygon, Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def preflight(
lib: Library,
sort: bool = True,
sort_elements: bool = False,
allow_dangling_refs: bool | None = None,
allow_named_layers: bool = True,
prune_empty_patterns: bool = False,
wrap_repeated_shapes: bool = False,
) -> Library:
"""
Run a standard set of useful operations and checks, usually done immediately prior
to writing to a file (or immediately after reading).
Args:
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
Default True. Useful for reproducible builds.
sort_elements: Whether to sort the pattern contents. Requires sort=True to run.
allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not
in the provided library. If `True`, no check is performed; if `False`, a `LibraryError`
is raised instead.
allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by
a string instead of a number (or tuple).
prune_empty_patterns: Runs `Library.prune_empty()`, recursively deleting any empty patterns.
wrap_repeated_shapes: Runs `Library.wrap_repeated_shapes()`, turning repeated shapes into
repeated refs containing non-repeated shapes.
Returns:
`lib` or an equivalent sorted library
"""
if sort:
lib = Library(dict(sorted(
(nn, pp.sort(sort_elements=sort_elements)) for nn, pp in lib.items()
)))
if not allow_dangling_refs:
refs = lib.referenced_patterns()
dangling = refs - set(lib.keys())
if dangling:
msg = 'Dangling refs found: ' + pformat(dangling)
if allow_dangling_refs is None:
logger.warning(msg)
else:
raise LibraryError(msg)
if not allow_named_layers:
named_layers: Mapping[str, set] = defaultdict(set)
for name, pat in lib.items():
for layer in chain(pat.shapes.keys(), pat.labels.keys()):
if isinstance(layer, str):
named_layers[name].add(layer)
named_layers = dict(named_layers)
if named_layers:
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
if prune_empty_patterns:
pruned = lib.prune_empty()
if pruned:
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
logger.debug('Pruned: ' + pformat(pruned))
else:
logger.debug('Preflight found no empty patterns')
if wrap_repeated_shapes:
lib.wrap_repeated_shapes()
return lib
def mangle_name(name: str) -> str: def mangle_name(name: str) -> str:
""" """
Sanitize a name. Sanitize a name.
@ -45,7 +117,7 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern:
for shapes in pat.shapes.values(): for shapes in pat.shapes.values():
remove_inds = [] remove_inds = []
for ii, shape in enumerate(shapes): for ii, shape in enumerate(shapes):
if not isinstance(shape, (Polygon, Path)): if not isinstance(shape, Polygon | Path):
continue continue
try: try:
shape.clean_vertices() shape.clean_vertices()
@ -57,7 +129,7 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern:
def is_gzipped(path: pathlib.Path) -> bool: def is_gzipped(path: pathlib.Path) -> bool:
with open(path, 'rb') as stream: with path.open('rb') as stream:
magic_bytes = stream.read(2) magic_bytes = stream.read(2)
return magic_bytes == b'\x1f\x8b' return magic_bytes == b'\x1f\x8b'

View File

@ -1,15 +1,17 @@
from typing import Self from typing import Self, Any
import copy import copy
import functools
import numpy import numpy
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
from .repetition import Repetition from .repetition import Repetition
from .utils import rotation_matrix_2d, annotations_t from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
from .traits import AnnotatableImpl from .traits import AnnotatableImpl
@functools.total_ordering
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
""" """
A text annotation with a position (but no size; it is not drawn) A text annotation with a position (but no size; it is not drawn)
@ -47,7 +49,7 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
annotations: annotations_t | None = None, annotations: annotations_t | None = None,
) -> None: ) -> None:
self.string = string self.string = string
self.offset = numpy.array(offset, dtype=float, copy=True) self.offset = numpy.array(offset, dtype=float)
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations if annotations is not None else {}
@ -64,6 +66,23 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
new._offset = self._offset.copy() new._offset = self._offset.copy()
return new return new
def __lt__(self, other: 'Label') -> bool:
if self.string != other.string:
return self.string < other.string
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def __eq__(self, other: Any) -> bool:
return (
self.string == other.string
and numpy.array_equal(self.offset, other.offset)
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
""" """
Rotate the label around a point. Rotate the label around a point.
@ -75,7 +94,7 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
Returns: Returns:
self self
""" """
pivot = numpy.array(pivot, dtype=float) pivot = numpy.asarray(pivot, dtype=float)
self.translate(-pivot) self.translate(-pivot)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
self.translate(+pivot) self.translate(+pivot)

View File

@ -14,22 +14,21 @@ Classes include:
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked - `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
library. Generated with `ILibraryView.abstract_view()`. library. Generated with `ILibraryView.abstract_view()`.
""" """
from typing import Callable, Self, Type, TYPE_CHECKING, cast from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
from typing import Iterator, Mapping, MutableMapping, Sequence from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
import logging import logging
import base64
import struct
import re import re
import copy import copy
from pprint import pformat from pprint import pformat
from collections import defaultdict from collections import defaultdict
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from graphlib import TopologicalSorter
import numpy import numpy
from numpy.typing import ArrayLike from numpy.typing import ArrayLike, NDArray
from .error import LibraryError, PatternError from .error import LibraryError, PatternError
from .utils import rotation_matrix_2d, layer_t from .utils import layer_t, apply_transforms
from .shapes import Shape, Polygon from .shapes import Shape, Polygon
from .label import Label from .label import Label
from .abstract import Abstract from .abstract import Abstract
@ -42,7 +41,24 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
visitor_function_t = Callable[..., 'Pattern'] class visitor_function_t(Protocol):
""" Signature for `Library.dfs()` visitor functions. """
def __call__(
self,
pattern: 'Pattern',
hierarchy: tuple[str | None, ...],
memo: dict,
transform: NDArray[numpy.float64] | Literal[False],
) -> 'Pattern':
...
TreeView: TypeAlias = Mapping[str, 'Pattern']
""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """
Tree: TypeAlias = MutableMapping[str, 'Pattern']
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
SINGLE_USE_PREFIX = '_' SINGLE_USE_PREFIX = '_'
""" """
@ -158,7 +174,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
tops = tuple(self.keys()) tops = tuple(self.keys())
if skip is None: if skip is None:
skip = set([None]) skip = {None}
if isinstance(tops, str): if isinstance(tops, str):
tops = (tops,) tops = (tops,)
@ -195,7 +211,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str): if isinstance(tops, str):
tops = (tops,) tops = (tops,)
keep = cast(set[str], self.referenced_patterns(tops) - set((None,))) keep = cast('set[str]', self.referenced_patterns(tops) - {None})
keep |= set(tops) keep |= set(tops)
filtered = {kk: vv for kk, vv in self.items() if kk in keep} filtered = {kk: vv for kk, vv in self.items() if kk in keep}
@ -267,7 +283,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str): if isinstance(tops, str):
tops = (tops,) tops = (tops,)
flattened: dict[str, 'Pattern | None'] = {} flattened: dict[str, Pattern | None] = {}
def flatten_single(name: str) -> None: def flatten_single(name: str) -> None:
flattened[name] = None flattened[name] = None
@ -298,7 +314,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
flatten_single(top) flatten_single(top)
assert None not in flattened.values() assert None not in flattened.values()
return cast(dict[str, 'Pattern'], flattened) return cast('dict[str, Pattern]', flattened)
def get_name( def get_name(
self, self,
@ -331,12 +347,13 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
else: else:
sanitized_name = name sanitized_name = name
ii = 0
suffixed_name = sanitized_name suffixed_name = sanitized_name
if sanitized_name in self:
ii = sum(1 for nn in self.keys() if nn.startswith(sanitized_name))
else:
ii = 0
while suffixed_name in self or suffixed_name == '': while suffixed_name in self or suffixed_name == '':
suffix = base64.b64encode(struct.pack('>Q', ii), altchars=b'$?').decode('ASCII') suffixed_name = sanitized_name + b64suffix(ii)
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
ii += 1 ii += 1
if len(suffixed_name) > max_length: if len(suffixed_name) > max_length:
@ -370,6 +387,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def top(self) -> str: def top(self) -> str:
""" """
Return the name of the topcell, or raise an exception if there isn't a single topcell Return the name of the topcell, or raise an exception if there isn't a single topcell
Raises:
LibraryError if there is not exactly one topcell.
""" """
tops = self.tops() tops = self.tops()
if len(tops) != 1: if len(tops) != 1:
@ -379,6 +399,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def top_pattern(self) -> 'Pattern': def top_pattern(self) -> 'Pattern':
""" """
Shorthand for self[self.top()] Shorthand for self[self.top()]
Raises:
LibraryError if there is not exactly one topcell.
""" """
return self[self.top()] return self[self.top()]
@ -438,7 +461,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if transform is None or transform is True: if transform is None or transform is True:
transform = numpy.zeros(4) transform = numpy.zeros(4)
elif transform is not False: elif transform is not False:
transform = numpy.array(transform) transform = numpy.asarray(transform, dtype=float)
original_pattern = pattern original_pattern = pattern
@ -452,24 +475,21 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
raise LibraryError(f'.dfs() called on pattern with circular reference to "{target}"') raise LibraryError(f'.dfs() called on pattern with circular reference to "{target}"')
for ref in pattern.refs[target]: for ref in pattern.refs[target]:
ref_transforms: list[bool] | NDArray[numpy.float64]
if transform is not False: if transform is not False:
sign = numpy.ones(2) ref_transforms = apply_transforms(transform, ref.as_transforms())
if transform[3]:
sign[1] = -1
xy = numpy.dot(rotation_matrix_2d(transform[2]), ref.offset * sign)
ref_transform = transform + (xy[0], xy[1], ref.rotation, ref.mirrored)
ref_transform[3] %= 2
else: else:
ref_transform = False ref_transforms = [False]
self.dfs( for ref_transform in ref_transforms:
pattern=self[target], self.dfs(
visit_before=visit_before, pattern=self[target],
visit_after=visit_after, visit_before=visit_before,
hierarchy=hierarchy + (target,), visit_after=visit_after,
transform=ref_transform, hierarchy=hierarchy + (target,),
memo=memo, transform=ref_transform,
) memo=memo,
)
if visit_after is not None: if visit_after is not None:
pattern = visit_after(pattern, hierarchy=hierarchy, memo=memo, transform=transform) pattern = visit_after(pattern, hierarchy=hierarchy, memo=memo, transform=transform)
@ -484,10 +504,147 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
raise LibraryError('visit_* functions returned a new `Pattern` object' raise LibraryError('visit_* functions returned a new `Pattern` object'
' but no top-level name was provided in `hierarchy`') ' but no top-level name was provided in `hierarchy`')
cast(ILibrary, self)[name] = pattern cast('ILibrary', self)[name] = pattern
return self return self
def child_graph(self) -> dict[str, set[str | None]]:
"""
Return a mapping from pattern name to a set of all child patterns
(patterns it references).
Returns:
Mapping from pattern name to a set of all pattern names it references.
"""
graph = {name: set(pat.refs.keys()) for name, pat in self.items()}
return graph
def parent_graph(self) -> dict[str, set[str]]:
"""
Return a mapping from pattern name to a set of all parent patterns
(patterns which reference it).
Returns:
Mapping from pattern name to a set of all patterns which reference it.
"""
igraph: dict[str, set[str]] = {name: set() for name in self}
for name, pat in self.items():
for child, reflist in pat.refs.items():
if reflist and child is not None:
igraph[child].add(name)
return igraph
def child_order(self) -> list[str]:
"""
Return a topologically sorted list of all contained pattern names.
Child (referenced) patterns will appear before their parents.
Return:
Topologically sorted list of pattern names.
"""
return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order()))
def find_refs_local(
self,
name: str,
parent_graph: dict[str, set[str]] | None = None,
) -> dict[str, list[NDArray[numpy.float64]]]:
"""
Find the location and orientation of all refs pointing to `name`.
Refs with a `repetition` are resolved into multiple instances (locations).
Args:
name: Name of the referenced pattern.
parent_graph: Mapping from pattern name to the set of patterns which
reference it. Default (`None`) calls `self.parent_graph()`.
The provided graph may be for a superset of `self` (i.e. it may
contain additional patterns which are not present in self; they
will be ignored).
Returns:
Mapping of {parent_name: transform_list}, where transform_list
is an Nx4 ndarray with rows
`(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
"""
instances = defaultdict(list)
if parent_graph is None:
parent_graph = self.parent_graph()
for parent in parent_graph[name]:
if parent not in self: # parent_graph may be a for a superset of self
continue
for ref in self[parent].refs[name]:
instances[parent].append(ref.as_transforms())
return instances
def find_refs_global(
self,
name: str,
order: list[str] | None = None,
parent_graph: dict[str, set[str]] | None = None,
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
"""
Find the absolute (top-level) location and orientation of all refs (including
repetitions) pointing to `name`.
Args:
name: Name of the referenced pattern.
order: List of pattern names in which children are guaranteed
to appear before their parents (i.e. topologically sorted).
Default (`None`) calls `self.child_order()`.
parent_graph: Passed to `find_refs_local`.
Mapping from pattern name to the set of patterns which
reference it. Default (`None`) calls `self.parent_graph()`.
The provided graph may be for a superset of `self` (i.e. it may
contain additional patterns which are not present in self; they
will be ignored).
Returns:
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
"""
if name not in self:
return {}
if order is None:
order = self.child_order()
if parent_graph is None:
parent_graph = self.parent_graph()
self_keys = set(self.keys())
transforms: dict[str, list[tuple[
tuple[str, ...],
NDArray[numpy.float64]
]]]
transforms = defaultdict(list)
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items():
transforms[parent] = [((name,), numpy.concatenate(vals))]
for next_name in order:
if next_name not in transforms:
continue
if not parent_graph[next_name] & self_keys:
continue
outers = self.find_refs_local(next_name, parent_graph=parent_graph)
inners = transforms.pop(next_name)
for parent, outer in outers.items():
for path, inner in inners:
combined = apply_transforms(numpy.concatenate(outer), inner)
transforms[parent].append((
(next_name,) + path,
combined,
))
result = {}
for parent, targets in transforms.items():
for path, instances in targets:
full_path = (parent,) + path
assert full_path not in result
result[full_path] = instances
return result
class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
""" """
@ -643,7 +800,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
duplicates = set(self.keys()) & set(other.keys()) duplicates = set(self.keys()) & set(other.keys())
if not duplicates: if not duplicates:
for key in other.keys(): for key in other:
self._merge(key, other, key) self._merge(key, other, key)
return {} return {}
@ -670,11 +827,19 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
for old_name in temp: for old_name in temp:
new_name = rename_map.get(old_name, old_name) new_name = rename_map.get(old_name, old_name)
pat = self[new_name] pat = self[new_name]
pat.refs = map_targets(pat.refs, lambda tt: cast(dict[str | None, str | None], rename_map).get(tt, tt)) pat.refs = map_targets(pat.refs, lambda tt: cast('dict[str | None, str | None]', rename_map).get(tt, tt))
return rename_map return rename_map
def __lshift__(self, other: Mapping[str, 'Pattern']) -> str: def __lshift__(self, other: TreeView) -> str:
"""
`add()` items from a tree (single-topcell name: pattern mapping) into this one,
and return the name of the tree's topcell (in this library; it may have changed
based on `add()`'s default `rename_theirs` argument).
Raises:
LibraryError if there is more than one topcell in `other`.
"""
if len(other) == 1: if len(other) == 1:
name = next(iter(other)) name = next(iter(other))
else: else:
@ -692,13 +857,20 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
return new_name return new_name
def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract: def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract:
"""
Perform the same operation as `__lshift__` / `<<`, but return an `Abstract` instead
of just the pattern's name.
Raises:
LibraryError if there is more than one topcell in `other`.
"""
new_name = self << other new_name = self << other
return self.abstract(new_name) return self.abstract(new_name)
def dedup( def dedup(
self, self,
norm_value: int = int(1e6), norm_value: int = int(1e6),
exclude_types: tuple[Type] = (Polygon,), exclude_types: tuple[type] = (Polygon,),
label2name: Callable[[tuple], str] | None = None, label2name: Callable[[tuple], str] | None = None,
threshold: int = 2, threshold: int = 2,
) -> Self: ) -> Self:
@ -736,7 +908,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
exclude_types = () exclude_types = ()
if label2name is None: if label2name is None:
def label2name(label): def label2name(label: tuple) -> str: # noqa: ARG001
return self.get_name(SINGLE_USE_PREFIX + 'shape') return self.get_name(SINGLE_USE_PREFIX + 'shape')
shape_counts: MutableMapping[tuple, int] = defaultdict(int) shape_counts: MutableMapping[tuple, int] = defaultdict(int)
@ -772,8 +944,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
shape_table: dict[tuple, list] = defaultdict(list) shape_table: dict[tuple, list] = defaultdict(list)
for layer, sseq in pat.shapes.items(): for layer, sseq in pat.shapes.items():
for i, shape in enumerate(sseq): for ii, shape in enumerate(sseq):
if any(isinstance(shape, t) for t in exclude_types): if any(isinstance(shape, tt) for tt in exclude_types):
continue continue
base_label, values, _func = shape.normalized_form(norm_value) base_label, values, _func = shape.normalized_form(norm_value)
@ -782,16 +954,16 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
if label not in shape_pats: if label not in shape_pats:
continue continue
shape_table[label].append((i, values)) shape_table[label].append((ii, values))
# For repeated shapes, create a `Pattern` holding a normalized shape object, # For repeated shapes, create a `Pattern` holding a normalized shape object,
# and add `pat.refs` entries for each occurrence in pat. Also, note down that # and add `pat.refs` entries for each occurrence in pat. Also, note down that
# we should delete the `pat.shapes` entries for which we made `Ref`s. # we should delete the `pat.shapes` entries for which we made `Ref`s.
shapes_to_remove = [] shapes_to_remove = []
for label in shape_table: for label, shape_entries in shape_table.items():
layer = label[-1] layer = label[-1]
target = label2name(label) target = label2name(label)
for ii, values in shape_table[label]: for ii, values in shape_entries:
offset, scale, rotation, mirror_x = values offset, scale, rotation, mirror_x = values
pat.ref(target=target, offset=offset, scale=scale, pat.ref(target=target, offset=offset, scale=scale,
rotation=rotation, mirrored=(mirror_x, False)) rotation=rotation, mirrored=(mirror_x, False))
@ -826,8 +998,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
from .pattern import Pattern from .pattern import Pattern
if name_func is None: if name_func is None:
def name_func(_pat, _shape): def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
return self.get_name(SINGLE_USE_PREFIX = 'rep') return self.get_name(SINGLE_USE_PREFIX + 'rep')
for pat in tuple(self.values()): for pat in tuple(self.values()):
for layer in pat.shapes: for layer in pat.shapes:
@ -875,7 +1047,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str): if isinstance(tops, str):
tops = (tops,) tops = (tops,)
keep = cast(set[str], self.referenced_patterns(tops) - set((None,))) keep = cast('set[str]', self.referenced_patterns(tops) - {None})
keep |= set(tops) keep |= set(tops)
new = type(self)() new = type(self)()
@ -896,20 +1068,22 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Returns: Returns:
A set containing the names of all deleted patterns A set containing the names of all deleted patterns
""" """
parent_graph = self.parent_graph()
empty = {name for name, pat in self.items() if pat.is_empty()}
trimmed = set() trimmed = set()
while empty := set(name for name, pat in self.items() if pat.is_empty()): while empty:
parents = set()
for name in empty: for name in empty:
del self[name] del self[name]
for parent in parent_graph[name]:
for pat in self.values(): del self[parent].refs[name]
for name in empty: parents |= parent_graph[name]
# Second pass to skip looking at refs in empty patterns
if name in pat.refs:
del pat.refs[name]
trimmed |= empty trimmed |= empty
if not repeat: if not repeat:
break break
empty = {parent for parent in parents if self[parent].is_empty()}
return trimmed return trimmed
def delete( def delete(
@ -1001,10 +1175,7 @@ class Library(ILibrary):
if key in self.mapping: if key in self.mapping:
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
if callable(value): value = value() if callable(value) else value
value = value()
else:
value = value
self.mapping[key] = value self.mapping[key] = value
def __delitem__(self, key: str) -> None: def __delitem__(self, key: str) -> None:
@ -1017,7 +1188,7 @@ class Library(ILibrary):
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>' return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
@classmethod @classmethod
def mktree(cls, name: str) -> tuple[Self, 'Pattern']: def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']:
""" """
Create a new Library and immediately add a pattern Create a new Library and immediately add a pattern
@ -1193,3 +1364,20 @@ class AbstractView(Mapping[str, Abstract]):
def __len__(self) -> int: def __len__(self) -> int:
return self.library.__len__() return self.library.__len__()
def b64suffix(ii: int) -> str:
"""
Turn an integer into a base64-equivalent suffix.
This could be done with base64.b64encode, but this way is faster for many small `ii`.
"""
def i2a(nn: int) -> str:
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]
parts = ['$', i2a(ii % 64)]
ii >>= 6
while ii:
parts.append(i2a(ii % 64))
ii >>= 6
return ''.join(parts)

View File

@ -2,9 +2,11 @@
Object representing a one multi-layer lithographic layout. Object representing a one multi-layer lithographic layout.
A single level of hierarchical references is included. A single level of hierarchical references is included.
""" """
from typing import Callable, Sequence, cast, Mapping, Self, Any, Iterable, TypeVar, MutableMapping from typing import cast, Self, Any, TypeVar
from collections.abc import Sequence, Mapping, MutableMapping, Iterable, Callable
import copy import copy
import logging import logging
import functools
from itertools import chain from itertools import chain
from collections import defaultdict from collections import defaultdict
@ -17,7 +19,8 @@ from .ref import Ref
from .abstract import Abstract from .abstract import Abstract
from .shapes import Shape, Polygon, Path, DEFAULT_POLY_NUM_VERTICES from .shapes import Shape, Polygon, Path, DEFAULT_POLY_NUM_VERTICES
from .label import Label from .label import Label
from .utils import rotation_matrix_2d, annotations_t, layer_t from .utils import rotation_matrix_2d, annotations_t, layer_t, annotations_eq, annotations_lt, layer2key
from .utils import ports_eq, ports_lt
from .error import PatternError, PortError from .error import PatternError, PortError
from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionable, Repeatable, Bounded
from .ports import Port, PortList from .ports import Port, PortList
@ -26,6 +29,7 @@ from .ports import Port, PortList
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@functools.total_ordering
class Pattern(PortList, AnnotatableImpl, Mirrorable): class Pattern(PortList, AnnotatableImpl, Mirrorable):
""" """
2D layout consisting of some set of shapes, labels, and references to other 2D layout consisting of some set of shapes, labels, and references to other
@ -87,7 +91,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
__slots__ = ( __slots__ = (
'shapes', 'labels', 'refs', '_ports', 'shapes', 'labels', 'refs', '_ports',
# inherited # inherited
'_offset', '_annotations', '_annotations',
) )
shapes: defaultdict[layer_t, list[Shape]] shapes: defaultdict[layer_t, list[Shape]]
@ -192,6 +196,146 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
# ) # )
# return new # return new
def __lt__(self, other: 'Pattern') -> bool:
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
if self_tgtkeys != other_tgtkeys:
return self_tgtkeys < other_tgtkeys
for _, target in self_tgtkeys:
refs_ours = tuple(sorted(self.refs[target]))
refs_theirs = tuple(sorted(other.refs[target]))
if refs_ours != refs_theirs:
return refs_ours < refs_theirs
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
if self_layerkeys != other_layerkeys:
return self_layerkeys < other_layerkeys
for _, _, layer in self_layerkeys:
shapes_ours = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(self.shapes[layer]))
if shapes_ours != shapes_theirs:
return shapes_ours < shapes_theirs
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
if self_txtlayerkeys != other_txtlayerkeys:
return self_txtlayerkeys < other_txtlayerkeys
for _, _, layer in self_layerkeys:
labels_ours = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(self.labels[layer]))
if labels_ours != labels_theirs:
return labels_ours < labels_theirs
if not annotations_eq(self.annotations, other.annotations):
return annotations_lt(self.annotations, other.annotations)
if not ports_eq(self.ports, other.ports):
return ports_lt(self.ports, other.ports)
return False
def __eq__(self, other: Any) -> bool:
if type(self) is not type(other):
return False
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
if self_tgtkeys != other_tgtkeys:
return False
for _, target in self_tgtkeys:
refs_ours = tuple(sorted(self.refs[target]))
refs_theirs = tuple(sorted(other.refs[target]))
if refs_ours != refs_theirs:
return False
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
if self_layerkeys != other_layerkeys:
return False
for _, _, layer in self_layerkeys:
shapes_ours = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(self.shapes[layer]))
if shapes_ours != shapes_theirs:
return False
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
if self_txtlayerkeys != other_txtlayerkeys:
return False
for _, _, layer in self_layerkeys:
labels_ours = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(self.labels[layer]))
if labels_ours != labels_theirs:
return False
if not annotations_eq(self.annotations, other.annotations):
return False
if not ports_eq(self.ports, other.ports): # noqa: SIM103
return False
return True
def sort(self, sort_elements: bool = True) -> Self:
"""
Sort the element dicts (shapes, labels, refs) and (optionally) their contained lists.
This is primarily useful for making builds more reproducible.
Args:
sort_elements: Whether to sort all the shapes/labels/refs within each layer/target.
Returns:
self
"""
if sort_elements:
def maybe_sort(xx): # noqa:ANN001,ANN202
return sorted(xx)
else:
def maybe_sort(xx): # noqa:ANN001,ANN202
return xx
self.refs = defaultdict(list, sorted(
(tgt, maybe_sort(rrs)) for tgt, rrs in self.refs.items()
))
self.labels = defaultdict(list, sorted(
((layer, maybe_sort(lls)) for layer, lls in self.labels.items()),
key=lambda tt: layer2key(tt[0]),
))
self.shapes = defaultdict(list, sorted(
((layer, maybe_sort(sss)) for layer, sss in self.shapes.items()),
key=lambda tt: layer2key(tt[0]),
))
self.ports = dict(sorted(self.ports.items()))
self.annotations = dict(sorted(self.annotations.items()))
return self
def append(self, other_pattern: 'Pattern') -> Self: def append(self, other_pattern: 'Pattern') -> Self:
""" """
Appends all shapes, labels and refs from other_pattern to self's shapes, Appends all shapes, labels and refs from other_pattern to self's shapes,
@ -328,10 +472,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.polygonize() self.polygonize()
for layer in self.shapes: for layer in self.shapes:
self.shapes[layer] = list(chain.from_iterable(( self.shapes[layer] = list(chain.from_iterable(
ss.manhattanize(grid_x, grid_y) ss.manhattanize(grid_x, grid_y)
for ss in self.shapes[layer] for ss in self.shapes[layer]
))) ))
return self return self
def as_polygons(self, library: Mapping[str, 'Pattern']) -> list[NDArray[numpy.float64]]: def as_polygons(self, library: Mapping[str, 'Pattern']) -> list[NDArray[numpy.float64]]:
@ -347,7 +491,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
""" """
pat = self.deepcopy().polygonize().flatten(library=library) pat = self.deepcopy().polygonize().flatten(library=library)
polys = [ polys = [
cast(Polygon, shape).vertices + cast(Polygon, shape).offset cast('Polygon', shape).vertices + cast('Polygon', shape).offset
for shape in chain_elements(pat.shapes) for shape in chain_elements(pat.shapes)
] ]
return polys return polys
@ -389,7 +533,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
n_elems = sum(1 for _ in chain_elements(self.shapes, self.labels)) n_elems = sum(1 for _ in chain_elements(self.shapes, self.labels))
ebounds = numpy.full((n_elems, 2, 2), nan) ebounds = numpy.full((n_elems, 2, 2), nan)
for ee, entry in enumerate(chain_elements(self.shapes, self.labels)): for ee, entry in enumerate(chain_elements(self.shapes, self.labels)):
maybe_ebounds = cast(Bounded, entry).get_bounds() maybe_ebounds = cast('Bounded', entry).get_bounds()
if maybe_ebounds is not None: if maybe_ebounds is not None:
ebounds[ee] = maybe_ebounds ebounds[ee] = maybe_ebounds
mask = ~numpy.isnan(ebounds[:, 0, 0]) mask = ~numpy.isnan(ebounds[:, 0, 0])
@ -436,6 +580,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
corners = (rotation_matrix_2d(ref.rotation) @ ubounds.T).T corners = (rotation_matrix_2d(ref.rotation) @ ubounds.T).T
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:
bounds += ref.repetition.get_bounds()
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
@ -449,8 +595,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if (cbounds[1] < cbounds[0]).any(): if (cbounds[1] < cbounds[0]).any():
return None return None
else: return cbounds
return cbounds
def get_bounds_nonempty( def get_bounds_nonempty(
self, self,
@ -471,7 +616,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns: Returns:
`[[x_min, y_min], [x_max, y_max]]` `[[x_min, y_min], [x_max, y_max]]`
""" """
bounds = self.get_bounds(library) bounds = self.get_bounds(library, recurse=recurse)
assert bounds is not None assert bounds is not None
return bounds return bounds
@ -486,7 +631,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast(Positionable, entry).translate(offset) cast('Positionable', entry).translate(offset)
return self return self
def scale_elements(self, c: float) -> Self: def scale_elements(self, c: float) -> Self:
@ -500,33 +645,37 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain_elements(self.shapes, self.refs): for entry in chain_elements(self.shapes, self.refs):
cast(Scalable, entry).scale_by(c) cast('Scalable', entry).scale_by(c)
return self return self
def scale_by(self, c: float) -> Self: def scale_by(self, c: float, scale_refs: bool = True) -> Self:
""" """
Scale this Pattern by the given value Scale this Pattern by the given value
(all shapes and refs and their offsets are scaled, All shapes and (optionally) refs and their offsets are scaled,
as are all label and port offsets) as are all label and port offsets.
Args: Args:
c: factor to scale by c: factor to scale by
scale_refs: Whether to scale refs. Ref offsets are always scaled,
but it may be desirable to not scale the ref itself (e.g. if
the target cell was also scaled).
Returns: Returns:
self self
""" """
for entry in chain_elements(self.shapes, self.refs): for entry in chain_elements(self.shapes, self.refs):
cast(Positionable, entry).offset *= c cast('Positionable', entry).offset *= c
cast(Scalable, entry).scale_by(c) if scale_refs or not isinstance(entry, Ref):
cast('Scalable', entry).scale_by(c)
rep = cast(Repeatable, entry).repetition rep = cast('Repeatable', entry).repetition
if rep: if rep:
rep.scale_by(c) rep.scale_by(c)
for label in chain_elements(self.labels): for label in chain_elements(self.labels):
cast(Positionable, label).offset *= c cast('Positionable', label).offset *= c
rep = cast(Repeatable, label).repetition rep = cast('Repeatable', label).repetition
if rep: if rep:
rep.scale_by(c) rep.scale_by(c)
@ -545,7 +694,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns: Returns:
self self
""" """
pivot = numpy.array(pivot) pivot = numpy.asarray(pivot, dtype=float)
self.translate_elements(-pivot) self.translate_elements(-pivot)
self.rotate_elements(rotation) self.rotate_elements(rotation)
self.rotate_element_centers(rotation) self.rotate_element_centers(rotation)
@ -563,8 +712,8 @@ 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()):
old_offset = cast(Positionable, entry).offset old_offset = cast('Positionable', entry).offset
cast(Positionable, entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
return self return self
def rotate_elements(self, rotation: float) -> Self: def rotate_elements(self, rotation: float) -> Self:
@ -578,7 +727,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast(Rotatable, entry).rotate(rotation) cast('Rotatable', entry).rotate(rotation)
return self return self
def mirror_element_centers(self, across_axis: int = 0) -> Self: def mirror_element_centers(self, across_axis: int = 0) -> Self:
@ -593,7 +742,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[across_axis - 1] *= -1
return self return self
def mirror_elements(self, across_axis: int = 0) -> Self: def mirror_elements(self, across_axis: int = 0) -> Self:
@ -609,7 +758,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self self
""" """
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast(Mirrorable, entry).mirror(across_axis) cast('Mirrorable', entry).mirror(across_axis)
return self return self
def mirror(self, across_axis: int = 0) -> Self: def mirror(self, across_axis: int = 0) -> Self:
@ -808,7 +957,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns: Returns:
self self
""" """
flattened: dict[str | None, 'Pattern | None'] = {} flattened: dict[str | None, Pattern | None] = {}
def flatten_single(name: str | None) -> None: def flatten_single(name: str | None) -> None:
if name is None: if name is None:
@ -870,15 +1019,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
try: try:
from matplotlib import pyplot # type: ignore from matplotlib import pyplot # type: ignore
import matplotlib.collections # type: ignore import matplotlib.collections # type: ignore
except ImportError as err: except ImportError:
logger.error('Pattern.visualize() depends on matplotlib!') logger.exception('Pattern.visualize() depends on matplotlib!\n'
logger.error('Make sure to install masque with the [visualize] option to pull in the needed dependencies.') + 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
raise err raise
if self.has_refs() and library is None: if self.has_refs() and library is None:
raise PatternError('Must provide a library when visualizing a pattern with refs') raise PatternError('Must provide a library when visualizing a pattern with refs')
offset = numpy.array(offset, dtype=float) offset = numpy.asarray(offset, dtype=float)
if not overdraw: if not overdraw:
figure = pyplot.figure() figure = pyplot.figure()
@ -1080,6 +1229,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
inherit_name: bool = True, inherit_name: bool = 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:
""" """
Instantiate or append a pattern into the current pattern, connecting Instantiate or append a pattern into the current pattern, connecting
@ -1087,7 +1237,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
ports specified by `map_out`. ports specified by `map_out`.
Examples: Examples:
========= ======list, ===
- `my_pat.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` - `my_pat.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
instantiates `subdevice` into `my_pat`, plugging ports 'A' and 'B' instantiates `subdevice` into `my_pat`, plugging ports 'A' and 'B'
of `my_pat` into ports 'C' and 'B' of `subdevice`. The connected ports of `my_pat` into ports 'C' and 'B' of `subdevice`. The connected ports
@ -1125,6 +1275,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
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
@ -1155,6 +1310,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_in, map_in,
mirrored=mirrored, mirrored=mirrored,
set_rotation=set_rotation, set_rotation=set_rotation,
ok_connections=ok_connections,
) )
# get rid of plugged ports # get rid of plugged ports
@ -1163,7 +1319,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out[vi] = None map_out[vi] = None
if isinstance(other, Pattern): if isinstance(other, Pattern):
assert append assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
self.place( self.place(
other, other,
@ -1179,7 +1335,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
@classmethod @classmethod
def interface( def interface(
cls, cls: type['Pattern'],
source: PortList | Mapping[str, Port], source: PortList | Mapping[str, Port],
*, *,
in_prefix: str = 'in_', in_prefix: str = 'in_',

View File

@ -1,9 +1,12 @@
from typing import Iterable, KeysView, ValuesView, overload, Self, Mapping, NoReturn from typing import overload, Self, NoReturn, Any
from collections.abc import Iterable, KeysView, ValuesView, Mapping
import warnings import warnings
import traceback import traceback
import logging import logging
import functools
from collections import Counter from collections import Counter
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from itertools import chain
import numpy import numpy
from numpy import pi from numpy import pi
@ -17,6 +20,7 @@ from .error import PortError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@functools.total_ordering
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
""" """
A point at which a `Device` can be snapped to another `Device`. A point at which a `Device` can be snapped to another `Device`.
@ -68,7 +72,28 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
raise PortError('Rotation must be a scalar') raise PortError('Rotation must be a scalar')
self._rotation = val % (2 * pi) self._rotation = val % (2 * pi)
def get_bounds(self): @property
def x(self) -> float:
""" Alias for offset[0] """
return self.offset[0]
@x.setter
def x(self, val: float) -> None:
self.offset[0] = val
@property
def y(self) -> float:
""" Alias for offset[1] """
return self.offset[1]
@y.setter
def y(self, val: float) -> None:
self.offset[1] = val
def copy(self) -> Self:
return self.deepcopy()
def get_bounds(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset, self.offset)) return numpy.vstack((self.offset, self.offset))
def set_ptype(self, ptype: str) -> Self: def set_ptype(self, ptype: str) -> Self:
@ -99,6 +124,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
rot = str(numpy.rad2deg(self.rotation)) rot = str(numpy.rad2deg(self.rotation))
return f'<{self.offset}, {rot}, [{self.ptype}]>' return f'<{self.offset}, {rot}, [{self.ptype}]>'
def __lt__(self, other: 'Port') -> bool:
if self.ptype != other.ptype:
return self.ptype < other.ptype
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
if self.rotation is None:
return True
if other.rotation is None:
return False
return self.rotation < other.rotation
return False
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and self.ptype == other.ptype
and numpy.array_equal(self.offset, other.offset)
and self.rotation == other.rotation
)
class PortList(metaclass=ABCMeta): class PortList(metaclass=ABCMeta):
__slots__ = () # Allow subclasses to use __slots__ __slots__ = () # Allow subclasses to use __slots__
@ -135,7 +181,7 @@ class PortList(metaclass=ABCMeta):
""" """
if isinstance(key, str): if isinstance(key, str):
return self.ports[key] return self.ports[key]
else: else: # noqa: RET505
return {k: self.ports[k] for k in key} return {k: self.ports[k] for k in key}
def __contains__(self, key: str) -> NoReturn: def __contains__(self, key: str) -> NoReturn:
@ -193,7 +239,7 @@ class PortList(metaclass=ABCMeta):
if duplicates: if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()} renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
if None in renamed: if None in renamed:
del renamed[None] del renamed[None]
@ -228,6 +274,75 @@ class PortList(metaclass=ABCMeta):
self.ports.update(new_ports) self.ports.update(new_ports)
return self return self
def plugged(
self,
connections: dict[str, str],
) -> Self:
"""
Verify that the ports specified by `connections` are coincident and have opposing
rotations, then remove the ports.
This is used when ports have been "manually" aligned as part of some other routing,
but for whatever reason were not eliminated via `plug()`.
Args:
connections: Pairs of ports which "plug" each other (same offset, opposing directions)
Returns:
self
Raises:
`PortError` if the ports are not properly aligned.
"""
a_names, b_names = list(zip(*connections.items(), strict=True))
a_ports = [self.ports[pp] for pp in a_names]
b_ports = [self.ports[pp] for pp in b_names]
a_types = [pp.ptype for pp in a_ports]
b_types = [pp.ptype for pp in b_ports]
type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
for at, bt in zip(a_types, b_types, strict=True)])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (k, v) in enumerate(connections.items()):
if type_conflicts[nn]:
msg += f'{k} | {a_types[nn]}:{b_types[nn]} | {v}\n'
msg = ''.join(traceback.format_stack()) + '\n' + msg
warnings.warn(msg, stacklevel=2)
a_offsets = numpy.array([pp.offset for pp in a_ports])
b_offsets = numpy.array([pp.offset for pp in b_ports])
a_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in a_ports])
b_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in b_ports])
a_has_rot = numpy.array([pp.rotation is not None for pp in a_ports], dtype=bool)
b_has_rot = numpy.array([pp.rotation is not None for pp in b_ports], dtype=bool)
has_rot = a_has_rot & b_has_rot
if has_rot.any():
rotations = numpy.mod(a_rotations - b_rotations - pi, 2 * pi)
rotations[~has_rot] = rotations[has_rot][0]
if not numpy.allclose(rotations, 0):
rot_deg = numpy.rad2deg(rotations)
msg = 'Port orientations do not match:\n'
for nn, (k, v) in enumerate(connections.items()):
if not numpy.isclose(rot_deg[nn], 0):
msg += f'{k} | {rot_deg[nn]:g} | {v}\n'
raise PortError(msg)
translations = a_offsets - b_offsets
if not numpy.allclose(translations, 0):
msg = 'Port translations do not match:\n'
for nn, (k, v) in enumerate(connections.items()):
if not numpy.allclose(translations[nn], 0):
msg += f'{k} | {translations[nn]} | {v}\n'
raise PortError(msg)
for pp in chain(a_names, b_names):
del self.ports[pp]
return self
def check_ports( def check_ports(
self, self,
other_names: Iterable[str], other_names: Iterable[str],
@ -304,6 +419,7 @@ class PortList(metaclass=ABCMeta):
*, *,
mirrored: bool = False, mirrored: bool = False,
set_rotation: bool | None = None, set_rotation: bool | None = None,
ok_connections: Iterable[tuple[str, str]] = (),
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: ) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
""" """
Given a device `other` and a mapping `map_in` specifying port connections, Given a device `other` and a mapping `map_in` specifying port connections,
@ -320,6 +436,11 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided port with `rotation=None`), `set_rotation` must be provided
to indicate how much `other` should be rotated. Otherwise, to indicate how much `other` should be rotated. Otherwise,
`set_rotation` must remain `None`. `set_rotation` must remain `None`.
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:
- The (x, y) translation (performed last) - The (x, y) translation (performed last)
@ -336,6 +457,7 @@ class PortList(metaclass=ABCMeta):
map_in=map_in, map_in=map_in,
mirrored=mirrored, mirrored=mirrored,
set_rotation=set_rotation, set_rotation=set_rotation,
ok_connections=ok_connections,
) )
@staticmethod @staticmethod
@ -346,13 +468,14 @@ class PortList(metaclass=ABCMeta):
*, *,
mirrored: bool = False, mirrored: bool = False,
set_rotation: bool | None = None, set_rotation: bool | None = None,
ok_connections: Iterable[tuple[str, str]] = (),
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: ) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
""" """
Given two sets of ports (s_ports and o_ports) and a mapping `map_in` Given two sets of ports (s_ports and o_ports) and a mapping `map_in`
specifying port connections, find the transform which will correctly specifying port connections, find the transform which will correctly
align the specified o_ports onto their respective s_ports. align the specified o_ports onto their respective s_ports.
Args:t Args:
s_ports: A list of stationary ports s_ports: A list of stationary ports
o_ports: A list of ports which are to be moved/mirrored. o_ports: A list of ports which are to be moved/mirrored.
map_in: dict of `{'s_port': 'o_port'}` mappings, specifying map_in: dict of `{'s_port': 'o_port'}` mappings, specifying
@ -364,6 +487,11 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided port with `rotation=None`), `set_rotation` must be provided
to indicate how much `o_ports` should be rotated. Otherwise, to indicate how much `o_ports` should be rotated. Otherwise,
`set_rotation` must remain `None`. `set_rotation` must remain `None`.
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:
- The (x, y) translation (performed last) - The (x, y) translation (performed last)
@ -387,8 +515,9 @@ class PortList(metaclass=ABCMeta):
o_offsets[:, 1] *= -1 o_offsets[:, 1] *= -1
o_rotations *= -1 o_rotations *= -1
type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk' ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
for st, ot in zip(s_types, o_types)]) type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs)
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, (k, v) in enumerate(map_in.items()):
@ -408,8 +537,8 @@ class PortList(metaclass=ABCMeta):
if not numpy.allclose(rotations[:1], rotations): if not numpy.allclose(rotations[:1], rotations):
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(map_in.items()): for nn, (kk, vv) in enumerate(map_in.items()):
msg += f'{k} | {rot_deg[nn]:g} | {v}\n' msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n'
raise PortError(msg) raise PortError(msg)
pivot = o_offsets[0].copy() pivot = o_offsets[0].copy()
@ -417,8 +546,8 @@ 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'
for nn, (k, v) in enumerate(map_in.items()): for nn, (kk, vv) in enumerate(map_in.items()):
msg += f'{k} | {translations[nn]} | {v}\n' msg += f'{kk} | {translations[nn]} | {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

@ -2,14 +2,16 @@
Ref provides basic support for nesting Pattern objects within each other. Ref provides basic support for nesting Pattern objects within each other.
It carries offset, rotation, mirroring, and scaling data for each individual instance. It carries offset, rotation, mirroring, and scaling data for each individual instance.
""" """
from typing import Mapping, TYPE_CHECKING, Self from typing import TYPE_CHECKING, Self, Any
from collections.abc import Mapping
import copy import copy
import functools
import numpy 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 from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key
from .repetition import Repetition from .repetition import Repetition
from .traits import ( from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl, PositionableImpl, RotatableImpl, ScalableImpl,
@ -21,6 +23,7 @@ if TYPE_CHECKING:
from . import Pattern from . import Pattern
@functools.total_ordering
class Ref( class Ref(
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
@ -99,6 +102,29 @@ class Ref(
#new.annotations = copy.deepcopy(self.annotations, memo) #new.annotations = copy.deepcopy(self.annotations, memo)
return new return new
def __lt__(self, other: 'Ref') -> bool:
if (self.offset != other.offset).any():
return tuple(self.offset) < tuple(other.offset)
if self.mirrored != other.mirrored:
return self.mirrored < other.mirrored
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.scale != other.scale:
return self.scale < other.scale
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def __eq__(self, other: Any) -> bool:
return (
numpy.array_equal(self.offset, other.offset)
and self.mirrored == other.mirrored
and self.rotation == other.rotation
and self.scale == other.scale
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def as_pattern( def as_pattern(
self, self,
pattern: 'Pattern', pattern: 'Pattern',
@ -157,6 +183,16 @@ class Ref(
self.rotation += pi self.rotation += pi
return self return self
def as_transforms(self) -> NDArray[numpy.float64]:
xys = self.offset[None, :]
if self.repetition is not None:
xys = xys + self.repetition.displacements
transforms = numpy.empty((xys.shape[0], 4))
transforms[:, :2] = xys
transforms[:, 2] = self.rotation
transforms[:, 3] = self.mirrored
return transforms
def get_bounds_single( def get_bounds_single(
self, self,
pattern: 'Pattern', pattern: 'Pattern',

View File

@ -2,8 +2,9 @@
Repetitions provide support for efficiently representing multiple identical Repetitions provide support for efficiently representing multiple identical
instances of an object . instances of an object .
""" """
from typing import Any, Type, Self, TypeVar from typing import Any, Self, TypeVar, cast
import copy import copy
import functools
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import numpy import numpy
@ -17,6 +18,7 @@ from .utils import rotation_matrix_2d
GG = TypeVar('GG', bound='Grid') GG = TypeVar('GG', bound='Grid')
@functools.total_ordering
class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=ABCMeta): class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=ABCMeta):
""" """
Interface common to all objects which specify repetitions Interface common to all objects which specify repetitions
@ -31,6 +33,14 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A
""" """
pass pass
@abstractmethod
def __le__(self, other: 'Repetition') -> bool:
pass
@abstractmethod
def __eq__(self, other: Any) -> bool:
pass
class Grid(Repetition): class Grid(Repetition):
""" """
@ -91,8 +101,7 @@ class Grid(Repetition):
if b_vector is None: if b_vector is None:
if b_count > 1: if b_count > 1:
raise PatternError('Repetition has b_count > 1 but no b_vector') raise PatternError('Repetition has b_count > 1 but no b_vector')
else: b_vector = numpy.array([0.0, 0.0])
b_vector = numpy.array([0.0, 0.0])
if a_count < 1: if a_count < 1:
raise PatternError(f'Repetition has too-small a_count: {a_count}') raise PatternError(f'Repetition has too-small a_count: {a_count}')
@ -106,7 +115,7 @@ class Grid(Repetition):
@classmethod @classmethod
def aligned( def aligned(
cls: Type[GG], cls: type[GG],
x: float, x: float,
y: float, y: float,
x_count: int, x_count: int,
@ -147,12 +156,11 @@ class Grid(Repetition):
@a_vector.setter @a_vector.setter
def a_vector(self, val: ArrayLike) -> None: def a_vector(self, val: ArrayLike) -> None:
if not isinstance(val, numpy.ndarray): val = numpy.array(val, dtype=float)
val = numpy.array(val, dtype=float)
if val.size != 2: if val.size != 2:
raise PatternError('a_vector must be convertible to size-2 ndarray') raise PatternError('a_vector must be convertible to size-2 ndarray')
self._a_vector = val.flatten().astype(float) self._a_vector = val.flatten()
# b_vector property # b_vector property
@property @property
@ -161,8 +169,7 @@ class Grid(Repetition):
@b_vector.setter @b_vector.setter
def b_vector(self, val: ArrayLike) -> None: def b_vector(self, val: ArrayLike) -> None:
if not isinstance(val, numpy.ndarray): val = numpy.array(val, dtype=float)
val = numpy.array(val, dtype=float, copy=True)
if val.size != 2: if val.size != 2:
raise PatternError('b_vector must be convertible to size-2 ndarray') raise PatternError('b_vector must be convertible to size-2 ndarray')
@ -270,7 +277,7 @@ class Grid(Repetition):
return (f'<Grid {self.a_count}x{self.b_count} ({self.a_vector}{bv})>') return (f'<Grid {self.a_count}x{self.b_count} ({self.a_vector}{bv})>')
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
if not isinstance(other, type(self)): if type(other) is not type(self):
return False return False
if self.a_count != other.a_count or self.b_count != other.b_count: if self.a_count != other.a_count or self.b_count != other.b_count:
return False return False
@ -280,10 +287,28 @@ class Grid(Repetition):
return True return True
if self.b_vector is None or other.b_vector is None: if self.b_vector is None or other.b_vector is None:
return False return False
if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)): if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)): # noqa: SIM103
return False return False
return True return True
def __le__(self, other: Repetition) -> bool:
if type(self) is not type(other):
return repr(type(self)) < repr(type(other))
other = cast('Grid', other)
if self.a_count != other.a_count:
return self.a_count < other.a_count
if self.b_count != other.b_count:
return self.b_count < other.b_count
if not numpy.array_equal(self.a_vector, other.a_vector):
return tuple(self.a_vector) < tuple(other.a_vector)
if self.b_vector is None:
return other.b_vector is not None
if other.b_vector is None:
return False
if not numpy.array_equal(self.b_vector, other.b_vector):
return tuple(self.a_vector) < tuple(other.a_vector)
return False
class Arbitrary(Repetition): class Arbitrary(Repetition):
""" """
@ -307,9 +332,9 @@ class Arbitrary(Repetition):
@displacements.setter @displacements.setter
def displacements(self, val: ArrayLike) -> None: def displacements(self, val: ArrayLike) -> None:
vala: NDArray[numpy.float64] = numpy.array(val, dtype=float) vala = numpy.array(val, dtype=float)
vala = numpy.sort(vala.view([('', vala.dtype)] * vala.shape[1]), 0).view(vala.dtype) # sort rows order = numpy.lexsort(vala.T[::-1]) # sortrows
self._displacements = vala self._displacements = vala[order]
def __init__( def __init__(
self, self,
@ -325,10 +350,23 @@ class Arbitrary(Repetition):
return (f'<Arbitrary {len(self.displacements)}pts >') return (f'<Arbitrary {len(self.displacements)}pts >')
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
if not isinstance(other, type(self)): if not type(other) is not type(self):
return False return False
return numpy.array_equal(self.displacements, other.displacements) return numpy.array_equal(self.displacements, other.displacements)
def __le__(self, other: Repetition) -> bool:
if type(self) is not type(other):
return repr(type(self)) < repr(type(other))
other = cast('Arbitrary', other)
if self.displacements.size != other.displacements.size:
return self.displacements.size < other.displacements.size
neq = (self.displacements != other.displacements)
if neq.any():
return self.displacements[neq][0] < other.displacements[neq][0]
return False
def rotate(self, rotation: float) -> Self: def rotate(self, rotation: float) -> Self:
""" """
Rotate dispacements (around (0, 0)) Rotate dispacements (around (0, 0))

View File

@ -3,11 +3,15 @@ Shapes for use with the Pattern class, as well as the Shape abstract class from
which they are derived. which they are derived.
""" """
from .shape import Shape, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES from .shape import (
Shape as Shape,
normalized_shape_tuple as normalized_shape_tuple,
DEFAULT_POLY_NUM_VERTICES as DEFAULT_POLY_NUM_VERTICES,
)
from .polygon import Polygon from .polygon import Polygon as Polygon
from .circle import Circle from .circle import Circle as Circle
from .ellipse import Ellipse from .ellipse import Ellipse as Ellipse
from .arc import Arc from .arc import Arc as Arc
from .text import Text from .text import Text as Text
from .path import Path from .path import Path as Path

View File

@ -1,5 +1,6 @@
from typing import Any from typing import Any, cast
import copy import copy
import functools
import numpy import numpy
from numpy import pi from numpy import pi
@ -8,9 +9,10 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES 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 from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
@functools.total_ordering
class Arc(Shape): class Arc(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
@ -187,6 +189,38 @@ class Arc(Shape):
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.radii, other.radii)
and numpy.array_equal(self.angles, other.angles)
and self.width == other.width
and self.rotation == other.rotation
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('Arc', other)
if self.width != other.width:
return self.width < other.width
if not numpy.array_equal(self.radii, other.radii):
return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.angles, other.angles):
return tuple(self.angles) < tuple(other.angles)
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons( def to_polygons(
self, self,
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES, num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
@ -199,7 +233,7 @@ class Arc(Shape):
r0, r1 = self.radii r0, r1 = self.radii
# Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation) # Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
a_ranges = self._angles_to_parameters() a_ranges = cast('_array2x2_t', self._angles_to_parameters())
# Approximate perimeter via numerical integration # Approximate perimeter via numerical integration
@ -210,43 +244,50 @@ class Arc(Shape):
#t0 = ellipeinc(a0 - pi / 2, m) #t0 = ellipeinc(a0 - pi / 2, m)
#perimeter2 = r0 * (t1 - t0) #perimeter2 = r0 * (t1 - t0)
def get_arclens(n_pts: int, a0: float, a1: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]: def get_arclens(n_pts: int, a0: float, a1: float, dr: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
""" Get `n_pts` arclengths """ """ Get `n_pts` arclengths """
t, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points tt, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points
r0sin = r0 * numpy.sin(t) r0sin = (r0 + dr) * numpy.sin(tt)
r1cos = r1 * numpy.cos(t) r1cos = (r1 + dr) * numpy.cos(tt)
arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos) arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos)
#arc_lengths = numpy.diff(t) * (arc_dl[1:] + arc_dl[:-1]) / 2 #arc_lengths = numpy.diff(tt) * (arc_dl[1:] + arc_dl[:-1]) / 2
arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2 arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2
return arc_lengths, t return arc_lengths, tt
wh = self.width / 2.0
if num_vertices is not None: if num_vertices is not None:
n_pts = numpy.ceil(max(self.radii) / min(self.radii) * num_vertices * 100).astype(int) n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int)
perimeter_inner = get_arclens(n_pts, *a_ranges[0])[0].sum() perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum()
perimeter_outer = get_arclens(n_pts, *a_ranges[1])[0].sum() perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum()
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf) max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf)
assert max_arclen is not None assert max_arclen is not None
def get_thetas(inner: bool) -> NDArray[numpy.float64]: def get_thetas(inner: bool) -> NDArray[numpy.float64]:
""" Figure out the parameter values at which we should place vertices to meet the arclength constraint""" """ Figure out the parameter values at which we should place vertices to meet the arclength constraint"""
#dr = -self.width / 2.0 * (-1 if inner else 1) dr = -wh if inner else wh
n_pts = numpy.ceil(2 * pi * max(self.radii) / max_arclen).astype(int) n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int)
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1]) arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
keep = [] keep = [0]
removable = (numpy.cumsum(arc_lengths) <= max_arclen) removable = (numpy.cumsum(arc_lengths) <= max_arclen)
start = 0 start = 1
while start < arc_lengths.size: while start < arc_lengths.size:
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
keep.append(next_to_keep) keep.append(next_to_keep)
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
start = next_to_keep + 1 start = next_to_keep + 1
return thetas[keep] if keep[-1] != thetas.size - 1:
keep.append(thetas.size - 1)
wh = self.width / 2.0 thetas = thetas[keep]
if wh == r0 or wh == r1: if inner:
thetas = thetas[::-1]
return thetas
thetas_inner: NDArray[numpy.float64]
if wh in (r0, r1):
thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin
else: else:
thetas_inner = get_thetas(inner=True) thetas_inner = get_thetas(inner=True)
@ -268,7 +309,7 @@ class Arc(Shape):
return [poly] return [poly]
def get_bounds_single(self) -> NDArray[numpy.float64]: def get_bounds_single(self) -> NDArray[numpy.float64]:
''' """
Equation for rotated ellipse is Equation for rotated ellipse is
`x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)` `x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)`
`y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)` `y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)`
@ -279,12 +320,12 @@ class Arc(Shape):
where -+ is for x, y cases, so that's where the extrema are. where -+ is for x, y cases, so that's where the extrema are.
If the extrema are innaccessible due to arc constraints, check the arc endpoints instead. If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
''' """
a_ranges = self._angles_to_parameters() a_ranges = cast('_array2x2_t', self._angles_to_parameters())
mins = [] mins = []
maxs = [] maxs = []
for a, sgn in zip(a_ranges, (-1, +1)): for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2 wh = sgn * self.width / 2
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
@ -295,13 +336,13 @@ class Arc(Shape):
maxs.append([0, 0]) maxs.append([0, 0])
continue continue
a0, a1 = a a0, a1 = aa
a0_offset = a0 - (a0 % (2 * pi)) a0_offset = a0 - (a0 % (2 * pi))
sin_r = numpy.sin(self.rotation) sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation) cos_r = numpy.cos(self.rotation)
sin_a = numpy.sin(a) sin_a = numpy.sin(aa)
cos_a = numpy.cos(a) cos_a = numpy.cos(aa)
# Cutoff angles # Cutoff angles
xpt = (-self.rotation) % (2 * pi) + a0_offset xpt = (-self.rotation) % (2 * pi) + a0_offset
@ -384,26 +425,26 @@ class Arc(Shape):
)) ))
def get_cap_edges(self) -> NDArray[numpy.float64]: def get_cap_edges(self) -> NDArray[numpy.float64]:
''' """
Returns: Returns:
``` ```
[[[x0, y0], [x1, y1]], array of 4 points, specifying the two cuts which [[[x0, y0], [x1, y1]], array of 4 points, specifying the two cuts which
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
``` ```
''' """
a_ranges = self._angles_to_parameters() a_ranges = cast('_array2x2_t', self._angles_to_parameters())
mins = [] mins = []
maxs = [] maxs = []
for a, sgn in zip(a_ranges, (-1, +1)): for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2 wh = sgn * self.width / 2
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
sin_r = numpy.sin(self.rotation) sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation) cos_r = numpy.cos(self.rotation)
sin_a = numpy.sin(a) sin_a = numpy.sin(aa)
cos_a = numpy.cos(a) cos_a = numpy.cos(aa)
# arc endpoints # arc endpoints
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
@ -414,27 +455,30 @@ class Arc(Shape):
return numpy.array([mins, maxs]) + self.offset return numpy.array([mins, maxs]) + self.offset
def _angles_to_parameters(self) -> NDArray[numpy.float64]: def _angles_to_parameters(self) -> NDArray[numpy.float64]:
''' """
Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
Returns: Returns:
"Eccentric anomaly" parameter ranges for the inner and outer edges, in the form "Eccentric anomaly" parameter ranges for the inner and outer edges, in the form
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
''' """
a = [] aa = []
for sgn in (-1, +1): for sgn in (-1, +1):
wh = sgn * self.width / 2 wh = sgn * self.width / 2.0
rx = self.radius_x + wh rx = self.radius_x + wh
ry = self.radius_y + wh ry = self.radius_y + wh
# create paremeter 'a' for parametrized ellipse a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
a0, a1 = (numpy.arctan2(rx * numpy.sin(a), ry * numpy.cos(a)) for a in self.angles)
sign = numpy.sign(self.angles[1] - self.angles[0]) sign = numpy.sign(self.angles[1] - self.angles[0])
if sign != numpy.sign(a1 - a0): if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi a1 += sign * 2 * pi
a.append((a0, a1)) aa.append((a0, a1))
return numpy.array(a) return numpy.array(aa, dtype=float)
def __repr__(self) -> str: def __repr__(self) -> str:
angles = f'{numpy.rad2deg(self.angles)}' angles = f'{numpy.rad2deg(self.angles)}'
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>' return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]

View File

@ -1,4 +1,6 @@
from typing import Any, cast
import copy import copy
import functools
import numpy import numpy
from numpy import pi from numpy import pi
@ -7,9 +9,10 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES 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 from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
@functools.total_ordering
class Circle(Shape): class Circle(Shape):
""" """
A circle, which has a position and radius. A circle, which has a position and radius.
@ -67,6 +70,29 @@ class Circle(Shape):
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and self.radius == other.radius
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('Circle', other)
if not self.radius == other.radius:
return self.radius < other.radius
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons( def to_polygons(
self, self,
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES, num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
@ -93,10 +119,10 @@ class Circle(Shape):
return numpy.vstack((self.offset - self.radius, return numpy.vstack((self.offset - self.radius,
self.offset + self.radius)) self.offset + self.radius))
def rotate(self, theta: float) -> 'Circle': def rotate(self, theta: float) -> 'Circle': # noqa: ARG002 (theta unused)
return self return self
def mirror(self, axis: int = 0) -> 'Circle': def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
self.offset *= -1 self.offset *= -1
return self return self
@ -104,7 +130,7 @@ class Circle(Shape):
self.radius *= c self.radius *= c
return self return self
def normalized_form(self, norm_value) -> normalized_shape_tuple: def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
rotation = 0.0 rotation = 0.0
magnitude = self.radius / norm_value magnitude = self.radius / norm_value
return ((type(self),), return ((type(self),),

View File

@ -1,6 +1,7 @@
from typing import Any, Self from typing import Any, Self, cast
import copy import copy
import math import math
import functools
import numpy import numpy
from numpy import pi from numpy import pi
@ -9,9 +10,10 @@ from numpy.typing import ArrayLike, NDArray
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES 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 from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key
@functools.total_ordering
class Ellipse(Shape): class Ellipse(Shape):
""" """
An ellipse, which has a position, two radii, and a rotation. An ellipse, which has a position, two radii, and a rotation.
@ -117,6 +119,32 @@ class Ellipse(Shape):
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.radii, other.radii)
and self.rotation == other.rotation
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('Ellipse', other)
if not numpy.array_equal(self.radii, other.radii):
return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons( def to_polygons(
self, self,
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES, num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,

View File

@ -1,5 +1,7 @@
from typing import Sequence, Any, cast from typing import Any, cast
from collections.abc import Sequence
import copy import copy
import functools
from enum import Enum from enum import Enum
import numpy import numpy
@ -9,10 +11,11 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, normalized_shape_tuple, Polygon, Circle from . import Shape, normalized_shape_tuple, Polygon, Circle
from ..error import PatternError from ..error import PatternError
from ..repetition import Repetition from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
@functools.total_ordering
class PathCap(Enum): class PathCap(Enum):
Flush = 0 # Path ends at final vertices Flush = 0 # Path ends at final vertices
Circle = 1 # Path extends past final vertices with a semicircle of radius width/2 Circle = 1 # Path extends past final vertices with a semicircle of radius width/2
@ -20,14 +23,17 @@ class PathCap(Enum):
SquareCustom = 4 # Path extends past final vertices with a rectangle of length SquareCustom = 4 # Path extends past final vertices with a rectangle of length
# # defined by path.cap_extensions # # defined by path.cap_extensions
def __lt__(self, other: Any) -> bool:
return self.value == other.value
@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, an end-cap shape,
and an offset. and an offset.
Note that the setter for `Path.vertices` may (but may not) create a copy of the Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates.
passed vertex coordinates. See `numpy.array(..., copy=False)` for details.
A normalized_form(...) is available, but can be quite slow with lots of vertices. A normalized_form(...) is available, but can be quite slow with lots of vertices.
""" """
@ -98,11 +104,11 @@ class Path(Shape):
custom_caps = (PathCap.SquareCustom,) custom_caps = (PathCap.SquareCustom,)
if self.cap in custom_caps: if self.cap in custom_caps:
if vals is None: if vals is None:
raise Exception('Tried to set cap extensions to None on path with custom cap type') raise PatternError('Tried to set cap extensions to None on path with custom cap type')
self._cap_extensions = numpy.array(vals, dtype=float) self._cap_extensions = numpy.array(vals, dtype=float)
else: else:
if vals is not None: if vals is not None:
raise Exception('Tried to set custom cap extensions on path with non-custom cap type') raise PatternError('Tried to set custom cap extensions on path with non-custom cap type')
self._cap_extensions = vals self._cap_extensions = vals
# vertices property # vertices property
@ -111,8 +117,7 @@ class Path(Shape):
""" """
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]` Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`
When setting, note that a copy of the provided vertices may or may not be made, When setting, note that a copy of the provided vertices will be made.
following the rules from `numpy.array(.., copy=False)`.
""" """
return self._vertices return self._vertices
@ -201,6 +206,40 @@ class Path(Shape):
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.vertices, other.vertices)
and self.width == other.width
and self.cap == other.cap
and numpy.array_equal(self.cap_extensions, other.cap_extensions) # type: ignore
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('Path', other)
if self.width != other.width:
return self.width < other.width
if self.cap != other.cap:
return self.cap < other.cap
if not numpy.array_equal(self.cap_extensions, other.cap_extensions): # type: ignore
if other.cap_extensions is None:
return False
if self.cap_extensions is None:
return True
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:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
@staticmethod @staticmethod
def travel( def travel(
travel_pairs: Sequence[tuple[float, float]], travel_pairs: Sequence[tuple[float, float]],
@ -232,7 +271,7 @@ class Path(Shape):
# TODO: Path.travel() needs testing # TODO: Path.travel() needs testing
direction = numpy.array([1, 0]) direction = numpy.array([1, 0])
verts = [numpy.zeros(2)] verts: list[NDArray[numpy.float64]] = [numpy.zeros(2)]
for angle, distance in travel_pairs: for angle, distance in travel_pairs:
direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T
verts.append(verts[-1] + direction * distance) verts.append(verts[-1] + direction * distance)
@ -268,8 +307,8 @@ class Path(Shape):
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
rp = numpy.linalg.solve(As, bs)[:, 0, None] rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0]
rn = numpy.linalg.solve(As, ds)[:, 0, None] rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0]
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
@ -366,7 +405,7 @@ class Path(Shape):
x_min = rotated_vertices[:, 0].argmin() x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min): if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin() y_min = rotated_vertices[x_min, 1].argmin()
x_min = cast(Sequence, x_min)[y_min] x_min = cast('Sequence', x_min)[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
width0 = self.width / norm_value width0 = self.width / norm_value
@ -390,22 +429,22 @@ class Path(Shape):
return self return self
def remove_duplicate_vertices(self) -> 'Path': def remove_duplicate_vertices(self) -> 'Path':
''' """
Removes all consecutive duplicate (repeated) vertices. Removes all consecutive duplicate (repeated) vertices.
Returns: Returns:
self self
''' """
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False) self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False)
return self return self
def remove_colinear_vertices(self) -> 'Path': def remove_colinear_vertices(self) -> 'Path':
''' """
Removes consecutive co-linear vertices. Removes consecutive co-linear vertices.
Returns: Returns:
self self
''' """
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
return self return self

View File

@ -1,5 +1,6 @@
from typing import Sequence, Any, cast from typing import Any, cast, TYPE_CHECKING
import copy import copy
import functools
import numpy import numpy
from numpy import pi from numpy import pi
@ -8,17 +9,21 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, normalized_shape_tuple from . import Shape, normalized_shape_tuple
from ..error import PatternError from ..error import PatternError
from ..repetition import Repetition from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
if TYPE_CHECKING:
from collections.abc import Sequence
@functools.total_ordering
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, and an offset.
Note that the setter for `Polygon.vertices` may (but may not) create a copy of the Note that the setter for `Polygon.vertices` creates a copy of the
passed vertex coordinates. See `numpy.array(..., copy=False)` for details. passed vertex coordinates.
A `normalized_form(...)` is available, but can be quite slow with lots of vertices. A `normalized_form(...)` is available, but can be quite slow with lots of vertices.
""" """
@ -37,8 +42,7 @@ class Polygon(Shape):
""" """
Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`) Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
When setting, note that a copy of the provided vertices may or may not be made, When setting, note that a copy of the provided vertices will be made,
following the rules from `numpy.array(.., copy=False)`.
""" """
return self._vertices return self._vertices
@ -103,7 +107,8 @@ class Polygon(Shape):
self.offset = offset self.offset = offset
self.repetition = repetition self.repetition = repetition
self.annotations = annotations if annotations is not None else {} self.annotations = annotations if annotations is not None else {}
self.rotate(rotation) if rotation:
self.rotate(rotation)
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
@ -113,6 +118,35 @@ class Polygon(Shape):
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.vertices, other.vertices)
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('Polygon', other)
if not numpy.array_equal(self.vertices, other.vertices):
min_len = min(self.vertices.shape[0], other.vertices.shape[0])
eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
eq_lt_masked = eq_lt[eq_mask]
if eq_lt_masked.size > 0:
return eq_lt_masked.flat[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:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
@staticmethod @staticmethod
def square( def square(
side_length: float, side_length: float,
@ -221,7 +255,7 @@ class Polygon(Shape):
lx = 2 * (xmax - xctr) lx = 2 * (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: else: # noqa: PLR5501
if xctr is not None: if xctr is not None:
pass pass
elif xmax is None: elif xmax is None:
@ -251,7 +285,7 @@ class Polygon(Shape):
ly = 2 * (ymax - yctr) ly = 2 * (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: else: # noqa: PLR5501
if yctr is not None: if yctr is not None:
pass pass
elif ymax is None: elif ymax is None:
@ -299,10 +333,7 @@ class Polygon(Shape):
Returns: Returns:
A Polygon object containing the requested octagon A Polygon object containing the requested octagon
""" """
if regular: s = (1 + numpy.sqrt(2)) if regular else 2
s = 1 + numpy.sqrt(2)
else:
s = 2
norm_oct = numpy.array([ norm_oct = numpy.array([
[-1, -s], [-1, -s],
@ -326,8 +357,8 @@ class Polygon(Shape):
def to_polygons( def to_polygons(
self, self,
num_vertices: int | None = None, # unused num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused max_arclen: float | None = None, # unused # noqa: ARG002
) -> list['Polygon']: ) -> list['Polygon']:
return [copy.deepcopy(self)] return [copy.deepcopy(self)]
@ -351,8 +382,9 @@ class Polygon(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 polygons, relative to # Note: this function is going to be pretty slow for many-vertexed polygons, relative to
# other shapes # other shapes
offset = self.vertices.mean(axis=0) + self.offset meanv = self.vertices.mean(axis=0)
zeroed_vertices = self.vertices - offset zeroed_vertices = self.vertices - meanv
offset = meanv + self.offset
scale = zeroed_vertices.std() scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale normed_vertices = zeroed_vertices / scale
@ -366,7 +398,7 @@ class Polygon(Shape):
x_min = rotated_vertices[:, 0].argmin() x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min): if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin() y_min = rotated_vertices[x_min, 1].argmin()
x_min = cast(Sequence, x_min)[y_min] x_min = cast('Sequence', x_min)[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
# TODO: normalize mirroring? # TODO: normalize mirroring?
@ -386,22 +418,22 @@ class Polygon(Shape):
return self return self
def remove_duplicate_vertices(self) -> 'Polygon': def remove_duplicate_vertices(self) -> 'Polygon':
''' """
Removes all consecutive duplicate (repeated) vertices. Removes all consecutive duplicate (repeated) vertices.
Returns: Returns:
self self
''' """
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=True) self.vertices = remove_duplicate_vertices(self.vertices, closed_path=True)
return self return self
def remove_colinear_vertices(self) -> 'Polygon': def remove_colinear_vertices(self) -> 'Polygon':
''' """
Removes consecutive co-linear vertices. Removes consecutive co-linear vertices.
Returns: Returns:
self self
''' """
self.vertices = remove_colinear_vertices(self.vertices, closed_path=True) self.vertices = remove_colinear_vertices(self.vertices, closed_path=True)
return self return self

View File

@ -1,4 +1,5 @@
from typing import Callable, Self, TYPE_CHECKING from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import numpy import numpy
@ -32,16 +33,24 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
""" """
__slots__ = () # Children should use AutoSlots or set slots themselves __slots__ = () # Children should use AutoSlots or set slots themselves
def __copy__(self) -> Self: #def __copy__(self) -> Self:
cls = self.__class__ # cls = self.__class__
new = cls.__new__(cls) # new = cls.__new__(cls)
for name in self.__slots__: # type: str # for name in self.__slots__: # type: str
object.__setattr__(new, name, getattr(self, name)) # object.__setattr__(new, name, getattr(self, name))
return new # return new
# #
# Methods (abstract) # Methods (abstract)
# #
@abstractmethod
def __eq__(self, other: Any) -> bool:
pass
@abstractmethod
def __lt__(self, other: 'Shape') -> bool:
pass
@abstractmethod @abstractmethod
def to_polygons( def to_polygons(
self, self,
@ -126,7 +135,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
vertex_lists = [] vertex_lists = []
p_verts = polygon.vertices + polygon.offset p_verts = polygon.vertices + polygon.offset
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0)): 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
# Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape # Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
@ -156,7 +165,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
m = dv[1] / dv[0] m = dv[1] / dv[0]
def get_grid_inds(xes: ArrayLike) -> NDArray[numpy.float64]: def get_grid_inds(xes: ArrayLike, m: float = m, v: NDArray = v) -> NDArray[numpy.float64]:
ys = m * (xes - v[0]) + v[1] ys = m * (xes - v[0]) + v[1]
# (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid # (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid
@ -257,11 +266,12 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
mins, maxs = bounds mins, maxs = bounds
keep_x = numpy.logical_and(grx > mins[0], grx < maxs[0]) keep_x = numpy.logical_and(grx > mins[0], grx < maxs[0])
keep_y = numpy.logical_and(gry > mins[1], gry < maxs[1]) keep_y = numpy.logical_and(gry > mins[1], gry < maxs[1])
for k in (keep_x, keep_y): # Flood left & rightwards by 2 cells
for s in (1, 2): for kk in (keep_x, keep_y):
k[s:] += k[:-s] for ss in (1, 2):
k[:-s] += k[s:] kk[ss:] += kk[:-ss]
k = k > 0 kk[:-ss] += kk[ss:]
kk[:] = kk > 0
gx = grx[keep_x] gx = grx[keep_x]
gy = gry[keep_y] gy = gry[keep_y]

View File

@ -1,5 +1,6 @@
from typing import Self from typing import Self, Any, cast
import copy import copy
import functools
import numpy import numpy
from numpy import pi, nan from numpy import pi, nan
@ -9,13 +10,14 @@ 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 RotatableImpl
from ..utils import is_scalar, get_bit, annotations_t from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key
# Loaded on use: # Loaded on use:
# from freetype import Face # from freetype import Face
# from matplotlib.path import Path # from matplotlib.path import Path
@functools.total_ordering
class Text(RotatableImpl, Shape): class Text(RotatableImpl, Shape):
""" """
Text (to be printed e.g. as a set of polygons). Text (to be printed e.g. as a set of polygons).
@ -96,10 +98,42 @@ class Text(RotatableImpl, Shape):
new._annotations = copy.deepcopy(self._annotations) new._annotations = copy.deepcopy(self._annotations)
return new return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and self.string == other.string
and self.height == other.height
and self.font_path == other.font_path
and self.rotation == other.rotation
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('Text', other)
if not self.height == other.height:
return self.height < other.height
if not self.string == other.string:
return self.string < other.string
if not self.font_path == other.font_path:
return self.font_path < other.font_path
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons( def to_polygons(
self, self,
num_vertices: int | None = None, # unused num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused max_arclen: float | None = None, # unused # noqa: ARG002
) -> list[Polygon]: ) -> list[Polygon]:
all_polygons = [] all_polygons = []
total_advance = 0.0 total_advance = 0.0
@ -157,6 +191,11 @@ class Text(RotatableImpl, Shape):
return bounds return bounds
def __repr__(self) -> str:
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
mirrored = ' m{:d}' if self.mirrored else ''
return f'<TextShape "{self.string}" o{self.offset} h{self.height:g}{rotation}{mirrored}>'
def get_char_as_polygons( def get_char_as_polygons(
font_path: str, font_path: str,
@ -182,7 +221,7 @@ def get_char_as_polygons(
'advance' distance (distance from the start of this glyph to the start of the next one) 'advance' distance (distance from the start of this glyph to the start of the next one)
""" """
if len(char) != 1: if len(char) != 1:
raise Exception('get_char_as_polygons called with non-char') raise PatternError('get_char_as_polygons called with non-char')
face = Face(font_path) face = Face(font_path)
face.set_char_size(resolution) face.set_char_size(resolution)
@ -191,7 +230,8 @@ def get_char_as_polygons(
outline = slot.outline outline = slot.outline
start = 0 start = 0
all_verts_list, all_codes = [], [] all_verts_list = []
all_codes = []
for end in outline.contours: for end in outline.contours:
points = outline.points[start:end + 1] points = outline.points[start:end + 1]
points.append(points[0]) points.append(points[0])
@ -244,8 +284,3 @@ def get_char_as_polygons(
polygons = path.to_polygons() polygons = path.to_polygons()
return polygons, advance return polygons, advance
def __repr__(self) -> str:
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
mirrored = ' m{:d}' if self.mirrored else ''
return f'<TextShape "{self.string}" o{self.offset} h{self.height:g}{rotation}{mirrored}>'

View File

@ -3,11 +3,32 @@ Traits (mixins) and default implementations
Traits and mixins should set `__slots__ = ()` to enable use of `__slots__` in subclasses. Traits and mixins should set `__slots__ = ()` to enable use of `__slots__` in subclasses.
""" """
from .positionable import Positionable, PositionableImpl, Bounded from .positionable import (
from .layerable import Layerable, LayerableImpl Positionable as Positionable,
from .rotatable import Rotatable, RotatableImpl, Pivotable, PivotableImpl PositionableImpl as PositionableImpl,
from .repeatable import Repeatable, RepeatableImpl Bounded as Bounded,
from .scalable import Scalable, ScalableImpl )
from .mirrorable import Mirrorable from .layerable import (
from .copyable import Copyable Layerable as Layerable,
from .annotatable import Annotatable, AnnotatableImpl LayerableImpl as LayerableImpl,
)
from .rotatable import (
Rotatable as Rotatable,
RotatableImpl as RotatableImpl,
Pivotable as Pivotable,
PivotableImpl as PivotableImpl,
)
from .repeatable import (
Repeatable as Repeatable,
RepeatableImpl as RepeatableImpl,
)
from .scalable import (
Scalable as Scalable,
ScalableImpl as ScalableImpl,
)
from .mirrorable import Mirrorable as Mirrorable
from .copyable import Copyable as Copyable
from .annotatable import (
Annotatable as Annotatable,
AnnotatableImpl as AnnotatableImpl,
)

View File

@ -1,9 +1,8 @@
from typing import Self from typing import Self
from abc import ABCMeta
import copy import copy
class Copyable(metaclass=ABCMeta): class Copyable:
""" """
Trait class which adds .copy() and .deepcopy() Trait class which adds .copy() and .deepcopy()
""" """

View File

@ -63,7 +63,7 @@ class LayerableImpl(Layerable, metaclass=ABCMeta):
return self._layer return self._layer
@layer.setter @layer.setter
def layer(self, val: layer_t): def layer(self, val: layer_t) -> None:
self._layer = val self._layer = val
# #

View File

@ -44,7 +44,7 @@ class Mirrorable(metaclass=ABCMeta):
# """ # """
# __slots__ = () # __slots__ = ()
# #
# _mirrored: numpy.ndarray # ndarray[bool] # _mirrored: NDArray[numpy.bool]
# """ Whether to mirror the instance across the x and/or y axes. """ # """ Whether to mirror the instance across the x and/or y axes. """
# #
# # # #
@ -52,15 +52,15 @@ class Mirrorable(metaclass=ABCMeta):
# # # #
# # Mirrored property # # Mirrored property
# @property # @property
# def mirrored(self) -> numpy.ndarray: # ndarray[bool] # def mirrored(self) -> NDArray[numpy.bool]:
# """ Whether to mirror across the [x, y] axes, respectively """ # """ Whether to mirror across the [x, y] axes, respectively """
# return self._mirrored # return self._mirrored
# #
# @mirrored.setter # @mirrored.setter
# def mirrored(self, val: Sequence[bool]): # def mirrored(self, val: Sequence[bool]) -> None:
# if is_scalar(val): # if is_scalar(val):
# raise MasqueError('Mirrored must be a 2-element list of booleans') # raise MasqueError('Mirrored must be a 2-element list of booleans')
# self._mirrored = numpy.array(val, dtype=bool, copy=True) # self._mirrored = numpy.array(val, dtype=bool)
# #
# # # #
# # Methods # # Methods

View File

@ -81,12 +81,11 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
@offset.setter @offset.setter
def offset(self, val: ArrayLike) -> None: def offset(self, val: ArrayLike) -> None:
if not isinstance(val, numpy.ndarray) or val.dtype != numpy.float64: val = numpy.array(val, dtype=float)
val = numpy.array(val, dtype=float)
if val.size != 2: if val.size != 2:
raise MasqueError('Offset must be convertible to size-2 ndarray') raise MasqueError('Offset must be convertible to size-2 ndarray')
self._offset = val.flatten() # type: ignore self._offset = val.flatten()
# #
# Methods # Methods

View File

@ -34,7 +34,7 @@ class Repeatable(metaclass=ABCMeta):
# @repetition.setter # @repetition.setter
# @abstractmethod # @abstractmethod
# def repetition(self, repetition: 'Repetition | None'): # def repetition(self, repetition: 'Repetition | None') -> None:
# pass # pass
# #
@ -75,7 +75,7 @@ class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta):
return self._repetition return self._repetition
@repetition.setter @repetition.setter
def repetition(self, repetition: 'Repetition | None'): def repetition(self, repetition: 'Repetition | None') -> None:
from ..repetition import Repetition from ..repetition import Repetition
if repetition is not None and not isinstance(repetition, Repetition): if repetition is not None and not isinstance(repetition, Repetition):
raise MasqueError(f'{repetition} is not a valid Repetition object!') raise MasqueError(f'{repetition} is not a valid Repetition object!')

View File

@ -1,14 +1,15 @@
from typing import Self, cast, Any from typing import Self, cast, Any, TYPE_CHECKING
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import numpy import numpy
from numpy import pi from numpy import pi
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from .positionable import Positionable
from ..error import MasqueError from ..error import MasqueError
from ..utils import rotation_matrix_2d from ..utils import rotation_matrix_2d
if TYPE_CHECKING:
from .positionable import Positionable
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
@ -54,7 +55,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
return self._rotation return self._rotation
@rotation.setter @rotation.setter
def rotation(self, val: float): def rotation(self, val: float) -> None:
if not numpy.size(val) == 1: if not numpy.size(val) == 1:
raise MasqueError('Rotation must be a scalar') raise MasqueError('Rotation must be a scalar')
self._rotation = val % (2 * pi) self._rotation = val % (2 * pi)
@ -112,10 +113,10 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta):
""" `[x_offset, y_offset]` """ """ `[x_offset, y_offset]` """
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
pivot = numpy.array(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) # type: ignore # mypy#3004
cast(Positionable, self).translate(+pivot) cast('Positionable', self).translate(+pivot)
return self return self

View File

@ -48,7 +48,7 @@ class ScalableImpl(Scalable, metaclass=ABCMeta):
return self._scale return self._scale
@scale.setter @scale.setter
def scale(self, val: float): def scale(self, val: float) -> None:
if not is_scalar(val): if not is_scalar(val):
raise MasqueError('Scale must be a scalar') raise MasqueError('Scale must be a scalar')
if not val > 0: if not val > 0:

View File

@ -1,18 +1,43 @@
""" """
Various helper functions, type definitions, etc. Various helper functions, type definitions, etc.
""" """
from .types import layer_t, annotations_t, SupportsBool from .types import (
from .array import is_scalar layer_t as layer_t,
from .autoslots import AutoSlots annotations_t as annotations_t,
from .deferreddict import DeferredDict SupportsBool as SupportsBool,
from .decorators import oneshot
from .bitwise import get_bit, set_bit
from .vertices import (
remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points
) )
from .transform import rotation_matrix_2d, normalize_mirror, rotate_offsets_around from .array import is_scalar as is_scalar
from .autoslots import AutoSlots as AutoSlots
from .deferreddict import DeferredDict as DeferredDict
from .decorators import oneshot as oneshot
from . import ports2data from .bitwise import (
get_bit as get_bit,
set_bit as set_bit,
)
from .vertices import (
remove_duplicate_vertices as remove_duplicate_vertices,
remove_colinear_vertices as remove_colinear_vertices,
poly_contains_points as poly_contains_points,
)
from .transform import (
rotation_matrix_2d as rotation_matrix_2d,
normalize_mirror as normalize_mirror,
rotate_offsets_around as rotate_offsets_around,
apply_transforms as apply_transforms,
R90 as R90,
R180 as R180,
)
from .comparisons import (
annotation2key as annotation2key,
annotations_lt as annotations_lt,
annotations_eq as annotations_eq,
layer2key as layer2key,
ports_lt as ports_lt,
ports_eq as ports_eq,
rep2key as rep2key,
)
from . import pack2d from . import ports2data as ports2data
from . import pack2d as pack2d

View File

@ -12,16 +12,16 @@ class AutoSlots(ABCMeta):
classes, they can have empty `__slots__` and their attribute type annotations classes, they can have empty `__slots__` and their attribute type annotations
can be used to generate a full `__slots__` for the concrete class. can be used to generate a full `__slots__` for the concrete class.
""" """
def __new__(cls, name, bases, dctn): def __new__(cls, name, bases, dctn): # noqa: ANN001,ANN204
parents = set() parents = set()
for base in bases: for base in bases:
parents |= set(base.mro()) parents |= set(base.mro())
slots = tuple(dctn.get('__slots__', tuple())) slots = tuple(dctn.get('__slots__', ()))
for parent in parents: for parent in parents:
if not hasattr(parent, '__annotations__'): if not hasattr(parent, '__annotations__'):
continue continue
slots += tuple(getattr(parent, '__annotations__').keys()) slots += tuple(parent.__annotations__.keys())
dctn['__slots__'] = slots dctn['__slots__'] = slots
return super().__new__(cls, name, bases, dctn) return super().__new__(cls, name, bases, dctn)

106
masque/utils/comparisons.py Normal file
View File

@ -0,0 +1,106 @@
from typing import Any
from .types import annotations_t, layer_t
from ..ports import Port
from ..repetition import Repetition
def annotation2key(aaa: int | float | str) -> tuple[bool, Any]:
return (isinstance(aaa, str), aaa)
def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool:
if aa is None:
return bb is not None
elif bb is None: # noqa: RET505
return False
if len(aa) != len(bb):
return len(aa) < len(bb)
keys_a = tuple(sorted(aa.keys()))
keys_b = tuple(sorted(bb.keys()))
if keys_a != keys_b:
return keys_a < keys_b
for key in keys_a:
va = aa[key]
vb = bb[key]
if len(va) != len(vb):
return len(va) < len(vb)
for aaa, bbb in zip(va, vb, strict=True):
if aaa != bbb:
return annotation2key(aaa) < annotation2key(bbb)
return False
def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool:
if aa is None:
return bb is None
elif bb is None: # noqa: RET505
return False
if len(aa) != len(bb):
return False
keys_a = tuple(sorted(aa.keys()))
keys_b = tuple(sorted(bb.keys()))
if keys_a != keys_b:
return keys_a < keys_b
for key in keys_a:
va = aa[key]
vb = bb[key]
if len(va) != len(vb):
return False
for aaa, bbb in zip(va, vb, strict=True):
if aaa != bbb:
return False
return True
def layer2key(layer: layer_t) -> tuple[bool, bool, Any]:
is_int = isinstance(layer, int)
is_str = isinstance(layer, str)
layer_tup = (layer) if (is_str or is_int) else layer
tup = (
is_str,
not is_int,
layer_tup,
)
return tup
def rep2key(repetition: Repetition | None) -> tuple[bool, Repetition | None]:
return (repetition is None, repetition)
def ports_eq(aa: dict[str, Port], bb: dict[str, Port]) -> bool:
if len(aa) != len(bb):
return False
keys = sorted(aa.keys())
if keys != sorted(bb.keys()):
return False
return all(aa[kk] == bb[kk] for kk in keys)
def ports_lt(aa: dict[str, Port], bb: dict[str, Port]) -> bool:
if len(aa) != len(bb):
return len(aa) < len(bb)
aa_keys = tuple(sorted(aa.keys()))
bb_keys = tuple(sorted(bb.keys()))
if aa_keys != bb_keys:
return aa_keys < bb_keys
for key in aa_keys:
pa = aa[key]
pb = bb[key]
if pa != pb:
return pa < pb
return False

104
masque/utils/curves.py Normal file
View File

@ -0,0 +1,104 @@
import numpy
from numpy.typing import ArrayLike, NDArray
from numpy import pi
try:
from numpy import trapezoid
except ImportError:
from numpy import trapz as trapezoid
def bezier(
nodes: ArrayLike,
tt: ArrayLike,
weights: ArrayLike | None = None,
) -> NDArray[numpy.float64]:
"""
Sample a Bezier curve with the provided control points at the parametrized positions `tt`.
Using the calculation method from arXiv:1803.06843, Chudy and Woźny.
Args:
nodes: `[[x0, y0], ...]` control points for the Bezier curve
tt: Parametrized positions at which to sample the curve (1D array with values in the interval [0, 1])
weights: Control point weights; if provided, length should be the same as number of control points.
Default 1 for all control points.
Returns:
`[[x0, y0], [x1, y1], ...]` corresponding to `[tt0, tt1, ...]`
"""
nodes = numpy.asarray(nodes)
tt = numpy.asarray(tt)
nn = nodes.shape[0]
weights = numpy.ones(nn) if weights is None else numpy.asarray(weights)
with numpy.errstate(divide='ignore'):
umul = (tt / (1 - tt)).clip(max=1)
udiv = ((1 - tt) / tt).clip(max=1)
hh = numpy.ones((tt.size,))
qq = nodes[None, 0, :] * hh[:, None]
for kk in range(1, nn):
hh *= umul * (nn - kk) * weights[kk]
hh /= kk * udiv * weights[kk - 1] + hh
qq *= 1.0 - hh[:, None]
qq += hh[:, None] * nodes[None, kk, :]
return qq
def euler_bend(
switchover_angle: float,
num_points: int = 200,
) -> NDArray[numpy.float64]:
"""
Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral).
Args:
switchover_angle: After this angle, the bend will transition into a circular arc
(and transition back to an Euler spiral on the far side). If this is set to
`>= pi / 4`, no circular arc will be added.
num_points: Number of points in the curve
Returns:
`[[x0, y0], ...]` for the curve
"""
ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion
ll_tot = 2 * ll_max + (pi / 2 - 2 * switchover_angle)
num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int)
num_points_arc = num_points - 2 * num_points_spiral
def gen_spiral(ll_max: float) -> NDArray[numpy.float64]:
xx = []
yy = []
for ll in numpy.linspace(0, ll_max, num_points_spiral):
qq = numpy.linspace(0, ll, 1000) # integrate to current arclength
xx.append(trapezoid( numpy.cos(qq * qq / 2), qq))
yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq))
xy_part = numpy.stack((xx, yy), axis=1)
return xy_part
xy_spiral = gen_spiral(ll_max)
xy_parts = [xy_spiral]
if switchover_angle < pi / 4:
# Build a circular segment to join the two euler portions
rmin = 1.0 / ll_max
half_angle = pi / 4 - switchover_angle
qq = numpy.linspace(half_angle * 2, 0, num_points_arc + 1) + switchover_angle
xc = rmin * numpy.cos(qq)
yc = rmin * numpy.sin(qq) + xy_spiral[-1, 1]
xc += xy_spiral[-1, 0] - xc[0]
yc += xy_spiral[-1, 1] - yc[0]
xy_parts.append(numpy.stack((xc[1:], yc[1:]), axis=1))
endpoint_xy = xy_parts[-1][-1, :]
second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1]
xy_parts.append(second_spiral)
xy = numpy.concatenate(xy_parts)
# Remove any 2x-duplicate points
xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)]
return xy

View File

@ -1,4 +1,4 @@
from typing import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from ..error import OneShotError from ..error import OneShotError
@ -11,7 +11,7 @@ def oneshot(func: Callable) -> Callable:
expired = False expired = False
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs): # noqa: ANN202
nonlocal expired nonlocal expired
if expired: if expired:
raise OneShotError(func.__name__) raise OneShotError(func.__name__)

View File

@ -1,4 +1,5 @@
from typing import Callable, TypeVar, Generic from typing import TypeVar, Generic
from collections.abc import Callable
from functools import lru_cache from functools import lru_cache

View File

@ -1,7 +1,7 @@
""" """
2D bin-packing 2D bin-packing
""" """
from typing import Sequence, Callable, Mapping from collections.abc import Sequence, Mapping, Callable
import numpy import numpy
from numpy.typing import NDArray, ArrayLike from numpy.typing import NDArray, ArrayLike
@ -38,8 +38,8 @@ def maxrects_bssf(
Raises: Raises:
MasqueError if `allow_rejects` is `True` but some `rects` could not be placed. MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
""" """
regions = numpy.array(containers, copy=False, dtype=float) regions = numpy.asarray(containers, dtype=float)
rect_sizes = numpy.array(rects, copy=False, dtype=float) rect_sizes = numpy.asarray(rects, dtype=float)
rect_locs = numpy.zeros_like(rect_sizes) rect_locs = numpy.zeros_like(rect_sizes)
rejected_inds = set() rejected_inds = set()
@ -62,15 +62,15 @@ def maxrects_bssf(
''' Place the rect ''' ''' Place the rect '''
# Best short-side fit (bssf) to pick a region # Best short-side fit (bssf) to pick a region
bssf_scores = ((regions[:, 2:] - regions[:, :2]) - rect_size).min(axis=1).astype(float) region_sizes = regions[:, 2:] - regions[:, :2]
bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit! bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit!
rr = bssf_scores.argmin() rr = bssf_scores.argmin()
if numpy.isinf(bssf_scores[rr]): if numpy.isinf(bssf_scores[rr]):
if allow_rejects: if allow_rejects:
rejected_inds.add(rect_ind) rejected_inds.add(rect_ind)
continue continue
else: raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
# Read out location # Read out location
loc = regions[rr, :2] loc = regions[rr, :2]
@ -139,8 +139,8 @@ def guillotine_bssf_sas(
Raises: Raises:
MasqueError if `allow_rejects` is `True` but some `rects` could not be placed. MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
""" """
regions = numpy.array(containers, copy=False, dtype=float) regions = numpy.asarray(containers, dtype=float)
rect_sizes = numpy.array(rects, copy=False, dtype=float) rect_sizes = numpy.asarray(rects, dtype=float)
rect_locs = numpy.zeros_like(rect_sizes) rect_locs = numpy.zeros_like(rect_sizes)
rejected_inds = set() rejected_inds = set()
@ -152,21 +152,21 @@ def guillotine_bssf_sas(
for rect_ind, rect_size in enumerate(rect_sizes): for rect_ind, rect_size in enumerate(rect_sizes):
''' Place the rect ''' ''' Place the rect '''
# Best short-side fit (bssf) to pick a region # Best short-side fit (bssf) to pick a region
bssf_scores = ((regions[:, 2:] - regions[:, :2]) - rect_size).min(axis=1).astype(float) region_sizes = regions[:, 2:] - regions[:, :2]
bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit! bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit!
rr = bssf_scores.argmin() rr = bssf_scores.argmin()
if numpy.isinf(bssf_scores[rr]): if numpy.isinf(bssf_scores[rr]):
if allow_rejects: if allow_rejects:
rejected_inds.add(rect_ind) rejected_inds.add(rect_ind)
continue continue
else: raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
# Read out location # Read out location
loc = regions[rr, :2] loc = regions[rr, :2]
rect_locs[rect_ind] = loc rect_locs[rect_ind] = loc
region_size = regions[rr, 2:] - loc region_size = region_sizes[rr]
split_horiz = region_size[0] < region_size[1] split_horiz = region_size[0] < region_size[1]
new_region0 = regions[rr].copy() new_region0 = regions[rr].copy()
@ -227,7 +227,7 @@ def pack_patterns(
MasqueError if `allow_rejects` is `True` but some `rects` could not be placed. MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
""" """
half_spacing = numpy.array(spacing, copy=False, dtype=float) / 2 half_spacing = numpy.asarray(spacing, dtype=float) / 2
bounds = [library[pp].get_bounds() for pp in patterns] bounds = [library[pp].get_bounds() for pp in patterns]
sizes = [bb[1] - bb[0] + spacing if bb is not None else spacing for bb in bounds] sizes = [bb[1] - bb[0] + spacing if bb is not None else spacing for bb in bounds]
@ -236,7 +236,7 @@ def pack_patterns(
locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects)
pat = Pattern() pat = Pattern()
for pp, oo, loc in zip(patterns, offsets, locations): for pp, oo, loc in zip(patterns, offsets, locations, strict=True):
pat.ref(pp, offset=oo + loc) pat.ref(pp, offset=oo + loc)
rejects = [patterns[ii] for ii in reject_inds] rejects = [patterns[ii] for ii in reject_inds]

View File

@ -6,7 +6,7 @@ and retrieving it (`data_to_ports`).
the port locations. This particular approach is just a sensible default; feel free to the port locations. This particular approach is just a sensible default; feel free to
to write equivalent functions for your own format or alternate storage methods. to write equivalent functions for your own format or alternate storage methods.
""" """
from typing import Sequence, Mapping from collections.abc import Sequence, Mapping
import logging import logging
from itertools import chain from itertools import chain
@ -150,7 +150,7 @@ def data_to_ports_flat(
Returns: Returns:
The updated `pattern`. Port labels are not removed. The updated `pattern`. Port labels are not removed.
""" """
labels = list(chain.from_iterable((pattern.labels[layer] for layer in layers))) labels = list(chain.from_iterable(pattern.labels[layer] for layer in layers))
if not labels: if not labels:
return pattern return pattern

View File

@ -1,14 +1,19 @@
""" """
Geometric transforms Geometric transforms
""" """
from typing import Sequence from collections.abc import Sequence
from functools import lru_cache from functools import lru_cache
import numpy import numpy
from numpy.typing import NDArray from numpy.typing import NDArray, ArrayLike
from numpy import pi from numpy import pi
# Constants for shorthand rotations
R90 = pi / 2
R180 = pi
@lru_cache @lru_cache
def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
""" """
@ -57,8 +62,62 @@ def rotate_offsets_around(
) -> NDArray[numpy.float64]: ) -> NDArray[numpy.float64]:
""" """
Rotates offsets around a pivot point. Rotates offsets around a pivot point.
Args:
offsets: Nx2 array, rows are (x, y) offsets
pivot: (x, y) location to rotate around
angle: rotation angle in radians
Returns:
Nx2 ndarray of (x, y) position after the rotation is applied.
""" """
offsets -= pivot offsets -= pivot
offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T
offsets += pivot offsets += pivot
return offsets return offsets
def apply_transforms(
outer: ArrayLike,
inner: ArrayLike,
tensor: bool = False,
) -> NDArray[numpy.float64]:
"""
Apply a set of transforms (`outer`) to a second set (`inner`).
This is used to find the "absolute" transform for nested `Ref`s.
The two transforms should be of shape Ox4 and Ix4.
Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`).
Args:
outer: Transforms for the container refs. Shape Ox4.
inner: Transforms for the contained refs. Shape Ix4.
tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding
to the `oo`th `outer` transform applied to the `ii`th inner transform.
If `False` (default), this is concatenated into `(O*I)x4` to allow simple
chaining into additional `apply_transforms()` calls.
Returns:
OxIx4 or (O*I)x4 array. Final dimension is
`(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`.
"""
outer = numpy.atleast_2d(outer).astype(float, copy=False)
inner = numpy.atleast_2d(inner).astype(float, copy=False)
# If mirrored, flip y's
xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm
xy_mir[outer[:, 3].astype(bool), :, 1] *= -1
rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]]
xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir)
tot = numpy.empty((outer.shape[0], inner.shape[0], 4))
tot[:, :, :2] = outer[:, None, :2] + xy
tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored
tot[:, :, 2] %= 2 * pi # clamp rot
tot[:, :, 3] %= 2 # clamp mirrored
if tensor:
return tot
return numpy.concatenate(tot)

View File

@ -15,9 +15,9 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) ->
(i.e. the last vertex will be removed if it is the same as the first) (i.e. the last vertex will be removed if it is the same as the first)
Returns: Returns:
`vertices` with no consecutive duplicates. `vertices` with no consecutive duplicates. This may be a view into the original array.
""" """
vertices = numpy.array(vertices) vertices = numpy.asarray(vertices)
duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1) duplicates = (vertices == numpy.roll(vertices, 1, axis=0)).all(axis=1)
if not closed_path: if not closed_path:
duplicates[0] = False duplicates[0] = False
@ -35,7 +35,7 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N
closed path. If `False`, the path is assumed to be open. Default `True`. closed path. If `False`, the path is assumed to be open. Default `True`.
Returns: Returns:
`vertices` with colinear (superflous) vertices removed. `vertices` with colinear (superflous) vertices removed. May be a view into the original array.
""" """
vertices = remove_duplicate_vertices(vertices) vertices = remove_duplicate_vertices(vertices)
@ -73,8 +73,8 @@ def poly_contains_points(
Returns: Returns:
ndarray of booleans, [point0_is_in_shape, point1_is_in_shape, ...] ndarray of booleans, [point0_is_in_shape, point1_is_in_shape, ...]
""" """
points = numpy.array(points, copy=False) points = numpy.asarray(points, dtype=float)
vertices = numpy.array(vertices, copy=False) vertices = numpy.asarray(vertices, dtype=float)
if points.size == 0: if points.size == 0:
return numpy.zeros(0, dtype=numpy.int8) return numpy.zeros(0, dtype=numpy.int8)

View File

@ -39,11 +39,11 @@ classifiers = [
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
"Topic :: Scientific/Engineering :: Visualization", "Topic :: Scientific/Engineering :: Visualization",
] ]
requires-python = ">=3.8" requires-python = ">=3.11"
dynamic = ["version"] dynamic = ["version"]
dependencies = [ dependencies = [
"numpy~=1.21", "numpy>=1.26",
"klamath~=1.2", "klamath~=1.4",
] ]
@ -57,3 +57,39 @@ svg = ["svgwrite"]
visualize = ["matplotlib"] visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"] text = ["matplotlib", "freetype-py"]
[tool.ruff]
exclude = [
".git",
"dist",
]
line-length = 145
indent-width = 4
lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
lint.select = [
"NPY", "E", "F", "W", "B", "ANN", "UP", "SLOT", "SIM", "LOG",
"C4", "ISC", "PIE", "PT", "RET", "TCH", "PTH", "INT",
"ARG", "PL", "R", "TRY",
"G010", "G101", "G201", "G202",
"Q002", "Q003", "Q004",
]
lint.ignore = [
#"ANN001", # No annotation
"ANN002", # *args
"ANN003", # **kwargs
"ANN401", # Any
"SIM108", # single-line if / else assignment
"RET504", # x=y+z; return x
"PIE790", # unnecessary pass
"ISC003", # non-implicit string concatenation
"C408", # dict(x=y) instead of {'x': y}
"PLR09", # Too many xxx
"PLR2004", # magic number
"PLC0414", # import x as x
"TRY003", # Long exception message
]
[tool.pytest.ini_options]
addopts = "-rsXx"
testpaths = ["masque"]