Compare commits

...
Sign in to create a new pull request.

143 commits

Author SHA1 Message Date
e7f847d4c7 [Pather] make two-L path planning atomic (don't error out with only one half drawn) 2026-03-31 09:28:48 -07:00
3beadd2bf0 [Path] preserve cap extensions in normalized form, and scale them with scale() 2026-03-31 09:24:22 -07:00
1bcf5901d6 [Path] preserve width from normalized form 2026-03-31 09:23:19 -07:00
56e401196a [PathTool] fix pathtool L-shape 2026-03-31 00:25:45 -07:00
83ec64158a [AutoTool] fix exact s-bend validation 2026-03-31 00:24:52 -07:00
aa7007881f [pack2d] bin-packing fixes 2026-03-31 00:16:58 -07:00
d03fafcaf6 [ILibraryView] don't fail on nested dangling ref 2026-03-30 23:34:31 -07:00
d3be6aeba3 [PortList] add_port_pair requires unique port names 2026-03-30 23:33:33 -07:00
ffbe15c465 [Port / PortList] raise PortError on missing port name 2026-03-30 23:32:50 -07:00
b44c962e07 [Pattern] improve error handling in place() 2026-03-30 22:11:50 -07:00
20bd0640e1 [Library] improve handling of dangling refs 2026-03-30 22:10:26 -07:00
4ae8115139 [DeferredDict] implement get/items/values for deferreddict 2026-03-30 22:07:21 -07:00
c2ef3e4217 [test] data_to_ports should accurately preserve ports from a scaled ref 2026-03-30 21:19:10 -07:00
c32168dc64 [ILibraryView / Pattern] flatten() should raise PatternError if asked to preserve ports from a repeated ref 2026-03-30 21:17:33 -07:00
b843ffb4d3 [ILibraryView / Pattern] flatten() shouldn't drop ports-only patterns if flatten_ports=True 2026-03-30 21:12:20 -07:00
9adfcac437 [Ref] don't shadow ref property 2026-03-30 21:07:13 -07:00
26cc0290b9 [Abstract] respect ref scale 2026-03-30 21:06:51 -07:00
548b51df47 [Port] fix printing of None rotation 2026-03-30 20:25:45 -07:00
06f8611a90 [svg] fix rotation in svg 2026-03-30 20:24:24 -07:00
9ede16df5d [dxf] fix reading Polyline 2026-03-30 20:22:40 -07:00
add82e955d update dev deps 2026-03-30 19:39:25 -07:00
jan
cfec9e8c76 [euler_bend] speed up integration 2026-03-10 00:47:50 -07:00
jan
2275bf415a [Pattern] improve error message when attempting to reference a Pattern 2026-03-10 00:31:58 -07:00
jan
fa3dfa1e74 [Pattern] improve clarity of .copy()->.deepcopy() 2026-03-10 00:31:11 -07:00
jan
75dc391540 [pack2d] don't place rejects 2026-03-10 00:29:51 -07:00
jan
feb5d87cf4 [repetition.Arbitrary] fix zero-sized bounds 2026-03-10 00:29:10 -07:00
jan
5f91bd9c6c [BREAKING][Ref / Label / Pattern] Make rotate/mirror consistent intrinsic transfomations
offset and repetition are extrinsic; use rotate_around() and flip() to
alter both
mirror() and rotate() only affect the object's intrinsic properties
2026-03-09 23:34:39 -07:00
jan
db22237369 [PathCap] clean up comment 2026-03-09 11:20:04 -07:00
jan
a6ea5c08e6 [repetition.Grid] drop b_vector=None handling (guaranteed to be zeros now) 2026-03-09 11:19:42 -07:00
jan
3792248cd1 [dxf] improve dxf reader (ezdxf 1.4 related LWPolyLine changes) 2026-03-09 11:16:30 -07:00
jan
e8083cc24c [dxf] hide ezdxf warnings directly 2026-03-09 03:37:42 -07:00
jan
d307589995 [ports2data] add note about using id rather than name 2026-03-09 03:29:19 -07:00
jan
ea93a7ef37 [remove_colinear_vertices / Path] add preserve_uturns and use it for paths 2026-03-09 03:28:31 -07:00
jan
495babf837 [Path] revert endcap changes to avoid double-counting 2026-03-09 03:27:39 -07:00
jan
5d20a061fd [Path / Polygon] improve normalized_form approach to follow documented order 2026-03-09 02:42:13 -07:00
jan
25b8fe8448 [Path.to_polygons] Use linalg.solve() where possible; fallback to lstsq if singular 2026-03-09 02:41:15 -07:00
jan
f154303bef [remove_colinear_vertices] treat unclosed paths correctly 2026-03-09 02:38:33 -07:00
jan
5596e2b1af [tests] cover scale-aware transform 2026-03-09 02:35:35 -07:00
jan
6c42049b23 [PortList] actually raise the error 2026-03-09 02:34:57 -07:00
jan
da20922224 [apply_transform] include scale in transform 2026-03-09 02:34:11 -07:00
jan
b8ee4bb05d [ell] fix set_rotation check 2026-03-09 02:32:20 -07:00
jan
169f66cc85 [rotation_matrix_2d] improve manhattan angle detection
modulo causes issues with negative numbers
2026-03-09 01:16:54 -07:00
jan
a38c5bb085 [ports2data] deal with cycles better 2026-03-09 01:15:42 -07:00
jan
0ad89d6d95 [DeferredDict] capture value in set_const 2026-03-09 01:10:26 -07:00
jan
6c96968341 [Path] improve robustness of intersection calculations 2026-03-09 01:09:37 -07:00
jan
b7143e3287 [repetition.Grid] fix __le__ comparison of b_vector 2026-03-09 01:08:35 -07:00
jan
0cce5e0586 [Ref] misc copy fixes -- don't deepcopy repetition or annotations in __copy__ 2026-03-09 01:07:50 -07:00
jan
36cb86a15d [tests] clean unused imports 2026-03-09 00:20:29 -07:00
jan
5e0936e15f [dxf] update ezdxf dep 2026-03-09 00:18:06 -07:00
jan
a467a0baca [Path] simplify conditional 2026-03-09 00:17:50 -07:00
jan
564ff10db3 [dxf] add roundtrip dxf test, enable refs and improve path handling 2026-03-09 00:17:23 -07:00
jan
e261585894 [gdsii] Try to close files if able 2026-03-08 23:09:45 -07:00
jan
f42114bf43 [gdsii] explicitly cast cap_extensions to int 2026-03-08 22:47:22 -07:00
jan
5eb460ecb7 [repetition.Grid] disallow b_vector=None (except when initializing) 2026-03-08 22:43:58 -07:00
jan
fb822829ec [Polygon] rect() should call rectangle() with positive width/height
no big deal, but this makes vertex order consistent
2026-03-08 22:42:48 -07:00
jan
838c742651 [Path] Improve comparisons: compare vertices 2026-03-08 22:41:37 -07:00
jan
9a76ce5b66 [Path] cap_extensions=None should mean [0, 0] when using custom extensions 2026-03-08 22:41:11 -07:00
jan
2019fc0d74 [Path] Circular cap extensions should translate to square, not empty 2026-03-08 22:40:08 -07:00
jan
e3f8d28529 [Path] improve __lt__ for endcaps 2026-03-08 22:37:30 -07:00
jan
9296011d4b [Ref] deepcopy annotations and repetitions 2026-03-08 22:34:39 -07:00
jan
92d0140093 [Pattern] fix pattern comparisons 2026-03-08 22:33:59 -07:00
jan
c4dc9f9573 [oasis] comment and code cleanup 2026-03-08 22:32:16 -07:00
jan
0b8e11e8bf [dxf] improve manhattan check robustness 2026-03-08 22:31:18 -07:00
jan
5989e45906 [apply_transforms] fix handling of rotations while mirrored 2026-03-08 21:38:47 -07:00
jan
7eec2b7acf [LazyLibrary] report full cycle when one is detected 2026-03-08 21:18:54 -07:00
jan
2a6458b1ac [repetitions.Arbitrary] reassign to displacements when scaling or mirroring to trigger re-sort 2026-03-08 20:43:33 -07:00
jan
9ee3c7ff89 [ILibrary] make referenced_patterns more robust to cyclical dependencies 2026-03-08 20:01:00 -07:00
jan
3bedab2301 [ports2data] Make port label parsing more robust 2026-03-08 19:58:56 -07:00
jan
4eb1d8d486 [gdsii] fix missing paren in message 2026-03-08 19:57:49 -07:00
jan
3ceeba23b8 [tests] move imports into functions 2026-03-08 19:00:20 -07:00
jan
963103b859 [Pattern / Library] add resolve_repeated_refs 2026-03-08 15:15:53 -07:00
jan
e5a6aab940 [dxf] improve repetition handling 2026-03-08 15:15:28 -07:00
jan
042941c838 [DeferredDict] improve handling of constants 2026-03-08 15:05:08 -07:00
jan
0f63acbad0 [AutoSlots] deduplicate slots entries 2026-03-08 15:01:27 -07:00
jan
a0d7d0ed26 [annotations] fix annotations_eq
-e
2026-03-08 14:56:13 -07:00
jan
d32a5ee762 [dxf] fix typos 2026-03-08 14:53:28 -07:00
jan
19dafad157 [remove_duplicate_vertices] improve handling of degenerate shapes 2026-03-08 10:24:25 -07:00
jan
5cb608734d [poly_contains_points] consistently return boolean arrays 2026-03-08 10:17:52 -07:00
jan
d0b48e6bfc [tests] fix some tests 2026-03-08 10:15:09 -07:00
jan
ef5c8c715e [Pather] add auto_render_append arg 2026-03-08 10:12:43 -07:00
jan
049864ddc7 [manhattanize_fast] Improve handling of grids smaller than the shape 2026-03-08 10:10:46 -07:00
jan
3bf7efc404 [Polygon] fix offset error messages 2026-03-08 09:48:03 -07:00
jan
74fa377450 [repetition.Arbitrary] fix equality check 2026-03-08 09:47:50 -07:00
jan
c3581243c8 [Pather] Major pathing rework / Consolidate RenderPather, Pather, and Builder 2026-03-08 00:18:47 -08:00
jan
338c123fb1 [pattern] speed up visualize() 2026-03-07 23:57:12 -08:00
jan
a89f07c441 [Port] add describe() for logging 2026-03-07 23:36:14 -08:00
jan
bb7f4906af [ILibrary] add .resolve() 2026-03-07 23:35:47 -08:00
jan
2513c7f8fd [pattern.visualize] cleanup 2026-03-07 10:32:41 -08:00
jan
ad4e9af59d [svg] add annotate_ports arg 2026-03-07 10:32:22 -08:00
jan
46555dbd4d [pattern.visualize] add options for file output and port visualization 2026-03-07 10:22:54 -08:00
jan
26e6a44559 [readme] clean up todos 2026-03-07 00:48:50 -08:00
jan
32681edb47 [tests] fixup tests related to pather api changes 2026-03-07 00:48:22 -08:00
jan
84f37195ad [Pather / RenderPather / Tool] Rename path->trace in more locations 2026-03-07 00:33:18 -08:00
jan
0189756df4 [Pather/RenderPather] Add U-bend to trace_into 2026-03-07 00:03:07 -08:00
jan
1070815730 [AutoTool] add U-bend 2026-03-06 23:51:56 -08:00
jan
8a45c6d8d6 [tutorial] update pather and renderpather tutorials to new syntax 2026-03-06 23:31:44 -08:00
jan
9d6fb985d8 [Pather/RenderPather/PathTool] Add updated pather tests 2026-03-06 23:09:59 -08:00
jan
69ac25078c [Pather/RenderPather/Tool/PortPather] Add U-bends 2026-03-06 22:58:32 -08:00
jan
babbe78daa [Pather/RenderPather/PortPather] Rework pathing verbs *BREAKING CHANGE* 2026-03-06 22:58:03 -08:00
jan
16875e9cd6 [RenderPather / PathTool] Improve support for port transformations
So that moving a port while in the middle of planning a path doesn't
break everything
2026-03-06 13:07:06 -08:00
jan
4332cf14c0 [ezdxf] add stubs 2026-02-16 20:48:26 -08:00
jan
ff8ca92963 cleanup 2026-02-16 20:48:15 -08:00
jan
ed021e3d81 [Pattern] fix mirror_elements and change arg name to axis 2026-02-16 19:23:08 -08:00
jan
07a25ec290 [Mirrorable / Flippable] clarify docs 2026-02-16 18:53:31 -08:00
jan
504f89796c Add ruff and mypy to dev deps 2026-02-16 18:08:40 -08:00
jan
0f49924aa6 Add ezdxf stubs 2026-02-16 18:04:16 -08:00
jan
ebfe1b559c misc cleanup (mostly type-related) 2026-02-16 17:58:34 -08:00
jan
7ad59d6b89 [boolean] Add basic boolean functionality (boolean() and Polygon.boolean()) 2026-02-16 17:42:19 -08:00
jan
5d040061f4 [set_dead] improve docs 2026-02-16 13:57:16 -08:00
jan
f42e720c68 [set_dead / skip_geometry] Improve dead pathers so more "broken" layouts can be successfully executed 2026-02-16 13:44:56 -08:00
jan
cf822c7dcf [Port] add more logging to aid in debug 2026-02-16 12:23:40 -08:00
jan
59e996e680 [tutorial] include a repetition and update docs 2026-02-15 20:05:38 -08:00
jan
abf236a046 [mirror / flip_across] improve documentation 2026-02-15 19:46:47 -08:00
jan
d40bdb1cb2 add 'dev' dependency group and 'manhattanize' optional dep 2026-02-15 19:23:02 -08:00
5e08579498 [tests] add round-trip file tests 2026-02-15 16:44:17 -08:00
c18e5b8d3e [OASIS] cleanup 2026-02-15 16:43:46 -08:00
48f7569c1f [traits] Formalize Flippable and Pivotable depending on Positionable 2026-02-15 14:34:10 -08:00
8a56679884 Clean up types/imports 2026-02-15 12:40:47 -08:00
1cce6c1f70 [Tests] cleanup 2026-02-15 12:36:13 -08:00
d9adb4e1b9 [Tools] fixup imports 2026-02-15 12:35:58 -08:00
1de76bff47 [tests] Add machine-generated test suite 2026-02-15 01:41:31 -08:00
9bb0d5190d [Arc] improve some edge cases when calculating arclengths 2026-02-15 01:37:53 -08:00
ad49276345 [Arc] improve bounding box edge cases 2026-02-15 01:35:43 -08:00
fe70d0574b [Arc] Improve handling of full rings 2026-02-15 01:34:56 -08:00
36fed84249 [PolyCollection] fix slicing 2026-02-15 01:31:15 -08:00
278f0783da [PolyCollection] gracefully handle empty PolyCollections 2026-02-15 01:26:06 -08:00
72f462d077 [AutoTool] Enable running AutoTool without any bends in the list 2026-02-15 01:18:21 -08:00
66d6fae2bd [AutoTool] Fix error handling for ccw=None 2026-02-15 01:15:07 -08:00
2b7ad00204 [Port] add custom __deepcopy__ 2026-02-15 00:57:47 -08:00
2d63e72802 fixup! [Mirrorable / Flippable] Bifurcate mirror into flip (relative to line) vs mirror (relative to own offset/origin) 2026-02-15 00:49:34 -08:00
51ced2fe83 [Text] use translate instead of offset 2026-02-15 00:07:43 -08:00
19fac463e4 [Shape] fix annotation 2026-02-15 00:07:27 -08:00
44986bac67 [Mirrorable / Flippable] Bifurcate mirror into flip (relative to line) vs mirror (relative to own offset/origin) 2026-02-15 00:05:53 -08:00
accad3db9f Prefer [1 - axis] for clarity 2026-02-14 19:20:50 -08:00
05098c0c13 [remove_colinear_vertices] keep two vertices if all were colinear 2026-02-14 19:15:54 -08:00
f64b080b15 [repetition.Arbitrary] fix mirroring 2026-02-14 19:10:01 -08:00
54f3b273bc [Label] don't drop annotations when copying 2026-02-14 18:53:23 -08:00
add0600bac [RenderPather] warn about unrendered paths on deletion 2026-02-14 17:13:22 -08:00
737d41d592 [examples] expand port_pather tutorial 2026-02-14 17:06:29 -08:00
395244ee83 [examples] some cleanup 2026-02-14 16:58:24 -08:00
43ccd8de2f [examples] type annotations 2026-02-14 16:57:34 -08:00
dfa0259997 [examples] clean up imports 2026-02-14 16:57:11 -08:00
37418d2137 [examples] fixup examples and add port_pather example 2026-02-14 16:07:19 -08:00
92 changed files with 6951 additions and 2808 deletions

View file

@ -277,12 +277,6 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
## TODO
* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath)
* PolyCollection & arrow-based read/write
* pather and renderpather examples, including .at() (PortPather)
* Bus-to-bus connections?
* Tests tests tests
* Better interface for polygon operations (e.g. with `pyclipper`)
- de-embedding
- boolean ops
* tuple / string layer auto-translation

View file

@ -6,7 +6,7 @@ from masque.file import gdsii
from masque import Arc, Pattern
def main():
def main() -> None:
pat = Pattern()
layer = (0, 0)
pat.shapes[layer].extend([

View file

@ -1,7 +1,5 @@
import numpy
from pyclipper import (
Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO,
scale_to_clipper, scale_from_clipper,
Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO,
)
p = Pyclipper()
p.AddPaths([
@ -12,8 +10,8 @@ p.AddPaths([
], PT_SUBJECT, closed=True)
#p.Execute2?
#p.Execute?
p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO)
p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO)
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
p = Pyclipper()

View file

@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis
def main():
def main() -> None:
lib = Library()
cell_name = 'ellip_grating'

View file

@ -18,11 +18,14 @@ Contents
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
- [pather](pather.py)
* Use `Pather` to route individual wires and wire bundles
* Use `BasicTool` to generate paths
* Use `BasicTool` to automatically transition between path types
- [renderpather](rendpather.py)
* Use `AutoTool` to generate paths
* Use `AutoTool` to automatically transition between path types
- [renderpather](renderpather.py)
* Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py),
but using `Path` shapes instead of `Polygon`s.
- [port_pather](port_pather.py)
* Use `PortPather` and the `.at()` syntax for more concise routing
* Advanced port manipulation and connections
Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices.

View file

@ -1,12 +1,9 @@
from collections.abc import Sequence
import numpy
from numpy import pi
from masque import (
layer_t, Pattern, Label, Port,
Circle, Arc, Polygon,
)
from masque import layer_t, Pattern, Circle, Arc, Ref
from masque.repetition import Grid
import masque.file.gdsii
@ -39,6 +36,45 @@ def hole(
return pat
def hole_array(
radius: float,
num_x: int = 5,
num_y: int = 3,
pitch: float = 2000,
layer: layer_t = (1, 0),
) -> Pattern:
"""
Generate an array of circular holes using `Repetition`.
Args:
radius: Circle radius.
num_x, num_y: Number of holes in x and y.
pitch: Center-to-center spacing.
layer: Layer to draw the holes on.
Returns:
Pattern containing a grid of holes.
"""
# First, make a pattern for a single hole
hpat = hole(radius, layer)
# Now, create a pattern that references it multiple times using a Grid
pat = Pattern()
pat.refs['hole'] = [
Ref(
offset=(0, 0),
repetition=Grid(a_vector=(pitch, 0), a_count=num_x,
b_vector=(0, pitch), b_count=num_y)
)]
# We can also add transformed references (rotation, mirroring, etc.)
pat.refs['hole'].append(
Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True)
)
return pat, hpat
def triangle(
radius: float,
layer: layer_t = (1, 0),
@ -60,9 +96,7 @@ def triangle(
]) * radius
pat = Pattern()
pat.shapes[layer].extend([
Polygon(offset=(0, 0), vertices=vertices),
])
pat.polygon(layer, vertices=vertices)
return pat
@ -111,9 +145,13 @@ def main() -> None:
lib['smile'] = smile(1000)
lib['triangle'] = triangle(1000)
# Use a Grid to make many holes efficiently
lib['grid'], lib['hole'] = hole_array(1000)
masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS)
lib['triangle'].visualize()
lib['grid'].visualize(lib)
if __name__ == '__main__':

View file

@ -4,8 +4,8 @@ import numpy
from numpy import pi
from masque import (
layer_t, Pattern, Ref, Label, Builder, Port, Polygon,
Library, ILibraryView,
layer_t, Pattern, Ref, Builder, Port, Polygon,
Library,
)
from masque.utils import ports2data
from masque.file.gdsii import writefile, check_valid_names

View file

@ -1,17 +1,13 @@
from typing import Any
from collections.abc import Sequence, Callable
from pprint import pformat
import numpy
from numpy import pi
from masque import Pattern, Builder, LazyLibrary
from masque import Builder, LazyLibrary
from masque.file.gdsii import writefile, load_libraryfile
import pcgen
import basic_shapes
import devices
from devices import ports_to_data, data_to_ports
from devices import data_to_ports
from basic_shapes import GDS_OPTS

View file

@ -1,10 +1,9 @@
"""
Manual wire routing tutorial: Pather and BasicTool
Manual wire routing tutorial: Pather and AutoTool
"""
from collections.abc import Callable
from numpy import pi
from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque.builder.tools import BasicTool, PathTool
from masque import Pather, Library, Pattern, Port, layer_t
from masque.builder.tools import AutoTool, Tool
from masque.file.gdsii import writefile
from basic_shapes import GDS_OPTS
@ -107,7 +106,99 @@ def map_layer(layer: layer_t) -> layer_t:
'M2': (20, 0),
'V1': (30, 0),
}
return layer_mapping.get(layer, layer)
if isinstance(layer, str):
return layer_mapping.get(layer, layer)
return layer
def prepare_tools() -> tuple[Library, Tool, Tool]:
"""
Create some basic library elements and tools for drawing M1 and M2
"""
# Build some patterns (static cells) using the above functions and store them in a library
library = Library()
library['pad'] = make_pad()
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
library['v1_via'] = make_via(
layer_top = 'M2',
layer_via = 'V1',
layer_bot = 'M1',
width_top = M2_WIDTH,
width_via = V1_WIDTH,
width_bot = M1_WIDTH,
ptype_bot = 'm1wire',
ptype_top = 'm2wire',
)
#
# Now, define two tools.
# M1_tool will route on M1, using wires with M1_WIDTH
# M2_tool will route on M2, using wires with M2_WIDTH
# Both tools are able to automatically transition from the other wire type (with a via)
#
# Note that while we use AutoTool for this tutorial, you can define your own `Tool`
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
# transmission line geometry, or other features.
#
M1_tool = AutoTool(
# First, we need a function which takes in a length and spits out an M1 wire
straights = [
AutoTool.Straight(
ptype = 'm1wire',
fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
out_port_name = 'output', # and use the port named 'output' as the output
),
],
bends = [
AutoTool.Bend(
abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
in_port_name = 'input',
out_port_name = 'output',
clockwise = True,
),
],
transitions = { # We can automate transitions for different (normally incompatible) port types
('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire'
library.abstract('v1_via'), # we can place a V1 via
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
'bottom', # and using the port named 'bottom' as the output
),
},
sbends = [],
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
)
M2_tool = AutoTool(
straights = [
# Again, we use make_straight_wire, but this time we set parameters for M2
AutoTool.Straight(
ptype = 'm2wire',
fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
in_port_name = 'input',
out_port_name = 'output',
),
],
bends = [
# and we use an M2 bend
AutoTool.Bend(
abstract = library.abstract('m2_bend'),
in_port_name = 'input',
out_port_name = 'output',
),
],
transitions = {
('m1wire', 'm2wire'): AutoTool.Transition(
library.abstract('v1_via'), # We still use the same via,
'bottom', # but the input port is now 'bottom'
'top', # and the output port is now 'top'
),
},
sbends = [],
default_out_ptype = 'm2wire', # We default to trying to stay on M2
)
return library, M1_tool, M2_tool
#
@ -118,75 +209,7 @@ def map_layer(layer: layer_t) -> layer_t:
# (e.g. geometry definition).
#
def main() -> None:
# Build some patterns (static cells) using the above functions and store them in a library
library = Library()
library['pad'] = make_pad()
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
library['v1_via'] = make_via(
layer_top='M2',
layer_via='V1',
layer_bot='M1',
width_top=M2_WIDTH,
width_via=V1_WIDTH,
width_bot=M1_WIDTH,
ptype_bot='m1wire',
ptype_top='m2wire',
)
#
# Now, define two tools.
# M1_tool will route on M1, using wires with M1_WIDTH
# M2_tool will route on M2, using wires with M2_WIDTH
# Both tools are able to automatically transition from the other wire type (with a via)
#
# Note that while we use BasicTool for this tutorial, you can define your own `Tool`
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
# transmission line geometry, or other features.
#
M1_tool = BasicTool(
straight = (
# First, we need a function which takes in a length and spits out an M1 wire
lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
'output', # and use the port named 'output' as the output
),
bend = (
library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
'input', # To orient it clockwise, use the port named 'input' as the input
'output', # and 'output' as the output
),
transitions = { # We can automate transitions for different (normally incompatible) port types
'm2wire': ( # For example, when we're attaching to a port with type 'm2wire'
library.abstract('v1_via'), # we can place a V1 via
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
'bottom', # and using the port named 'bottom' as the output
),
},
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
)
M2_tool = BasicTool(
straight = (
# Again, we use make_straight_wire, but this time we set parameters for M2
lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
'input',
'output',
),
bend = (
library.abstract('m2_bend'), # and we use an M2 bend
'input',
'output',
),
transitions = {
'm1wire': (
library.abstract('v1_via'), # We still use the same via,
'bottom', # but the input port is now 'bottom'
'top', # and the output port is now 'top'
),
},
default_out_ptype = 'm2wire', # We default to trying to stay on M2
)
library, M1_tool, M2_tool = prepare_tools()
#
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
@ -203,27 +226,25 @@ def main() -> None:
# Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False)
# The total distance forward (including the bend's forward component) must be 6um
pather.path('VCC', ccw=False, length=6_000)
pather.cw('VCC', 6_000)
# Now path VCC to x=0. This time, don't include any bend (ccw=None).
# Now path VCC to x=0. This time, don't include any bend.
# Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction.
pather.path_to('VCC', ccw=None, x=0)
pather.straight('VCC', x=0)
# Path GND forward by 5um, turning clockwise 90 degrees.
# This time we use shorthand (bool(0) == False) and omit the parameter labels
# Note that although ccw=0 is equivalent to ccw=False, ccw=None is not!
pather.path('GND', 0, 5_000)
pather.cw('GND', 5_000)
# This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend.
pather.path_to('GND', None, x=pather['VCC'].offset[0])
pather.straight('GND', x=pather['VCC'].offset[0])
# Now, start using M1_tool for GND.
# Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves.
# Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves.
# If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here
# and achieve the same result without having to define any transitions in M1_tool.
# Note that even though we have changed the tool used for GND, the via doesn't get placed until
# the next time we draw a path on GND (the pather.mpath() statement below).
pather.retool(M1_tool, keys=['GND'])
pather.retool(M1_tool, keys='GND')
# Bundle together GND and VCC, and path the bundle forward and counterclockwise.
# Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000.
@ -231,7 +252,7 @@ def main() -> None:
#
# Since we recently retooled GND, its path starts with a via down to M1 (included in the distance
# calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2.
pather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000)
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
# Now use M1_tool as the default tool for all ports/signals.
# Since VCC does not have an explicitly assigned tool, it will now transition down to M1.
@ -241,38 +262,37 @@ def main() -> None:
# The total extension (travel distance along the forward direction) for the longest segment (in
# this case the segment being added to GND) should be exactly 50um.
# After turning, the wire pitch should be reduced only 1.2um.
pather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200)
pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
# Make a U-turn with the bundle and expand back out to 4.5um wire pitch.
# Here, emin specifies the travel distance for the shortest segment. For the first mpath() call
# that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the
# Here, emin specifies the travel distance for the shortest segment. For the first call
# that applies to VCC, and for the second call, that applies to GND; the relative lengths of the
# segments depend on their starting positions and their ordering within the bundle.
pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200)
pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500)
pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
# Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been
# explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default.
# explicitly assigned a tool.
pather.retool(M2_tool)
# Now path both ports to x=-28_000.
# When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate,
# However, with ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
# With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
# equivalent.
pather.mpath(['GND', 'VCC'], None, xmin=-28_000)
pather.straight(['GND', 'VCC'], xmin=-28_000)
# Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1.
# This results in a via at the end of the wire (instead of having one at the start like we got
# when using pather.retool().
pather.path_to('VCC', None, -50_000, out_ptype='m1wire')
pather.straight('VCC', x=-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)
with pather.toolctx(M2_tool, keys='GND'):
pather.straight('GND', x=-40_000)
pather.straight('GND', x=-50_000)
# Save the pather's pattern into our library
library['Pather_and_BasicTool'] = pather.pattern
library['Pather_and_AutoTool'] = pather.pattern
# Convert from text-based layers to numeric layers for GDS, and output the file
library.map_layers(map_layer)

View file

@ -2,7 +2,7 @@
Routines for creating normalized 2D lattices and common photonic crystal
cavity designs.
"""
from collection.abc import Sequence
from collections.abc import Sequence
import numpy
from numpy.typing import ArrayLike, NDArray
@ -50,7 +50,7 @@ def triangular_lattice(
elif origin == 'corner':
pass
else:
raise Exception(f'Invalid value for `origin`: {origin}')
raise ValueError(f'Invalid value for `origin`: {origin}')
return xy[xy[:, 0].argsort(), :]
@ -197,12 +197,12 @@ def ln_defect(
`[[x0, y0], [x1, y1], ...]` for all the holes
"""
if defect_length % 2 != 1:
raise Exception('defect_length must be odd!')
p = triangular_lattice([2 * d + 1 for d in mirror_dims])
raise ValueError('defect_length must be odd!')
pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims])
half_length = numpy.floor(defect_length / 2)
hole_nums = numpy.arange(-half_length, half_length + 1)
holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True)
return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ]
holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True)
return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :]
def ln_shift_defect(
@ -248,7 +248,7 @@ def ln_shift_defect(
for sign in (-1, 1):
x_val = sign * (x_removed + ind + 1)
which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0)
xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
return xyr
@ -309,7 +309,7 @@ def l3_shift_perturbed_defect(
# which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2])
perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2)))
for row in xyr:
if numpy.fabs(row) in perturbed_holes:
row[2] = perturbed_radius
for xy in perturbed_holes:
which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1)
xyr[which, 2] = perturbed_radius
return xyr

View file

@ -0,0 +1,171 @@
"""
PortPather tutorial: Using .at() syntax
"""
from masque import RenderPather, Pattern, Port, R90
from masque.file.gdsii import writefile
from basic_shapes import GDS_OPTS
from pather import map_layer, prepare_tools
def main() -> None:
# Reuse the same patterns (pads, bends, vias) and tools as in pather.py
library, M1_tool, M2_tool = prepare_tools()
# Create a RenderPather and place some initial pads (same as Pather tutorial)
rpather = RenderPather(library, tools=M2_tool)
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
#
# Routing with .at() chaining
#
# The .at(port_name) method returns a PortPather object which wraps the Pather
# and remembers the selected port(s). This allows method chaining.
# Route VCC: 6um South, then West to x=0.
# (Note: since the port points North into the pad, path() moves South by default)
(rpather.at('VCC')
.path(ccw=False, length=6_000) # Move South, turn West (Clockwise)
.path_to(ccw=None, x=0) # Continue West to x=0
)
# Route GND: 5um South, then West to match VCC's x-coordinate.
rpather.at('GND').path(ccw=False, length=5_000).path_to(ccw=None, x=rpather['VCC'].x)
#
# Tool management and manual plugging
#
# We can use .retool() to change the tool for specific ports.
# We can also use .plug() directly on a PortPather.
# Manually add a via to GND and switch to M1_tool for subsequent segments
(rpather.at('GND')
.plug('v1_via', 'top')
.retool(M1_tool) # this only retools the 'GND' port
)
# We can also pass multiple ports to .at(), and then use .mpath() on them.
# Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via).
(rpather.at(['GND', 'VCC'])
.mpath(ccw=True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
.retool(M1_tool) # Retools both GND and VCC
.mpath(ccw=True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension
.mpath(ccw=False, emin=1_000, spacing=1_200) # U-turn back South
.mpath(ccw=False, emin=2_000, spacing=4_500) # U-turn back West
)
# Retool VCC back to M2 and move both to x=-28k
rpather.at('VCC').retool(M2_tool)
rpather.at(['GND', 'VCC']).mpath(ccw=None, xmin=-28_000)
# Final segments to -50k
rpather.at('VCC').path_to(ccw=None, x=-50_000, out_ptype='m1wire')
with rpather.at('GND').toolctx(M2_tool):
rpather.at('GND').path_to(ccw=None, x=-40_000)
rpather.at('GND').path_to(ccw=None, x=-50_000)
#
# Branching with save_copy and into_copy
#
# .save_copy(new_name) creates a port copy and keeps the original selected.
# .into_copy(new_name) creates a port copy and selects the new one.
# Create a tap on GND
(rpather.at('GND')
.path(ccw=None, length=5_000) # Move GND further West
.save_copy('GND_TAP') # Mark this location for a later branch
.pathS(length=10_000, jog=-10_000) # Continue GND with an S-bend
)
# Branch VCC and follow the new branch
(rpather.at('VCC')
.path(ccw=None, length=5_000)
.into_copy('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH'
.path(ccw=True, length=5_000) # VCC_BRANCH turns South
)
# The original 'VCC' port remains at x=-55k, y=VCC.y
#
# Port set management: add, drop, rename, delete
#
# Route the GND_TAP we saved earlier.
(rpather.at('GND_TAP')
.retool(M1_tool)
.path(ccw=True, length=10_000) # Turn South
.rename_to('GND_FEED') # Give it a more descriptive name
.retool(M1_tool) # Re-apply tool to the new name
)
# We can manage the active set of ports in a PortPather
pp = rpather.at(['VCC_BRANCH', 'GND_FEED'])
pp.add_port('GND') # Now tracking 3 ports
pp.drop_port('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND
pp.path_each(ccw=None, length=5_000) # Move both 5um forward (length > transition size)
# We can also delete ports from the pather entirely
rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead)
#
# Advanced Connections: path_into and path_from
#
# path_into routes FROM the selected port TO a target port.
# path_from routes TO the selected port FROM a source port.
# Create a destination component
dest_ports = {
'in_A': Port((0, 0), rotation=R90, ptype='m2wire'),
'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire')
}
library['dest'] = Pattern(ports=dest_ports)
# Place dest so that its ports are to the West and South of our current wires.
# Rotating by pi/2 makes the ports face West (pointing East).
rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'})
# Connect GND_FEED to DEST_A
# Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice.
rpather.at('GND_FEED').path_into('DEST_A')
# Connect VCC_BRANCH to DEST_B using path_from
rpather.at('DEST_B').path_from('VCC_BRANCH')
#
# Direct Port Transformations and Metadata
#
(rpather.at('GND')
.set_ptype('m1wire') # Change metadata
.translate((1000, 0)) # Shift the port 1um East
.rotate(R90 / 2) # Rotate it 45 degrees
.set_rotation(R90) # Force it to face West
)
# Demonstrate .plugged() to acknowledge a manual connection
# (Normally used when you place components so their ports perfectly overlap)
rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2'))
rpather.at('TMP1').plugged('TMP2') # Removes both ports
#
# Rendering and Saving
#
# Since we used RenderPather, we must call .render() to generate the geometry.
rpather.render()
library['PortPather_Tutorial'] = rpather.pattern
library.map_layers(map_layer)
writefile(library, 'port_pather.gds', **GDS_OPTS)
print("Tutorial complete. Output written to port_pather.gds")
if __name__ == '__main__':
main()

View file

@ -1,8 +1,7 @@
"""
Manual wire routing tutorial: RenderPather an PathTool
"""
from collections.abc import Callable
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque import RenderPather, Library
from masque.builder.tools import PathTool
from masque.file.gdsii import writefile
@ -13,7 +12,7 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via
def main() -> None:
#
# To illustrate the advantages of using `RenderPather`, we use `PathTool` instead
# of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
# but when used with `RenderPather`, it can consolidate multiple routing steps into
# a single `Path` shape.
#
@ -25,64 +24,66 @@ def main() -> None:
library = Library()
library['pad'] = make_pad()
library['v1_via'] = make_via(
layer_top='M2',
layer_via='V1',
layer_bot='M1',
width_top=M2_WIDTH,
width_via=V1_WIDTH,
width_bot=M1_WIDTH,
ptype_bot='m1wire',
ptype_top='m2wire',
layer_top = 'M2',
layer_via = 'V1',
layer_bot = 'M1',
width_top = M2_WIDTH,
width_via = V1_WIDTH,
width_bot = M1_WIDTH,
ptype_bot = 'm1wire',
ptype_top = 'm2wire',
)
# `PathTool` is more limited than `BasicTool`. It only generates one type of shape
# `PathTool` is more limited than `AutoTool`. It only generates one type of shape
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
# and what port type to present.
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
rpather = RenderPather(tools=M2_ptool, library=library)
# As in the pather tutorial, we make soem pads and labels...
# As in the pather tutorial, we make some pads and labels...
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
# ...and start routing the signals.
rpather.path('VCC', ccw=False, length=6_000)
rpather.path_to('VCC', ccw=None, x=0)
rpather.path('GND', 0, 5_000)
rpather.path_to('GND', None, x=rpather['VCC'].offset[0])
rpather.cw('VCC', 6_000)
rpather.straight('VCC', x=0)
rpather.cw('GND', 5_000)
rpather.straight('GND', x=rpather.pattern['VCC'].x)
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
# `plug` the via into the GND wire ourselves.
rpather.plug('v1_via', {'GND': 'top'})
rpather.retool(M1_ptool, keys=['GND'])
rpather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000)
rpather.retool(M1_ptool, keys='GND')
rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
# Same thing on the VCC wire when it goes down to M1.
rpather.plug('v1_via', {'VCC': 'top'})
rpather.retool(M1_ptool)
rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200)
rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200)
rpather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500)
rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
# And again when VCC goes back up to M2.
rpather.plug('v1_via', {'VCC': 'bottom'})
rpather.retool(M2_ptool)
rpather.mpath(['GND', 'VCC'], None, xmin=-28_000)
rpather.straight(['GND', 'VCC'], xmin=-28_000)
# Finally, since PathTool has no conception of transitions, we can't
# just ask it to transition to an 'm1wire' port at the end of the final VCC segment.
# Instead, we have to calculate the via size ourselves, and adjust the final position
# to account for it.
via_size = abs(
library['v1_via'].ports['top'].offset[0]
- library['v1_via'].ports['bottom'].offset[0]
)
rpather.path_to('VCC', None, -50_000 + via_size)
v1pat = library['v1_via']
via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x)
# alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0]
# would take into account the port orientations if we didn't already know they're along x
rpather.straight('VCC', x=-50_000 + via_size)
rpather.plug('v1_via', {'VCC': 'top'})
# Render the path we defined
rpather.render()
library['RenderPather_and_PathTool'] = rpather.pattern

View file

@ -55,6 +55,7 @@ from .pattern import (
map_targets as map_targets,
chain_elements as chain_elements,
)
from .utils.boolean import boolean as boolean
from .library import (
ILibraryView as ILibraryView,

View file

@ -8,16 +8,13 @@ from numpy.typing import ArrayLike
from .ref import Ref
from .ports import PortList, Port
from .utils import rotation_matrix_2d
#if TYPE_CHECKING:
# from .builder import Builder, Tool
# from .library import ILibrary
from .traits import Mirrorable
logger = logging.getLogger(__name__)
class Abstract(PortList):
class Abstract(PortList, Mirrorable):
"""
An `Abstract` is a container for a name and associated ports.
@ -131,50 +128,18 @@ class Abstract(PortList):
port.rotate(rotation)
return self
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
Mirror the Abstract across an axis through its origin.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across (0: x-axis, 1: y-axis).
Returns:
self
"""
for port in self.ports.values():
port.offset[across_axis - 1] *= -1
return self
def mirror_ports(self, across_axis: int = 0) -> Self:
"""
Mirror each port's rotation across an axis, relative to its
offset
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
for port in self.ports.values():
port.mirror(across_axis)
return self
def mirror(self, across_axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis
Args:
axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
Returns:
self
"""
self.mirror_ports(across_axis)
self.mirror_port_offsets(across_axis)
port.flip_across(axis=axis)
return self
def apply_ref_transform(self, ref: Ref) -> Self:
@ -192,6 +157,8 @@ class Abstract(PortList):
self.mirror()
self.rotate_ports(ref.rotation)
self.rotate_port_offsets(ref.rotation)
if ref.scale != 1:
self.scale_by(ref.scale)
self.translate_ports(ref.offset)
return self
@ -209,6 +176,8 @@ class Abstract(PortList):
# TODO test undo_ref_transform
"""
self.translate_ports(-ref.offset)
if ref.scale != 1:
self.scale_by(1 / ref.scale)
self.rotate_port_offsets(-ref.rotation)
self.rotate_ports(-ref.rotation)
if ref.mirrored:

View file

@ -1,7 +1,9 @@
from .builder import Builder as Builder
from .pather import Pather as Pather
from .renderpather import RenderPather as RenderPather
from .pather_mixin import PortPather as PortPather
from .pather import (
Pather as Pather,
PortPather as PortPather,
Builder as Builder,
RenderPather as RenderPather,
)
from .utils import ell as ell
from .tools import (
Tool as Tool,
@ -9,4 +11,5 @@ from .tools import (
SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool,
)
)
from .logging import logged_op as logged_op

View file

@ -1,448 +0,0 @@
"""
Simplified Pattern assembly (`Builder`)
"""
from typing import Self
from collections.abc import Iterable, Sequence, Mapping
import copy
import logging
from functools import wraps
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..library import ILibrary, TreeView
from ..error import BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
logger = logging.getLogger(__name__)
class Builder(PortList):
"""
A `Builder` is a helper object used for snapping together multiple
lower-level patterns at their `Port`s.
The `Builder` mostly just holds context, in the form of a `Library`,
in addition to its underlying pattern. This simplifies some calls
to `plug` and `place`, by making the library implicit.
`Builder` can also be `set_dead()`, at which point further calls to `plug()`
and `place()` are ignored (intended for debugging).
Examples: Creating a Builder
===========================
- `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes
an empty pattern, adds the given ports, and places it into `library`
under the name `'mypat'`.
- `Builder(library)` makes an empty pattern with no ports. The pattern
is not added into `library` and must later be added with e.g.
`library['mypat'] = builder.pattern`
- `Builder(library, pattern=pattern, name='mypat')` uses an existing
pattern (including its ports) and sets `library['mypat'] = pattern`.
- `Builder.interface(other_pat, port_map=['A', 'B'], library=library)`
makes a new (empty) pattern, copies over ports 'A' and 'B' from
`other_pat`, and creates additional ports 'in_A' and 'in_B' facing
in the opposite directions. This can be used to build a device which
can plug into `other_pat` (using the 'in_*' ports) but which does not
itself include `other_pat` as a subcomponent.
- `Builder.interface(other_builder, ...)` does the same thing as
`Builder.interface(other_builder.pattern, ...)` but also uses
`other_builder.library` as its library by default.
Examples: Adding to a pattern
=============================
- `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B'
of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports
are removed and any unconnected ports from `subdevice` are added to
`my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
argument is provided, and the `thru` argument is not explicitly
set to `False`, the unconnected port of `wire` is automatically renamed to
'myport'. This allows easy extension of existing ports without changing
their names or having to provide `map_out` each time `plug` is called.
- `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
instantiates `pad` at the specified (x, y) offset and with the specified
rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is
renamed to 'gnd' so that further routing can use this signal or net name
rather than the port name on the original `pad` device.
"""
__slots__ = ('pattern', 'library', '_dead')
pattern: Pattern
""" Layout of this device """
library: ILibrary
"""
Library from which patterns should be referenced
"""
_dead: bool
""" If True, plug()/place() are skipped (for debugging)"""
@property
def ports(self) -> dict[str, Port]:
return self.pattern.ports
@ports.setter
def ports(self, value: dict[str, Port]) -> None:
self.pattern.ports = value
def __init__(
self,
library: ILibrary,
*,
pattern: Pattern | None = None,
ports: str | Mapping[str, Port] | None = None,
name: str | None = None,
) -> None:
"""
Args:
library: The library from which referenced patterns will be taken
pattern: The pattern which will be modified by subsequent operations.
If `None` (default), a new pattern is created.
ports: Allows specifying the initial set of ports, if `pattern` does
not already have any ports (or is not provided). May be a string,
in which case it is interpreted as a name in `library`.
Default `None` (no ports).
name: If specified, `library[name]` is set to `self.pattern`.
"""
self._dead = False
self.library = library
if pattern is not None:
self.pattern = pattern
else:
self.pattern = Pattern()
if ports is not None:
if self.pattern.ports:
raise BuildError('Ports supplied for pattern with pre-existing ports!')
if isinstance(ports, str):
ports = library.abstract(ports).ports
self.pattern.ports.update(copy.deepcopy(dict(ports)))
if name is not None:
library[name] = self.pattern
@classmethod
def interface(
cls: type['Builder'],
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
in_prefix: str = 'in_',
out_prefix: str = '',
port_map: dict[str, str] | Sequence[str] | None = None,
name: str | None = None,
) -> 'Builder':
"""
Wrapper for `Pattern.interface()`, which returns a Builder instead.
Args:
source: A collection of ports (e.g. Pattern, Builder, or dict)
from which to create the interface. May be a pattern name if
`library` is provided.
library: Library from which existing patterns should be referenced,
and to which the new one should be added (if named). If not provided,
`source.library` must exist and will be used.
in_prefix: Prepended to port names for newly-created ports with
reversed directions compared to the current device.
out_prefix: Prepended to port names for ports which are directly
copied from the current device.
port_map: Specification for ports to copy into the new device:
- If `None`, all ports are copied.
- If a sequence, only the listed ports are copied
- If a mapping, the listed ports (keys) are copied and
renamed (to the values).
Returns:
The new builder, with an empty pattern and 2x as many ports as
listed in port_map.
Raises:
`PortError` if `port_map` contains port names not present in the
current device.
`PortError` if applying the prefixes results in duplicate port
names.
"""
if library is None:
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
library = source.library
else:
raise BuildError('No library was given, and `source.library` does not have one either.')
if isinstance(source, str):
source = library.abstract(source).ports
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
new = Builder(library=library, pattern=pat, name=name)
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,
# so we shouldn't wrap Pattern.path()
#@wraps(Pattern.path)
#def path(self, *args, **kwargs) -> Self:
# self.pattern.path(*args, **kwargs)
# return self
def plug(
self,
other: Abstract | str | Pattern | TreeView,
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: bool = False,
thru: bool | str = True,
set_rotation: bool | None = None,
append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
) -> Self:
"""
Wrapper around `Pattern.plug` which allows a string for `other`.
The `Builder`'s library is used to dereference the string (or `Abstract`, if
one is passed with `append=True`). If a `TreeView` is passed, it is first
added into `self.library`.
Args:
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
port connections between the two devices.
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
new names for ports in `other`.
mirrored: Enables mirroring `other` across the x axis prior to
connecting any ports.
thru: If map_in specifies only a single port, `thru` provides a mechainsm
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
- If True (default), and `other` has only two ports total, and map_out
doesn't specify a name for the other port, its name is set to the key
in `map_in`, i.e. 'myport'.
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
An error is raised if that entry already exists.
This makes it easy to extend a pattern with simple 2-port devices
(e.g. wires) without providing `map_out` each time `plug` is
called. See "Examples" above for more info. Default `True`.
set_rotation: If the necessary rotation cannot be determined from
the ports being connected (i.e. all pairs have at least one
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `other` should be rotated. Otherwise,
`set_rotation` must remain `None`.
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
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:
self
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other_names`.
`PortError` if there are any duplicate names after `map_in` and `map_out`
are applied.
`PortError` if the specified port mapping is not achieveable (the ports
do not line up)
"""
if self._dead:
logger.error('Skipping plug() since device is dead')
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):
other = self.library.abstract(other)
if append and isinstance(other, Abstract):
other = self.library[other.name]
self.pattern.plug(
other = other,
map_in = map_in,
map_out = map_out,
mirrored = mirrored,
thru = thru,
set_rotation = set_rotation,
append = append,
ok_connections = ok_connections,
)
return self
def place(
self,
other: Abstract | str | Pattern | TreeView,
*,
offset: ArrayLike = (0, 0),
rotation: float = 0,
pivot: ArrayLike = (0, 0),
mirrored: bool = False,
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
) -> Self:
"""
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
one is passed with `append=True`). If a `TreeView` is passed, it is first
added into `self.library`.
Args:
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).
rotation: Rotation applied to the instance before placement. Default 0.
pivot: Rotation is applied around this pivot point (default (0, 0)).
Rotation is applied prior to translation (`offset`).
mirrored: Whether theinstance should be mirrored across the x axis.
Mirroring is applied before translation and rotation.
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
new names for ports in the instantiated device. New names can be
`None`, which will delete those ports.
skip_port_check: Can be used to skip the internal call to `check_ports`,
in case it has already been performed elsewhere.
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
Returns:
self
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other.ports`.
`PortError` if there are any duplicate names after `map_in` and `map_out`
are applied.
"""
if self._dead:
logger.error('Skipping place() since device is dead')
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):
other = self.library.abstract(other)
if append and isinstance(other, Abstract):
other = self.library[other.name]
self.pattern.place(
other = other,
offset = offset,
rotation = rotation,
pivot = pivot,
mirrored = mirrored,
port_map = port_map,
skip_port_check = skip_port_check,
append = append,
)
return self
def translate(self, offset: ArrayLike) -> Self:
"""
Translate the pattern and all ports.
Args:
offset: (x, y) distance to translate by
Returns:
self
"""
self.pattern.translate_elements(offset)
return self
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
"""
Rotate the pattern and all ports.
Args:
angle: angle (radians, counterclockwise) to rotate by
pivot: location to rotate around
Returns:
self
"""
self.pattern.rotate_around(pivot, angle)
for port in self.ports.values():
port.rotate_around(pivot, angle)
return self
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the pattern and all ports across the specified axis.
Args:
axis: Axis to mirror across (x=0, y=1)
Returns:
self
"""
self.pattern.mirror(axis)
return self
def set_dead(self) -> Self:
"""
Disallows further changes through `plug()` or `place()`.
This is meant for debugging:
```
dev.plug(a, ...)
dev.set_dead() # added for debug purposes
dev.plug(b, ...) # usually raises an error, but now skipped
dev.plug(c, ...) # also skipped
dev.pattern.visualize() # shows the device as of the set_dead() call
```
Returns:
self
"""
self._dead = True
return self
def __repr__(self) -> str:
s = f'<Builder {self.pattern} L({len(self.library)})>'
return s

120
masque/builder/logging.py Normal file
View file

@ -0,0 +1,120 @@
"""
Logging and operation decorators for Builder/Pather
"""
from typing import TYPE_CHECKING, Any
from collections.abc import Iterator, Sequence, Callable
import logging
from functools import wraps
import inspect
import numpy
from contextlib import contextmanager
if TYPE_CHECKING:
from .pather import Pather
logger = logging.getLogger(__name__)
def _format_log_args(**kwargs) -> str:
arg_strs = []
for k, v in kwargs.items():
if isinstance(v, str | int | float | bool | None):
arg_strs.append(f"{k}={v}")
elif isinstance(v, numpy.ndarray):
arg_strs.append(f"{k}={v.tolist()}")
elif isinstance(v, list | tuple) and len(v) <= 10:
arg_strs.append(f"{k}={v}")
else:
arg_strs.append(f"{k}=...")
return ", ".join(arg_strs)
class PatherLogger:
"""
Encapsulates state for Pather/Builder diagnostic logging.
"""
debug: bool
indent: int
depth: int
def __init__(self, debug: bool = False) -> None:
self.debug = debug
self.indent = 0
self.depth = 0
def _log(self, module_name: str, msg: str) -> None:
if self.debug and self.depth <= 1:
log_obj = logging.getLogger(module_name)
log_obj.info(' ' * self.indent + msg)
@contextmanager
def log_operation(
self,
pather: 'Pather',
op: str,
portspec: str | Sequence[str] | None = None,
**kwargs: Any,
) -> Iterator[None]:
if not self.debug or self.depth > 0:
self.depth += 1
try:
yield
finally:
self.depth -= 1
return
target = f"({portspec})" if portspec else ""
module_name = pather.__class__.__module__
self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}")
before_ports = {name: port.copy() for name, port in pather.ports.items()}
self.depth += 1
self.indent += 1
try:
yield
finally:
after_ports = pather.ports
for name in sorted(after_ports.keys()):
if name not in before_ports or after_ports[name] != before_ports[name]:
self._log(module_name, f"Port {name}: {pather.ports[name].describe()}")
for name in sorted(before_ports.keys()):
if name not in after_ports:
self._log(module_name, f"Port {name}: removed")
self.indent -= 1
self.depth -= 1
def logged_op(
portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Decorator to wrap Builder methods with logging.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
sig = inspect.signature(func)
@wraps(func)
def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any:
logger_obj = getattr(self, '_logger', None)
if logger_obj is None or not logger_obj.debug:
return func(self, *args, **kwargs)
bound = sig.bind(self, *args, **kwargs)
bound.apply_defaults()
all_args = bound.arguments
# remove 'self' from logged args
logged_args = {k: v for k, v in all_args.items() if k != 'self'}
ps = portspec_getter(all_args) if portspec_getter else None
# Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation
logged_args.pop('portspec', None)
with logger_obj.log_operation(self, func.__name__, ps, **logged_args):
if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'):
logger.warning(f"Skipping geometry for {func.__name__}() since device is dead")
return func(self, *args, **kwargs)
return wrapper
return decorator

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,646 +0,0 @@
"""
Pather with batched (multi-step) rendering
"""
from typing import Self
from collections.abc import Sequence, Mapping, MutableMapping, Iterable
import copy
import logging
from collections import defaultdict
from functools import wraps
from pprint import pformat
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..library import ILibrary, TreeView
from ..error import BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
from ..utils import SupportsBool
from .tools import Tool, RenderStep
from .pather_mixin import PatherMixin
logger = logging.getLogger(__name__)
class RenderPather(PatherMixin):
"""
`RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath`
functions to plan out wire paths without incrementally generating the layout. Instead,
it waits until `render` is called, at which point it draws all the planned segments
simultaneously. This allows it to e.g. draw each wire using a single `Path` or
`Polygon` shape instead of multiple rectangles.
`RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific
dimensions and build the final geometry for each wire. `Tool.planL` provides the
output port data (relative to the input) for each segment. The tool, input and output
ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for
each port. When `render` is called, it bundles `RenderStep`s into batches which use
the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build
the geometry.
See `Pather` for routing examples. After routing is complete, `render` must be called
to generate the final geometry.
"""
__slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', )
pattern: Pattern
""" Layout of this device """
library: ILibrary
""" Library from which patterns should be referenced """
_dead: bool
""" If True, plug()/place() are skipped (for debugging) """
paths: defaultdict[str, list[RenderStep]]
""" Per-port list of operations, to be used by `render` """
tools: dict[str | None, Tool]
"""
Tool objects are used to dynamically generate new single-use Devices
(e.g wires or waveguides) to be plugged into this device.
"""
@property
def ports(self) -> dict[str, Port]:
return self.pattern.ports
@ports.setter
def ports(self, value: dict[str, Port]) -> None:
self.pattern.ports = value
def __init__(
self,
library: ILibrary,
*,
pattern: Pattern | None = None,
ports: str | Mapping[str, Port] | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
name: str | None = None,
) -> None:
"""
Args:
library: The library from which referenced patterns will be taken,
and where new patterns (e.g. generated by the `tools`) will be placed.
pattern: The pattern which will be modified by subsequent operations.
If `None` (default), a new pattern is created.
ports: Allows specifying the initial set of ports, if `pattern` does
not already have any ports (or is not provided). May be a string,
in which case it is interpreted as a name in `library`.
Default `None` (no ports).
tools: A mapping of {port: tool} which specifies what `Tool` should be used
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
are called. Relies on `Tool.planL` and `Tool.render` implementations.
name: If specified, `library[name]` is set to `self.pattern`.
"""
self._dead = False
self.paths = defaultdict(list)
self.library = library
if pattern is not None:
self.pattern = pattern
else:
self.pattern = Pattern()
if ports is not None:
if self.pattern.ports:
raise BuildError('Ports supplied for pattern with pre-existing ports!')
if isinstance(ports, str):
ports = library.abstract(ports).ports
self.pattern.ports.update(copy.deepcopy(dict(ports)))
if name is not None:
library[name] = self.pattern
if tools is None:
self.tools = {}
elif isinstance(tools, Tool):
self.tools = {None: tools}
else:
self.tools = dict(tools)
@classmethod
def interface(
cls: type['RenderPather'],
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
in_prefix: str = 'in_',
out_prefix: str = '',
port_map: dict[str, str] | Sequence[str] | None = None,
name: str | None = None,
) -> 'RenderPather':
"""
Wrapper for `Pattern.interface()`, which returns a RenderPather instead.
Args:
source: A collection of ports (e.g. Pattern, Builder, or dict)
from which to create the interface. May be a pattern name if
`library` is provided.
library: Library from which existing patterns should be referenced,
and to which the new one should be added (if named). If not provided,
`source.library` must exist and will be used.
tools: `Tool`s which will be used by the pather for generating new wires
or waveguides (via `path`/`path_to`/`mpath`).
in_prefix: Prepended to port names for newly-created ports with
reversed directions compared to the current device.
out_prefix: Prepended to port names for ports which are directly
copied from the current device.
port_map: Specification for ports to copy into the new device:
- If `None`, all ports are copied.
- If a sequence, only the listed ports are copied
- If a mapping, the listed ports (keys) are copied and
renamed (to the values).
Returns:
The new `RenderPather`, with an empty pattern and 2x as many ports as
listed in port_map.
Raises:
`PortError` if `port_map` contains port names not present in the
current device.
`PortError` if applying the prefixes results in duplicate port
names.
"""
if library is None:
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
library = source.library
else:
raise BuildError('No library provided (and not present in `source.library`')
if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
tools = source.tools
if isinstance(source, str):
source = library.abstract(source).ports
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
new = RenderPather(library=library, pattern=pat, name=name, tools=tools)
return new
def __repr__(self) -> str:
s = f'<RenderPather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
return s
def plug(
self,
other: Abstract | str | Pattern | TreeView,
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: bool = False,
thru: bool | str = True,
set_rotation: bool | None = None,
append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
) -> Self:
"""
Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P'
for any affected ports. This separates any future `RenderStep`s on the
same port into a new batch, since the plugged device interferes with drawing.
Args:
other: An `Abstract`, string, or `Pattern` describing the device to be instatiated.
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
port connections between the two devices.
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
new names for ports in `other`.
mirrored: Enables mirroring `other` across the x axis prior to
connecting any ports.
thru: If map_in specifies only a single port, `thru` provides a mechainsm
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
- If True (default), and `other` has only two ports total, and map_out
doesn't specify a name for the other port, its name is set to the key
in `map_in`, i.e. 'myport'.
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
An error is raised if that entry already exists.
This makes it easy to extend a pattern with simple 2-port devices
(e.g. wires) without providing `map_out` each time `plug` is
called. See "Examples" above for more info. Default `True`.
set_rotation: If the necessary rotation cannot be determined from
the ports being connected (i.e. all pairs have at least one
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `other` should be rotated. Otherwise,
`set_rotation` must remain `None`.
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
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:
self
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other_names`.
`PortError` if there are any duplicate names after `map_in` and `map_out`
are applied.
`PortError` if the specified port mapping is not achieveable (the ports
do not line up)
"""
if self._dead:
logger.error('Skipping plug() since device is dead')
return self
other_tgt: Pattern | Abstract
if isinstance(other, str):
other_tgt = self.library.abstract(other)
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
# get rid of plugged ports
for kk in map_in:
if kk in self.paths:
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
plugged = map_in.values()
for name, port in other_tgt.ports.items():
if name in plugged:
continue
new_name = map_out.get(name, name) if map_out is not None else name
if new_name is not None and new_name in self.paths:
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
self.pattern.plug(
other = other_tgt,
map_in = map_in,
map_out = map_out,
mirrored = mirrored,
thru = thru,
set_rotation = set_rotation,
append = append,
ok_connections = ok_connections,
)
return self
def place(
self,
other: Abstract | str,
*,
offset: ArrayLike = (0, 0),
rotation: float = 0,
pivot: ArrayLike = (0, 0),
mirrored: bool = False,
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
) -> Self:
"""
Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P'
for any affected ports. This separates any future `RenderStep`s on the
same port into a new batch, since the placed device interferes with drawing.
Note that mirroring is applied before rotation; translation (`offset`) is applied last.
Args:
other: An `Abstract` or `Pattern` describing the device to be instatiated.
offset: Offset at which to place the instance. Default (0, 0).
rotation: Rotation applied to the instance before placement. Default 0.
pivot: Rotation is applied around this pivot point (default (0, 0)).
Rotation is applied prior to translation (`offset`).
mirrored: Whether theinstance should be mirrored across the x axis.
Mirroring is applied before translation and rotation.
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
new names for ports in the instantiated pattern. New names can be
`None`, which will delete those ports.
skip_port_check: Can be used to skip the internal call to `check_ports`,
in case it has already been performed elsewhere.
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
Returns:
self
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other.ports`.
`PortError` if there are any duplicate names after `map_in` and `map_out`
are applied.
"""
if self._dead:
logger.error('Skipping place() since device is dead')
return self
other_tgt: Pattern | Abstract
if isinstance(other, str):
other_tgt = self.library.abstract(other)
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
for name, port in other_tgt.ports.items():
new_name = port_map.get(name, name) if port_map is not None else name
if new_name is not None and new_name in self.paths:
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
self.pattern.place(
other = other_tgt,
offset = offset,
rotation = rotation,
pivot = pivot,
mirrored = mirrored,
port_map = port_map,
skip_port_check = skip_port_check,
append = append,
)
return self
def plugged(
self,
connections: dict[str, str],
) -> Self:
for aa, bb in connections.items():
porta = self.ports[aa]
portb = self.ports[bb]
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
PortList.plugged(self, connections)
return self
def path(
self,
portspec: str,
ccw: SupportsBool | None,
length: float,
*,
plug_into: str | None = None,
**kwargs,
) -> Self:
"""
Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim
of traveling exactly `length` distance.
The wire will travel `length` distance along the port's axis, an an unspecified
(tool-dependent) distance in the perpendicular direction. The output port will
be rotated (or not) based on the `ccw` parameter.
`RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec: The name of the port into which the wire will be plugged.
ccw: If `None`, the output should be along the same axis as the input.
Otherwise, cast to bool and turn counterclockwise if True
and clockwise otherwise.
length: The total distance from input to output, along the input's axis only.
(There may be a tool-dependent offset along the other axis.)
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns:
self
Raises:
BuildError if `distance` is too small to fit the bend (if a bend is present).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping path() since device is dead')
return self
port = self.pattern[portspec]
in_ptype = port.ptype
port_rot = port.rotation
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
tool = self.tools.get(portspec, self.tools[None])
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
# Update port
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy()
if plug_into is not None:
self.plugged({portspec: plug_into})
return self
def pathS(
self,
portspec: str,
length: float,
jog: float,
*,
plug_into: str | None = None,
**kwargs,
) -> Self:
"""
Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
left of direction of travel).
The output port will have the same orientation as the source port (`portspec`).
`RenderPather.render` must be called after all paths have been fully planned.
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
raises a NotImplementedError.
Args:
portspec: The name of the port into which the wire will be plugged.
jog: Total manhattan distance perpendicular to the direction of travel.
Positive values are to the left of the direction of travel.
length: The total manhattan distance from input to output, along the input's axis only.
(There may be a tool-dependent offset along the other axis.)
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns:
self
Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping pathS() since device is dead')
return self
port = self.pattern[portspec]
in_ptype = port.ptype
port_rot = port.rotation
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
tool = self.tools.get(portspec, self.tools[None])
# check feasibility, get output port and data
try:
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
except NotImplementedError:
# Fall back to drawing two L-bends
ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None})
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
kwargs_plug = kwargs | {'plug_into': plug_into}
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy()
if plug_into is not None:
self.plugged({portspec: plug_into})
return self
def render(
self,
append: bool = True,
) -> Self:
"""
Generate the geometry which has been planned out with `path`/`path_to`/etc.
Args:
append: If `True`, the rendered geometry will be directly appended to
`self.pattern`. Note that it will not be flattened, so if only one
layer of hierarchy is eliminated.
Returns:
self
"""
lib = self.library
tool_port_names = ('A', 'B')
pat = Pattern()
def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
assert batch[0].tool is not None
name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
pat.ports[portspec] = batch[0].start_port.copy()
if append:
pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append)
del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened
else:
pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append)
for portspec, steps in self.paths.items():
batch: list[RenderStep] = []
for step in steps:
appendable_op = step.opcode in ('L', 'S', 'U')
same_tool = batch and step.tool == batch[0].tool
# If we can't continue a batch, render it
if batch and (not appendable_op or not same_tool):
render_batch(portspec, batch, append)
batch = []
# batch is emptied already if we couldn't continue it
if appendable_op:
batch.append(step)
# Opcodes which break the batch go below this line
if not appendable_op and portspec in pat.ports:
del pat.ports[portspec]
#If the last batch didn't end yet
if batch:
render_batch(portspec, batch, append)
self.paths.clear()
pat.ports.clear()
self.pattern.append(pat)
return self
def translate(self, offset: ArrayLike) -> Self:
"""
Translate the pattern and all ports.
Args:
offset: (x, y) distance to translate by
Returns:
self
"""
self.pattern.translate_elements(offset)
return self
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
"""
Rotate the pattern and all ports.
Args:
angle: angle (radians, counterclockwise) to rotate by
pivot: location to rotate around
Returns:
self
"""
self.pattern.rotate_around(pivot, angle)
return self
def mirror(self, axis: int) -> Self:
"""
Mirror the pattern and all ports across the specified axis.
Args:
axis: Axis to mirror across (x=0, y=1)
Returns:
self
"""
self.pattern.mirror(axis)
return self
def set_dead(self) -> Self:
"""
Disallows further changes through `plug()` or `place()`.
This is meant for debugging:
```
dev.plug(a, ...)
dev.set_dead() # added for debug purposes
dev.plug(b, ...) # usually raises an error, but now skipped
dev.plug(c, ...) # also skipped
dev.pattern.visualize() # shows the device as of the set_dead() call
```
Returns:
self
"""
self._dead = True
return self
@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

View file

@ -3,8 +3,8 @@ Tools are objects which dynamically generate simple single-use devices (e.g. wir
# TODO document all tools
"""
from typing import Literal, Any, Self
from collections.abc import Sequence, Callable
from typing import Literal, Any, Self, cast
from collections.abc import Sequence, Callable, Iterator
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
from dataclasses import dataclass
@ -47,16 +47,72 @@ class RenderStep:
if self.opcode != 'P' and self.tool is None:
raise BuildError('Got tool=None but the opcode is not "P"')
def is_continuous_with(self, other: 'RenderStep') -> bool:
"""
Check if another RenderStep can be appended to this one.
"""
# Check continuity with tolerance
offsets_match = bool(numpy.allclose(other.start_port.offset, self.end_port.offset))
rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or (
other.start_port.rotation is not None and self.end_port.rotation is not None and
bool(numpy.isclose(other.start_port.rotation, self.end_port.rotation))
)
return offsets_match and rotations_match
def transformed(self, translation: NDArray[numpy.float64], rotation: float, pivot: NDArray[numpy.float64]) -> 'RenderStep':
"""
Return a new RenderStep with transformed start and end ports.
"""
new_start = self.start_port.copy()
new_end = self.end_port.copy()
for pp in (new_start, new_end):
pp.rotate_around(pivot, rotation)
pp.translate(translation)
return RenderStep(
opcode = self.opcode,
tool = self.tool,
start_port = new_start,
end_port = new_end,
data = self.data,
)
def mirrored(self, axis: int) -> 'RenderStep':
"""
Return a new RenderStep with mirrored start and end ports.
"""
new_start = self.start_port.copy()
new_end = self.end_port.copy()
new_start.mirror(axis)
new_end.mirror(axis)
return RenderStep(
opcode = self.opcode,
tool = self.tool,
start_port = new_start,
end_port = new_end,
data = self.data,
)
def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]:
"""
Extracts a Port and returns the tree (as data) for tool planning fallbacks.
"""
pat = tree.top_pattern()
in_p = pat[port_names[0]]
out_p = pat[port_names[1]]
(travel, jog), rot = in_p.measure_travel(out_p)
return Port((travel, jog), rotation=rot, ptype=out_p.ptype), tree
class Tool:
"""
Interface for path (e.g. wire or waveguide) generation.
Note that subclasses may implement only a subset of the methods and leave others
unimplemented (e.g. in cases where they don't make sense or the required components
are impractical or unavailable).
"""
def path(
def traceL(
self,
ccw: SupportsBool | None,
length: float,
@ -99,9 +155,9 @@ class Tool:
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'path() not implemented for {type(self)}')
raise NotImplementedError(f'traceL() not implemented for {type(self)}')
def pathS(
def traceS(
self,
length: float,
jog: float,
@ -141,7 +197,7 @@ class Tool:
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'path() not implemented for {type(self)}')
raise NotImplementedError(f'traceS() not implemented for {type(self)}')
def planL(
self,
@ -183,7 +239,17 @@ class Tool:
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'planL() not implemented for {type(self)}')
# Fallback implementation using traceL
port_names = kwargs.get('port_names', ('A', 'B'))
tree = self.traceL(
ccw,
length,
in_ptype=in_ptype,
out_ptype=out_ptype,
port_names=port_names,
**kwargs,
)
return measure_tool_plan(tree, port_names)
def planS(
self,
@ -221,7 +287,57 @@ class Tool:
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'planS() not implemented for {type(self)}')
# Fallback implementation using traceS
port_names = kwargs.get('port_names', ('A', 'B'))
tree = self.traceS(
length,
jog,
in_ptype=in_ptype,
out_ptype=out_ptype,
port_names=port_names,
**kwargs,
)
return measure_tool_plan(tree, port_names)
def traceU(
self,
jog: float,
*,
length: float = 0,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
"""
Create a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
Used by `Pather` and `RenderPather`.
The output port must have an orientation identical to the input port.
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively. They should also be named `port_names[0]` and
`port_names[1]`, respectively.
Args:
jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a leftwards shift (i.e. counterclockwise bend
followed by a clockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
port_names: The output pattern will have its input port named `port_names[0]` and
its output named `port_names[1]`.
kwargs: Custom tool-specific parameters.
Returns:
A pattern tree containing the requested U-shaped wire or waveguide
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'traceU() not implemented for {type(self)}')
def planU(
self,
@ -246,7 +362,7 @@ class Tool:
Args:
jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a leftwards shift (i.e. counterclockwise bend
A positive number implies a leftwards shift (i.e. counterclockwise_bend
followed by a clockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
@ -259,14 +375,26 @@ class Tool:
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'planU() not implemented for {type(self)}')
# Fallback implementation using traceU
kwargs = dict(kwargs)
length = kwargs.pop('length', 0)
port_names = kwargs.pop('port_names', ('A', 'B'))
tree = self.traceU(
jog,
length=length,
in_ptype=in_ptype,
out_ptype=out_ptype,
port_names=port_names,
**kwargs,
)
return measure_tool_plan(tree, port_names)
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused)
**kwargs, # noqa: ARG002 (unused)
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> ILibrary:
"""
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
@ -280,7 +408,48 @@ class Tool:
kwargs: Custom tool-specific parameters.
"""
assert not batch or batch[0].tool == self
raise NotImplementedError(f'render() not implemented for {type(self)}')
# Fallback: render each step individually
lib, pat = Library.mktree(SINGLE_USE_PREFIX + 'batch')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
for step in batch:
if step.opcode == 'L':
if isinstance(step.data, ILibrary):
seg_tree = step.data
else:
# extract parameters from kwargs or data
seg_tree = self.traceL(
ccw=step.data.get('ccw') if isinstance(step.data, dict) else None,
length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0,
port_names=port_names,
**kwargs,
)
elif step.opcode == 'S':
if isinstance(step.data, ILibrary):
seg_tree = step.data
else:
seg_tree = self.traceS(
length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0,
jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0,
port_names=port_names,
**kwargs,
)
elif step.opcode == 'U':
if isinstance(step.data, ILibrary):
seg_tree = step.data
else:
seg_tree = self.traceU(
jog=float(step.data.get('jog', 0)) if isinstance(step.data, dict) else 0.0,
length=float(step.data.get('length', 0)) if isinstance(step.data, dict) else 0.0,
port_names=port_names,
**kwargs,
)
else:
continue
pat.plug(seg_tree.top_pattern(), {port_names[1]: port_names[0]}, append=True)
return lib
abstract_tuple_t = tuple[Abstract, str, str]
@ -390,7 +559,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored)
return tree
def path(
def traceL(
self,
ccw: SupportsBool | None,
length: float,
@ -407,7 +576,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
return tree
@ -420,7 +589,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
**kwargs,
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
pat.add_port_pair(names=(port_names[0], port_names[1]))
for step in batch:
@ -497,6 +666,19 @@ class AutoTool(Tool, metaclass=ABCMeta):
def reversed(self) -> Self:
return type(self)(self.abstract, self.our_port_name, self.their_port_name)
@dataclass(frozen=True, slots=True)
class LPlan:
""" Template for an L-path configuration """
straight: 'AutoTool.Straight'
bend: 'AutoTool.Bend | None'
in_trans: 'AutoTool.Transition | None'
b_trans: 'AutoTool.Transition | None'
out_trans: 'AutoTool.Transition | None'
overhead_x: float
overhead_y: float
bend_angle: float
out_ptype: str
@dataclass(frozen=True, slots=True)
class LData:
""" Data for planL """
@ -509,6 +691,65 @@ class AutoTool(Tool, metaclass=ABCMeta):
b_transition: 'AutoTool.Transition | None'
out_transition: 'AutoTool.Transition | None'
def _iter_l_plans(
self,
ccw: SupportsBool | None,
in_ptype: str | None,
out_ptype: str | None,
) -> Iterator[LPlan]:
"""
Iterate over all possible combinations of straights and bends that
could form an L-path.
"""
bends = cast('list[AutoTool.Bend | None]', self.bends)
if ccw is None and not bends:
bends = [None]
for straight in self.straights:
for bend in bends:
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
in_transition = self.transitions.get(in_ptype_pair, None)
itrans_dxy = self._itransition2dxy(in_transition)
out_ptype_pair = (
'unk' if out_ptype is None else out_ptype,
straight.ptype if ccw is None else cast('AutoTool.Bend', bend).out_port.ptype
)
out_transition = self.transitions.get(out_ptype_pair, None)
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
b_transition = None
if ccw is not None:
assert bend is not None
if bend.in_port.ptype != straight.ptype:
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
btrans_dxy = self._itransition2dxy(b_transition)
overhead_x = bend_dxy[0] + itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]
overhead_y = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1]
if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype
elif ccw is not None:
assert bend is not None
out_ptype_actual = bend.out_port.ptype
else:
out_ptype_actual = straight.ptype
yield self.LPlan(
straight = straight,
bend = bend,
in_trans = in_transition,
b_trans = b_transition,
out_trans = out_transition,
overhead_x = overhead_x,
overhead_y = overhead_y,
bend_angle = bend_angle,
out_ptype = out_ptype_actual,
)
@dataclass(frozen=True, slots=True)
class SData:
""" Data for planS """
@ -521,6 +762,80 @@ class AutoTool(Tool, metaclass=ABCMeta):
b_transition: 'AutoTool.Transition | None'
out_transition: 'AutoTool.Transition | None'
@dataclass(frozen=True, slots=True)
class UData:
""" Data for planU or planS (double-L) """
ldata0: 'AutoTool.LData'
ldata1: 'AutoTool.LData'
straight2: 'AutoTool.Straight'
l2_length: float
mid_transition: 'AutoTool.Transition | None'
def _solve_double_l(
self,
length: float,
jog: float,
ccw1: SupportsBool,
ccw2: SupportsBool,
in_ptype: str | None,
out_ptype: str | None,
**kwargs,
) -> tuple[Port, UData]:
"""
Solve for a path consisting of two L-bends connected by a straight segment.
Used for both U-turns (ccw1 == ccw2) and S-bends (ccw1 != ccw2).
"""
for plan1 in self._iter_l_plans(ccw1, in_ptype, None):
for plan2 in self._iter_l_plans(ccw2, plan1.out_ptype, out_ptype):
# Solving for:
# X = L1_total +/- R2_actual = length
# Y = R1_actual + L2_straight + overhead_mid + overhead_b2 + L3_total = jog
# Sign for overhead_y2 depends on whether it's a U-turn or S-bend
is_u = bool(ccw1) == bool(ccw2)
# U-turn: X = L1_total - R2 = length => L1_total = length + R2
# S-bend: X = L1_total + R2 = length => L1_total = length - R2
l1_total = length + (abs(plan2.overhead_y) if is_u else -abs(plan2.overhead_y))
l1_straight = l1_total - plan1.overhead_x
if plan1.straight.length_range[0] <= l1_straight < plan1.straight.length_range[1]:
for straight_mid in self.straights:
# overhead_mid accounts for the transition from bend1 to straight_mid
mid_ptype_pair = (plan1.out_ptype, straight_mid.ptype)
mid_trans = self.transitions.get(mid_ptype_pair, None)
mid_trans_dxy = self._itransition2dxy(mid_trans)
# b_trans2 accounts for the transition from straight_mid to bend2
b2_trans = None
if plan2.bend is not None and plan2.bend.in_port.ptype != straight_mid.ptype:
b2_trans = self.transitions.get((plan2.bend.in_port.ptype, straight_mid.ptype), None)
b2_trans_dxy = self._itransition2dxy(b2_trans)
l2_straight = abs(jog) - abs(plan1.overhead_y) - plan2.overhead_x - mid_trans_dxy[0] - b2_trans_dxy[0]
if straight_mid.length_range[0] <= l2_straight < straight_mid.length_range[1]:
# Found a solution!
# For plan2, we assume l3_straight = 0.
# We need to verify if l3=0 is valid for plan2.straight.
l3_straight = 0
if plan2.straight.length_range[0] <= l3_straight < plan2.straight.length_range[1]:
ldata0 = self.LData(
l1_straight, plan1.straight, kwargs, ccw1, plan1.bend,
plan1.in_trans, plan1.b_trans, plan1.out_trans,
)
ldata1 = self.LData(
l3_straight, plan2.straight, kwargs, ccw2, plan2.bend,
b2_trans, None, plan2.out_trans,
)
data = self.UData(ldata0, ldata1, straight_mid, l2_straight, mid_trans)
# out_port is at (length, jog) rot pi (for S-bend) or 0 (for U-turn) relative to input
out_rot = 0 if is_u else pi
out_port = Port((length, jog), rotation=out_rot, ptype=plan2.out_ptype)
return out_port, data
raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}")
straights: list[Straight]
""" List of straight-generators to choose from, in order of priority """
@ -543,9 +858,10 @@ class AutoTool(Tool, metaclass=ABCMeta):
return self
@staticmethod
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
def _bend2dxy(bend: Bend | None, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
if ccw is None:
return numpy.zeros(2), pi
assert bend is not None
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
assert bend_angle is not None
if bool(ccw):
@ -589,54 +905,23 @@ class AutoTool(Tool, metaclass=ABCMeta):
**kwargs,
) -> tuple[Port, LData]:
success = False
for straight in self.straights:
for bend in self.bends:
bend_dxy, bend_angle = self._bend2dxy(bend, ccw)
in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype)
in_transition = self.transitions.get(in_ptype_pair, None)
itrans_dxy = self._itransition2dxy(in_transition)
out_ptype_pair = (
'unk' if out_ptype is None else out_ptype,
straight.ptype if ccw is None else bend.out_port.ptype
for plan in self._iter_l_plans(ccw, in_ptype, out_ptype):
straight_length = length - plan.overhead_x
if plan.straight.length_range[0] <= straight_length < plan.straight.length_range[1]:
data = self.LData(
straight_length = straight_length,
straight = plan.straight,
straight_kwargs = kwargs,
ccw = ccw,
bend = plan.bend,
in_transition = plan.in_trans,
b_transition = plan.b_trans,
out_transition = plan.out_trans,
)
out_transition = self.transitions.get(out_ptype_pair, None)
otrans_dxy = self._otransition2dxy(out_transition, bend_angle)
out_port = Port((length, plan.overhead_y), rotation=plan.bend_angle, ptype=plan.out_ptype)
return out_port, data
b_transition = None
if ccw is not None and bend.in_port.ptype != straight.ptype:
b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None)
btrans_dxy = self._itransition2dxy(b_transition)
straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0]
bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1]
success = straight.length_range[0] <= straight_length < straight.length_range[1]
if success:
break
if success:
break
else:
# Failed to break
raise BuildError(
f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n'
f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n'
f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}'
)
if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype
elif ccw is not None:
out_ptype_actual = bend.out_port.ptype
elif not numpy.isclose(straight_length, 0):
out_ptype_actual = straight.ptype
else:
out_ptype_actual = self.default_out_ptype
data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition)
out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual)
return out_port, data
raise BuildError(f'Failed to find a valid L-path configuration for {length=:,g}, {ccw=}, {in_ptype=}, {out_ptype=}')
def _renderL(
self,
@ -673,7 +958,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
return tree
def path(
def traceL(
self,
ccw: SupportsBool | None,
length: float,
@ -690,7 +975,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
return tree
@ -746,7 +1031,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1]
if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]:
sbend_dxy = self._sbend2dxy(sbend, jog_remaining)
success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1])
success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[0] + otrans_dxy[0])
if success:
b_transition = None
straight_length = 0
@ -755,26 +1040,8 @@ class AutoTool(Tool, metaclass=ABCMeta):
break
if not success:
try:
ccw0 = jog > 0
p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype)
p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype)
dx = p_test1.x - length / 2
p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype)
p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype)
success = True
except BuildError as err:
l2_err: BuildError | None = err
else:
l2_err = None
raise NotImplementedError('TODO need to handle ldata below')
if not success:
# Failed to break
raise BuildError(
f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}'
) from l2_err
ccw0 = jog > 0
return self._solve_double_l(length, jog, ccw0, not ccw0, in_ptype, out_ptype, **kwargs)
if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype
@ -829,7 +1096,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
return tree
def pathS(
def traceS(
self,
length: float,
jog: float,
@ -845,9 +1112,74 @@ class AutoTool(Tool, metaclass=ABCMeta):
in_ptype = in_ptype,
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
if isinstance(data, self.UData):
self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
else:
self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
return tree
def planU(
self,
jog: float,
*,
length: float = 0,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs,
) -> tuple[Port, UData]:
ccw = jog > 0
return self._solve_double_l(length, jog, ccw, ccw, in_ptype, out_ptype, **kwargs)
def _renderU(
self,
data: UData,
tree: ILibrary,
port_names: tuple[str, str],
gen_kwargs: dict[str, Any],
) -> ILibrary:
pat = tree.top_pattern()
# 1. First L-bend
self._renderL(data.ldata0, tree, port_names, gen_kwargs)
# 2. Connecting straight
if data.mid_transition:
pat.plug(data.mid_transition.abstract, {port_names[1]: data.mid_transition.their_port_name})
if not numpy.isclose(data.l2_length, 0):
s2_pat_or_tree = data.straight2.fn(data.l2_length, **(gen_kwargs | data.ldata0.straight_kwargs))
pmap = {port_names[1]: data.straight2.in_port_name}
if isinstance(s2_pat_or_tree, Pattern):
pat.plug(s2_pat_or_tree, pmap, append=True)
else:
s2_tree = s2_pat_or_tree
top = s2_tree.top()
s2_tree.flatten(top, dangling_ok=True)
pat.plug(s2_tree[top], pmap, append=True)
# 3. Second L-bend
self._renderL(data.ldata1, tree, port_names, gen_kwargs)
return tree
def traceU(
self,
jog: float,
*,
length: float = 0,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
_out_port, data = self.planU(
jog,
length = length,
in_ptype = in_ptype,
out_ptype = out_ptype,
**kwargs,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceU')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderU(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
return tree
def render(
@ -858,7 +1190,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
**kwargs,
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
pat.add_port_pair(names=(port_names[0], port_names[1]))
for step in batch:
@ -866,7 +1198,12 @@ class AutoTool(Tool, metaclass=ABCMeta):
if step.opcode == 'L':
self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs)
elif step.opcode == 'S':
self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
if isinstance(step.data, self.UData):
self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
else:
self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
elif step.opcode == 'U':
self._renderU(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
return tree
@ -897,7 +1234,7 @@ class PathTool(Tool, metaclass=ABCMeta):
# self.width = width
# self.ptype: str
def path(
def traceL(
self,
ccw: SupportsBool | None,
length: float,
@ -907,15 +1244,20 @@ class PathTool(Tool, metaclass=ABCMeta):
port_names: tuple[str, str] = ('A', 'B'),
**kwargs, # noqa: ARG002 (unused)
) -> Library:
out_port, dxy = self.planL(
out_port, _data = self.planL(
ccw,
length,
in_ptype=in_ptype,
out_ptype=out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)])
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
vertices: list[tuple[float, float]]
if ccw is None:
vertices = [(0.0, 0.0), (length, 0.0)]
else:
vertices = [(0.0, 0.0), (length, 0.0), tuple(out_port.offset)]
pat.path(layer=self.layer, width=self.width, vertices=vertices)
if ccw is None:
out_rot = pi
@ -926,7 +1268,7 @@ class PathTool(Tool, metaclass=ABCMeta):
pat.ports = {
port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype),
port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype),
port_names[1]: Port(out_port.offset, rotation=out_rot, ptype=self.ptype),
}
return tree
@ -975,29 +1317,44 @@ class PathTool(Tool, metaclass=ABCMeta):
**kwargs, # noqa: ARG002 (unused)
) -> ILibrary:
path_vertices = [batch[0].start_port.offset]
for step in batch:
# Transform the batch so the first port is local (at 0,0) but retains its global rotation.
# This allows the path to be rendered with its original orientation, simplified by
# translation to the origin. RenderPather.render will handle the final placement
# (including rotation alignment) via `pat.plug`.
first_port = batch[0].start_port
translation = -first_port.offset
rotation = 0
pivot = first_port.offset
# Localize the batch for rendering
local_batch = [step.transformed(translation, rotation, pivot) for step in batch]
path_vertices = [local_batch[0].start_port.offset]
for step in local_batch:
assert step.tool == self
port_rot = step.start_port.rotation
# Masque convention: Port rotation points INTO the device.
# So the direction of travel for the path is AWAY from the port, i.e., port_rot + pi.
assert port_rot is not None
if step.opcode == 'L':
length, bend_run = step.data
length, _ = step.data
dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0)
#path_vertices.append(step.start_port.offset)
path_vertices.append(step.start_port.offset + dxy)
else:
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
if (path_vertices[-1] != batch[-1].end_port.offset).any():
# Check if the last vertex added is already at the end port location
if not numpy.allclose(path_vertices[-1], local_batch[-1].end_port.offset):
# If the path ends in a bend, we need to add the final vertex
path_vertices.append(batch[-1].end_port.offset)
path_vertices.append(local_batch[-1].end_port.offset)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL')
pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
pat.ports = {
port_names[0]: batch[0].start_port.copy().rotate(pi),
port_names[1]: batch[-1].end_port.copy().rotate(pi),
port_names[0]: local_batch[0].start_port.copy().rotate(pi),
port_names[1]: local_batch[-1].end_port.copy().rotate(pi),
}
return tree

View file

@ -106,7 +106,7 @@ def ell(
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
+ pformat(port_rotations))
else:
if set_rotation is not None:
if set_rotation is None:
raise BuildError('set_rotation must be specified if no ports have rotations!')
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)

View file

@ -16,7 +16,7 @@ import gzip
import numpy
import ezdxf
from ezdxf.enums import TextEntityAlignment
from ezdxf.entities import LWPolyline, Polyline, Text, Insert
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label
@ -55,8 +55,7 @@ def write(
tuple: (1, 2) -> '1.2'
str: '1.2' -> '1.2' (no change)
DXF does not support shape repetition (only block repeptition). Please call
library.wrap_repeated_shapes() before writing to file.
Shape repetitions are expanded into individual DXF entities.
Other functions you may want to call:
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
@ -213,32 +212,60 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
if isinstance(element, LWPolyline | Polyline):
if isinstance(element, LWPolyline):
points = numpy.asarray(element.get_points())
elif isinstance(element, Polyline):
is_closed = element.closed
else:
points = numpy.asarray([pp.xyz for pp in element.points()])
is_closed = element.is_closed
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
if points.shape[1] == 2:
raise PatternError('Invalid or unimplemented polygon?')
width = 0
if isinstance(element, LWPolyline):
# ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge)
if points.shape[1] >= 5:
if (points[:, 4] != 0).any():
raise PatternError('LWPolyline has bulge (not yet representable in masque!)')
if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any():
raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)')
width = points[0, 2]
elif points.shape[1] == 3:
# width used to be in column 2
width = points[0, 2]
if points.shape[1] > 2:
if (points[0, 2] != points[:, 2]).any():
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
if points.shape[1] == 4 and (points[:, 3] != 0).any():
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
if width == 0:
width = attr.get('const_width', 0)
width = points[0, 2]
if width == 0:
width = attr.get('const_width', 0)
verts = points[:, :2]
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
verts = numpy.vstack((verts, verts[0]))
shape: Path | Polygon
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
shape = Polygon(vertices=points[:-1, :2])
shape: Path | Polygon
if width == 0 and is_closed:
# Use Polygon if it has at least 3 unique vertices
shape_verts = verts[:-1] if len(verts) > 1 else verts
if len(shape_verts) >= 3:
shape = Polygon(vertices=shape_verts)
else:
shape = Path(width=width, vertices=points[:, :2])
shape = Path(width=width, vertices=verts)
else:
shape = Path(width=width, vertices=verts)
pat.shapes[layer].append(shape)
elif isinstance(element, Solid | Trace):
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4)
if element.has_dxf_attrib(f'vtx{i}')])
if len(points) >= 3:
# If vtx2 == vtx3, it's a triangle. ezdxf handles this.
if len(points) == 4 and numpy.allclose(points[2], points[3]):
verts = points[:3, :2]
# DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals!
elif len(points) == 4:
verts = points[[0, 1, 3, 2], :2]
else:
verts = points[:, :2]
pat.shapes[layer].append(Polygon(vertices=verts))
elif isinstance(element, Text):
args = dict(
offset=numpy.asarray(element.get_placement()[1])[:2],
@ -303,15 +330,23 @@ def _mrefs_to_drefs(
elif isinstance(rep, Grid):
a = rep.a_vector
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
if rotated_a[1] == 0 and rotated_b[0] == 0:
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
# which ARE then rotated by the block reference's rotation.
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
# AND the grid is already manhattan.
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
rotated_a = rotation_matrix_2d(ref.rotation) @ a
rotated_b = rotation_matrix_2d(ref.rotation) @ b
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
attribs['column_count'] = rep.a_count
attribs['row_count'] = rep.b_count
attribs['column_spacing'] = rotated_a[0]
attribs['row_spacing'] = rotated_b[1]
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
elif rotated_a[0] == 0 and rotated_b[1] == 0:
elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8):
attribs['column_count'] = rep.b_count
attribs['row_count'] = rep.a_count
attribs['column_spacing'] = rotated_b[0]
@ -344,16 +379,23 @@ def _shapes_to_elements(
for layer, sseq in shapes.items():
attribs = dict(layer=_mlayer2dxf(layer))
for shape in sseq:
displacements = [numpy.zeros(2)]
if shape.repetition is not None:
raise PatternError(
'Shape repetitions are not supported by DXF.'
' Please call library.wrap_repeated_shapes() before writing to file.'
)
displacements = shape.repetition.displacements
for polygon in shape.to_polygons():
xy_open = polygon.vertices
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
for dd in displacements:
if isinstance(shape, Path):
# preserve path.
# Note: DXF paths don't support endcaps well, so this is still a bit limited.
xy = shape.vertices + dd
attribs_path = {**attribs}
if shape.width > 0:
attribs_path['const_width'] = shape.width
block.add_lwpolyline(xy, dxfattribs=attribs_path)
else:
for polygon in shape.to_polygons():
xy_open = polygon.vertices + dd
block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs)
def _labels_to_texts(
@ -363,11 +405,17 @@ def _labels_to_texts(
for layer, lseq in labels.items():
attribs = dict(layer=_mlayer2dxf(layer))
for label in lseq:
xy = label.offset
block.add_text(
label.string,
dxfattribs=attribs
).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
if label.repetition is None:
block.add_text(
label.string,
dxfattribs=attribs
).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT)
else:
for dd in label.repetition.displacements:
block.add_text(
label.string,
dxfattribs=attribs
).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT)
def _mlayer2dxf(layer: layer_t) -> str:

View file

@ -82,7 +82,7 @@ def write(
datatype is chosen to be `shape.layer[1]` if available,
otherwise `0`
GDS does not support shape repetition (only cell repeptition). Please call
GDS does not support shape repetition (only cell repetition). Please call
`library.wrap_repeated_shapes()` before writing to file.
Other functions you may want to call:
@ -453,7 +453,7 @@ def _shapes_to_elements(
extension: tuple[int, int]
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
extension = tuple(shape.cap_extensions) # type: ignore
extension = tuple(rint_cast(shape.cap_extensions))
else:
extension = (0, 0)
@ -617,7 +617,12 @@ def load_libraryfile(
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
else:
stream = path.open(mode='rb') # noqa: SIM115
return load_library(stream, full_load=full_load, postprocess=postprocess)
try:
return load_library(stream, full_load=full_load, postprocess=postprocess)
finally:
if full_load:
stream.close()
def check_valid_names(
@ -648,7 +653,7 @@ def check_valid_names(
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
if bad_lengths:
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths))
if bad_chars or bad_lengths:
raise LibraryError('Library contains invalid names, see log above')

View file

@ -120,10 +120,10 @@ def build(
layer, data_type = _mlayer2oas(layer_num)
lib.layers += [
fatrec.LayerName(
nstring=name,
layer_interval=(layer, layer),
type_interval=(data_type, data_type),
is_textlayer=tt,
nstring = name,
layer_interval = (layer, layer),
type_interval = (data_type, data_type),
is_textlayer = tt,
)
for tt in (True, False)]
@ -182,8 +182,8 @@ def writefile(
Args:
library: A {name: Pattern} mapping of patterns to write.
filename: Filename to save to.
*args: passed to `oasis.write`
**kwargs: passed to `oasis.write`
*args: passed to `oasis.build()`
**kwargs: passed to `oasis.build()`
"""
path = pathlib.Path(filename)
@ -213,9 +213,9 @@ def readfile(
Will automatically decompress gzipped files.
Args:
filename: Filename to save to.
*args: passed to `oasis.read`
**kwargs: passed to `oasis.read`
filename: Filename to load from.
*args: passed to `oasis.read()`
**kwargs: passed to `oasis.read()`
"""
path = pathlib.Path(filename)
if is_gzipped(path):
@ -286,11 +286,11 @@ def read(
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.polygon(
vertices=vertices,
layer=element.get_layer_tuple(),
offset=element.get_xy(),
annotations=annotations,
repetition=repetition,
vertices = vertices,
layer = element.get_layer_tuple(),
offset = element.get_xy(),
annotations = annotations,
repetition = repetition,
)
elif isinstance(element, fatrec.Path):
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
@ -310,13 +310,13 @@ def read(
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.path(
vertices=vertices,
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
annotations=annotations,
width=element.get_half_width() * 2,
cap=cap,
vertices = vertices,
layer = element.get_layer_tuple(),
offset = element.get_xy(),
repetition = repetition,
annotations = annotations,
width = element.get_half_width() * 2,
cap = cap,
**path_args,
)
@ -325,11 +325,11 @@ def read(
height = element.get_height()
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.polygon(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
annotations=annotations,
layer = element.get_layer_tuple(),
offset = element.get_xy(),
repetition = repetition,
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
annotations = annotations,
)
elif isinstance(element, fatrec.Trapezoid):
@ -440,11 +440,11 @@ def read(
else:
string = str_or_ref.string
pat.label(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
annotations=annotations,
string=string,
layer = element.get_layer_tuple(),
offset = element.get_xy(),
repetition = repetition,
annotations = annotations,
string = string,
)
else:
@ -549,13 +549,13 @@ def _shapes_to_elements(
offset = rint_cast(shape.offset + rep_offset)
radius = rint_cast(shape.radius)
circle = fatrec.Circle(
layer=layer,
datatype=datatype,
radius=cast('int', radius),
x=offset[0],
y=offset[1],
properties=properties,
repetition=repetition,
layer = layer,
datatype = datatype,
radius = cast('int', radius),
x = offset[0],
y = offset[1],
properties = properties,
repetition = repetition,
)
elements.append(circle)
elif isinstance(shape, Path):
@ -566,16 +566,16 @@ def _shapes_to_elements(
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
path = fatrec.Path(
layer=layer,
datatype=datatype,
point_list=cast('Sequence[Sequence[int]]', deltas),
half_width=cast('int', half_width),
x=xy[0],
y=xy[1],
extension_start=extension_start, # TODO implement multiple cap types?
extension_end=extension_end,
properties=properties,
repetition=repetition,
layer = layer,
datatype = datatype,
point_list = cast('Sequence[Sequence[int]]', deltas),
half_width = cast('int', half_width),
x = xy[0],
y = xy[1],
extension_start = extension_start, # TODO implement multiple cap types?
extension_end = extension_end,
properties = properties,
repetition = repetition,
)
elements.append(path)
else:
@ -583,13 +583,13 @@ def _shapes_to_elements(
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
elements.append(fatrec.Polygon(
layer=layer,
datatype=datatype,
x=xy[0],
y=xy[1],
point_list=cast('list[list[int]]', points),
properties=properties,
repetition=repetition,
layer = layer,
datatype = datatype,
x = xy[0],
y = xy[1],
point_list = cast('list[list[int]]', points),
properties = properties,
repetition = repetition,
))
return elements
@ -606,13 +606,13 @@ def _labels_to_texts(
xy = rint_cast(label.offset + rep_offset)
properties = annotations_to_properties(label.annotations)
texts.append(fatrec.Text(
layer=layer,
datatype=datatype,
x=xy[0],
y=xy[1],
string=label.string,
properties=properties,
repetition=repetition,
layer = layer,
datatype = datatype,
x = xy[0],
y = xy[1],
string = label.string,
properties = properties,
repetition = repetition,
))
return texts
@ -622,10 +622,12 @@ def repetition_fata2masq(
) -> Repetition | None:
mrep: Repetition | None
if isinstance(rep, fatamorgana.GridRepetition):
mrep = Grid(a_vector=rep.a_vector,
b_vector=rep.b_vector,
a_count=rep.a_count,
b_count=rep.b_count)
mrep = Grid(
a_vector = rep.a_vector,
b_vector = rep.b_vector,
a_count = rep.a_count,
b_count = rep.b_count,
)
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
displacements = numpy.cumsum(numpy.column_stack((
rep.x_displacements,
@ -647,14 +649,19 @@ def repetition_masq2fata(
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
if isinstance(rep, Grid):
a_vector = rint_cast(rep.a_vector)
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
a_count = rint_cast(rep.a_count)
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
a_count = int(rep.a_count)
if rep.b_count > 1:
b_vector = rint_cast(rep.b_vector)
b_count = int(rep.b_count)
else:
b_vector = None
b_count = None
frep = fatamorgana.GridRepetition(
a_vector=cast('list[int]', a_vector),
b_vector=cast('list[int] | None', b_vector),
a_count=cast('int', a_count),
b_count=cast('int | None', b_count),
a_vector = a_vector,
b_vector = b_vector,
a_count = a_count,
b_count = b_count,
)
offset = (0, 0)
elif isinstance(rep, Arbitrary):
@ -710,10 +717,6 @@ def properties_to_annotations(
annotations[key] = values
return annotations
properties = [fatrec.Property(key, vals, is_standard=False)
for key, vals in annotations.items()]
return properties
def check_valid_names(
names: Iterable[str],

View file

@ -10,16 +10,32 @@ import svgwrite # type: ignore
from .utils import mangle_name
from .. import Pattern
from ..utils import rotation_matrix_2d
logger = logging.getLogger(__name__)
def _ref_to_svg_transform(ref) -> str:
linear = rotation_matrix_2d(ref.rotation) * ref.scale
if ref.mirrored:
linear = linear @ numpy.diag((1.0, -1.0))
a = linear[0, 0]
b = linear[1, 0]
c = linear[0, 1]
d = linear[1, 1]
e = ref.offset[0]
f = ref.offset[1]
return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})'
def writefile(
library: Mapping[str, Pattern],
top: str,
filename: str,
custom_attributes: bool = False,
annotate_ports: bool = False,
) -> None:
"""
Write a Pattern to an SVG file, by first calling .polygonize() on it
@ -44,6 +60,8 @@ def writefile(
filename: Filename to write to.
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
SVG elements.
annotate_ports: If True, draw an arrow for each port (similar to
`Pattern.visualize(..., ports=True)`).
"""
pattern = library[top]
@ -79,11 +97,32 @@ def writefile(
svg_group.add(path)
if annotate_ports:
# Draw arrows for the ports, pointing into the device (per port definition)
for port_name, port in pat.ports.items():
if port.rotation is not None:
p1 = port.offset
angle = port.rotation
size = 1.0 # arrow size
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
# head
head_angle = 0.5
h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)])
h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)])
line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2)
head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2)
svg_group.add(line)
svg_group.add(head)
svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green'))
for target, refs in pat.refs.items():
if target is None:
continue
for ref in refs:
transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})'
transform = _ref_to_svg_transform(ref)
use = svg.use(href='#' + mangle_name(target), transform=transform)
svg_group.add(use)

View file

@ -75,7 +75,8 @@ def preflight(
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
if prune_empty_patterns:
pruned = lib.prune_empty()
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
pruned = lib.prune_empty(dangling=prune_dangling)
if pruned:
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
logger.debug('Pruned: ' + pformat(pruned))

View file

@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray
from .repetition import Repetition
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, Flippable
from .traits import AnnotatableImpl
@functools.total_ordering
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
"""
A text annotation with a position (but no size; it is not drawn)
"""
@ -58,12 +58,14 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
string=self.string,
offset=self.offset.copy(),
repetition=self.repetition,
annotations=copy.copy(self.annotations),
)
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._offset = self._offset.copy()
new._annotations = copy.deepcopy(self._annotations, memo)
return new
def __lt__(self, other: 'Label') -> bool:
@ -96,10 +98,34 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
"""
pivot = numpy.asarray(pivot, dtype=float)
self.translate(-pivot)
if self.repetition is not None:
self.repetition.rotate(rotation)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
self.translate(+pivot)
return self
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
"""
Extrinsic transformation: Flip the label across a line in the pattern's
coordinate system. This affects both the label's offset and its
repetition grid.
Args:
axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x).
x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across.
Returns:
self
"""
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
self.translate(-pivot)
if self.repetition is not None:
self.repetition.mirror(axis)
self.offset[1 - axis] *= -1
self.translate(+pivot)
return self
def get_bounds_single(self) -> NDArray[numpy.float64]:
"""
Return the bounds of the label.

View file

@ -59,6 +59,9 @@ TreeView: TypeAlias = Mapping[str, 'Pattern']
Tree: TypeAlias = MutableMapping[str, 'Pattern']
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
""" How helpers should handle refs whose targets are not present in the library. """
SINGLE_USE_PREFIX = '_'
"""
@ -186,9 +189,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
# Perform recursive lookups, but only once for each name
for target in targets - skip:
assert target is not None
skip.add(target)
if target in self:
targets |= self.referenced_patterns(target, skip=skip)
skip.add(target)
return targets
@ -291,8 +294,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def flatten_single(name: str) -> None:
flattened[name] = None
pat = self[name].deepcopy()
refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items())
for target in pat.refs:
for target, refs in refs_by_target:
if target is None:
continue
if dangling_ok and target not in self:
@ -303,10 +307,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
target_pat = flattened[target]
if target_pat is None:
raise PatternError(f'Circular reference in {name} to {target}')
if target_pat.is_empty(): # avoid some extra allocations
ports_only = flatten_ports and bool(target_pat.ports)
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
continue
for ref in pat.refs[target]:
for ref in refs:
if flatten_ports and ref.repetition is not None and target_pat.ports:
raise PatternError(
f'Cannot flatten ports from repeated ref to {target!r}; '
'flatten with flatten_ports=False or expand/rename the ports manually first.'
)
p = ref.as_pattern(pattern=target_pat)
if not flatten_ports:
p.ports.clear()
@ -412,6 +422,21 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
"""
return self[self.top()]
@staticmethod
def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError:
dangling_list = sorted(dangling)
return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list))
def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]:
existing = set(self.keys())
graph: dict[str, set[str]] = {}
dangling: set[str] = set()
for name, pat in self.items():
children = {child for child, refs in pat.refs.items() if child is not None and refs}
graph[name] = children
dangling |= children - existing
return graph, dangling
def dfs(
self,
pattern: 'Pattern',
@ -466,9 +491,11 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
memo = {}
if transform is None or transform is True:
transform = numpy.zeros(4)
transform = numpy.array([0, 0, 0, 0, 1], dtype=float)
elif transform is not False:
transform = numpy.asarray(transform, dtype=float)
if transform.size == 4:
transform = numpy.append(transform, 1.0)
original_pattern = pattern
@ -515,46 +542,88 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
return self
def child_graph(self) -> dict[str, set[str | None]]:
def child_graph(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
"""
Return a mapping from pattern name to a set of all child patterns
(patterns it references).
Only non-empty ref lists with non-`None` targets are treated as graph edges.
Args:
dangling: How refs to missing targets are handled. `'error'` raises,
`'ignore'` drops those edges, and `'include'` exposes them as
synthetic leaf nodes.
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()}
graph, dangling_refs = self._raw_child_graph()
if dangling == 'error':
if dangling_refs:
raise self._dangling_refs_error(dangling_refs, 'building child graph')
return graph
if dangling == 'ignore':
existing = set(graph)
return {name: {child for child in children if child in existing} for name, children in graph.items()}
for target in dangling_refs:
graph.setdefault(target, set())
return graph
def parent_graph(self) -> dict[str, set[str]]:
def parent_graph(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
"""
Return a mapping from pattern name to a set of all parent patterns
(patterns which reference it).
Args:
dangling: How refs to missing targets are handled. `'error'` raises,
`'ignore'` drops those targets, and `'include'` adds them as
synthetic keys whose values are their existing parents.
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)
child_graph, dangling_refs = self._raw_child_graph()
if dangling == 'error' and dangling_refs:
raise self._dangling_refs_error(dangling_refs, 'building parent graph')
existing = set(child_graph)
igraph: dict[str, set[str]] = {name: set() for name in existing}
for parent, children in child_graph.items():
for child in children:
if child in existing:
igraph[child].add(parent)
elif dangling == 'include':
igraph.setdefault(child, set()).add(parent)
return igraph
def child_order(self) -> list[str]:
def child_order(
self,
dangling: dangling_mode_t = 'error',
) -> list[str]:
"""
Return a topologically sorted list of all contained pattern names.
Return a topologically sorted list of graph node names.
Child (referenced) patterns will appear before their parents.
Args:
dangling: Passed to `child_graph()`.
Return:
Topologically sorted list of pattern names.
"""
return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order()))
return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order()))
def find_refs_local(
self,
name: str,
parent_graph: dict[str, set[str]] | None = None,
dangling: dangling_mode_t = 'error',
) -> dict[str, list[NDArray[numpy.float64]]]:
"""
Find the location and orientation of all refs pointing to `name`.
@ -567,6 +636,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
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).
dangling: How refs to missing targets are handled if `parent_graph`
is not provided. `'include'` also allows querying missing names.
Returns:
Mapping of {parent_name: transform_list}, where transform_list
@ -575,8 +646,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
"""
instances = defaultdict(list)
if parent_graph is None:
parent_graph = self.parent_graph()
for parent in parent_graph[name]:
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
parent_graph = self.parent_graph(dangling=graph_mode)
if name not in self:
if name not in parent_graph:
return instances
if dangling == 'error':
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
if dangling == 'ignore':
return instances
for parent in parent_graph.get(name, set()):
if parent not in self: # parent_graph may be a for a superset of self
continue
for ref in self[parent].refs[name]:
@ -589,6 +670,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
name: str,
order: list[str] | None = None,
parent_graph: dict[str, set[str]] | None = None,
dangling: dangling_mode_t = 'error',
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
"""
Find the absolute (top-level) location and orientation of all refs (including
@ -605,18 +687,28 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
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).
dangling: How refs to missing targets are handled if `order` or
`parent_graph` are not provided. `'include'` also allows
querying missing names.
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 {}
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
if order is None:
order = self.child_order()
order = self.child_order(dangling=graph_mode)
if parent_graph is None:
parent_graph = self.parent_graph()
parent_graph = self.parent_graph(dangling=graph_mode)
if name not in self:
if name not in parent_graph:
return {}
if dangling == 'error':
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
if dangling == 'ignore':
return {}
self_keys = set(self.keys())
@ -625,16 +717,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
NDArray[numpy.float64]
]]]
transforms = defaultdict(list)
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items():
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).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:
if not parent_graph.get(next_name, set()) & self_keys:
continue
outers = self.find_refs_local(next_name, parent_graph=parent_graph)
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
inners = transforms.pop(next_name)
for parent, outer in outers.items():
for path, inner in inners:
@ -682,6 +774,33 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
pass
def resolve(
self,
other: 'Abstract | str | Pattern | TreeView',
append: bool = False,
) -> 'Abstract | Pattern':
"""
Resolve another device (name, Abstract, Pattern, or TreeView) into an Abstract or Pattern.
If it is a TreeView, it is first added into this library.
Args:
other: The device to resolve.
append: If True and `other` is an `Abstract`, returns the full `Pattern` from the library.
Returns:
An `Abstract` or `Pattern` object.
"""
from .pattern import Pattern #noqa: PLC0415
if not isinstance(other, (str, Abstract, Pattern)):
# We got a TreeView; add it into self and grab its topcell as an Abstract
other = self << other
if isinstance(other, str):
other = self.abstract(other)
if append and isinstance(other, Abstract):
other = self[other.name]
return other
def rename(
self,
old_name: str,
@ -763,7 +882,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Returns:
(name, pattern) tuple
"""
from .pattern import Pattern
from .pattern import Pattern #noqa: PLC0415
pat = Pattern()
self[name] = pat
return name, pat
@ -803,7 +922,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Raises:
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
"""
from .pattern import map_targets
from .pattern import map_targets #noqa: PLC0415
duplicates = set(self.keys()) & set(other.keys())
if not duplicates:
@ -909,7 +1028,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# This currently simplifies globally (same shape in different patterns is
# merged into the same ref target).
from .pattern import Pattern
from .pattern import Pattern #noqa: PLC0415
if exclude_types is None:
exclude_types = ()
@ -1002,7 +1121,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Returns:
self
"""
from .pattern import Pattern
from .pattern import Pattern #noqa: PLC0415
if name_func is None:
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
@ -1036,6 +1155,25 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
return self
def resolve_repeated_refs(self, name: str | None = None) -> Self:
"""
Expand all repeated references into multiple individual references.
Alters the library in-place.
Args:
name: If specified, only resolve repeated refs in this pattern.
Otherwise, resolve in all patterns.
Returns:
self
"""
if name is not None:
self[name].resolve_repeated_refs()
else:
for pat in self.values():
pat.resolve_repeated_refs()
return self
def subtree(
self,
tops: str | Sequence[str],
@ -1065,17 +1203,19 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
def prune_empty(
self,
repeat: bool = True,
dangling: dangling_mode_t = 'error',
) -> set[str]:
"""
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
Args:
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
dangling: Passed to `parent_graph()`.
Returns:
A set containing the names of all deleted patterns
"""
parent_graph = self.parent_graph()
parent_graph = self.parent_graph(dangling=dangling)
empty = {name for name, pat in self.items() if pat.is_empty()}
trimmed = set()
while empty:
@ -1205,7 +1345,7 @@ class Library(ILibrary):
Returns:
The newly created `Library` and the newly created `Pattern`
"""
from .pattern import Pattern
from .pattern import Pattern #noqa: PLC0415
tree = cls()
pat = Pattern()
tree[name] = pat
@ -1221,12 +1361,12 @@ class LazyLibrary(ILibrary):
"""
mapping: dict[str, Callable[[], 'Pattern']]
cache: dict[str, 'Pattern']
_lookups_in_progress: set[str]
_lookups_in_progress: list[str]
def __init__(self) -> None:
self.mapping = {}
self.cache = {}
self._lookups_in_progress = set()
self._lookups_in_progress = []
def __setitem__(
self,
@ -1257,16 +1397,20 @@ class LazyLibrary(ILibrary):
return self.cache[key]
if key in self._lookups_in_progress:
chain = ' -> '.join(self._lookups_in_progress + [key])
raise LibraryError(
f'Detected multiple simultaneous lookups of "{key}".\n'
f'Detected circular reference or recursive lookup of "{key}".\n'
f'Lookup chain: {chain}\n'
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' # TODO give advice on finding cycles
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
)
self._lookups_in_progress.add(key)
func = self.mapping[key]
pat = func()
self._lookups_in_progress.remove(key)
self._lookups_in_progress.append(key)
try:
func = self.mapping[key]
pat = func()
finally:
self._lookups_in_progress.pop()
self.cache[key] = pat
return pat

View file

@ -26,6 +26,7 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab
from .ports import Port, PortList
logger = logging.getLogger(__name__)
@ -171,7 +172,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
return s
def __copy__(self) -> 'Pattern':
logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!')
logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! '
'Consider using .deepcopy() if this was not intended.')
new = Pattern(
annotations=copy.deepcopy(self.annotations),
ports=copy.deepcopy(self.ports),
@ -198,7 +200,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
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]
other_nonempty_targets = [target for target, reflist in other.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))
@ -212,7 +214,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
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]
other_nonempty_layers = [ll for ll, elems in other.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))
@ -221,21 +223,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for _, _, layer in self_layerkeys:
shapes_ours = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(other.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]
other_nonempty_txtlayers = [ll for ll, elems in other.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:
for _, _, layer in self_txtlayerkeys:
labels_ours = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(other.labels[layer]))
if labels_ours != labels_theirs:
return labels_ours < labels_theirs
@ -252,7 +254,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
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]
other_nonempty_targets = [target for target, reflist in other.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))
@ -266,7 +268,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
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]
other_nonempty_layers = [ll for ll, elems in other.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))
@ -275,21 +277,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for _, _, layer in self_layerkeys:
shapes_ours = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(other.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]
other_nonempty_txtlayers = [ll for ll, elems in other.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:
for _, _, layer in self_txtlayerkeys:
labels_ours = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(other.labels[layer]))
if labels_ours != labels_theirs:
return False
@ -499,6 +501,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
]
return polys
def layer_as_polygons(
self,
layer: layer_t,
flatten: bool = True,
library: Mapping[str, 'Pattern'] | None = None,
) -> list[Polygon]:
"""
Collect all geometry effectively on a given layer as a list of polygons.
If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`.
`Repetition` objects are expanded, and non-polygon shapes are converted
to `Polygon` approximations.
Args:
layer: The layer to collect geometry from.
flatten: If `True`, include geometry from referenced patterns.
library: Required if `flatten=True` to resolve references.
Returns:
A list of `Polygon` objects.
"""
if flatten and self.has_refs() and library is None:
raise PatternError("Must provide a library to layer_as_polygons() when flatten=True")
polys: list[Polygon] = []
# Local shapes
for shape in self.shapes.get(layer, []):
for p in shape.to_polygons():
# expand repetitions
if p.repetition is not None:
for offset in p.repetition.displacements:
polys.append(p.deepcopy().translate(offset).set_repetition(None))
else:
polys.append(p.deepcopy())
if flatten and self.has_refs():
assert library is not None
for target, refs in self.refs.items():
if target is None:
continue
target_pat = library[target]
for ref in refs:
# Get polygons from target pattern on the same layer
ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library)
# Apply ref transformations
for p in ref_polys:
p_pat = ref.as_pattern(Pattern(shapes={layer: [p]}))
# as_pattern expands repetition of the ref itself
# but we need to pull the polygons back out
for p_transformed in p_pat.shapes[layer]:
polys.append(cast('Polygon', p_transformed))
return polys
def referenced_patterns(self) -> set[str | None]:
"""
Get all pattern namers referenced by this pattern. Non-recursive.
@ -635,6 +692,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
"""
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast('Positionable', entry).translate(offset)
self._log_bulk_update(f"translate({offset!r})")
return self
def scale_elements(self, c: float) -> Self:
@ -688,7 +746,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
"""
Rotate the Pattern around the a location.
Extrinsic transformation: Rotate the Pattern around the a location in the
container's coordinate system. This affects all elements' offsets and
their repetition grids.
Args:
pivot: (x, y) location to rotate around
@ -702,11 +762,14 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.rotate_elements(rotation)
self.rotate_element_centers(rotation)
self.translate_elements(+pivot)
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
return self
def rotate_element_centers(self, rotation: float) -> Self:
"""
Rotate the offsets of all shapes, labels, refs, and ports around (0, 0)
Extrinsic transformation part: Rotate the offsets and repetition grids of all
shapes, labels, refs, and ports around (0, 0) in the container's
coordinate system.
Args:
rotation: Angle to rotate by (counter-clockwise, radians)
@ -717,11 +780,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
old_offset = cast('Positionable', entry).offset
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
if isinstance(entry, Repeatable) and entry.repetition is not None:
entry.repetition.rotate(rotation)
return self
def rotate_elements(self, rotation: float) -> Self:
"""
Rotate each shape, ref, and port around its origin (offset)
Intrinsic transformation part: Rotate each shape, ref, label, and port around its
origin (offset) in the container's coordinate system. This does NOT
affect their repetition grids.
Args:
rotation: Angle to rotate by (counter-clockwise, radians)
@ -729,54 +796,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Rotatable', entry).rotate(rotation)
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
if isinstance(entry, Rotatable):
entry.rotate(rotation)
return self
def mirror_element_centers(self, across_axis: int = 0) -> Self:
def mirror_element_centers(self, axis: int = 0) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
Extrinsic transformation part: Mirror the offsets and repetition grids of all
shapes, labels, refs, and ports relative to the container's origin.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across (0: x-axis, 1: y-axis)
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
cast('Positionable', entry).offset[1 - across_axis] *= -1
cast('Positionable', entry).offset[1 - axis] *= -1
if isinstance(entry, Repeatable) and entry.repetition is not None:
entry.repetition.mirror(axis)
return self
def mirror_elements(self, across_axis: int = 0) -> Self:
def mirror_elements(self, axis: int = 0) -> Self:
"""
Mirror each shape, ref, and pattern across an axis, relative
to its offset
Intrinsic transformation part: Mirror each shape, ref, label, and port relative
to its offset. This does NOT affect their repetition grids.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across
0: mirror across x axis (flip y),
1: mirror across y axis (flip x)
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Mirrorable', entry).mirror(across_axis)
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
if isinstance(entry, Mirrorable):
entry.mirror(axis=axis)
self._log_bulk_update(f"mirror_elements({axis})")
return self
def mirror(self, across_axis: int = 0) -> Self:
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis
Extrinsic transformation: Mirror the Pattern across an axis through its origin.
This affects all elements' offsets and their internal orientations.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across (0: x-axis, 1: y-axis).
Returns:
self
"""
self.mirror_elements(across_axis)
self.mirror_element_centers(across_axis)
self.mirror_elements(axis=axis)
self.mirror_element_centers(axis=axis)
self._log_bulk_update(f"mirror({axis})")
return self
def copy(self) -> Self:
@ -787,7 +861,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
A deep copy of the current Pattern.
"""
return copy.deepcopy(self)
return self.deepcopy()
def deepcopy(self) -> Self:
"""
@ -930,6 +1004,28 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
del self.labels[layer]
return self
def resolve_repeated_refs(self) -> Self:
"""
Expand all repeated references into multiple individual references.
Alters the current pattern in-place.
Returns:
self
"""
new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list)
for target, rseq in self.refs.items():
for ref in rseq:
if ref.repetition is None:
new_refs[target].append(ref)
else:
for dd in ref.repetition.displacements:
new_ref = ref.deepcopy()
new_ref.offset = ref.offset + dd
new_ref.repetition = None
new_refs[target].append(new_ref)
self.refs = new_refs
return self
def prune_refs(self) -> Self:
"""
Remove empty ref lists in `self.refs`.
@ -981,10 +1077,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if target_pat is None:
raise PatternError(f'Circular reference in {name} to {target}')
if target_pat.is_empty(): # avoid some extra allocations
ports_only = flatten_ports and bool(target_pat.ports)
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
continue
for ref in refs:
if flatten_ports and ref.repetition is not None and target_pat.ports:
raise PatternError(
f'Cannot flatten ports from repeated ref to {target!r}; '
'flatten with flatten_ports=False or expand/rename the ports manually first.'
)
p = ref.as_pattern(pattern=target_pat)
if not flatten_ports:
p.ports.clear()
@ -1003,6 +1105,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
line_color: str = 'k',
fill_color: str = 'none',
overdraw: bool = False,
filename: str | None = None,
ports: bool = False,
) -> None:
"""
Draw a picture of the Pattern and wait for the user to inspect it
@ -1013,15 +1117,18 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
klayout or a different GDS viewer!
Args:
offset: Coordinates to offset by before drawing
line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
overdraw: Whether to create a new figure or draw on a pre-existing one
library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`.
offset: Coordinates to offset by before drawing.
line_color: Outlines are drawn with this color.
fill_color: Interiors are drawn with this color.
overdraw: Whether to create a new figure or draw on a pre-existing one.
filename: If provided, save the figure to this file instead of showing it.
ports: If True, annotate the plot with arrows representing the ports.
"""
# TODO: add text labels to visualize()
try:
from matplotlib import pyplot # type: ignore
import matplotlib.collections # type: ignore
from matplotlib import pyplot # type: ignore #noqa: PLC0415
import matplotlib.collections # type: ignore #noqa: PLC0415
except ImportError:
logger.exception('Pattern.visualize() depends on matplotlib!\n'
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
@ -1030,48 +1137,155 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if self.has_refs() and library is None:
raise PatternError('Must provide a library when visualizing a pattern with refs')
offset = numpy.asarray(offset, dtype=float)
# Cache for {Pattern object ID: List of local polygon vertex arrays}
# Polygons are stored relative to the pattern's origin (offset included)
poly_cache: dict[int, list[NDArray[numpy.float64]]] = {}
def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]:
pid = id(pat)
if pid not in poly_cache:
polys = []
for shape in chain.from_iterable(pat.shapes.values()):
for ss in shape.to_polygons():
# Shape.to_polygons() returns Polygons with their own offsets and vertices.
# We need to expand any shape-level repetition here.
v_base = ss.vertices + ss.offset
if ss.repetition is not None:
for disp in ss.repetition.displacements:
polys.append(v_base + disp)
else:
polys.append(v_base)
poly_cache[pid] = polys
return poly_cache[pid]
all_polygons: list[NDArray[numpy.float64]] = []
port_info: list[tuple[str, NDArray[numpy.float64], float]] = []
def collect_polys_recursive(
pat: 'Pattern',
c_offset: NDArray[numpy.float64],
c_rotation: float,
c_mirrored: bool,
c_scale: float,
) -> None:
# Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale)
# 1. Transform and collect local polygons
local_polys = get_local_polys(pat)
if local_polys:
rot_mat = rotation_matrix_2d(c_rotation)
for v in local_polys:
vt = v * c_scale
if c_mirrored:
vt = vt.copy()
vt[:, 1] *= -1
vt = (rot_mat @ vt.T).T + c_offset
all_polygons.append(vt)
# 2. Collect ports if requested
if ports:
for name, p in pat.ports.items():
pt_v = p.offset * c_scale
if c_mirrored:
pt_v = pt_v.copy()
pt_v[1] *= -1
pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset
if p.rotation is not None:
pt_rot = p.rotation
if c_mirrored:
pt_rot = -pt_rot
pt_rot += c_rotation
port_info.append((name, pt_v, pt_rot))
# 3. Recurse into refs
for target, refs in pat.refs.items():
if target is None:
continue
assert library is not None
target_pat = library[target]
for ref in refs:
# Ref order of operations: mirror, rotate, scale, translate, repeat
# Combined scale and mirror
r_scale = c_scale * ref.scale
r_mirrored = c_mirrored ^ ref.mirrored
# Combined rotation: push c_mirrored and c_rotation through ref.rotation
r_rot_relative = -ref.rotation if c_mirrored else ref.rotation
r_rotation = c_rotation + r_rot_relative
# Offset composition helper
def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
o = rel_offset * c_scale
if c_mirrored:
o = o.copy()
o[1] *= -1
return rotation_matrix_2d(c_rotation) @ o + c_offset
if ref.repetition is not None:
for disp in ref.repetition.displacements:
collect_polys_recursive(
target_pat,
get_full_offset(ref.offset + disp),
r_rotation,
r_mirrored,
r_scale
)
else:
collect_polys_recursive(
target_pat,
get_full_offset(ref.offset),
r_rotation,
r_mirrored,
r_scale
)
# Start recursive collection
collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0)
# Plotting
if not overdraw:
figure = pyplot.figure()
pyplot.axis('equal')
else:
figure = pyplot.gcf()
axes = figure.gca()
polygons = []
for shape in chain.from_iterable(self.shapes.values()):
polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
if all_polygons:
mpl_poly_collection = matplotlib.collections.PolyCollection(
all_polygons,
facecolors = fill_color,
edgecolors = line_color,
)
axes.add_collection(mpl_poly_collection)
mpl_poly_collection = matplotlib.collections.PolyCollection(
polygons,
facecolors=fill_color,
edgecolors=line_color,
)
axes.add_collection(mpl_poly_collection)
pyplot.axis('equal')
if ports:
for port_name, pt_v, pt_rot in port_info:
p1 = pt_v
angle = pt_rot
size = 1.0 # arrow size
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
for target, refs in self.refs.items():
if target is None:
continue
if not refs:
continue
assert library is not None
target_pat = library[target]
for ref in refs:
ref.as_pattern(target_pat).visualize(
library=library,
offset=offset,
overdraw=True,
line_color=line_color,
fill_color=fill_color,
axes.annotate(
port_name,
xy = tuple(p1),
xytext = tuple(p2),
arrowprops = dict(arrowstyle="->", color='g', linewidth=1),
color = 'g',
fontsize = 8,
)
axes.autoscale_view()
axes.set_aspect('equal')
if not overdraw:
pyplot.xlabel('x')
pyplot.ylabel('y')
pyplot.show()
axes.set_xlabel('x')
axes.set_ylabel('y')
if filename:
figure.savefig(filename)
else:
figure.show()
# @overload
# def place(
@ -1114,6 +1328,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
skip_geometry: bool = False,
) -> Self:
"""
Instantiate or append the pattern `other` into the current pattern, adding its
@ -1145,6 +1360,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
skip_geometry: If `True`, the operation only updates the port list and
skips adding any geometry (shapes, labels, or references). This
allows the pattern assembly to proceed for port-tracking purposes
even when layout generation is suppressed.
Returns:
self
@ -1176,6 +1395,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
pp.rotate_around(pivot, rotation)
pp.translate(offset)
self.ports[name] = pp
self._log_port_update(name)
if skip_geometry:
return self
if append:
if isinstance(other, Abstract):
@ -1188,7 +1411,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
other_copy.translate_elements(offset)
self.append(other_copy)
else:
assert not isinstance(other, Pattern)
if isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
ref = Ref(mirrored=mirrored)
ref.rotate_around(pivot, rotation)
ref.translate(offset)
@ -1234,6 +1459,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation: bool | None = None,
append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
skip_geometry: bool = False,
) -> Self:
"""
Instantiate or append a pattern into the current pattern, connecting
@ -1288,6 +1514,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
skip_geometry: If `True`, only ports are updated and geometry is
skipped. If a valid transform cannot be found (e.g. due to
misaligned ports), a 'best-effort' dummy transform is used
to ensure new ports are still added at approximate locations,
allowing downstream routing to continue.
Returns:
self
@ -1320,21 +1551,42 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out = {out_port_name: next(iter(map_in.keys()))}
self.check_ports(other.ports.keys(), map_in, map_out)
translation, rotation, pivot = self.find_transform(
other,
map_in,
mirrored = mirrored,
set_rotation = set_rotation,
ok_connections = ok_connections,
)
try:
translation, rotation, pivot = self.find_transform(
other,
map_in,
mirrored = mirrored,
set_rotation = set_rotation,
ok_connections = ok_connections,
)
except PortError:
if not skip_geometry:
raise
logger.warning("Port transform failed for dead device. Using dummy transform.")
if map_in:
ki, vi = next(iter(map_in.items()))
s_port = self.ports[ki]
o_port = other.ports[vi].deepcopy()
if mirrored:
o_port.mirror()
o_port.offset[1] *= -1
translation = s_port.offset - o_port.offset
rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0
pivot = o_port.offset
else:
translation = numpy.zeros(2)
rotation = 0.0
pivot = numpy.zeros(2)
# get rid of plugged ports
for ki, vi in map_in.items():
del self.ports[ki]
self._log_port_removal(ki)
map_out[vi] = None
if isinstance(other, Pattern):
assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
if isinstance(other, Pattern) and not (append or skip_geometry):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
self.place(
other,
@ -1345,6 +1597,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map = map_out,
skip_port_check = True,
append = append,
skip_geometry = skip_geometry,
)
return self

View file

@ -2,6 +2,7 @@ from typing import overload, Self, NoReturn, Any
from collections.abc import Iterable, KeysView, ValuesView, Mapping
import logging
import functools
import copy
from collections import Counter
from abc import ABCMeta, abstractmethod
from itertools import chain
@ -10,16 +11,17 @@ import numpy
from numpy import pi
from numpy.typing import ArrayLike, NDArray
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
from .utils import rotate_offsets_around, rotation_matrix_2d
from .error import PortError, format_stacktrace
logger = logging.getLogger(__name__)
port_logger = logging.getLogger('masque.ports')
@functools.total_ordering
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
"""
A point at which a `Device` can be snapped to another `Device`.
@ -91,6 +93,12 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
def copy(self) -> Self:
return self.deepcopy()
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._offset = self._offset.copy()
return new
def get_bounds(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset, self.offset))
@ -99,6 +107,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
self.ptype = ptype
return self
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
"""
Mirror the object across a line in the container's coordinate system.
Note this operation is performed relative to the pattern's origin and modifies the port's offset.
Args:
axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0.
x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across.
Returns:
self
"""
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
self.translate(-pivot)
self.mirror(axis)
self.offset[1 - axis] *= -1
self.translate(+pivot)
return self
def mirror(self, axis: int = 0) -> Self:
if self.rotation is not None:
self.rotation *= -1
@ -114,6 +143,34 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
self.rotation = rotation
return self
def describe(self) -> str:
"""
Returns a human-readable description of the port's state including cardinal directions.
"""
deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None
cardinal = ""
travel_dir = ""
if self.rotation is not None:
dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"}
# normalize to [0, 360)
deg_norm = deg % 360
# Find closest cardinal
closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180))
if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3):
cardinal = f" ({dirs[closest]})"
# Travel direction (rotation + 180)
t_deg = (deg_norm + 180) % 360
closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180))
if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3):
travel_dir = f" (Travel -> {dirs[closest_t]})"
deg_text = 'any' if deg is None else f'{deg:g}'
return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}"
def __repr__(self) -> str:
if self.rotation is None:
rot = 'any'
@ -179,6 +236,19 @@ class PortList(metaclass=ABCMeta):
def ports(self, value: dict[str, Port]) -> None:
pass
def _log_port_update(self, name: str) -> None:
""" Log the current state of the named port """
port_logger.debug("Port %s: %s", name, self.ports[name].describe())
def _log_port_removal(self, name: str) -> None:
""" Log that the named port has been removed """
port_logger.debug("Port %s: removed", name)
def _log_bulk_update(self, label: str) -> None:
""" Log all current ports at DEBUG level """
for name, port in self.ports.items():
port_logger.debug("%s: Port %s: %s", label, name, port)
@overload
def __getitem__(self, key: str) -> Port:
pass
@ -232,6 +302,7 @@ class PortList(metaclass=ABCMeta):
raise PortError(f'Port {name} already exists.')
assert name not in self.ports
self.ports[name] = value
self._log_port_update(name)
return self
def rename_ports(
@ -257,12 +328,24 @@ class PortList(metaclass=ABCMeta):
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
missing = set(mapping) - set(self.ports)
if missing:
raise PortError(f'Ports to rename were not found: {missing}')
for kk, vv in mapping.items():
if vv is None or vv != kk:
self._log_port_removal(kk)
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
if None in renamed:
del renamed[None]
self.ports.update(renamed) # type: ignore
for vv in mapping.values():
if vv is not None:
self._log_port_update(vv)
return self
def add_port_pair(
@ -285,12 +368,16 @@ class PortList(metaclass=ABCMeta):
Returns:
self
"""
if names[0] == names[1]:
raise PortError(f'Port names must be distinct: {names[0]!r}')
new_ports = {
names[0]: Port(offset, rotation=rotation, ptype=ptype),
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
}
self.check_ports(names)
self.ports.update(new_ports)
self._log_port_update(names[0])
self._log_port_update(names[1])
return self
def plugged(
@ -313,6 +400,14 @@ class PortList(metaclass=ABCMeta):
Raises:
`PortError` if the ports are not properly aligned.
"""
if not connections:
raise PortError('Must provide at least one port connection')
missing_a = set(connections) - set(self.ports)
if missing_a:
raise PortError(f'Connection source ports were not found: {missing_a}')
missing_b = set(connections.values()) - set(self.ports)
if missing_b:
raise PortError(f'Connection destination ports were not found: {missing_b}')
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]
@ -360,6 +455,7 @@ class PortList(metaclass=ABCMeta):
for pp in chain(a_names, b_names):
del self.ports[pp]
self._log_port_removal(pp)
return self
def check_ports(
@ -548,7 +644,7 @@ class PortList(metaclass=ABCMeta):
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
if not has_rot.any():
if set_rotation is None:
PortError('Must provide set_rotation if rotation is indeterminate')
raise PortError('Must provide set_rotation if rotation is indeterminate')
rotations[:] = set_rotation
else:
rotations[~has_rot] = rotations[has_rot][0]
@ -573,4 +669,3 @@ class PortList(metaclass=ABCMeta):
raise PortError(msg)
return translations[0], rotations[0], o_offsets[0]

View file

@ -15,7 +15,8 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation
from .repetition import Repetition
from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl,
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
FlippableImpl,
)
@ -25,8 +26,9 @@ if TYPE_CHECKING:
@functools.total_ordering
class Ref(
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
PositionableImpl, RotatableImpl, ScalableImpl,
Copyable,
):
"""
`Ref` provides basic support for nesting Pattern objects within each other.
@ -42,7 +44,7 @@ class Ref(
__slots__ = (
'_mirrored',
# inherited
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
)
_mirrored: bool
@ -90,18 +92,22 @@ class Ref(
rotation=self.rotation,
scale=self.scale,
mirrored=self.mirrored,
repetition=copy.deepcopy(self.repetition),
annotations=copy.deepcopy(self.annotations),
repetition=self.repetition,
annotations=self.annotations,
)
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
memo = {} if memo is None else memo
new = copy.copy(self)
#new.repetition = copy.deepcopy(self.repetition, memo)
#new.annotations = copy.deepcopy(self.annotations, memo)
new._offset = self._offset.copy()
new.repetition = copy.deepcopy(self.repetition, memo)
new.annotations = copy.deepcopy(self.annotations, memo)
return new
def copy(self) -> 'Ref':
return self.deepcopy()
def __lt__(self, other: 'Ref') -> bool:
if (self.offset != other.offset).any():
return tuple(self.offset) < tuple(other.offset)
@ -160,16 +166,16 @@ class Ref(
return pattern
def rotate(self, rotation: float) -> Self:
"""
Intrinsic transformation: Rotate the target pattern relative to this Ref's
origin. This does NOT affect the repetition grid.
"""
self.rotation += rotation
if self.repetition is not None:
self.repetition.rotate(rotation)
return self
def mirror(self, axis: int = 0) -> Self:
self.mirror_target(axis)
self.rotation *= -1
if self.repetition is not None:
self.repetition.mirror(axis)
return self
def mirror_target(self, axis: int = 0) -> Self:
@ -187,10 +193,11 @@ class Ref(
xys = self.offset[None, :]
if self.repetition is not None:
xys = xys + self.repetition.displacements
transforms = numpy.empty((xys.shape[0], 4))
transforms = numpy.empty((xys.shape[0], 5))
transforms[:, :2] = xys
transforms[:, 2] = self.rotation
transforms[:, 3] = self.mirrored
transforms[:, 4] = self.scale
return transforms
def get_bounds_single(

View file

@ -64,7 +64,7 @@ class Grid(Repetition):
_a_count: int
""" Number of instances along the direction specified by the `a_vector` """
_b_vector: NDArray[numpy.float64] | None
_b_vector: NDArray[numpy.float64]
""" Vector `[x, y]` specifying a second lattice vector for the grid.
Specifies center-to-center spacing between adjacent elements.
Can be `None` for a 1D array.
@ -199,9 +199,6 @@ class Grid(Repetition):
@property
def displacements(self) -> NDArray[numpy.float64]:
if self.b_vector is None:
return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :]
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
return (aa.flatten()[:, None] * self.a_vector[None, :]
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
@ -301,12 +298,8 @@ class Grid(Repetition):
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 tuple(self.b_vector) < tuple(other.b_vector)
return False
@ -350,7 +343,7 @@ class Arbitrary(Repetition):
return (f'<Arbitrary {len(self.displacements)}pts >')
def __eq__(self, other: Any) -> bool:
if not type(other) is not type(self):
if type(other) is not type(self):
return False
return numpy.array_equal(self.displacements, other.displacements)
@ -391,7 +384,9 @@ class Arbitrary(Repetition):
Returns:
self
"""
self.displacements[1 - axis] *= -1
new_displacements = self.displacements.copy()
new_displacements[:, 1 - axis] *= -1
self.displacements = new_displacements
return self
def get_bounds(self) -> NDArray[numpy.float64] | None:
@ -402,6 +397,8 @@ class Arbitrary(Repetition):
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
"""
if self.displacements.size == 0:
return None
xy_min = numpy.min(self.displacements, axis=0)
xy_max = numpy.max(self.displacements, axis=0)
return numpy.array((xy_min, xy_max))
@ -416,6 +413,6 @@ class Arbitrary(Repetition):
Returns:
self
"""
self.displacements *= c
self.displacements = self.displacements * c
return self

View file

@ -272,13 +272,16 @@ class Arc(PositionableImpl, Shape):
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
keep = [0]
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
start = 1
start = 0
while start < arc_lengths.size:
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
if not removable.any():
next_to_keep = start + 1
else:
next_to_keep = start + numpy.where(removable)[0][-1] + 1
keep.append(next_to_keep)
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
start = next_to_keep + 1
start = next_to_keep
if keep[-1] != thetas.size - 1:
keep.append(thetas.size - 1)
@ -362,17 +365,20 @@ class Arc(PositionableImpl, Shape):
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
# If our arc subtends a coordinate axis, use the extremum along that axis
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
xp = xr
if abs(a1 - a0) >= 2 * pi:
xn, xp, yn, yp = -xr, xr, -yr, yr
else:
if a0 <= xpt <= a1 or a0 <= xpt + 2 * pi <= a1:
xp = xr
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
xn = -xr
if a0 <= xnt <= a1 or a0 <= xnt + 2 * pi <= a1:
xn = -xr
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
yp = yr
if a0 <= ypt <= a1 or a0 <= ypt + 2 * pi <= a1:
yp = yr
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
yn = -yr
if a0 <= ynt <= a1 or a0 <= ynt + 2 * pi <= a1:
yn = -yr
mins.append([xn, yn])
maxs.append([xp, yp])
@ -384,7 +390,6 @@ class Arc(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> 'Arc':
self.offset[axis - 1] *= -1
self.rotation *= -1
self.rotation += axis * pi
self.angles *= -1
@ -464,13 +469,18 @@ class Arc(PositionableImpl, Shape):
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
"""
aa = []
d_angle = self.angles[1] - self.angles[0]
if abs(d_angle) >= 2 * pi:
# Full ring
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
for sgn in (-1, +1):
wh = sgn * self.width / 2.0
rx = self.radius_x + wh
ry = self.radius_y + wh
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
sign = numpy.sign(self.angles[1] - self.angles[0])
sign = numpy.sign(d_angle)
if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi

View file

@ -124,7 +124,6 @@ class Circle(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
self.offset[axis - 1] *= -1
return self
def scale_by(self, c: float) -> 'Circle':

View file

@ -189,7 +189,6 @@ class Ellipse(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> Self:
self.offset[axis - 1] *= -1
self.rotation *= -1
self.rotation += axis * pi
return self

View file

@ -24,7 +24,16 @@ class PathCap(Enum):
# # defined by path.cap_extensions
def __lt__(self, other: Any) -> bool:
return self.value == other.value
if self.__class__ is not other.__class__:
return self.__class__.__name__ < other.__class__.__name__
# Order: Flush, Square, Circle, SquareCustom
order = {
PathCap.Flush: 0,
PathCap.Square: 1,
PathCap.Circle: 2,
PathCap.SquareCustom: 3,
}
return order[self] < order[other]
@functools.total_ordering
@ -79,10 +88,10 @@ class Path(Shape):
def cap(self, val: PathCap) -> None:
self._cap = PathCap(val)
if self.cap != PathCap.SquareCustom:
self.cap_extensions = None
elif self.cap_extensions is None:
self._cap_extensions = None
elif self._cap_extensions is None:
# just got set to SquareCustom
self.cap_extensions = numpy.zeros(2)
self._cap_extensions = numpy.zeros(2)
# cap_extensions property
@property
@ -209,9 +218,12 @@ class Path(Shape):
self.vertices = vertices
self.repetition = repetition
self.annotations = annotations
self._cap = cap
if cap == PathCap.SquareCustom and cap_extensions is None:
self._cap_extensions = numpy.zeros(2)
else:
self.cap_extensions = cap_extensions
self.width = width
self.cap = cap
self.cap_extensions = cap_extensions
if rotation:
self.rotate(rotation)
if numpy.any(offset):
@ -253,6 +265,14 @@ class Path(Shape):
if self.cap_extensions is None:
return True
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
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 self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
@ -303,9 +323,30 @@ class Path(Shape):
) -> list['Polygon']:
extensions = self._calculate_cap_extensions()
v = remove_colinear_vertices(self.vertices, closed_path=False)
v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
dv = numpy.diff(v, axis=0)
dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]
norms = numpy.sqrt((dv * dv).sum(axis=1))
# Filter out zero-length segments if any remained after remove_colinear_vertices
valid = (norms > 1e-18)
if not numpy.all(valid):
# This shouldn't happen much if remove_colinear_vertices is working
v = v[numpy.append(valid, True)]
dv = numpy.diff(v, axis=0)
norms = norms[valid]
if dv.shape[0] == 0:
# All vertices were the same. It's a point.
if self.width == 0:
return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate
if self.cap == PathCap.Circle:
return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
if self.cap == PathCap.Square:
return [Polygon.square(side_length=self.width, offset=v[0])]
# Flush or CustomSquare
return [Polygon(vertices=numpy.zeros((3, 2)))]
dvdir = dv / norms[:, None]
if self.width == 0:
verts = numpy.vstack((v, v[::-1]))
@ -324,11 +365,21 @@ class Path(Shape):
bs = 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[:, :, None])[:, 0]
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0]
try:
# Vectorized solve for all intersections
# solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1)
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0]
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0]
except numpy.linalg.LinAlgError:
# Fallback to slower lstsq if some segments are parallel (singular matrix)
rp = numpy.zeros(As.shape[0])
rn = numpy.zeros(As.shape[0])
for ii in range(As.shape[0]):
rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0]
rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0]
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1]
intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1]
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
@ -396,12 +447,14 @@ class Path(Shape):
return self
def mirror(self, axis: int = 0) -> 'Path':
self.vertices[:, axis - 1] *= -1
self.vertices[:, 1 - axis] *= -1
return self
def scale_by(self, c: float) -> 'Path':
self.vertices *= c
self.width *= c
if self.cap_extensions is not None:
self.cap_extensions *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
@ -418,21 +471,22 @@ class Path(Shape):
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
for v in normed_vertices])
# Reorder the vertices so that the one with lowest x, then y, comes first.
x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin()
x_min = cast('Sequence', x_min)[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
# Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller
if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat):
reordered_vertices = rotated_vertices[::-1]
else:
reordered_vertices = rotated_vertices
width0 = self.width / norm_value
cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions)
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0),
(offset, scale / norm_value, rotation, False),
lambda: Path(
reordered_vertices * norm_value,
width=self.width * norm_value,
width=width0 * norm_value,
cap=self.cap,
cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0),
))
def clean_vertices(self) -> 'Path':
@ -462,7 +516,7 @@ class Path(Shape):
Returns:
self
"""
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
return self
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:

View file

@ -56,9 +56,11 @@ class PolyCollection(Shape):
"""
Iterator which provides slices which index vertex_lists
"""
if self._vertex_offsets.size == 0:
return
for ii, ff in zip(
self._vertex_offsets,
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
strict=True,
):
yield slice(ii, ff)
@ -82,7 +84,7 @@ class PolyCollection(Shape):
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
raise PatternError('PolyCollection offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
@ -168,7 +170,9 @@ class PolyCollection(Shape):
annotations = copy.deepcopy(self.annotations),
) for vv in self.polygon_vertices]
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
if self._vertex_lists.size == 0:
return None
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
numpy.max(self._vertex_lists, axis=0)))
@ -179,7 +183,7 @@ class PolyCollection(Shape):
return self
def mirror(self, axis: int = 0) -> Self:
self._vertex_lists[:, axis - 1] *= -1
self._vertex_lists[:, 1 - axis] *= -1
return self
def scale_by(self, c: float) -> Self:

View file

@ -1,4 +1,4 @@
from typing import Any, cast, TYPE_CHECKING, Self
from typing import Any, cast, TYPE_CHECKING, Self, Literal
import copy
import functools
@ -96,11 +96,11 @@ class Polygon(Shape):
@offset.setter
def offset(self, val: ArrayLike) -> None:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
raise PatternError('Polygon offset is forced to (0, 0)')
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
raise PatternError('Polygon offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
@ -321,7 +321,7 @@ class Polygon(Shape):
else:
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition)
return poly
@staticmethod
@ -394,7 +394,7 @@ class Polygon(Shape):
return self
def mirror(self, axis: int = 0) -> 'Polygon':
self.vertices[:, axis - 1] *= -1
self.vertices[:, 1 - axis] *= -1
return self
def scale_by(self, c: float) -> 'Polygon':
@ -417,11 +417,15 @@ class Polygon(Shape):
for v in normed_vertices])
# Reorder the vertices so that the one with lowest x, then y, comes first.
x_min = rotated_vertices[:, 0].argmin()
if not is_scalar(x_min):
y_min = rotated_vertices[x_min, 1].argmin()
x_min = cast('Sequence', x_min)[y_min]
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
x_min_val = rotated_vertices[:, 0].min()
x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0]
if x_min_inds.size > 1:
y_min_val = rotated_vertices[x_min_inds, 1].min()
tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0]
start_ind = x_min_inds[tie_breaker]
else:
start_ind = x_min_inds[0]
reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0)
# TODO: normalize mirroring?
@ -462,3 +466,23 @@ class Polygon(Shape):
def __repr__(self) -> str:
centroid = self.vertices.mean(axis=0)
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
def boolean(
self,
other: Any,
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
scale: float = 1e6,
) -> list['Polygon']:
"""
Perform a boolean operation using this polygon as the subject.
Args:
other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP.
operation: 'union', 'intersection', 'difference', 'xor'.
scale: Scaling factor for integer conversion.
Returns:
A list of resulting Polygons.
"""
from ..utils.boolean import boolean #noqa: PLC0415
return boolean([self], other, operation=operation, scale=scale)

View file

@ -6,8 +6,8 @@ import numpy
from numpy.typing import NDArray, ArrayLike
from ..traits import (
Rotatable, Mirrorable, Copyable, Scalable,
Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
Copyable, Scalable, FlippableImpl,
PivotableImpl, RepeatableImpl, AnnotatableImpl,
)
if TYPE_CHECKING:
@ -26,8 +26,9 @@ normalized_shape_tuple = tuple[
DEFAULT_POLY_NUM_VERTICES = 24
class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
Copyable, Scalable,
metaclass=ABCMeta):
"""
Class specifying functions common to all shapes.
"""
@ -73,7 +74,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
pass
@abstractmethod
def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
"""
Writes the shape in a standardized notation, with offset, scale, and rotation
information separated out from the remaining values.
@ -120,7 +121,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
Returns:
List of `Polygon` objects with grid-aligned edges.
"""
from . import Polygon
from . import Polygon #noqa: PLC0415
gx = numpy.unique(grid_x)
gy = numpy.unique(grid_y)
@ -138,22 +139,24 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
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
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1)
gxi_max = numpy.max(gxi_range).clip(0, len(gx))
gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1))
gxi_max = int(numpy.max(gxi_range).clip(0, len(gx)))
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
if gxi_min < len(gx) - 1:
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
if err_xmin >= 0.5:
gxi_min += 1
if err_xmin >= 0.5:
gxi_min += 1
if err_xmax >= 0.5:
gxi_max += 1
if gxi_max > 0 and gxi_max < len(gx):
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
if err_xmax >= 0.5:
gxi_max += 1
if abs(dv[0]) < 1e-20:
# Vertical line, don't calculate slope
xi = [gxi_min, gxi_max - 1]
xi = [gxi_min, max(gxi_min, gxi_max - 1)]
ys = numpy.array([v[1], v_next[1]])
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
@ -249,9 +252,9 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
Returns:
List of `Polygon` objects with grid-aligned edges.
"""
from . import Polygon
import skimage.measure # type: ignore
import float_raster
from . import Polygon #noqa: PLC0415
import skimage.measure #noqa: PLC0415
import float_raster #noqa: PLC0415
grx = numpy.unique(grid_x)
gry = numpy.unique(grid_y)

View file

@ -70,6 +70,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: bool = False,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
@ -80,6 +81,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
self._string = string
self._height = height
self._rotation = rotation
self._mirrored = mirrored
self._repetition = repetition
self._annotations = annotations
else:
@ -87,6 +89,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
self.string = string
self.height = height
self.rotation = rotation
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations
self.font_path = font_path
@ -146,7 +149,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
if self.mirrored:
poly.mirror()
poly.scale_by(self.height)
poly.offset = self.offset + [total_advance, 0]
poly.translate(self.offset + [total_advance, 0])
poly.rotate_around(self.offset, self.rotation)
all_polygons += [poly]
@ -202,8 +205,8 @@ def get_char_as_polygons(
char: str,
resolution: float = 48 * 64,
) -> tuple[list[NDArray[numpy.float64]], float]:
from freetype import Face # type: ignore
from matplotlib.path import Path # type: ignore
from freetype import Face # type: ignore #noqa: PLC0415
from matplotlib.path import Path # type: ignore #noqa: PLC0415
"""
Get a list of polygons representing a single character.

3
masque/test/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
"""

13
masque/test/conftest.py Normal file
View file

@ -0,0 +1,13 @@
"""
Test fixtures
"""
# ruff: noqa: ARG001
from typing import Any
import numpy
FixtureRequest = Any
PRNG = numpy.random.RandomState(12345)

View file

@ -0,0 +1,85 @@
from numpy.testing import assert_allclose
from numpy import pi
from ..abstract import Abstract
from ..ports import Port
from ..ref import Ref
def test_abstract_init() -> None:
ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)}
abs_obj = Abstract("test", ports)
assert abs_obj.name == "test"
assert len(abs_obj.ports) == 2
assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied
def test_abstract_transform() -> None:
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
# Rotate 90 deg around (0,0)
abs_obj.rotate_around((0, 0), pi / 2)
# (10, 0) rot 0 -> (0, 10) rot pi/2
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
# Mirror across x axis (axis 0): flips y-offset
abs_obj.mirror(0)
# (0, 10) mirrored(0) -> (0, -10)
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
def test_abstract_ref_transform() -> None:
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
# Apply ref transform
abs_obj.apply_ref_transform(ref)
# Ref order: mirror, rotate, scale, translate
# 1. mirror (across x: y -> -y)
# (10, 0) rot 0 -> (10, 0) rot 0
# 2. rotate pi/2 around (0,0)
# (10, 0) rot 0 -> (0, 10) rot pi/2
# 3. translate (100, 100)
# (0, 10) -> (100, 110)
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
def test_abstract_ref_transform_scales_offsets() -> None:
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
abs_obj.apply_ref_transform(ref)
assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
def test_abstract_undo_transform() -> None:
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
abs_obj.undo_ref_transform(ref)
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
def test_abstract_undo_transform_scales_offsets() -> None:
abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)})
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
abs_obj.undo_ref_transform(ref)
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
assert abs_obj.ports["A"].rotation is not None
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)

View file

@ -0,0 +1,76 @@
import pytest
from numpy.testing import assert_equal
from numpy import pi
from ..builder import Pather
from ..builder.tools import PathTool
from ..library import Library
from ..ports import Port
@pytest.fixture
def advanced_pather() -> tuple[Pather, PathTool, Library]:
lib = Library()
# Simple PathTool: 2um width on layer (1,0)
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
return p, tool, lib
def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = advanced_pather
# Facing ports
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device)
# Forward (+pi relative to port) is West (-x).
# Put destination at (-20, 0) pointing East (pi).
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.trace_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
# Pather._traceL adds a Reference to the generated pattern
assert len(p.pattern.refs) == 1
def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = advanced_pather
# Source at (0,0) rot 0 (facing East). Forward is West (-x).
p.ports["src"] = Port((0, 0), 0, ptype="wire")
# Destination at (-20, -20) rot pi (facing West). Forward is East (+x).
# Wait, src forward is -x. dst is at -20, -20.
# To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South).
# Forward for South is North (+y).
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
p.trace_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
# Single bend should result in 2 segments (one for x move, one for y move)
assert len(p.pattern.refs) == 2
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = advanced_pather
# Facing but offset ports
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x)
p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi)
p.trace_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = advanced_pather
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.ports["other"] = Port((10, 10), 0)
p.trace_into("src", "dst", thru="other")
assert "src" in p.ports
assert_equal(p.ports["src"].offset, [10, 10])
assert "other" not in p.ports

View file

@ -0,0 +1,81 @@
import pytest
from numpy.testing import assert_allclose
from numpy import pi
from ..builder import Pather
from ..builder.tools import AutoTool
from ..library import Library
from ..pattern import Pattern
from ..ports import Port
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
pat = Pattern()
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
pat.ports["in"] = Port((0, 0), 0, ptype=ptype)
pat.ports["out"] = Port((length, 0), pi, ptype=ptype)
return pat
@pytest.fixture
def autotool_setup() -> tuple[Pather, AutoTool, Library]:
lib = Library()
# Define a simple bend
bend_pat = Pattern()
# 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise)
bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire")
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire")
lib["bend"] = bend_pat
lib.abstract("bend")
# Define a transition (e.g., via)
via_pat = Pattern()
via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1")
via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2")
lib["via"] = via_pat
via_abs = lib.abstract("via")
tool_m1 = AutoTool(
straights=[
AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out")
],
bends=[],
sbends=[],
transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")},
default_out_ptype="wire_m1",
)
p = Pather(lib, tools=tool_m1)
# Start with an m2 port
p.ports["start"] = Port((0, 0), pi, ptype="wire_m2")
return p, tool_m1, lib
def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None:
p, _tool, _lib = autotool_setup
# Route m1 from an m2 port. Should trigger via.
# length 10. Via length is 1. So straight m1 should be 9.
p.straight("start", 10)
# Start at (0,0) rot pi (facing West).
# Forward (+pi relative to port) is East (+x).
# Via: m2(1,0)pi -> m1(0,0)0.
# Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi.
# rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi.
# Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi.
# Wait, (length, 0) relative to (-1, 0) rot pi:
# transform (9, 0) by pi: (-9, 0).
# (-1, 0) + (-9, 0) = (-10, 0)? No.
# Let's re-calculate.
# start (0,0) rot pi. Direction East.
# via m2 is at (0,0), m1 is at (1,0).
# When via is plugged into start: m2 goes to (0,0).
# since start is pi and m2 is pi, rotation is 0.
# so via m1 is at (1,0) rot 0.
# then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0.
assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10)
assert p.ports["start"].ptype == "wire_m1"

View file

@ -0,0 +1,226 @@
import pytest
from numpy.testing import assert_allclose
from numpy import pi
from masque.builder.tools import AutoTool
from masque.pattern import Pattern
from masque.ports import Port
from masque.library import Library
from masque.builder.pather import Pather, RenderPather
def make_straight(length, width=2, ptype="wire"):
pat = Pattern()
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
return pat
def make_bend(R, width=2, ptype="wire", clockwise=True):
pat = Pattern()
# 90 degree arc approximation (just two rects for start and end)
if clockwise:
# (0,0) rot 0 to (R, -R) rot pi/2
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype)
else:
# (0,0) rot 0 to (R, R) rot -pi/2
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype)
return pat
@pytest.fixture
def multi_bend_tool():
lib = Library()
# Bend 1: R=2
lib["b1"] = make_bend(2, ptype="wire")
b1_abs = lib.abstract("b1")
# Bend 2: R=5
lib["b2"] = make_bend(5, ptype="wire")
b2_abs = lib.abstract("b2")
tool = AutoTool(
straights=[
# Straight 1: only for length < 10
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
# Straight 2: for length >= 10
AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8))
],
bends=[
AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True),
AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True)
],
sbends=[],
transitions={},
default_out_ptype="wire"
)
return tool, lib
def test_autotool_planL_selection(multi_bend_tool) -> None:
tool, _ = multi_bend_tool
# Small length: should pick straight 1 and bend 1 (R=2)
# L = straight + R. If L=5, straight=3.
p, data = tool.planL(True, 5)
assert data.straight.length_range == (0, 10)
assert data.straight_length == 3
assert data.bend.abstract.name == "b1"
assert_allclose(p.offset, [5, 2])
# Large length: should pick straight 2 and bend 1 (R=2)
# If L=15, straight=13.
p, data = tool.planL(True, 15)
assert data.straight.length_range == (10, 1e8)
assert data.straight_length == 13
assert_allclose(p.offset, [15, 2])
def test_autotool_planU_consistency(multi_bend_tool) -> None:
tool, lib = multi_bend_tool
# length=10, jog=20.
# U-turn: Straight1 -> Bend1 -> Straight_mid -> Straight3(0) -> Bend2
# X = L1_total - R2 = length
# Y = R1 + L2_mid + R2 = jog
p, data = tool.planU(20, length=10)
assert data.ldata0.straight_length == 7
assert data.ldata0.bend.abstract.name == "b2"
assert data.l2_length == 13
assert data.ldata1.straight_length == 0
assert data.ldata1.bend.abstract.name == "b1"
def test_autotool_planS_double_L(multi_bend_tool) -> None:
tool, lib = multi_bend_tool
# length=20, jog=10. S-bend (ccw1, cw2)
# X = L1_total + R2 = length
# Y = R1 + L2_mid + R2 = jog
p, data = tool.planS(20, 10)
assert_allclose(p.offset, [20, 10])
assert_allclose(p.rotation, pi)
assert data.ldata0.straight_length == 16
assert data.ldata1.straight_length == 0
assert data.l2_length == 6
def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
lib = Library()
def make_straight(length: float) -> Pattern:
pat = Pattern()
pat.ports["A"] = Port((0, 0), 0, ptype="core")
pat.ports["B"] = Port((length, 0), pi, ptype="core")
return pat
def make_sbend(jog: float) -> Pattern:
pat = Pattern()
pat.ports["A"] = Port((0, 0), 0, ptype="core")
pat.ports["B"] = Port((10, jog), pi, ptype="core")
return pat
trans_pat = Pattern()
trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext")
trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core")
lib["xin"] = trans_pat
tool = AutoTool(
straights=[
AutoTool.Straight(
ptype="core",
fn=make_straight,
in_port_name="A",
out_port_name="B",
length_range=(1, 1e8),
)
],
bends=[],
sbends=[
AutoTool.SBend(
ptype="core",
fn=make_sbend,
in_port_name="A",
out_port_name="B",
jog_range=(0, 1e8),
)
],
transitions={
("ext", "core"): AutoTool.Transition(lib.abstract("xin"), "EXT", "CORE"),
},
default_out_ptype="core",
)
p, data = tool.planS(15, 4, in_ptype="ext")
assert_allclose(p.offset, [15, 4])
assert_allclose(p.rotation, pi)
assert data.straight_length == 0
assert data.jog_remaining == 4
assert data.in_transition is not None
def test_renderpather_autotool_double_L(multi_bend_tool) -> None:
tool, lib = multi_bend_tool
rp = RenderPather(lib, tools=tool)
rp.ports["A"] = Port((0,0), 0, ptype="wire")
# This should trigger double-L fallback in planS
rp.jog("A", 10, length=20)
# port_rot=0 -> forward is -x. jog=10 (left) is -y.
assert_allclose(rp.ports["A"].offset, [-20, -10])
assert_allclose(rp.ports["A"].rotation, 0) # jog rot is pi relative to input, input rot is pi relative to port.
# Wait, planS returns out_port at (length, jog) rot pi relative to input (0,0) rot 0.
# Input rot relative to port is pi.
# Rotate (length, jog) rot pi by pi: (-length, -jog) rot 0. Correct.
rp.render()
assert len(rp.pattern.refs) > 0
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
tool, lib = multi_bend_tool
class BasicTool(AutoTool):
def planU(self, *args, **kwargs):
raise NotImplementedError()
tool_basic = BasicTool(
straights=tool.straights,
bends=tool.bends,
sbends=tool.sbends,
transitions=tool.transitions,
default_out_ptype=tool.default_out_ptype
)
p = Pather(lib, tools=tool_basic)
p.ports["A"] = Port((0,0), 0, ptype="wire") # facing West (Actually East points Inwards, West is Extension)
# uturn jog=10, length=5.
# R=2. L1 = 5+2=7. L2 = 10-2=8.
p.uturn("A", 10, length=5)
# port_rot=0 -> forward is -x. jog=10 (left) is -y.
# L1=7 along -x -> (-7, 0). Bend1 (ccw) -> rot -pi/2 (South).
# L2=8 along -y -> (-7, -8). Bend2 (ccw) -> rot 0 (East).
# wait. CCW turn from facing South (-y): turn towards East (+x).
# Wait.
# Input facing -x. CCW turn -> face -y.
# Input facing -y. CCW turn -> face +x.
# So final rotation is 0.
# Bend1 (ccw) relative to -x: global offset is (-7, -2)?
# Let's re-run my manual calculation.
# Port rot 0. Wire input rot pi. Wire output relative to input:
# L1=7, R1=2, CCW=True. Output (7, 2) rot pi/2.
# Rotate wire by pi: output (-7, -2) rot 3pi/2.
# Second turn relative to (-7, -2) rot 3pi/2:
# local output (8, 2) rot pi/2.
# global: (-7, -2) + 8*rot(3pi/2)*x + 2*rot(3pi/2)*y
# = (-7, -2) + 8*(0, -1) + 2*(1, 0) = (-7, -2) + (0, -8) + (2, 0) = (-5, -10).
# YES! ACTUAL result was (-5, -10).
assert_allclose(p.ports["A"].offset, [-5, -10])
assert_allclose(p.ports["A"].rotation, pi)

120
masque/test/test_boolean.py Normal file
View file

@ -0,0 +1,120 @@
# ruff: noqa: PLC0415
import pytest
import numpy
from numpy.testing import assert_allclose
from masque.pattern import Pattern
from masque.shapes.polygon import Polygon
from masque.repetition import Grid
from masque.library import Library
def test_layer_as_polygons_basic() -> None:
pat = Pattern()
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
polys = pat.layer_as_polygons((1, 0), flatten=False)
assert len(polys) == 1
assert isinstance(polys[0], Polygon)
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
def test_layer_as_polygons_repetition() -> None:
pat = Pattern()
rep = Grid(a_vector=(2, 0), a_count=2)
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
polys = pat.layer_as_polygons((1, 0), flatten=False)
assert len(polys) == 2
# First polygon at (0,0)
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
# Second polygon at (2,0)
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
def test_layer_as_polygons_flatten() -> None:
lib = Library()
child = Pattern()
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
lib['child'] = child
parent = Pattern()
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
assert len(polys) == 1
# Original child at (0,0) with rot pi/2 is still at (0,0) in its own space?
# No, ref.as_pattern(child) will apply the transform.
# Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1)
# Then offset by (10,10) -> (10,10), (10,11), (9,11)
# Let's verify the vertices
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
assert_allclose(polys[0].vertices, expected, atol=1e-10)
def test_boolean_import_error() -> None:
from masque import boolean
# If pyclipper is not installed, this should raise ImportError
try:
import pyclipper # noqa: F401
pytest.skip("pyclipper is installed, cannot test ImportError")
except ImportError:
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
boolean([], [], operation='union')
def test_polygon_boolean_shortcut() -> None:
poly = Polygon([[0, 0], [1, 0], [1, 1]])
# This should also raise ImportError if pyclipper is missing
try:
import pyclipper # noqa: F401
pytest.skip("pyclipper is installed")
except ImportError:
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
poly.boolean(poly)
def test_bridge_holes() -> None:
from masque.utils.boolean import _bridge_holes
# Outer: 10x10 square
outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
# Hole: 2x2 square in the middle
hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]])
bridged = _bridge_holes(outer, [hole])
# We expect more vertices than outer + hole
# Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop?
# Our implementation:
# 1. outer up to bridge edge (best_edge_idx)
# 2. bridge point on outer
# 3. hole reordered starting at max X
# 4. close hole loop (repeat max X)
# 5. bridge point on outer again
# 6. rest of outer
# max X of hole is 6 at (6,4) or (6,6). argmax will pick first one.
# hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4)
# roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4]
# intersection of ray from (6,4) to right:
# edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0)
# edge (10,0)-(10,10) spans y=4.
# intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2)
# vertices added:
# outer[0:2]: (0,0), (10,0)
# bridge pt: (10,4)
# hole: (6,4), (6,6), (4,6), (4,4)
# hole close: (6,4)
# bridge pt back: (10,4)
# outer[2:]: (10,10), (0,10)
expected_len = 11
assert len(bridged) == expected_len
# verify it wraps around the hole and back
# index 2 is bridge_pt
assert_allclose(bridged[2], [10, 4])
# index 3 is hole reordered max X
assert_allclose(bridged[3], [6, 4])
# index 7 is hole closed at max X
assert_allclose(bridged[7], [6, 4])
# index 8 is bridge_pt back
assert_allclose(bridged[8], [10, 4])

131
masque/test/test_builder.py Normal file
View file

@ -0,0 +1,131 @@
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import Builder
from ..library import Library
from ..pattern import Pattern
from ..ports import Port
def test_builder_init() -> None:
lib = Library()
b = Builder(lib, name="mypat")
assert b.pattern is lib["mypat"]
assert b.library is lib
def test_builder_place() -> None:
lib = Library()
child = Pattern()
child.ports["A"] = Port((0, 0), 0)
lib["child"] = child
b = Builder(lib)
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
assert "child_A" in b.ports
assert_equal(b.ports["child_A"].offset, [10, 20])
assert "child" in b.pattern.refs
def test_builder_plug() -> None:
lib = Library()
wire = Pattern()
wire.ports["in"] = Port((0, 0), 0)
wire.ports["out"] = Port((10, 0), pi)
lib["wire"] = wire
b = Builder(lib)
b.ports["start"] = Port((100, 100), 0)
# Plug wire's "in" port into builder's "start" port
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
# builder start: (100, 100) rotation 0
# wire in: (0, 0) rotation 0
# wire out: (10, 0) rotation pi
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
b.plug("wire", map_in={"start": "in"})
assert "start" in b.ports
assert_equal(b.ports["start"].offset, [90, 100])
assert b.ports["start"].rotation is not None
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
def test_builder_interface() -> None:
lib = Library()
source = Pattern()
source.ports["P1"] = Port((0, 0), 0)
lib["source"] = source
b = Builder.interface("source", library=lib, name="iface")
assert "in_P1" in b.ports
assert "P1" in b.ports
assert b.pattern is lib["iface"]
def test_builder_set_dead() -> None:
lib = Library()
lib["sub"] = Pattern()
b = Builder(lib)
b.set_dead()
b.place("sub")
assert not b.pattern.has_refs()
def test_builder_dead_ports() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Builder(lib, pattern=pat)
b.set_dead()
# Attempt to plug a device where ports don't line up
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
# This should NOT raise PortError because b is dead
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
# Port A should be removed, and Port B (renamed from D) should be added
assert 'A' not in b.ports
assert 'B' in b.ports
# Verify geometry was not added
assert not b.pattern.has_refs()
assert not b.pattern.has_shapes()
def test_dead_plug_best_effort() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Builder(lib, pattern=pat)
b.set_dead()
# Device with multiple ports, none of which line up correctly
other = Pattern(ports={
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
})
# Try to plug. find_transform will fail.
# It should fall back to aligning the first pair ('A' and 'P1').
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
assert 'A' not in b.ports
assert 'B' in b.ports
# Dummy transform aligns A (0,0) with P1 (10,10)
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
# P2 (20,20) rotation pi:
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
# 2. Rotate (10,10) by -pi: (-10,-10)
# 3. Translate by s_port.offset (0,0): (-10,-10)
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
# P2 rot pi + transform rot -pi = 0
assert b.ports['B'].rotation is not None
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)

129
masque/test/test_dxf.py Normal file
View file

@ -0,0 +1,129 @@
import io
import numpy
import ezdxf
from numpy.testing import assert_allclose
from pathlib import Path
from ..pattern import Pattern
from ..library import Library
from ..shapes import Path as MPath, Polygon
from ..repetition import Grid
from ..file import dxf
def test_dxf_roundtrip(tmp_path: Path):
lib = Library()
pat = Pattern()
# 1. Polygon (closed)
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
pat.polygon("1", vertices=poly_verts)
# 2. Path (open, 3 points)
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
pat.path("2", vertices=path_verts, width=2)
# 3. Path (open, 2 points) - Testing the fix for 2-point polylines
path2_verts = numpy.array([[40, 0], [50, 10]])
pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful
# 4. Ref with Grid repetition (Manhattan)
subpat = Pattern()
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = subpat
pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3))
lib["top"] = pat
dxf_file = tmp_path / "test.dxf"
dxf.writefile(lib, "top", dxf_file)
read_lib, _ = dxf.readfile(dxf_file)
# In DXF read, the top level is usually called "Model"
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
# Verify Polygon
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
assert len(polys) >= 1
poly_read = polys[0]
# DXF polyline might be shifted or vertices reordered, but here they should be simple
assert_allclose(poly_read.vertices, poly_verts)
# Verify 3-point Path
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
assert len(paths) >= 1
path_read = paths[0]
assert_allclose(path_read.vertices, path_verts)
assert path_read.width == 2
# Verify 2-point Path
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
assert len(paths2) >= 1
path2_read = paths2[0]
assert_allclose(path2_read.vertices, path2_verts)
assert path2_read.width == 0
# Verify Ref with Grid
# Finding the sub pattern name might be tricky because of how DXF stores blocks
# but "sub" should be in read_lib
assert "sub" in read_lib
# Check refs in the top pattern
found_grid = False
for target, reflist in top_pat.refs.items():
# DXF names might be case-insensitive or modified, but ezdxf usually preserves them
if target.upper() == "SUB":
for ref in reflist:
if isinstance(ref.repetition, Grid):
assert ref.repetition.a_count == 2
assert ref.repetition.b_count == 3
assert_allclose(ref.repetition.a_vector, (10, 0))
assert_allclose(ref.repetition.b_vector, (0, 10))
found_grid = True
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
def test_dxf_manhattan_precision(tmp_path: Path):
# Test that float precision doesn't break Manhattan grid detection
lib = Library()
sub = Pattern()
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = sub
top = Pattern()
# 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]]
# In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]].
# So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array.
angle = numpy.pi / 2 # 90 degrees
top.ref("sub", offset=(0, 0), rotation=angle,
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
lib["top"] = top
dxf_file = tmp_path / "precision.dxf"
dxf.writefile(lib, "top", dxf_file)
# If the isclose() fix works, this should still be a Grid when read back
read_lib, _ = dxf.readfile(dxf_file)
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
ref = read_top.refs[target_name][0]
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"
def test_dxf_read_legacy_polyline() -> None:
doc = ezdxf.new()
msp = doc.modelspace()
msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True)
stream = io.StringIO()
doc.write(stream)
stream.seek(0)
read_lib, _ = dxf.read(stream)
top_pat = read_lib.get("Model") or list(read_lib.values())[0]
polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)]
assert len(polys) == 1
assert_allclose(polys[0].vertices, [[0, 0], [10, 0], [10, 10]])

24
masque/test/test_fdfd.py Normal file
View file

@ -0,0 +1,24 @@
# ruff: noqa
# ruff: noqa: ARG001
import dataclasses
import pytest # type: ignore
import numpy
from numpy import pi
from numpy.typing import NDArray
# from numpy.testing import assert_allclose, assert_array_equal
from .. import Pattern, Arc, Circle
def test_circle_mirror():
cc = Circle(radius=4, offset=(10, 20))
cc.flip_across(axis=0) # flip across y=0
assert cc.offset[0] == 10
assert cc.offset[1] == -20
assert cc.radius == 4
cc.flip_across(axis=1) # flip across x=0
assert cc.offset[0] == -10
assert cc.offset[1] == -20
assert cc.radius == 4

View file

@ -0,0 +1,152 @@
from pathlib import Path
from typing import cast
import pytest
from numpy.testing import assert_allclose
from ..pattern import Pattern
from ..library import Library
from ..shapes import Path as MPath, Circle, Polygon
from ..repetition import Grid, Arbitrary
def create_test_library(for_gds: bool = False) -> Library:
lib = Library()
# 1. Polygons
pat_poly = Pattern()
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
lib["polygons"] = pat_poly
# 2. Paths with different endcaps
pat_paths = Pattern()
# Flush
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
# Square
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
# Circle (Only for GDS)
if for_gds:
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
# SquareCustom
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
lib["paths"] = pat_paths
# 3. Circles (only for OASIS or polygonized for GDS)
pat_circles = Pattern()
if for_gds:
# GDS writer calls to_polygons() for non-supported shapes,
# but we can also pre-polygonize
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
else:
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
lib["circles"] = pat_circles
# 4. Refs with repetitions
pat_refs = Pattern()
# Simple Ref
pat_refs.ref("polygons", offset=(0, 0))
# Ref with Grid repetition
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
# Ref with Arbitrary repetition
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
lib["refs"] = pat_refs
# 5. Shapes with repetitions (OASIS only, must be wrapped for GDS)
pat_rep_shapes = Pattern()
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
lib["rep_shapes"] = pat_rep_shapes
if for_gds:
lib.wrap_repeated_shapes()
return lib
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
from ..file import gdsii
lib = create_test_library(for_gds=True)
gds_file = tmp_path / "full_test.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, _ = gdsii.readfile(gds_file)
# Check existence
for name in lib:
assert name in read_lib
# Check Paths
read_paths = read_lib["paths"]
# Check caps (GDS stores them as path_type)
# Order might be different depending on how they were written,
# but here they should match the order they were added if dict order is preserved.
# Actually, they are grouped by layer.
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
assert p_flush.cap == MPath.Cap.Flush
p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
assert p_square.cap == MPath.Cap.Square
p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
assert p_circle.cap == MPath.Cap.Circle
p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
assert p_custom.cap == MPath.Cap.SquareCustom
assert p_custom.cap_extensions is not None
assert_allclose(p_custom.cap_extensions, (1, 5))
# Check Refs with repetitions
read_refs = read_lib["refs"]
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
# AREF check
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
assert len(arefs) == 1
assert isinstance(arefs[0].repetition, Grid)
assert arefs[0].repetition.a_count == 3
assert arefs[0].repetition.b_count == 2
# Check wrapped shapes
# lib.wrap_repeated_shapes() created new patterns
# Original pattern "rep_shapes" now should have a Ref
assert len(read_lib["rep_shapes"].refs) > 0
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
pytest.importorskip("fatamorgana")
from ..file import oasis
lib = create_test_library(for_gds=False)
oas_file = tmp_path / "full_test.oas"
oasis.writefile(lib, oas_file, units_per_micron=1000)
read_lib, _ = oasis.readfile(oas_file)
# Check existence
for name in lib:
assert name in read_lib
# Check Circle
read_circles = read_lib["circles"]
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
assert read_circles.shapes[(3, 0)][0].radius == 5
# Check Path caps
read_paths = read_lib["paths"]
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
# OASIS HalfWidth is Square. masque's Square is also HalfWidth extension.
# Wait, Circle cap in OASIS?
# masque/file/oasis.py:
# path_cap_map = {
# PathExtensionScheme.Flush: Path.Cap.Flush,
# PathExtensionScheme.HalfWidth: Path.Cap.Square,
# PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
# }
# It seems Circle cap is NOT supported in OASIS by masque currently.
# Let's verify what happens with Circle cap in OASIS write.
# _shapes_to_elements in oasis.py:
# path_type = next(k for k, v in path_cap_map.items() if v == shape.cap)
# This will raise StopIteration if Circle is not in path_cap_map.
# Check Shape repetition
read_rep_shapes = read_lib["rep_shapes"]
poly = read_rep_shapes.shapes[(4, 0)][0]
assert poly.repetition is not None
assert isinstance(poly.repetition, Grid)
assert poly.repetition.a_count == 5

71
masque/test/test_gdsii.py Normal file
View file

@ -0,0 +1,71 @@
from pathlib import Path
from typing import cast
import numpy
from numpy.testing import assert_equal, assert_allclose
from ..pattern import Pattern
from ..library import Library
from ..file import gdsii
from ..shapes import Path as MPath, Polygon
def test_gdsii_roundtrip(tmp_path: Path) -> None:
lib = Library()
# Simple polygon cell
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["poly_cell"] = pat1
# Path cell
pat2 = Pattern()
pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10)
lib["path_cell"] = pat2
# Cell with Ref
pat3 = Pattern()
pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2)
lib["ref_cell"] = pat3
gds_file = tmp_path / "test.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, info = gdsii.readfile(gds_file)
assert "poly_cell" in read_lib
assert "path_cell" in read_lib
assert "ref_cell" in read_lib
# Check polygon
read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0])
# GDSII closes polygons, so it might have an extra vertex or different order
assert len(read_poly.vertices) >= 4
# Check bounds as a proxy for geometry correctness
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
# Check path
read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0])
assert isinstance(read_path, MPath)
assert read_path.width == 10
assert_equal(read_path.vertices, [[0, 0], [100, 0]])
# Check Ref
read_ref = read_lib["ref_cell"].refs["poly_cell"][0]
assert_equal(read_ref.offset, [50, 50])
assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5)
def test_gdsii_annotations(tmp_path: Path) -> None:
lib = Library()
pat = Pattern()
# GDS only supports integer keys in range [1, 126] for properties
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]})
lib["cell"] = pat
gds_file = tmp_path / "test_ann.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, _ = gdsii.readfile(gds_file)
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
assert read_ann is not None
assert read_ann["1"] == ["hello"]

48
masque/test/test_label.py Normal file
View file

@ -0,0 +1,48 @@
import copy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..label import Label
from ..repetition import Grid
from ..utils import annotations_eq
def test_label_init() -> None:
lbl = Label("test", offset=(10, 20))
assert lbl.string == "test"
assert_equal(lbl.offset, [10, 20])
def test_label_transform() -> None:
lbl = Label("test", offset=(10, 0))
# Rotate 90 deg CCW around (0,0)
lbl.rotate_around((0, 0), pi / 2)
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
# Translate
lbl.translate((5, 5))
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
def test_label_repetition() -> None:
rep = Grid(a_vector=(10, 0), a_count=3)
lbl = Label("rep", offset=(0, 0), repetition=rep)
assert lbl.repetition is rep
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
# seem to automatically include repetition bounds in label.py itself,
# it's handled during pattern bounding.
def test_label_copy() -> None:
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
l2 = copy.deepcopy(l1)
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
assert l1 == l2
assert l1 is not l2
l2.offset[0] = 100
assert l1.offset[0] == 1

261
masque/test/test_library.py Normal file
View file

@ -0,0 +1,261 @@
import pytest
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose
from ..library import Library, LazyLibrary
from ..pattern import Pattern
from ..error import LibraryError, PatternError
from ..ports import Port
from ..repetition import Grid
from ..shapes import Path
from ..file.utils import preflight
if TYPE_CHECKING:
from ..shapes import Polygon
def test_library_basic() -> None:
lib = Library()
pat = Pattern()
lib["cell1"] = pat
assert "cell1" in lib
assert lib["cell1"] is pat
assert len(lib) == 1
with pytest.raises(LibraryError):
lib["cell1"] = Pattern() # Overwriting not allowed
def test_library_tops() -> None:
lib = Library()
lib["child"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("child")
assert set(lib.tops()) == {"parent"}
assert lib.top() == "parent"
def test_library_dangling() -> None:
lib = Library()
lib["parent"] = Pattern()
lib["parent"].ref("missing")
assert lib.dangling_refs() == {"missing"}
def test_library_dangling_graph_modes() -> None:
lib = Library()
lib["parent"] = Pattern()
lib["parent"].ref("missing")
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.child_graph()
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.parent_graph()
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.child_order()
assert lib.child_graph(dangling="ignore") == {"parent": set()}
assert lib.parent_graph(dangling="ignore") == {"parent": set()}
assert lib.child_order(dangling="ignore") == ["parent"]
assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()}
assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}}
assert lib.child_order(dangling="include") == ["missing", "parent"]
def test_find_refs_with_dangling_modes() -> None:
lib = Library()
lib["target"] = Pattern()
mid = Pattern()
mid.ref("target", offset=(2, 0))
lib["mid"] = mid
top = Pattern()
top.ref("mid", offset=(5, 0))
top.ref("missing", offset=(9, 0))
lib["top"] = top
assert lib.find_refs_local("missing", dangling="ignore") == {}
assert lib.find_refs_global("missing", dangling="ignore") == {}
local_missing = lib.find_refs_local("missing", dangling="include")
assert set(local_missing) == {"top"}
assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]])
global_missing = lib.find_refs_global("missing", dangling="include")
assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]])
with pytest.raises(LibraryError, match="missing"):
lib.find_refs_local("missing")
with pytest.raises(LibraryError, match="missing"):
lib.find_refs_global("missing")
global_target = lib.find_refs_global("target")
assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]])
def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None:
def make_lib() -> Library:
lib = Library()
lib["empty"] = Pattern()
lib["top"] = Pattern()
lib["top"].ref("missing")
return lib
caplog.set_level("WARNING")
warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True)
assert "empty" not in warned
assert any("Dangling refs found" in record.message for record in caplog.records)
allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True)
assert "empty" not in allowed
with pytest.raises(LibraryError, match="Dangling refs found"):
preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True)
def test_library_flatten() -> None:
lib = Library()
child = Pattern()
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
lib["child"] = child
parent = Pattern()
parent.ref("child", offset=(10, 10))
lib["parent"] = parent
flat_lib = lib.flatten("parent")
flat_parent = flat_lib["parent"]
assert not flat_parent.has_refs()
assert len(flat_parent.shapes[(1, 0)]) == 1
# Transformations are baked into vertices for Polygon
assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices
assert tuple(assert_vertices[0]) == (10.0, 10.0)
def test_library_flatten_preserves_ports_only_child() -> None:
lib = Library()
child = Pattern(ports={"P1": Port((1, 2), 0)})
lib["child"] = child
parent = Pattern()
parent.ref("child", offset=(10, 10))
lib["parent"] = parent
flat_parent = lib.flatten("parent", flatten_ports=True)["parent"]
assert set(flat_parent.ports) == {"P1"}
assert cast("Port", flat_parent.ports["P1"]).rotation == 0
assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0)
def test_library_flatten_repeated_ref_with_ports_raises() -> None:
lib = Library()
child = Pattern(ports={"P1": Port((1, 2), 0)})
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
lib["child"] = child
parent = Pattern()
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
lib["parent"] = parent
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
lib.flatten("parent", flatten_ports=True)
def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None:
lib = Library()
child = Pattern()
child.ref("missing")
lib["child"] = child
parent = Pattern()
parent.ref("child")
lib["parent"] = parent
flat = lib.flatten("parent", dangling_ok=True)
assert set(flat["child"].refs) == {"missing"}
assert flat["child"].has_refs()
assert set(flat["parent"].refs) == {"missing"}
assert flat["parent"].has_refs()
def test_lazy_library() -> None:
lib = LazyLibrary()
called = 0
def make_pat() -> Pattern:
nonlocal called
called += 1
return Pattern()
lib["lazy"] = make_pat
assert called == 0
pat = lib["lazy"]
assert called == 1
assert isinstance(pat, Pattern)
# Second access should be cached
pat2 = lib["lazy"]
assert called == 1
assert pat is pat2
def test_library_rename() -> None:
lib = Library()
lib["old"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("old")
lib.rename("old", "new", move_references=True)
assert "old" not in lib
assert "new" in lib
assert "new" in lib["parent"].refs
assert "old" not in lib["parent"].refs
def test_library_subtree() -> None:
lib = Library()
lib["a"] = Pattern()
lib["b"] = Pattern()
lib["c"] = Pattern()
lib["a"].ref("b")
sub = lib.subtree("a")
assert "a" in sub
assert "b" in sub
assert "c" not in sub
def test_library_get_name() -> None:
lib = Library()
lib["cell"] = Pattern()
name1 = lib.get_name("cell")
assert name1 != "cell"
assert name1.startswith("cell")
name2 = lib.get_name("other")
assert name2 == "other"
def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None:
lib = Library()
pat = Pattern()
pat.shapes[(1, 0)] += [
Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)),
Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)),
]
lib["top"] = pat
lib.dedup(norm_value=1, threshold=2)
assert not lib["top"].refs
assert len(lib["top"].shapes[(1, 0)]) == 2

25
masque/test/test_oasis.py Normal file
View file

@ -0,0 +1,25 @@
from pathlib import Path
import pytest
from numpy.testing import assert_equal
from ..pattern import Pattern
from ..library import Library
def test_oasis_roundtrip(tmp_path: Path) -> None:
# Skip if fatamorgana is not installed
pytest.importorskip("fatamorgana")
from ..file import oasis
lib = Library()
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["cell1"] = pat1
oas_file = tmp_path / "test.oas"
# OASIS needs units_per_micron
oasis.writefile(lib, oas_file, units_per_micron=1000)
read_lib, info = oasis.readfile(oas_file)
assert "cell1" in read_lib
# Check bounds
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])

View file

@ -0,0 +1,96 @@
from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns
from ..library import Library
from ..pattern import Pattern
def test_maxrects_bssf_simple() -> None:
# Pack two 10x10 squares into one 20x10 container
rects = [[10, 10], [10, 10]]
containers = [[0, 0, 20, 10]]
locs, rejects = maxrects_bssf(rects, containers)
assert not rejects
# They should be at (0,0) and (10,0)
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
def test_maxrects_bssf_reject() -> None:
# Try to pack a too-large rectangle
rects = [[10, 10], [30, 30]]
containers = [[0, 0, 20, 20]]
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
assert 1 in rejects # Second rect rejected
assert 0 not in rejects
def test_maxrects_bssf_exact_fill_rejects_remaining() -> None:
rects = [[20, 20], [1, 1]]
containers = [[0, 0, 20, 20]]
locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True)
assert tuple(locs[0]) == (0.0, 0.0)
assert rejects == {1}
def test_maxrects_bssf_presort_reject_mapping() -> None:
rects = [[10, 12], [19, 14], [13, 11]]
containers = [[0, 0, 20, 20]]
_locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True)
assert rejects == {0, 2}
def test_guillotine_bssf_sas_presort_reject_mapping() -> None:
rects = [[2, 1], [17, 15], [16, 11]]
containers = [[0, 0, 20, 20]]
_locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True)
assert rejects == {2}
def test_pack_patterns() -> None:
lib = Library()
p1 = Pattern()
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["p1"] = p1
p2 = Pattern()
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
lib["p2"] = p2
# Containers: one 20x20
containers = [[0, 0, 20, 20]]
# 2um spacing
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
assert not rejects
assert len(pat.refs) == 2
assert "p1" in pat.refs
assert "p2" in pat.refs
# Check that they don't overlap (simple check via bounds)
# p1 size 10x10, effectively 12x12
# p2 size 5x5, effectively 7x7
# Both should fit in 20x20
def test_pack_patterns_reject_names_match_original_patterns() -> None:
lib = Library()
for name, (lx, ly) in {
"p0": (10, 12),
"p1": (19, 14),
"p2": (13, 11),
}.items():
pat = Pattern()
pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly)
lib[name] = pat
pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0))
assert set(rejects) == {"p0", "p2"}
assert set(pat.refs) == {"p1"}

111
masque/test/test_path.py Normal file
View file

@ -0,0 +1,111 @@
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Path
def test_path_init() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
assert_equal(p.vertices, [[0, 0], [10, 0]])
assert p.width == 2
assert p.cap == Path.Cap.Flush
def test_path_to_polygons_flush() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
polys = p.to_polygons()
assert len(polys) == 1
# Rectangle from (0, -1) to (10, 1)
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[0, -1], [10, 1]])
def test_path_to_polygons_square() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
polys = p.to_polygons()
assert len(polys) == 1
# Square cap adds width/2 = 1 to each end
# Rectangle from (-1, -1) to (11, 1)
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
def test_path_to_polygons_circle() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
polys = p.to_polygons(num_vertices=32)
# Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps
assert len(polys) >= 3
# Combined bounds should be from (-1, -1) to (11, 1)
# But wait, Path.get_bounds_single() handles this more directly
bounds = p.get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
def test_path_custom_cap() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
polys = p.to_polygons()
assert len(polys) == 1
# Extends 5 units at start, 10 at end
# Starts at -5, ends at 20
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-5, -1], [20, 1]])
def test_path_bend() -> None:
# L-shaped path
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
# Outer corner at (11, -1) is not right.
# Segments: (0,0)-(10,0) and (10,0)-(10,10)
# Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1)
# Corners of segment 2: (9,0), (9,10), (11,10), (11,0)
# Bounds should be [[-1 (if start is square), -1], [11, 11]]?
# Flush cap start at (0,0) with width 2 means y from -1 to 1.
# Vertical segment end at (10,10) with width 2 means x from 9 to 11.
# So bounds should be x: [0, 11], y: [-1, 10]
assert_equal(bounds, [[0, -1], [11, 10]])
def test_path_mirror() -> None:
p = Path(vertices=[[10, 5], [20, 10]], width=2)
p.mirror(0) # Mirror across x axis (y -> -y)
assert_equal(p.vertices, [[10, -5], [20, -10]])
def test_path_scale() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2)
p.scale_by(2)
assert_equal(p.vertices, [[0, 0], [20, 0]])
assert p.width == 4
def test_path_scale_custom_cap_extensions() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
p.scale_by(3)
assert_equal(p.vertices, [[0, 0], [30, 0]])
assert p.width == 6
assert p.cap_extensions is not None
assert_allclose(p.cap_extensions, [3, 6])
assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]])
def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
intrinsic, _extrinsic, ctor = p.normalized_form(5)
q = ctor()
assert intrinsic[-1] == (0.2, 0.4)
assert q.width == 2
assert q.cap_extensions is not None
assert_allclose(q.cap_extensions, [1, 2])
def test_path_normalized_form_distinguishes_custom_caps() -> None:
p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]

108
masque/test/test_pather.py Normal file
View file

@ -0,0 +1,108 @@
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import Pather
from ..builder.tools import PathTool
from ..library import Library
from ..ports import Port
@pytest.fixture
def pather_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
# Simple PathTool: 2um width on layer (1,0)
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
# Add an initial port facing North (pi/2)
# Port rotation points INTO device. So "North" rotation means device is North of port.
# Pathing "forward" moves South.
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return p, tool, lib
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Route 10um "forward"
p.straight("start", 10)
# port rot pi/2 (North). Travel +pi relative to port -> South.
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Start (0,0) rot pi/2 (North).
# Path 10um "forward" (South), then turn Clockwise (ccw=False).
# Facing South, turn Right -> West.
p.cw("start", 10)
# PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0.
# Transformed by port rot pi/2 (North) + pi (to move "forward" away from device):
# Transformation rot = pi/2 + pi = 3pi/2.
# (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10).
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
# North (pi/2) + CW (90 deg) -> West (pi)?
# Actual behavior results in 0 (East) - apparently rotation is flipped.
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# start at (0,0) rot pi/2 (North)
# path "forward" (South) to y=-50
p.straight("start", y=-50)
assert_equal(p.ports["start"].offset, [0, -50])
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
# Path both "forward" (South) to y=-20
p.straight(["A", "B"], ymin=-20)
assert_equal(p.ports["A"].offset, [0, -20])
assert_equal(p.ports["B"].offset, [10, -20])
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Fluent API test
p.at("start").straight(10).ccw(10)
# 10um South -> (0, -10) rot pi/2
# then 10um South and turn CCW (Facing South, CCW is East)
# PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0
# Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10)
# (0, -10) + (1, -10) = (1, -20)
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
# pi/2 (North) + CCW (90 deg) -> 0 (East)?
# Actual behavior results in pi (West).
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
def test_pather_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
p.set_dead()
# Path with negative length (impossible for PathTool, would normally raise BuildError)
p.straight("in", -10)
# Port 'in' should be updated by dummy extension despite tool failure
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
# Downstream path should work correctly using the dummy port location
p.straight("in", 20)
# 10 + (-20) = -10
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
# Verify no geometry
assert not p.pattern.has_shapes()

View file

@ -0,0 +1,272 @@
import pytest
import numpy
from numpy import pi
from masque import Pather, RenderPather, Library, Pattern, Port
from masque.builder.tools import PathTool
from masque.error import BuildError
def test_pather_trace_basic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
# Port rotation 0 points in +x (INTO device).
# To extend it, we move in -x direction.
p.pattern.ports['A'] = Port((0, 0), rotation=0)
# Trace single port
p.at('A').trace(None, 5000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
# Trace with bend
p.at('A').trace(True, 5000) # CCW bend
# Port was at (-5000, 0) rot 0.
# New wire starts at (-5000, 0) rot 0.
# Output port of wire before rotation: (5000, 500) rot -pi/2
# Rotate by pi (since dev port rot is 0 and tool port rot is 0):
# (-5000, -500) rot pi - pi/2 = pi/2
# Add to start: (-10000, -500) rot pi/2
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2)
def test_pather_trace_to() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
# Trace to x=-10000
p.at('A').trace_to(None, x=-10000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
# Trace to position=-20000
p.at('A').trace_to(None, p=-20000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0))
def test_pather_bundle_trace() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
# Straight bundle - all should align to same x
p.at(['A', 'B']).straight(xmin=-10000)
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
# Bundle with bend
p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
# Traveling in -x direction. CCW turn turns towards -y.
# A is at y=0, B is at y=2000.
# Rotation center is at y = -R.
# A is closer to center than B. So A is inner, B is outer.
# xmin is coordinate of innermost bend (A).
assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000)
# B's bend is further out (more negative x)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000)
def test_pather_each_bound() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
# Each should move by 5000 (towards -x)
p.at(['A', 'B']).trace(None, each=5000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000))
def test_selection_management() -> None:
lib = Library()
p = Pather(lib)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 0), rotation=0)
pp = p.at('A')
assert pp.ports == ['A']
pp.select('B')
assert pp.ports == ['A', 'B']
pp.deselect('A')
assert pp.ports == ['B']
pp.select(['A'])
assert pp.ports == ['B', 'A']
pp.drop()
assert 'A' not in p.pattern.ports
assert 'B' not in p.pattern.ports
assert pp.ports == []
def test_mark_fork() -> None:
lib = Library()
p = Pather(lib)
p.pattern.ports['A'] = Port((100, 200), rotation=1)
pp = p.at('A')
pp.mark('B')
assert 'B' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200))
assert p.pattern.ports['B'].rotation == 1
assert pp.ports == ['A'] # mark keeps current selection
pp.fork('C')
assert 'C' in p.pattern.ports
assert pp.ports == ['C'] # fork switches to new name
def test_rename() -> None:
lib = Library()
p = Pather(lib)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').rename('B')
assert 'A' not in p.pattern.ports
assert 'B' in p.pattern.ports
p.pattern.ports['C'] = Port((0, 0), rotation=0)
pp = p.at(['B', 'C'])
pp.rename({'B': 'D', 'C': 'E'})
assert 'B' not in p.pattern.ports
assert 'C' not in p.pattern.ports
assert 'D' in p.pattern.ports
assert 'E' in p.pattern.ports
assert set(pp.ports) == {'D', 'E'}
def test_renderpather_uturn_fallback() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = RenderPather(lib, tools=tool)
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
# PathTool doesn't implement planU, so it should fall back to two planL calls
rp.at('A').uturn(offset=10000, length=5000)
# Two steps should be added
assert len(rp.paths['A']) == 2
assert rp.paths['A'][0].opcode == 'L'
assert rp.paths['A'][1].opcode == 'L'
rp.render()
assert rp.pattern.ports['A'].rotation is not None
assert numpy.isclose(rp.pattern.ports['A'].rotation, pi)
def test_autotool_uturn() -> None:
from masque.builder.tools import AutoTool
lib = Library()
# Setup AutoTool with a simple straight and a bend
def make_straight(length: float) -> Pattern:
pat = Pattern()
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
pat.ports['in'] = Port((0, 0), 0)
pat.ports['out'] = Port((length, 0), pi)
return pat
bend_pat = Pattern()
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
bend_pat.ports['in'] = Port((0, 0), 0)
bend_pat.ports['out'] = Port((500, -500), pi/2)
lib['bend'] = bend_pat
tool = AutoTool(
straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')],
bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)],
sbends=[],
transitions={},
default_out_ptype='wire'
)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), 0)
# CW U-turn (jog < 0)
# R = 500. jog = -2000. length = 1000.
# p0 = planL(length=1000) -> out at (1000, -500) rot pi/2
# R2 = 500.
# l2_length = abs(-2000) - abs(-500) - 500 = 1000.
p.at('A').uturn(offset=-2000, length=1000)
# Final port should be at (-1000, 2000) rot pi
# Start: (0,0) rot 0. Wire direction is rot + pi = pi (West, -x).
# Tool planU returns (length, jog) = (1000, -2000) relative to (0,0) rot 0.
# Rotation of pi transforms (1000, -2000) to (-1000, 2000).
# Final rotation: 0 + pi = pi.
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
def test_pather_trace_into() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
# 1. Straight connector
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
p.at('A').trace_into('B', plug_destination=False)
assert 'B' in p.pattern.ports
assert 'A' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
# 2. Single bend
p.pattern.ports['C'] = Port((0, 0), rotation=0)
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2)
p.at('C').trace_into('D', plug_destination=False)
assert 'D' in p.pattern.ports
assert 'C' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000))
# 3. Jog (S-bend)
p.pattern.ports['E'] = Port((0, 0), rotation=0)
p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi)
p.at('E').trace_into('F', plug_destination=False)
assert 'F' in p.pattern.ports
assert 'E' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000))
# 4. U-bend (0 deg angle)
p.pattern.ports['G'] = Port((0, 0), rotation=0)
p.pattern.ports['H'] = Port((-10000, 2000), rotation=0)
p.at('G').trace_into('H', plug_destination=False)
assert 'H' in p.pattern.ports
assert 'G' in p.pattern.ports
# A U-bend with length=-travel=10000 and jog=-2000 from (0,0) rot 0
# ends up at (-10000, 2000) rot pi.
assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000))
assert p.pattern.ports['G'].rotation is not None
assert numpy.isclose(p.pattern.ports['G'].rotation, pi)
def test_pather_jog_failed_fallback_is_atomic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='shorter than required bend'):
p.jog('A', 1.5, length=5)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0
def test_pather_uturn_failed_fallback_is_atomic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=2, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='shorter than required bend'):
p.uturn('A', 1.5, length=0)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].rotation == 0
assert len(p.paths['A']) == 0

150
masque/test/test_pattern.py Normal file
View file

@ -0,0 +1,150 @@
import pytest
from typing import cast
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..error import PatternError
from ..pattern import Pattern
from ..shapes import Polygon
from ..ref import Ref
from ..ports import Port
from ..label import Label
from ..repetition import Grid
def test_pattern_init() -> None:
pat = Pattern()
assert pat.is_empty()
assert not pat.has_shapes()
assert not pat.has_refs()
assert not pat.has_labels()
assert not pat.has_ports()
def test_pattern_with_elements() -> None:
poly = Polygon.square(10)
label = Label("test", offset=(5, 5))
ref = Ref(offset=(100, 100))
port = Port((0, 0), 0)
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
assert pat.has_shapes()
assert pat.has_labels()
assert pat.has_refs()
assert pat.has_ports()
assert not pat.is_empty()
assert pat.shapes[(1, 0)] == [poly]
assert pat.labels[(1, 2)] == [label]
assert pat.refs["sub"] == [ref]
assert pat.ports["P1"] == port
def test_pattern_append() -> None:
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
pat2 = Pattern()
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
pat1.append(pat2)
assert len(pat1.shapes[(1, 0)]) == 1
assert len(pat1.shapes[(2, 0)]) == 1
def test_pattern_translate() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
pat.ports["P1"] = Port((5, 5), 0)
pat.translate_elements((10, 20))
# Polygon.translate adds to vertices, and offset is always (0,0)
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20])
assert_equal(pat.ports["P1"].offset, [15, 25])
def test_pattern_scale() -> None:
pat = Pattern()
# Polygon.rect sets an offset in its constructor which is immediately translated into vertices
pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
pat.scale_by(2)
# Vertices should be scaled
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
def test_pattern_rotate() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]])
# Rotate 90 degrees CCW around (0,0)
pat.rotate_around((0, 0), pi / 2)
# [10, 0] rotated 90 deg around (0,0) is [0, 10]
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10)
def test_pattern_mirror() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]])
# Mirror across X axis (y -> -y)
pat.mirror(0)
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5])
def test_pattern_get_bounds() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
bounds = pat.get_bounds()
assert_equal(bounds, [[-5, -5], [10, 10]])
def test_pattern_flatten_preserves_ports_only_child() -> None:
child = Pattern(ports={"P1": Port((1, 2), 0)})
parent = Pattern()
parent.ref("child", offset=(10, 10))
parent.flatten({"child": child}, flatten_ports=True)
assert set(parent.ports) == {"P1"}
assert parent.ports["P1"].rotation == 0
assert tuple(parent.ports["P1"].offset) == (11.0, 12.0)
def test_pattern_flatten_repeated_ref_with_ports_raises() -> None:
child = Pattern(ports={"P1": Port((1, 2), 0)})
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
parent = Pattern()
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
parent.flatten({"child": child}, flatten_ports=True)
def test_pattern_place_requires_abstract_for_reference() -> None:
parent = Pattern()
child = Pattern()
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
parent.place(child)
def test_pattern_interface() -> None:
source = Pattern()
source.ports["A"] = Port((10, 20), 0, ptype="test")
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
assert "in_A" in iface.ports
assert "out_A" in iface.ports
assert iface.ports["in_A"].rotation is not None
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
assert iface.ports["out_A"].rotation is not None
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
assert iface.ports["in_A"].ptype == "test"
assert iface.ports["out_A"].ptype == "test"

125
masque/test/test_polygon.py Normal file
View file

@ -0,0 +1,125 @@
import pytest
import numpy
from numpy.testing import assert_equal
from ..shapes import Polygon
from ..utils import R90
from ..error import PatternError
@pytest.fixture
def polygon() -> Polygon:
return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
def test_vertices(polygon: Polygon) -> None:
assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
def test_xs(polygon: Polygon) -> None:
assert_equal(polygon.xs, [0, 1, 1, 0])
def test_ys(polygon: Polygon) -> None:
assert_equal(polygon.ys, [0, 0, 1, 1])
def test_offset(polygon: Polygon) -> None:
assert_equal(polygon.offset, [0, 0])
def test_square() -> None:
square = Polygon.square(1)
assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]])
def test_rectangle() -> None:
rectangle = Polygon.rectangle(1, 2)
assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]])
def test_rect() -> None:
rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1)
assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2)
assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2)
assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]])
rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0)
assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]])
with pytest.raises(PatternError):
Polygon.rect(xctr=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(xmin=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(xmax=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(lx=0, yctr=-2, ymax=0)
with pytest.raises(PatternError):
Polygon.rect(yctr=0, xctr=-2, xmax=0)
with pytest.raises(PatternError):
Polygon.rect(ymin=0, xctr=-2, xmax=0)
with pytest.raises(PatternError):
Polygon.rect(ymax=0, xctr=-2, xmax=0)
with pytest.raises(PatternError):
Polygon.rect(ly=0, xctr=-2, xmax=0)
def test_octagon() -> None:
octagon = Polygon.octagon(side_length=1) # regular=True
assert_equal(octagon.vertices.shape, (8, 2))
diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0)
side_len = numpy.sqrt((diff * diff).sum(axis=1))
assert numpy.allclose(side_len, 1)
def test_to_polygons(polygon: Polygon) -> None:
assert polygon.to_polygons() == [polygon]
def test_get_bounds_single(polygon: Polygon) -> None:
assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]])
def test_rotate(polygon: Polygon) -> None:
rotated_polygon = polygon.rotate(R90)
assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]])
def test_mirror(polygon: Polygon) -> None:
mirrored_by_y = polygon.deepcopy().mirror(1)
assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]])
print(polygon.vertices)
mirrored_by_x = polygon.deepcopy().mirror(0)
assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]])
def test_scale_by(polygon: Polygon) -> None:
scaled_polygon = polygon.scale_by(2)
assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]])
def test_clean_vertices(polygon: Polygon) -> None:
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices()
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
def test_remove_duplicate_vertices() -> None:
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices()
assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]])
def test_remove_colinear_vertices() -> None:
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices()
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
def test_vertices_dtype() -> None:
polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32))
polygon.scale_by(0.5)
assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]])

189
masque/test/test_ports.py Normal file
View file

@ -0,0 +1,189 @@
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..ports import Port, PortList
from ..error import PortError
def test_port_init() -> None:
p = Port(offset=(10, 20), rotation=pi / 2, ptype="test")
assert_equal(p.offset, [10, 20])
assert p.rotation == pi / 2
assert p.ptype == "test"
def test_port_transform() -> None:
p = Port(offset=(10, 0), rotation=0)
p.rotate_around((0, 0), pi / 2)
assert_allclose(p.offset, [0, 10], atol=1e-10)
assert p.rotation is not None
assert_allclose(p.rotation, pi / 2, atol=1e-10)
p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
assert_allclose(p.offset, [0, 10], atol=1e-10)
# rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2
assert p.rotation is not None
assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10)
def test_port_flip_across() -> None:
p = Port(offset=(10, 0), rotation=0)
p.flip_across(axis=1) # Mirror across x=0: flips x-offset
assert_equal(p.offset, [-10, 0])
# rotation was 0, mirrored(1) -> pi
assert p.rotation is not None
assert_allclose(p.rotation, pi, atol=1e-10)
def test_port_measure_travel() -> None:
p1 = Port((0, 0), 0)
p2 = Port((10, 5), pi) # Facing each other
(travel, jog), rotation = p1.measure_travel(p2)
assert travel == 10
assert jog == 5
assert rotation == pi
def test_port_describe_any_rotation() -> None:
p = Port((0, 0), None)
assert p.describe() == "pos=(0, 0), rot=any"
def test_port_list_rename() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((0, 0), 0)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
pl.rename_ports({"A": "B"})
assert "A" not in pl.ports
assert "B" in pl.ports
def test_port_list_rename_missing_port_raises() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((0, 0), 0)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
with pytest.raises(PortError, match="Ports to rename were not found"):
pl.rename_ports({"missing": "B"})
assert set(pl.ports) == {"A"}
def test_port_list_add_port_pair_requires_distinct_names() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports: dict[str, Port] = {}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
with pytest.raises(PortError, match="Port names must be distinct"):
pl.add_port_pair(names=("A", "A"))
assert not pl.ports
def test_port_list_plugged() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
pl.plugged({"A": "B"})
assert not pl.ports # Both should be removed
def test_port_list_plugged_empty_raises() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
with pytest.raises(PortError, match="Must provide at least one port connection"):
pl.plugged({})
assert set(pl.ports) == {"A", "B"}
def test_port_list_plugged_missing_port_raises() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
with pytest.raises(PortError, match="Connection source ports were not found"):
pl.plugged({"missing": "B"})
assert set(pl.ports) == {"A", "B"}
with pytest.raises(PortError, match="Connection destination ports were not found"):
pl.plugged({"A": "missing"})
assert set(pl.ports) == {"A", "B"}
def test_port_list_plugged_mismatch() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
self._ports = {
"A": Port((10, 10), 0),
"B": Port((11, 10), pi), # Offset mismatch
}
@property
def ports(self) -> dict[str, Port]:
return self._ports
@ports.setter
def ports(self, val: dict[str, Port]) -> None:
self._ports = val
pl = MyPorts()
with pytest.raises(PortError):
pl.plugged({"A": "B"})

View file

@ -0,0 +1,76 @@
import numpy
from numpy.testing import assert_allclose
from ..utils.ports2data import ports_to_data, data_to_ports
from ..pattern import Pattern
from ..ports import Port
from ..library import Library
def test_ports2data_roundtrip() -> None:
pat = Pattern()
pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test")
layer = (10, 0)
ports_to_data(pat, layer)
assert len(pat.labels[layer]) == 1
assert pat.labels[layer][0].string == "P1:test 90"
assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0)
# New pattern, read ports back
pat2 = Pattern()
pat2.labels[layer] = pat.labels[layer]
data_to_ports([layer], {}, pat2)
assert "P1" in pat2.ports
assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10)
assert pat2.ports["P1"].rotation is not None
assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10)
assert pat2.ports["P1"].ptype == "test"
def test_data_to_ports_hierarchical() -> None:
lib = Library()
# Child has port data in labels
child = Pattern()
layer = (10, 0)
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
lib["child"] = child
# Parent references child
parent = Pattern()
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2)
# Read ports hierarchically (max_depth > 0)
data_to_ports([layer], lib, parent, max_depth=1)
# child port A (5,0) rot 0
# transformed by parent ref: rot pi/2, trans (100, 100)
# (5,0) rot pi/2 -> (0, 5)
# (0, 5) + (100, 100) = (100, 105)
# rot 0 + pi/2 = pi/2
assert "A" in parent.ports
assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10)
assert parent.ports["A"].rotation is not None
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
def test_data_to_ports_hierarchical_scaled_ref() -> None:
lib = Library()
child = Pattern()
layer = (10, 0)
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
lib["child"] = child
parent = Pattern()
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2)
data_to_ports([layer], lib, parent, max_depth=1)
assert "A" in parent.ports
assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10)
assert parent.ports["A"].rotation is not None
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)

89
masque/test/test_ref.py Normal file
View file

@ -0,0 +1,89 @@
from typing import cast, TYPE_CHECKING
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..error import MasqueError
from ..pattern import Pattern
from ..ref import Ref
from ..repetition import Grid
if TYPE_CHECKING:
from ..shapes import Polygon
def test_ref_init() -> None:
ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0)
assert_equal(ref.offset, [10, 20])
assert ref.rotation == pi / 4
assert ref.mirrored is True
assert ref.scale == 2.0
def test_ref_as_pattern() -> None:
sub_pat = Pattern()
sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0)
transformed_pat = ref.as_pattern(sub_pat)
# Check transformed shape
shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0])
# ref.as_pattern deepcopies sub_pat then applies transformations:
# 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]]
# 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]]
# 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]]
assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10)
def test_ref_with_repetition() -> None:
sub_pat = Pattern()
sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2)
ref = Ref(repetition=rep)
repeated_pat = ref.as_pattern(sub_pat)
# Should have 4 shapes
assert len(repeated_pat.shapes[(1, 0)]) == 4
first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]])
assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)]
def test_ref_get_bounds() -> None:
sub_pat = Pattern()
sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]])
ref = Ref(offset=(10, 10), scale=2.0)
bounds = ref.get_bounds_single(sub_pat)
# sub_pat bounds [[0,0], [5,5]]
# scaled [[0,0], [10,10]]
# translated [[10,10], [20,20]]
assert_equal(bounds, [[10, 10], [20, 20]])
def test_ref_copy() -> None:
ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]})
ref2 = ref1.copy()
assert ref1 == ref2
assert ref1 is not ref2
ref2.offset[0] = 100
assert ref1.offset[0] == 1
def test_ref_rejects_nonpositive_scale() -> None:
with pytest.raises(MasqueError, match='Scale must be positive'):
Ref(scale=0)
with pytest.raises(MasqueError, match='Scale must be positive'):
Ref(scale=-1)
def test_ref_scale_by_rejects_nonpositive_scale() -> None:
ref = Ref(scale=2.0)
with pytest.raises(MasqueError, match='Scale must be positive'):
ref.scale_by(-1)

View file

@ -0,0 +1,132 @@
import pytest
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose
from numpy import pi
from ..builder import RenderPather
from ..builder.tools import PathTool
from ..library import Library
from ..ports import Port
if TYPE_CHECKING:
from ..shapes import Path
@pytest.fixture
def rpather_setup() -> tuple[RenderPather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
rp = RenderPather(lib, tools=tool)
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return rp, tool, lib
def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
# Plan two segments
rp.at("start").straight(10).straight(10)
# Before rendering, no shapes in pattern
assert not rp.pattern.has_shapes()
assert len(rp.paths["start"]) == 2
# Render
rp.render()
assert rp.pattern.has_shapes()
assert len(rp.pattern.shapes[(1, 0)]) == 1
# Path vertices should be (0,0), (0,-10), (0,-20)
# transformed by start port (rot pi/2 -> 270 deg transform)
# wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi)
# start_port rot pi/2. pi/2 + pi = 3pi/2.
# (10, 0) rotated 3pi/2 -> (0, -10)
# So vertices: (0,0), (0,-10), (0,-20)
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert len(path_shape.vertices) == 3
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
# Plan straight then bend
rp.at("start").straight(10).cw(10)
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
# Path vertices:
# 1. Start (0,0)
# 2. Straight end: (0, -10)
# 3. Bend end: (-1, -20)
# PathTool.planL(ccw=False, length=10) returns data=[10, -1]
# start_port for 2nd segment is at (0, -10) with rotation pi/2
# dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20).
# and final end_port.offset is (-1, -20).
assert len(path_shape.vertices) == 4
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool1, lib = rpather_setup
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
rp.at("start").straight(10)
rp.retool(tool2, keys=["start"])
rp.at("start").straight(10)
rp.render()
# Different tools should cause different batches/shapes
assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 0)]) == 1
def test_renderpather_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
rp = RenderPather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
rp.set_dead()
# Impossible path
rp.straight("in", -10)
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
# Verify no render steps were added
assert len(rp.paths["in"]) == 0
# Verify no geometry
rp.render()
assert not rp.pattern.has_shapes()
def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
rp.at("start").straight(10)
# Rename port while path is planned
rp.rename_ports({"start": "new_start"})
# Continue path on new name
rp.at("new_start").straight(10)
assert "start" not in rp.paths
assert len(rp.paths["new_start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
assert len(rp.pattern.shapes[(1, 0)]) == 1
# Total length 20. start_port rot pi/2 -> 270 deg transform.
# Vertices (0,0), (0,-10), (0,-20)
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
assert "new_start" in rp.ports
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
def test_pathtool_traceL_bend_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
tree = tool.traceL(True, 10)
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10)

View file

@ -0,0 +1,51 @@
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..repetition import Grid, Arbitrary
def test_grid_displacements() -> None:
# 2x2 grid
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
disps = sorted([tuple(d) for d in grid.displacements])
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
def test_grid_1d() -> None:
grid = Grid(a_vector=(10, 0), a_count=3)
disps = sorted([tuple(d) for d in grid.displacements])
assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]
def test_grid_rotate() -> None:
grid = Grid(a_vector=(10, 0), a_count=2)
grid.rotate(pi / 2)
assert_allclose(grid.a_vector, [0, 10], atol=1e-10)
def test_grid_get_bounds() -> None:
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
bounds = grid.get_bounds()
assert_equal(bounds, [[0, 0], [10, 5]])
def test_arbitrary_displacements() -> None:
pts = [[0, 0], [10, 20], [-5, 30]]
arb = Arbitrary(pts)
# They should be sorted by displacements.setter
disps = arb.displacements
assert len(disps) == 3
assert any((disps == [0, 0]).all(axis=1))
assert any((disps == [10, 20]).all(axis=1))
assert any((disps == [-5, 30]).all(axis=1))
def test_arbitrary_transform() -> None:
arb = Arbitrary([[10, 0]])
arb.rotate(pi / 2)
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is:
# self.displacements[:, 1 - axis] *= -1
# if axis=0, 1-axis=1, so y *= -1
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)

View file

@ -0,0 +1,133 @@
from typing import cast
import numpy as np
from numpy.testing import assert_allclose
from ..pattern import Pattern
from ..ref import Ref
from ..label import Label
from ..repetition import Grid
def test_ref_rotate_intrinsic() -> None:
# Intrinsic rotate() should NOT affect repetition
rep = Grid(a_vector=(10, 0), a_count=2)
ref = Ref(repetition=rep)
ref.rotate(np.pi/2)
assert_allclose(ref.rotation, np.pi/2, atol=1e-10)
# Grid vector should still be (10, 0)
assert ref.repetition is not None
assert_allclose(cast('Grid', ref.repetition).a_vector, [10, 0], atol=1e-10)
def test_ref_rotate_around_extrinsic() -> None:
# Extrinsic rotate_around() SHOULD affect repetition
rep = Grid(a_vector=(10, 0), a_count=2)
ref = Ref(repetition=rep)
ref.rotate_around((0, 0), np.pi/2)
assert_allclose(ref.rotation, np.pi/2, atol=1e-10)
# Grid vector should be rotated to (0, 10)
assert ref.repetition is not None
assert_allclose(cast('Grid', ref.repetition).a_vector, [0, 10], atol=1e-10)
def test_pattern_rotate_around_extrinsic() -> None:
# Pattern.rotate_around() SHOULD affect repetition of its elements
rep = Grid(a_vector=(10, 0), a_count=2)
ref = Ref(repetition=rep)
pat = Pattern()
pat.refs['cell'].append(ref)
pat.rotate_around((0, 0), np.pi/2)
# Check the ref inside the pattern
ref_in_pat = pat.refs['cell'][0]
assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10)
# Grid vector should be rotated to (0, 10)
assert ref_in_pat.repetition is not None
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10)
def test_label_rotate_around_extrinsic() -> None:
# Extrinsic rotate_around() SHOULD affect repetition of labels
rep = Grid(a_vector=(10, 0), a_count=2)
lbl = Label("test", repetition=rep, offset=(5, 0))
lbl.rotate_around((0, 0), np.pi/2)
# Label offset should be (0, 5)
assert_allclose(lbl.offset, [0, 5], atol=1e-10)
# Grid vector should be rotated to (0, 10)
assert lbl.repetition is not None
assert_allclose(cast('Grid', lbl.repetition).a_vector, [0, 10], atol=1e-10)
def test_pattern_rotate_elements_intrinsic() -> None:
# rotate_elements() should NOT affect repetition
rep = Grid(a_vector=(10, 0), a_count=2)
ref = Ref(repetition=rep)
pat = Pattern()
pat.refs['cell'].append(ref)
pat.rotate_elements(np.pi/2)
ref_in_pat = pat.refs['cell'][0]
assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10)
# Grid vector should still be (10, 0)
assert ref_in_pat.repetition is not None
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 0], atol=1e-10)
def test_pattern_rotate_element_centers_extrinsic() -> None:
# rotate_element_centers() SHOULD affect repetition and offset
rep = Grid(a_vector=(10, 0), a_count=2)
ref = Ref(repetition=rep, offset=(5, 0))
pat = Pattern()
pat.refs['cell'].append(ref)
pat.rotate_element_centers(np.pi/2)
ref_in_pat = pat.refs['cell'][0]
# Offset should be (0, 5)
assert_allclose(ref_in_pat.offset, [0, 5], atol=1e-10)
# Grid vector should be rotated to (0, 10)
assert ref_in_pat.repetition is not None
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10)
# Ref rotation should NOT be changed
assert_allclose(ref_in_pat.rotation, 0, atol=1e-10)
def test_pattern_mirror_elements_intrinsic() -> None:
# mirror_elements() should NOT affect repetition or offset
rep = Grid(a_vector=(10, 5), a_count=2)
ref = Ref(repetition=rep, offset=(5, 2))
pat = Pattern()
pat.refs['cell'].append(ref)
pat.mirror_elements(axis=0) # Mirror across x (flip y)
ref_in_pat = pat.refs['cell'][0]
assert ref_in_pat.mirrored is True
# Repetition and offset should be unchanged
assert ref_in_pat.repetition is not None
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 5], atol=1e-10)
assert_allclose(ref_in_pat.offset, [5, 2], atol=1e-10)
def test_pattern_mirror_element_centers_extrinsic() -> None:
# mirror_element_centers() SHOULD affect repetition and offset
rep = Grid(a_vector=(10, 5), a_count=2)
ref = Ref(repetition=rep, offset=(5, 2))
pat = Pattern()
pat.refs['cell'].append(ref)
pat.mirror_element_centers(axis=0) # Mirror across x (flip y)
ref_in_pat = pat.refs['cell'][0]
# Offset should be (5, -2)
assert_allclose(ref_in_pat.offset, [5, -2], atol=1e-10)
# Grid vector should be (10, -5)
assert ref_in_pat.repetition is not None
assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, -5], atol=1e-10)
# Ref mirrored state should NOT be changed
assert ref_in_pat.mirrored is False

View file

@ -0,0 +1,147 @@
from pathlib import Path
import pytest
import numpy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection
from ..error import PatternError
# 1. Text shape tests
def test_text_to_polygons() -> None:
pytest.importorskip("freetype")
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
if not Path(font_path).exists():
pytest.skip("Font file not found")
t = Text("Hi", height=10, font_path=font_path)
polys = t.to_polygons()
assert len(polys) > 0
assert all(isinstance(p, Polygon) for p in polys)
# Check that it advances
# Character 'H' and 'i' should have different vertices
# Each character is a set of polygons. We check the mean x of vertices for each character.
char_x_means = [p.vertices[:, 0].mean() for p in polys]
assert len(set(char_x_means)) >= 2
# 2. Manhattanization tests
def test_manhattanize() -> None:
pytest.importorskip("float_raster")
pytest.importorskip("skimage.measure")
# Diamond shape
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
grid = numpy.arange(0, 11, 1)
manhattan_polys = poly.manhattanize(grid, grid)
assert len(manhattan_polys) >= 1
for mp in manhattan_polys:
# Check that all edges are axis-aligned
dv = numpy.diff(mp.vertices, axis=0)
# For each segment, either dx or dy must be zero
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
# 3. Comparison and Sorting tests
def test_shape_comparisons() -> None:
c1 = Circle(radius=10)
c2 = Circle(radius=20)
assert c1 < c2
assert not (c2 < c1)
p1 = Polygon([[0, 0], [10, 0], [10, 10]])
p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex
assert p1 < p2
# Different types
assert c1 < p1 or p1 < c1
assert (c1 < p1) != (p1 < c1)
# 4. Arc/Path Edge Cases
def test_arc_edge_cases() -> None:
# Wrapped arc (> 360 deg)
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
a.to_polygons(num_vertices=64)
# Should basically be a ring
bounds = a.get_bounds_single()
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
def test_path_edge_cases() -> None:
# Zero-length segments
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
# 5. PolyCollection with holes
def test_poly_collection_holes() -> None:
# Outer square, inner square hole
# PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do?
# wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple
# polygons or using specific winding rules.
# masque.shapes.Polygon doc says "specify an implicitly-closed boundary".
# Pyclipper is used in connectivity.py for holes.
# Let's test PolyCollection with multiple polygons
verts = [
[0, 0],
[10, 0],
[10, 10],
[0, 10], # Poly 1
[2, 2],
[2, 8],
[8, 8],
[8, 2], # Poly 2
]
offsets = [0, 4]
pc = PolyCollection(verts, offsets)
polys = pc.to_polygons()
assert len(polys) == 2
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
def test_poly_collection_constituent_empty() -> None:
# One real triangle, one "empty" polygon (0 vertices), one real square
# Note: Polygon requires 3 vertices, so "empty" here might mean just some junk
# that to_polygons should handle.
# Actually PolyCollection doesn't check vertex count per polygon.
verts = [
[0, 0],
[1, 0],
[0, 1], # Tri
# Empty space
[10, 10],
[11, 10],
[11, 11],
[10, 11], # Square
]
offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square?
# No, offsets should be strictly increasing or handle 0-length slices.
# vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)]))
# if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7]
offsets = [0, 3, 3]
pc = PolyCollection(verts, offsets)
# Polygon(vertices=[]) will fail because of the setter check.
# Let's see if pc.to_polygons() handles it.
# It calls Polygon(vertices=vv) for each slice.
# slice [3:3] gives empty vv.
with pytest.raises(PatternError):
pc.to_polygons()
def test_poly_collection_valid() -> None:
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 3]
pc = PolyCollection(verts, offsets)
assert len(pc.to_polygons()) == 2
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
sorted_shapes = sorted(shapes)
assert len(sorted_shapes) == 4
# Just verify it doesn't crash and is stable
assert sorted(sorted_shapes) == sorted_shapes

142
masque/test/test_shapes.py Normal file
View file

@ -0,0 +1,142 @@
import numpy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection
def test_poly_collection_init() -> None:
# Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]]
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 4]
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
assert len(list(pc.polygon_vertices)) == 2
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
def test_poly_collection_to_polygons() -> None:
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 4]
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
polys = pc.to_polygons()
assert len(polys) == 2
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
def test_circle_init() -> None:
c = Circle(radius=10, offset=(5, 5))
assert c.radius == 10
assert_equal(c.offset, [5, 5])
def test_circle_to_polygons() -> None:
c = Circle(radius=10)
polys = c.to_polygons(num_vertices=32)
assert len(polys) == 1
assert isinstance(polys[0], Polygon)
# A circle with 32 vertices should have vertices distributed around (0,0)
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
def test_ellipse_init() -> None:
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
assert_equal(e.radii, [10, 5])
assert_equal(e.offset, [1, 2])
assert e.rotation == pi / 4
def test_ellipse_to_polygons() -> None:
e = Ellipse(radii=(10, 5))
polys = e.to_polygons(num_vertices=64)
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
def test_arc_init() -> None:
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
assert_equal(a.radii, [10, 10])
assert_equal(a.angles, [0, pi / 2])
assert a.width == 2
def test_arc_to_polygons() -> None:
# Quarter circle arc
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
polys = a.to_polygons(num_vertices=32)
assert len(polys) == 1
# Outer radius 11, inner radius 9
# Quarter circle from 0 to 90 deg
bounds = polys[0].get_bounds_single()
# Min x should be 0 (inner edge start/stop or center if width is large)
# But wait, the arc is centered at 0,0.
# Outer edge goes from (11, 0) to (0, 11)
# Inner edge goes from (9, 0) to (0, 9)
# So x ranges from 0 to 11, y ranges from 0 to 11.
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
def test_shape_mirror() -> None:
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
assert_equal(e.offset, [10, 20])
# rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi)
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20))
a.mirror(0)
assert_equal(a.offset, [10, 20])
# For Arc, mirror(0) negates rotation and angles
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
def test_shape_flip_across() -> None:
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
e.flip_across(axis=0) # Mirror across y=0: flips y-offset
assert_equal(e.offset, [10, -20])
# rotation also flips: -pi/4 == 3pi/4 (mod pi)
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
# Mirror across specific y
e = Ellipse(radii=(10, 5), offset=(10, 20))
e.flip_across(y=10) # Mirror across y=10
# y=20 mirrored across y=10 -> y=0
assert_equal(e.offset, [10, 0])
def test_shape_scale() -> None:
e = Ellipse(radii=(10, 5))
e.scale_by(2)
assert_equal(e.radii, [20, 10])
a = Arc(radii=(10, 5), angles=(0, pi), width=2)
a.scale_by(0.5)
assert_equal(a.radii, [5, 2.5])
assert a.width == 1
def test_shape_arclen() -> None:
# Test that max_arclen correctly limits segment lengths
# Ellipse
e = Ellipse(radii=(10, 5))
# Approximate perimeter is ~48.4
# With max_arclen=5, should have > 10 segments
polys = e.to_polygons(max_arclen=5)
v = polys[0].vertices
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
assert numpy.all(dist <= 5.000001)
assert len(v) > 10
# Arc
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
# Outer perimeter is 11 * pi/2 ~ 17.27
# Inner perimeter is 9 * pi/2 ~ 14.14
# With max_arclen=2, should have > 8 segments on outer edge
polys = a.to_polygons(max_arclen=2)
v = polys[0].vertices
# Arc polygons are closed, but contain both inner and outer edges and caps
# Let's just check that all segment lengths are within limit
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
assert numpy.all(dist <= 2.000001)

70
masque/test/test_svg.py Normal file
View file

@ -0,0 +1,70 @@
from pathlib import Path
import xml.etree.ElementTree as ET
import numpy
import pytest
from numpy.testing import assert_allclose
pytest.importorskip("svgwrite")
from ..library import Library
from ..pattern import Pattern
from ..file import svg
SVG_NS = "{http://www.w3.org/2000/svg}"
XLINK_HREF = "{http://www.w3.org/1999/xlink}href"
def _child_transform(svg_path: Path) -> tuple[float, ...]:
root = ET.fromstring(svg_path.read_text())
for use in root.iter(f"{SVG_NS}use"):
if use.attrib.get(XLINK_HREF) == "#child":
raw = use.attrib["transform"]
assert raw.startswith("matrix(") and raw.endswith(")")
return tuple(float(value) for value in raw[7:-1].split())
raise AssertionError("No child reference found in SVG output")
def test_svg_ref_rotation_uses_correct_affine_transform(tmp_path: Path) -> None:
lib = Library()
child = Pattern()
child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]])
lib["child"] = child
top = Pattern()
top.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2)
lib["top"] = top
svg_path = tmp_path / "rotation.svg"
svg.writefile(lib, "top", str(svg_path))
assert_allclose(_child_transform(svg_path), (0, 2, -2, 0, 3, 4), atol=1e-10)
def test_svg_ref_mirroring_changes_affine_transform(tmp_path: Path) -> None:
base = Library()
child = Pattern()
child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]])
base["child"] = child
top_plain = Pattern()
top_plain.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=False)
base["plain"] = top_plain
plain_path = tmp_path / "plain.svg"
svg.writefile(base, "plain", str(plain_path))
plain_transform = _child_transform(plain_path)
mirrored = Library()
mirrored["child"] = child.deepcopy()
top_mirrored = Pattern()
top_mirrored.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=True)
mirrored["mirrored"] = top_mirrored
mirrored_path = tmp_path / "mirrored.svg"
svg.writefile(mirrored, "mirrored", str(mirrored_path))
mirrored_transform = _child_transform(mirrored_path)
assert_allclose(plain_transform, (0, 2, -2, 0, 3, 4), atol=1e-10)
assert_allclose(mirrored_transform, (0, 2, 2, 0, 3, 4), atol=1e-10)

106
masque/test/test_utils.py Normal file
View file

@ -0,0 +1,106 @@
import numpy
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, DeferredDict
def test_remove_duplicate_vertices() -> None:
# Closed path (default)
v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]]
v_clean = remove_duplicate_vertices(v, closed_path=True)
# The last [0,0] is a duplicate of the first [0,0] if closed_path=True
assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]])
# Open path
v_clean_open = remove_duplicate_vertices(v, closed_path=False)
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
def test_remove_colinear_vertices() -> None:
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
v_clean = remove_colinear_vertices(v, closed_path=True)
# [1, 0] is between [0, 0] and [2, 0]
# [2, 1] is between [2, 0] and [2, 2]
# [1, 1] is between [2, 2] and [0, 0]
assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]])
def test_remove_colinear_vertices_exhaustive() -> None:
# U-turn
v = [[0, 0], [10, 0], [0, 0]]
v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True)
# Open path should keep ends. [10,0] is between [0,0] and [0,0]?
# They are colinear, but it's a 180 degree turn.
# We preserve 180 degree turns if preserve_uturns is True.
assert len(v_clean) == 3
v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False)
# If not preserving u-turns, it should collapse to just the endpoints
assert len(v_collapsed) == 2
# 180 degree U-turn in closed path
v = [[0, 0], [10, 0], [5, 0]]
v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False)
assert len(v_clean) == 2
def test_poly_contains_points() -> None:
v = [[0, 0], [10, 0], [10, 10], [0, 10]]
pts = [[5, 5], [-1, -1], [10, 10], [11, 5]]
inside = poly_contains_points(v, pts)
assert_equal(inside, [True, False, True, False])
def test_rotation_matrix_2d() -> None:
m = rotation_matrix_2d(pi / 2)
assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10)
def test_rotation_matrix_non_manhattan() -> None:
# 45 degrees
m = rotation_matrix_2d(pi / 4)
s = numpy.sqrt(2) / 2
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10)
def test_apply_transforms() -> None:
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
t1 = [10, 20, 0, 0]
t2 = [[5, 0, 0, 0], [0, 5, 0, 0]]
combined = apply_transforms(t1, t2)
assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]])
def test_apply_transforms_advanced() -> None:
# Ox4: (x, y, rot, mir)
# Outer: mirror x (axis 0), then rotate 90 deg CCW
# apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror)
outer = [0, 0, pi / 2, 1]
# Inner: (10, 0, 0, 0)
inner = [10, 0, 0, 0]
combined = apply_transforms(outer, inner)
# 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0)
# 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10)
# 3. add outer offset (0, 0) -> (0, 10)
assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10)
def test_deferred_dict_accessors_resolve_values_once() -> None:
calls = 0
def make_value() -> int:
nonlocal calls
calls += 1
return 7
deferred = DeferredDict[str, int]()
deferred["x"] = make_value
assert deferred.get("missing", 9) == 9
assert deferred.get("x") == 7
assert list(deferred.values()) == [7]
assert list(deferred.items()) == [("x", 7)]
assert calls == 1

View file

@ -0,0 +1,55 @@
import numpy as np
import pytest
from masque.pattern import Pattern
from masque.ports import Port
from masque.repetition import Grid
try:
import matplotlib
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
def test_visualize_noninteractive(tmp_path) -> None:
"""
Test that visualize() runs and saves a file without error.
This covers the recursive transformation and collection logic.
"""
# Create a hierarchy
child = Pattern()
child.polygon('L1', [[0, 0], [1, 0], [1, 1], [0, 1]])
child.ports['P1'] = Port((0.5, 0.5), 0)
parent = Pattern()
# Add some refs with various transforms
parent.ref('child', offset=(10, 0), rotation=np.pi/4, mirrored=True, scale=2.0)
# Add a repetition
rep = Grid(a_vector=(5, 5), a_count=2)
parent.ref('child', offset=(0, 10), repetition=rep)
library = {'child': child}
output_file = tmp_path / "test_plot.png"
# Run visualize with filename to avoid showing window
parent.visualize(library=library, filename=str(output_file), ports=True)
assert output_file.exists()
assert output_file.stat().st_size > 0
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
def test_visualize_empty() -> None:
""" Test visualizing an empty pattern. """
pat = Pattern()
# Should not raise
pat.visualize(overdraw=True)
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
def test_visualize_no_refs() -> None:
""" Test visualizing a pattern with only local shapes (no library needed). """
pat = Pattern()
pat.polygon('L1', [[0, 0], [1, 0], [0, 1]])
# Should not raise even if library is None
pat.visualize(overdraw=True)

View file

@ -26,7 +26,11 @@ from .scalable import (
Scalable as Scalable,
ScalableImpl as ScalableImpl,
)
from .mirrorable import Mirrorable as Mirrorable
from .mirrorable import (
Mirrorable as Mirrorable,
Flippable as Flippable,
FlippableImpl as FlippableImpl,
)
from .copyable import Copyable as Copyable
from .annotatable import (
Annotatable as Annotatable,

View file

@ -1,6 +1,13 @@
from typing import Self
from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import NDArray
from ..error import MasqueError
from .positionable import Positionable
from .repeatable import Repeatable
class Mirrorable(metaclass=ABCMeta):
"""
@ -11,11 +18,17 @@ class Mirrorable(metaclass=ABCMeta):
@abstractmethod
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the entity across an axis.
Intrinsic transformation: Mirror the entity across an axis through its origin.
This does NOT affect the object's repetition grid.
This operation is performed relative to the object's internal origin (ignoring
its offset). For objects like `Polygon` and `Path` where the offset is forced
to (0, 0), this is equivalent to mirroring in the container's coordinate system.
Args:
axis: Axis to mirror across.
axis: Axis to mirror across:
0: X-axis (flip y coords),
1: Y-axis (flip x coords)
Returns:
self
"""
@ -23,10 +36,11 @@ class Mirrorable(metaclass=ABCMeta):
def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
"""
Optionally mirror the entity across both axes
Optionally mirror the entity across both axes through its origin.
Args:
axes: (mirror_across_x, mirror_across_y)
across_x: Mirror across the horizontal X-axis (flip Y coordinates).
across_y: Mirror across the vertical Y-axis (flip X coordinates).
Returns:
self
@ -38,30 +52,61 @@ class Mirrorable(metaclass=ABCMeta):
return self
#class MirrorableImpl(Mirrorable, metaclass=ABCMeta):
# """
# Simple implementation of `Mirrorable`
# """
# __slots__ = ()
#
# _mirrored: NDArray[numpy.bool]
# """ Whether to mirror the instance across the x and/or y axes. """
#
# #
# # Properties
# #
# # Mirrored property
# @property
# def mirrored(self) -> NDArray[numpy.bool]:
# """ Whether to mirror across the [x, y] axes, respectively """
# return self._mirrored
#
# @mirrored.setter
# def mirrored(self, val: Sequence[bool]) -> None:
# if is_scalar(val):
# raise MasqueError('Mirrored must be a 2-element list of booleans')
# self._mirrored = numpy.array(val, dtype=bool)
#
# #
# # Methods
# #
class Flippable(Positionable, metaclass=ABCMeta):
"""
Trait class for entities which can be mirrored relative to an external line.
"""
__slots__ = ()
@staticmethod
def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, NDArray[numpy.float64]]:
pivot = numpy.zeros(2)
if axis is not None:
if x is not None or y is not None:
raise MasqueError('Cannot specify both axis and x or y')
return axis, pivot
if x is not None:
if y is not None:
raise MasqueError('Cannot specify both x and y')
return 1, pivot + (x, 0)
if y is not None:
return 0, pivot + (0, y)
raise MasqueError('Must specify one of axis, x, or y')
@abstractmethod
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
"""
Extrinsic transformation: Mirror the object across a line in the container's
coordinate system. This affects both the object's offset and its repetition grid.
Unlike `mirror()`, this operation is performed relative to the container's origin
(e.g. the `Pattern` origin, in the case of shapes) and takes the object's offset
into account.
Args:
axis: Axis to mirror across. 0: x-axis (flip y coord), 1: y-axis (flip x coord).
x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across.
Returns:
self
"""
pass
class FlippableImpl(Flippable, Mirrorable, Repeatable, metaclass=ABCMeta):
"""
Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`,
and `Repeatable`.
"""
__slots__ = ()
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
self.translate(-pivot)
self.mirror(axis)
if self.repetition is not None:
self.repetition.mirror(axis)
self.offset[1 - axis] *= -1
self.translate(+pivot)
return self

View file

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

View file

@ -1,4 +1,4 @@
from typing import Self, cast, Any, TYPE_CHECKING
from typing import Self
from abc import ABCMeta, abstractmethod
import numpy
@ -8,8 +8,7 @@ from numpy.typing import ArrayLike
from ..error import MasqueError
from ..utils import rotation_matrix_2d
if TYPE_CHECKING:
from .positionable import Positionable
from .positionable import Positionable
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
@ -26,7 +25,8 @@ class Rotatable(metaclass=ABCMeta):
@abstractmethod
def rotate(self, val: float) -> Self:
"""
Rotate the shape around its origin (0, 0), ignoring its offset.
Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset.
This does NOT affect the object's repetition grid.
Args:
val: Angle to rotate by (counterclockwise, radians)
@ -64,6 +64,10 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
# Methods
#
def rotate(self, rotation: float) -> Self:
"""
Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset.
This does NOT affect the object's repetition grid.
"""
self.rotation += rotation
return self
@ -81,9 +85,9 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
return self
class Pivotable(metaclass=ABCMeta):
class Pivotable(Positionable, metaclass=ABCMeta):
"""
Trait class for entites which can be rotated around a point.
Trait class for entities which can be rotated around a point.
This requires that they are `Positionable` but not necessarily `Rotatable` themselves.
"""
__slots__ = ()
@ -91,7 +95,11 @@ class Pivotable(metaclass=ABCMeta):
@abstractmethod
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
"""
Rotate the object around a point.
Extrinsic transformation: Rotate the object around a point in the container's
coordinate system. This affects both the object's offset and its repetition grid.
For objects that are also `Rotatable`, this also performs an intrinsic
rotation of the object.
Args:
pivot: Point (x, y) to rotate around
@ -103,20 +111,21 @@ class Pivotable(metaclass=ABCMeta):
pass
class PivotableImpl(Pivotable, metaclass=ABCMeta):
class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta):
"""
Implementation of `Pivotable` for objects which are `Rotatable`
and `Positionable`.
"""
__slots__ = ()
offset: Any # TODO see if we can get around defining `offset` in PivotableImpl
""" `[x_offset, y_offset]` """
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
from .repeatable import Repeatable #noqa: PLC0415
pivot = numpy.asarray(pivot, dtype=float)
cast('Positionable', self).translate(-pivot)
cast('Rotatable', self).rotate(rotation)
self.translate(-pivot)
self.rotate(rotation)
if isinstance(self, Repeatable) and self.repetition is not None:
self.repetition.rotate(rotation)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
cast('Positionable', self).translate(+pivot)
self.translate(+pivot)
return self

View file

@ -17,11 +17,12 @@ class AutoSlots(ABCMeta):
for base in bases:
parents |= set(base.mro())
slots = tuple(dctn.get('__slots__', ()))
slots = list(dctn.get('__slots__', ()))
for parent in parents:
if not hasattr(parent, '__annotations__'):
continue
slots += tuple(parent.__annotations__.keys())
slots.extend(parent.__annotations__.keys())
dctn['__slots__'] = slots
# Deduplicate (dict to preserve order)
dctn['__slots__'] = tuple(dict.fromkeys(slots))
return super().__new__(cls, name, bases, dctn)

182
masque/utils/boolean.py Normal file
View file

@ -0,0 +1,182 @@
from typing import Any, Literal
from collections.abc import Iterable
import logging
import numpy
from numpy.typing import NDArray
from ..shapes.polygon import Polygon
from ..error import PatternError
logger = logging.getLogger(__name__)
def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]:
"""
Bridge multiple holes into an outer boundary using zero-width slits.
"""
current_outer = outer_path
# Sort holes by max X to potentially minimize bridge lengths or complexity
# (though not strictly necessary for correctness)
holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True)
for hole in holes:
# Find max X vertex of hole
max_idx = numpy.argmax(hole[:, 0])
m = hole[max_idx]
# Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges
best_t = numpy.inf
best_pt = None
best_edge_idx = -1
n = len(current_outer)
for i in range(n):
p1 = current_outer[i]
p2 = current_outer[(i + 1) % n]
# Check if edge (p1, p2) spans m.y
if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]):
# Intersection x:
# x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y)
t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0]
if 0 <= t < best_t:
best_t = t
best_pt = numpy.array([m[0] + t, m[1]])
best_edge_idx = i
if best_edge_idx == -1:
# Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole)
dists = numpy.linalg.norm(current_outer - m, axis=1)
best_edge_idx = int(numpy.argmin(dists))
best_pt = current_outer[best_edge_idx]
# Adjust best_edge_idx to insert AFTER this vertex
# (treating it as a degenerate edge)
assert best_pt is not None
# Reorder hole vertices to start at m
hole_reordered = numpy.roll(hole, -max_idx, axis=0)
# Construct new outer:
# 1. Start of outer up to best_edge_idx
# 2. Intersection point
# 3. Hole vertices (starting and ending at m)
# 4. Intersection point (to close slit)
# 5. Rest of outer
new_outer: list[NDArray[numpy.float64]] = []
new_outer.extend(current_outer[:best_edge_idx + 1])
new_outer.append(best_pt)
new_outer.extend(hole_reordered)
new_outer.append(hole_reordered[0]) # close hole loop at m
new_outer.append(best_pt) # back to outer
new_outer.extend(current_outer[best_edge_idx + 1:])
current_outer = numpy.array(new_outer)
return current_outer
def boolean(
subjects: Iterable[Any],
clips: Iterable[Any] | None = None,
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
scale: float = 1e6,
) -> list[Polygon]:
"""
Perform a boolean operation on two sets of polygons.
Args:
subjects: List of subjects (Polygons or vertex arrays).
clips: List of clips (Polygons or vertex arrays).
operation: The boolean operation to perform.
scale: Scaling factor for integer conversion (pyclipper uses integers).
Returns:
A list of result Polygons.
"""
try:
import pyclipper #noqa: PLC0415
except ImportError:
raise ImportError(
"Boolean operations require 'pyclipper'. "
"Install it with 'pip install pyclipper' or 'pip install masque[boolean]'."
) from None
op_map = {
'union': pyclipper.PT_UNION,
'intersection': pyclipper.PT_INTERSECTION,
'difference': pyclipper.PT_DIFFERENCE,
'xor': pyclipper.PT_XOR,
}
def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]:
if objs is None:
return []
verts = []
for obj in objs:
if hasattr(obj, 'to_polygons'):
for p in obj.to_polygons():
verts.append(p.vertices)
elif isinstance(obj, numpy.ndarray):
verts.append(obj)
elif isinstance(obj, Polygon):
verts.append(obj.vertices)
else:
# Try to iterate if it's an iterable of shapes
try:
for sub in obj:
if hasattr(sub, 'to_polygons'):
for p in sub.to_polygons():
verts.append(p.vertices)
elif isinstance(sub, Polygon):
verts.append(sub.vertices)
except TypeError:
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
return verts
subject_verts = to_vertices(subjects)
clip_verts = to_vertices(clips)
pc = pyclipper.Pyclipper()
pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True)
if clip_verts:
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
# Use GetPolyTree to distinguish between outers and holes
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
result_polygons = []
def process_node(node: Any) -> None:
if not node.IsHole:
# This is an outer boundary
outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale))
# Find immediate holes
holes = []
for child in node.Childs:
if child.IsHole:
holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale)))
if holes:
combined_vertices = _bridge_holes(outer_path, holes)
result_polygons.append(Polygon(combined_vertices))
else:
result_polygons.append(Polygon(outer_path))
# Recursively process children of holes (which are nested outers)
for child in node.Childs:
if child.IsHole:
for grandchild in child.Childs:
process_node(grandchild)
else:
# Holes are processed as children of outers
pass
for top_node in polytree.Childs:
process_node(top_node)
return result_polygons

View file

@ -47,7 +47,7 @@ def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool:
keys_a = tuple(sorted(aa.keys()))
keys_b = tuple(sorted(bb.keys()))
if keys_a != keys_b:
return keys_a < keys_b
return False
for key in keys_a:
va = aa[key]

View file

@ -69,14 +69,25 @@ def euler_bend(
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
if ll_max == 0:
return numpy.zeros((num_points_spiral, 2))
resolution = 100000
qq = numpy.linspace(0, ll_max, resolution)
dx = numpy.cos(qq * qq / 2)
dy = -numpy.sin(qq * qq / 2)
dq = ll_max / (resolution - 1)
ix = numpy.zeros(resolution)
iy = numpy.zeros(resolution)
ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq
iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq
ll_target = numpy.linspace(0, ll_max, num_points_spiral)
x_target = numpy.interp(ll_target, qq, ix)
y_target = numpy.interp(ll_target, qq, iy)
return numpy.stack((x_target, y_target), axis=1)
xy_spiral = gen_spiral(ll_max)
xy_parts = [xy_spiral]

View file

@ -1,5 +1,5 @@
from typing import TypeVar, Generic
from collections.abc import Callable
from collections.abc import Callable, Iterator
from functools import lru_cache
@ -25,18 +25,45 @@ class DeferredDict(dict, Generic[Key, Value]):
"""
def __init__(self, *args, **kwargs) -> None:
dict.__init__(self)
self.update(*args, **kwargs)
if args or kwargs:
self.update(*args, **kwargs)
def __setitem__(self, key: Key, value: Callable[[], Value]) -> None:
"""
Set a value, which must be a callable that returns the actual value.
The result of the callable is cached after the first access.
"""
if not callable(value):
raise TypeError(f"DeferredDict value must be callable, got {type(value)}")
cached_fn = lru_cache(maxsize=1)(value)
dict.__setitem__(self, key, cached_fn)
def __getitem__(self, key: Key) -> Value:
return dict.__getitem__(self, key)()
def get(self, key: Key, default: Value | None = None) -> Value | None:
if key not in self:
return default
return self[key]
def items(self) -> Iterator[tuple[Key, Value]]:
for key in self.keys():
yield key, self[key]
def values(self) -> Iterator[Value]:
for key in self.keys():
yield self[key]
def update(self, *args, **kwargs) -> None:
"""
Update the DeferredDict. If a value is callable, it is used as a generator.
Otherwise, it is wrapped as a constant.
"""
for k, v in dict(*args, **kwargs).items():
self[k] = v
if callable(v):
self[k] = v
else:
self.set_const(k, v)
def __repr__(self) -> str:
return '<DeferredDict with keys ' + repr(set(self.keys())) + '>'
@ -46,4 +73,4 @@ class DeferredDict(dict, Generic[Key, Value]):
Convenience function to avoid having to manually wrap
constant values into callables.
"""
self[key] = lambda: value
self[key] = lambda v=value: v

View file

@ -60,6 +60,12 @@ def maxrects_bssf(
degenerate = (min_more & max_less).any(axis=0)
regions = regions[~degenerate]
if regions.shape[0] == 0:
if allow_rejects:
rejected_inds.add(rect_ind)
continue
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
''' Place the rect '''
# Best short-side fit (bssf) to pick a region
region_sizes = regions[:, 2:] - regions[:, :2]
@ -102,7 +108,7 @@ def maxrects_bssf(
if presort:
unsort_order = rect_order.argsort()
rect_locs = rect_locs[unsort_order]
rejected_inds = set(unsort_order[list(rejected_inds)])
rejected_inds = {int(rect_order[ii]) for ii in rejected_inds}
return rect_locs, rejected_inds
@ -187,7 +193,7 @@ def guillotine_bssf_sas(
if presort:
unsort_order = rect_order.argsort()
rect_locs = rect_locs[unsort_order]
rejected_inds = set(unsort_order[list(rejected_inds)])
rejected_inds = {int(rect_order[ii]) for ii in rejected_inds}
return rect_locs, rejected_inds
@ -236,7 +242,9 @@ def pack_patterns(
locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects)
pat = Pattern()
for pp, oo, loc in zip(patterns, offsets, locations, strict=True):
for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)):
if ii in reject_inds:
continue
pat.ref(pp, offset=oo + loc)
rejects = [patterns[ii] for ii in reject_inds]

View file

@ -57,11 +57,9 @@ def data_to_ports(
name: str | None = None, # Note: name optional, but arg order different from read(postprocess=)
max_depth: int = 0,
skip_subcells: bool = True,
# TODO missing ok?
visited: set[int] | None = None,
) -> Pattern:
"""
# TODO fixup documentation in ports2data
# TODO move to utils.file?
Examine `pattern` for labels specifying port info, and use that info
to fill out its `ports` attribute.
@ -70,18 +68,30 @@ def data_to_ports(
Args:
layers: Search for labels on all the given layers.
library: Mapping from pattern names to patterns.
pattern: Pattern object to scan for labels.
max_depth: Maximum hierarcy depth to search. Default 999_999.
name: Name of the pattern object.
max_depth: Maximum hierarcy depth to search. Default 0.
Reduce this to 0 to avoid ever searching subcells.
skip_subcells: If port labels are found at a given hierarcy level,
do not continue searching at deeper levels. This allows subcells
to contain their own port info without interfering with supercells'
port data.
Default True.
visited: Set of object IDs which have already been processed.
Returns:
The updated `pattern`. Port labels are not removed.
"""
if visited is None:
visited = set()
# Note: visited uses id(pattern) to detect cycles and avoid redundant processing.
# This may not catch identical patterns if they are loaded as separate object instances.
if id(pattern) in visited:
return pattern
visited.add(id(pattern))
if pattern.ports:
logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports')
return pattern
@ -99,12 +109,13 @@ def data_to_ports(
if target is None:
continue
pp = data_to_ports(
layers=layers,
library=library,
pattern=library[target],
name=target,
max_depth=max_depth - 1,
skip_subcells=skip_subcells,
layers = layers,
library = library,
pattern = library[target],
name = target,
max_depth = max_depth - 1,
skip_subcells = skip_subcells,
visited = visited,
)
found_ports |= bool(pp.ports)
@ -160,13 +171,17 @@ def data_to_ports_flat(
local_ports = {}
for label in labels:
name, property_string = label.string.split(':')
properties = property_string.split(' ')
ptype = properties[0]
angle_deg = float(properties[1]) if len(ptype) else 0
if ':' not in label.string:
logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (missing ":")')
continue
name, property_string = label.string.split(':', 1)
properties = property_string.split()
ptype = properties[0] if len(properties) > 0 else 'unk'
angle_deg = float(properties[1]) if len(properties) > 1 else numpy.inf
xy = label.offset
angle = numpy.deg2rad(angle_deg)
angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None
if name in local_ports:
logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"')

View file

@ -28,8 +28,9 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
[numpy.sin(theta), +numpy.cos(theta)]])
# If this was a manhattan rotation, round to remove some inacuraccies in sin & cos
if numpy.isclose(theta % (pi / 2), 0):
# If this was a manhattan rotation, round to remove some inaccuracies in sin & cos
# cos(4*theta) is 1 for any multiple of pi/2.
if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12):
arr = numpy.round(arr)
arr.flags.writeable = False
@ -86,37 +87,50 @@ def apply_transforms(
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`).
The two transforms should be of shape Ox5 and Ix5.
Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`.
The output will be of the form (O*I)x5 (if `tensor=False`) or OxIx5 (`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
outer: Transforms for the container refs. Shape Ox5.
inner: Transforms for the contained refs. Shape Ix5.
tensor: If `True`, an OxIx5 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
If `False` (default), this is concatenated into `(O*I)x5` 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)`.
OxIx5 or (O*I)x5 array. Final dimension is
`(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`.
"""
outer = numpy.atleast_2d(outer).astype(float, copy=False)
inner = numpy.atleast_2d(inner).astype(float, copy=False)
if outer.shape[1] == 4:
outer = numpy.pad(outer, ((0, 0), (0, 1)), constant_values=1.0)
if inner.shape[1] == 4:
inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0)
# 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
# Apply outer scale to inner offset
xy_mir *= outer[:, None, 4, None]
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 = numpy.empty((outer.shape[0], inner.shape[0], 5))
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 mirrored, flip inner rotation
mirrored_outer = outer[:, None, 3].astype(bool)
rotations = outer[:, None, 2] + numpy.where(mirrored_outer, -inner[None, :, 2], inner[None, :, 2])
tot[:, :, 2] = rotations % (2 * pi)
tot[:, :, 3] = (outer[:, None, 3] + inner[None, :, 3]) % 2 # net mirrored
tot[:, :, 4] = outer[:, None, 4] * inner[None, :, 4] # net scale
if tensor:
return tot

View file

@ -18,13 +18,23 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) ->
`vertices` with no consecutive duplicates. This may be a view into the original array.
"""
vertices = numpy.asarray(vertices)
if vertices.shape[0] <= 1:
return vertices
duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1)
if not closed_path:
duplicates[-1] = False
return vertices[~duplicates]
result = vertices[~duplicates]
if result.shape[0] == 0 and vertices.shape[0] > 0:
return vertices[:1]
return result
def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]:
def remove_colinear_vertices(
vertices: ArrayLike,
closed_path: bool = True,
preserve_uturns: bool = False,
) -> NDArray[numpy.float64]:
"""
Given a list of vertices, remove any superflous vertices (i.e.
those which lie along the line formed by their neighbors)
@ -33,24 +43,40 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N
vertices: Nx2 ndarray of vertices
closed_path: If `True`, the vertices are assumed to represent an implicitly
closed path. If `False`, the path is assumed to be open. Default `True`.
preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree
turn (a "spike") are preserved. Default `False`.
Returns:
`vertices` with colinear (superflous) vertices removed. May be a view into the original array.
"""
vertices = remove_duplicate_vertices(vertices)
vertices = remove_duplicate_vertices(vertices, closed_path=closed_path)
# Check for dx0/dy0 == dx1/dy1
dv = numpy.roll(vertices, -1, axis=0) - vertices
if not closed_path:
dv[-1] = 0
dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...]
dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]]
# dxdy[i] is based on dv[i] and dv[i-1]
# slopes_equal[i] refers to vertex i
dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1]
dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0]
err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40
slopes_equal = (dxdy_diff / err_mult) < 1e-15
if preserve_uturns:
# Only merge if segments are in the same direction (avoid collapsing u-turns)
dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1)
slopes_equal &= (dot_prod > 0)
if not closed_path:
slopes_equal[[0, -1]] = False
if slopes_equal.all() and vertices.shape[0] > 0:
# All colinear, keep the first and last
return vertices[[0, vertices.shape[0] - 1]]
return vertices[~slopes_equal]
@ -58,7 +84,7 @@ def poly_contains_points(
vertices: ArrayLike,
points: ArrayLike,
include_boundary: bool = True,
) -> NDArray[numpy.int_]:
) -> NDArray[numpy.bool_]:
"""
Tests whether the provided points are inside the implicitly closed polygon
described by the provided list of vertices.
@ -77,13 +103,13 @@ def poly_contains_points(
vertices = numpy.asarray(vertices, dtype=float)
if points.size == 0:
return numpy.zeros(0, dtype=numpy.int8)
return numpy.zeros(0, dtype=bool)
min_bounds = numpy.min(vertices, axis=0)[None, :]
max_bounds = numpy.max(vertices, axis=0)[None, :]
trivially_outside = ((points < min_bounds).any(axis=1)
| (points > max_bounds).any(axis=1)) # noqa: E128
| (points > max_bounds).any(axis=1))
nontrivial = ~trivially_outside
if trivially_outside.all():
@ -101,10 +127,10 @@ def poly_contains_points(
dv = numpy.roll(verts, -1, axis=0) - verts
is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1]) # >0 if left of dv, <0 if right, 0 if on the line
- dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) # noqa: E128
- dv[:, 1] * (ntpts[..., 0] - verts[:, 0]))
winding_number = ((upward & (is_left > 0)).sum(axis=0)
- (downward & (is_left < 0)).sum(axis=0)) # noqa: E128
- (downward & (is_left < 0)).sum(axis=0))
nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number
if include_boundary:

View file

@ -1,7 +1,3 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "masque"
description = "Lithography mask library"
@ -46,17 +42,38 @@ dependencies = [
"klamath~=1.4",
]
[dependency-groups]
dev = [
"pytest",
"masque[oasis]",
"masque[dxf]",
"masque[svg]",
"masque[visualize]",
"masque[text]",
"masque[manhattanize]",
"masque[manhattanize_slow]",
"matplotlib>=3.10.8",
"pytest>=9.0.2",
"ruff>=0.15.5",
"mypy>=1.19.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "masque/__init__.py"
[project.optional-dependencies]
oasis = ["fatamorgana~=0.11"]
dxf = ["ezdxf~=1.0.2"]
dxf = ["ezdxf~=1.4"]
svg = ["svgwrite"]
visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"]
manhatanize_slow = ["float_raster"]
manhattanize = ["scikit-image"]
manhattanize_slow = ["float_raster"]
boolean = ["pyclipper"]
[tool.ruff]
@ -87,10 +104,21 @@ lint.ignore = [
"PLR09", # Too many xxx
"PLR2004", # magic number
"PLC0414", # import x as x
# "PLC0415", # non-top-level import
"PLW1641", # missing __hash__ with total_ordering
"TRY003", # Long exception message
]
[tool.pytest.ini_options]
addopts = "-rsXx"
testpaths = ["masque"]
filterwarnings = [
"ignore::DeprecationWarning:ezdxf.*",
]
[tool.mypy]
mypy_path = "stubs"
python_version = "3.11"
strict = false
check_untyped_defs = true

13
stubs/ezdxf/__init__.pyi Normal file
View file

@ -0,0 +1,13 @@
from typing import Any, TextIO
from collections.abc import Iterable
from .layouts import Modelspace, BlockRecords
class Drawing:
blocks: BlockRecords
@property
def layers(self) -> Iterable[Any]: ...
def modelspace(self) -> Modelspace: ...
def write(self, stream: TextIO) -> None: ...
def new(version: str = ..., setup: bool = ...) -> Drawing: ...
def read(stream: TextIO) -> Drawing: ...

18
stubs/ezdxf/entities.pyi Normal file
View file

@ -0,0 +1,18 @@
from typing import Any
from collections.abc import Iterable, Sequence
class DXFEntity:
def dxfattribs(self) -> dict[str, Any]: ...
def dxftype(self) -> str: ...
class LWPolyline(DXFEntity):
def get_points(self) -> Iterable[tuple[float, ...]]: ...
class Polyline(DXFEntity):
def points(self) -> Iterable[Any]: ... # has .xyz
class Text(DXFEntity):
def get_placement(self) -> tuple[int, tuple[float, float, float]]: ...
def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ...
class Insert(DXFEntity): ...

4
stubs/ezdxf/enums.pyi Normal file
View file

@ -0,0 +1,4 @@
from enum import IntEnum
class TextEntityAlignment(IntEnum):
BOTTOM_LEFT = ...

21
stubs/ezdxf/layouts.pyi Normal file
View file

@ -0,0 +1,21 @@
from typing import Any
from collections.abc import Iterator, Sequence, Iterable
from .entities import DXFEntity
class BaseLayout:
def __iter__(self) -> Iterator[DXFEntity]: ...
def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ...
def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ...
def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ...
class Modelspace(BaseLayout):
@property
def name(self) -> str: ...
class BlockLayout(BaseLayout):
@property
def name(self) -> str: ...
class BlockRecords:
def new(self, name: str) -> BlockLayout: ...
def __iter__(self) -> Iterator[BlockLayout]: ...

View file

@ -0,0 +1,46 @@
from typing import Any
from collections.abc import Iterable, Sequence
import numpy
from numpy.typing import NDArray
# Basic types for Clipper integer coordinates
Path = Sequence[tuple[int, int]]
Paths = Sequence[Path]
# Types for input/output floating point coordinates
FloatPoint = tuple[float, float] | NDArray[numpy.floating]
FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating]
FloatPaths = Iterable[FloatPath]
# Constants
PT_SUBJECT: int
PT_CLIP: int
PT_UNION: int
PT_INTERSECTION: int
PT_DIFFERENCE: int
PT_XOR: int
PFT_EVENODD: int
PFT_NONZERO: int
PFT_POSITIVE: int
PFT_NEGATIVE: int
# Scaling functions
def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ...
def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ...
class PolyNode:
Contour: Path
Childs: list[PolyNode]
Parent: PolyNode
IsHole: bool
class Pyclipper:
def __init__(self) -> None: ...
def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ...
def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ...
def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ...
def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ...
def Clear(self) -> None: ...