Compare commits

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

206 commits

Author SHA1 Message Date
bdc4dfdd06 [Pather] fix using trees when append=True 2026-04-09 16:32:25 -07:00
6cf9600193 [PortList.measure_travel] add a convenience wrapper for measuring internal travel 2026-04-09 11:41:21 -07:00
e6f5136357 [PathTool] add native S-bend 2026-04-08 23:48:48 -07:00
778b3d9be7 [Builder / RenderPather] BREAKING remove aliases to old names 2026-04-08 23:08:26 -07:00
02f0833fb3 [pather] handle paths without existing ports 2026-04-08 22:33:07 -07:00
47f150f579 [curves.euler] clean up nearly-duplicate points 2026-04-08 18:07:54 -07:00
84106dc355 [set_dead] improve handling of dead ports 2026-04-08 17:41:50 -07:00
429e687666 [docs] add migration guide 2026-04-06 15:38:03 -07:00
c501a8ff99 [referenced_patterns] don't visit tops twice 2026-04-06 15:30:37 -07:00
fd2698c503 [docs / examples] Update docs and examples 2026-04-02 12:19:51 -07:00
8100d8095a [Pather] improve bounds handling for bundles 2026-04-02 12:18:03 -07:00
2c5243237e [Pather] rework pather internals -- split route planning vs strategy selection 2026-04-02 11:34:49 -07:00
cf0a245143 [dxf] ignore unreferenced internal dxf blocks 2026-04-02 10:09:38 -07:00
bbe3586ba9 [Pather] fix trace_into() for straight connections 2026-04-02 09:55:27 -07:00
e071bd89b0 [tests] clean up some over-specific tests 2026-04-02 00:40:18 -07:00
06ed2ce54a [Pather] avoid repeated resolve and non-atomic breaks 2026-04-02 00:11:26 -07:00
0f2b4d713b [Pattern] make plug/place atomic wrt. annotation conflicts 2026-04-01 23:34:40 -07:00
524503031c [ILibrary / LazyLibrary] allow mapping a name to itself 2026-04-01 22:59:18 -07:00
ce7bf5ce70 [ILibrary / LazyLibrary] raise a LibraryError instead of KeyError 2026-04-01 22:58:30 -07:00
f0a4b08a31 [PortList] find_transform requires a non-empty connection map 2026-04-01 22:49:35 -07:00
b3a1489258 [PortPather] complain if the user gives ambiguous port names 2026-04-01 22:47:22 -07:00
d366db5a62 [utils.transform] better input validation in normalize_mirror and apply_transform 2026-04-01 21:59:27 -07:00
20f37ea0f7 [ell] validate spacing length 2026-04-01 21:58:56 -07:00
6fd73b9d46 [ell] fix crash when ccw=None but spacing is non-scalar 2026-04-01 21:58:21 -07:00
32744512e0 [boolean] more work towards getting boolean ops working 2026-04-01 21:28:33 -07:00
75a9114709 [bezier] validate weights 2026-04-01 21:16:03 -07:00
df578d7764 [PolyCollection] copy vertex offsets when making normalized form 2026-04-01 21:15:44 -07:00
786716fc62 [preflight] document that preflight doesn't copy the library 2026-04-01 20:58:10 -07:00
a82365ec8c [svg] fix duplicate svg ids 2026-04-01 20:57:35 -07:00
28be89f047 [gdsii] make sure iterable is supported 2026-04-01 20:56:59 -07:00
afc49f945d [DeferredDict] add setdefault(), pop(), popitem(), copy() 2026-04-01 20:14:53 -07:00
ce46cc18dc [tmpfile] delete the temporary file if an error occurs 2026-04-01 20:12:24 -07:00
7c50f95fde [ILibrary] update docs for add() 2026-04-01 20:00:46 -07:00
ae314cce93 [ILibraryView] child_order shouldn't leak graphlib.CycleErrror 2026-04-01 19:59:59 -07:00
09a95a6608 [ILibraryView] fix assignment during dfs() 2026-04-01 19:57:29 -07:00
fbe138d443 [data_to_ports] warn on invalid angle 2026-04-01 19:22:16 -07:00
4b416745da [repetition.Grid] check for invalid displacements or counts 2026-04-01 19:21:47 -07:00
0830dce50c [data_to_ports] don't leave the pattern dirty if we error out part-way 2026-04-01 19:10:50 -07:00
ac87179da2 [data_to_ports] warn that repetitions are not not expanded 2026-04-01 19:01:47 -07:00
f0eea0382b [Ref] get_bounds_single shoudl ignore repetition 2026-04-01 19:00:59 -07:00
0c9b435e94 [PortList.check_ports] Check for duplicate map_in/map_out values 2026-04-01 19:00:19 -07:00
f461222852 [ILibrary.add] respect mutate_other=False even without duplicate keys 2026-04-01 18:58:01 -07:00
9767ee4e62 [Pattern] improve atomicity of place(), plug(), interface() 2026-03-31 23:00:35 -07:00
395ad4df9d [PortList] plugged() failure shouldn't dirty the ports 2026-03-31 22:38:27 -07:00
35b42c397b [Pattern.append] don't dirty pattern if append() fails 2026-03-31 22:37:16 -07:00
6a7b3b2259 [PorList] Error if multiple ports are renamed to the same name 2026-03-31 22:15:48 -07:00
8d50f497f1 [repetition / others] copies should get their own repetitions 2026-03-31 22:15:21 -07:00
2176d56b4c [Arc] Error out on zero radius 2026-03-31 22:03:42 -07:00
f1e25debec [Ellipse] force radii to float dtype 2026-03-31 22:03:19 -07:00
4b07bb9e25 [OASIS] raise PatternError for unsuppored caps 2026-03-31 21:42:49 -07:00
2952e6ef8f [Arc / Ellipse / Circle] gracefully handle large arclen 2026-03-31 21:42:16 -07:00
c303a0c114 [Ellipse / Arc] improve bounds calculation 2026-03-31 21:41:49 -07:00
f34b9b2f5c [Text] fixup bounds and normalized form 2026-03-31 21:22:35 -07:00
89cdd23f00 [Arc / Ellipse] make radii hashable 2026-03-31 21:22:15 -07:00
620b001af5 [ILibrary] fix dedup messing up rotations 2026-03-31 21:21:16 -07:00
46a3559391 [dxf] fix dxf repetition load 2026-03-31 21:19:29 -07:00
08421d6a54 [OASIS] repeated property keys should be merged, not overwritten 2026-03-31 19:00:38 -07:00
462a05a665 [Library] fix dedup()
- use consistent deduplicated target name
- remove shape indices per dedup
2026-03-31 18:58:37 -07:00
2b29e46b93 [Pather] fix port rename/deletion tracking 2026-03-31 18:49:41 -07:00
2e0b64bdab [Ref / Label] make equality safe for unrelated types 2026-03-31 17:51:02 -07:00
20c845a881 [Tool] avoid passing port_names down 2026-03-31 17:12:41 -07:00
707a16fe64 [RenderStep] fix mirroring a planned path 2026-03-31 17:11:26 -07:00
932565d531 [Repetition] fix ordering 2026-03-31 17:10:19 -07:00
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
93 changed files with 10223 additions and 3072 deletions

299
MIGRATION.md Normal file
View file

@ -0,0 +1,299 @@
# Migration Guide
This guide covers changes between the git tag `release` and the current tree.
At `release`, `masque.__version__` was `3.3`; the current tree reports `3.4`.
Most downstream changes are in `masque/builder/*`, but there are a few other
API changes that may require code updates.
## Routing API: renamed and consolidated
The routing helpers were consolidated into a single implementation in
`masque/builder/pather.py`.
The biggest migration point is that the old routing verbs were renamed:
| Old API | New API |
| --- | --- |
| `Pather.path(...)` | `Pather.trace(...)` |
| `Pather.path_to(...)` | `Pather.trace_to(...)` |
| `Pather.mpath(...)` | `Pather.trace(...)` / `Pather.trace_to(...)` with multiple ports |
| `Pather.pathS(...)` | `Pather.jog(...)` |
| `Pather.pathU(...)` | `Pather.uturn(...)` |
| `Pather.path_into(...)` | `Pather.trace_into(...)` |
| `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` |
| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` |
| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` |
| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` |
| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` |
| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` |
| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` |
| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` |
There are also new convenience wrappers:
- `straight(...)` for `trace_to(..., ccw=None, ...)`
- `ccw(...)` for `trace_to(..., ccw=True, ...)`
- `cw(...)` for `trace_to(..., ccw=False, ...)`
- `jog(...)` for S-bends
- `uturn(...)` for U-bends
Important: `Pather.path()` is no longer the routing API. It now forwards to
`Pattern.path()` and creates a geometric `Path` element. Any old routing code
that still calls `pather.path(...)` must be renamed.
### Common rewrites
```python
# old
pather.path('VCC', False, 6_000)
pather.path_to('VCC', None, x=0)
pather.mpath(['GND', 'VCC'], True, xmax=-10_000, spacing=5_000)
pather.pathS('VCC', offset=-2_000, length=8_000)
pather.pathU('VCC', offset=4_000, length=5_000)
pather.path_into('src', 'dst')
pather.path_from('src', 'dst')
# new
pather.cw('VCC', 6_000)
pather.straight('VCC', x=0)
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
pather.jog('VCC', offset=-2_000, length=8_000)
pather.uturn('VCC', offset=4_000, length=5_000)
pather.trace_into('src', 'dst')
pather.at('src').trace_into('dst')
```
If you prefer the more explicit spelling, `trace(...)` and `trace_to(...)`
remain the underlying primitives:
```python
pather.trace('VCC', False, 6_000)
pather.trace_to('VCC', None, x=0)
```
## `PortPather` and `.at(...)`
Routing can now be written in a fluent style via `.at(...)`, which returns a
`PortPather`.
```python
(rpather.at('VCC')
.trace(False, length=6_000)
.trace_to(None, x=0)
)
```
This is additive, not required for migration. Existing code can stay with the
non-fluent `Pather` methods after renaming the verbs above.
Old `PortPather` helper names were also cleaned up:
| Old API | New API |
| --- | --- |
| `save_copy(...)` | `mark(...)` |
| `rename_to(...)` | `rename(...)` |
Example:
```python
# old
pp.save_copy('branch')
pp.rename_to('feed')
# new
pp.mark('branch')
pp.rename('feed')
```
## Imports and module layout
`Pather` now provides the remaining builder/routing surface in
`masque/builder/pather.py`. The old module files
`masque/builder/builder.py` and `masque/builder/renderpather.py` were removed.
Update imports like this:
```python
# old
from masque.builder.builder import Builder
from masque.builder.renderpather import RenderPather
# new
from masque.builder import Pather
builder = Pather(...)
deferred = Pather(..., auto_render=False)
```
Top-level imports from `masque` also continue to work.
`Pather` now defaults to `auto_render=True`, so plain construction replaces the
old `Builder` behavior. Use `Pather(..., auto_render=False)` where you
previously used `RenderPather`.
## `BasicTool` was replaced
`BasicTool` is no longer exported. Use:
- `SimpleTool` for the simple "one straight generator + one bend cell" case
- `AutoTool` if you need transitions, multiple candidate straights/bends, or
S-bends/U-bends
### Old `BasicTool`
```python
from masque.builder.tools import BasicTool
tool = BasicTool(
straight=(make_straight, 'input', 'output'),
bend=(lib.abstract('bend'), 'input', 'output'),
transitions={
'm2wire': (lib.abstract('via'), 'top', 'bottom'),
},
)
```
### New `AutoTool`
```python
from masque.builder.tools import AutoTool
tool = AutoTool(
straights=[
AutoTool.Straight(
ptype='m1wire',
fn=make_straight,
in_port_name='input',
out_port_name='output',
),
],
bends=[
AutoTool.Bend(
abstract=lib.abstract('bend'),
in_port_name='input',
out_port_name='output',
clockwise=True,
),
],
sbends=[],
transitions={
('m2wire', 'm1wire'): AutoTool.Transition(
lib.abstract('via'),
'top',
'bottom',
),
},
default_out_ptype='m1wire',
)
```
The key differences are:
- `BasicTool` -> `SimpleTool` or `AutoTool`
- `straight=(fn, in_name, out_name)` -> `straights=[AutoTool.Straight(...)]`
- `bend=(abstract, in_name, out_name)` -> `bends=[AutoTool.Bend(...)]`
- transition keys are now `(external_ptype, internal_ptype)` tuples
- transitions use `AutoTool.Transition(...)` instead of raw tuples
If your old `BasicTool` usage did not rely on transitions or multiple routing
options, `SimpleTool` is the closest replacement.
## Custom `Tool` subclasses
If you maintain your own `Tool` subclass, the interface changed:
- `Tool.path(...)` became `Tool.traceL(...)`
- `Tool.traceS(...)` and `Tool.traceU(...)` were added for native S/U routes
- `planL()` / `planS()` / `planU()` remain the planning hooks used by deferred rendering
In practice, a minimal old implementation like:
```python
class MyTool(Tool):
def path(self, ccw, length, **kwargs):
...
```
should now become:
```python
class MyTool(Tool):
def traceL(self, ccw, length, **kwargs):
...
```
If you do not implement `traceS()` or `traceU()`, the unified pather will
either fall back to the planning hooks or synthesize those routes from simpler
steps where possible.
## Transform semantics changed
The other major user-visible change is that `mirror()` and `rotate()` are now
treated more consistently as intrinsic transforms on low-level objects.
The practical migration rule is:
- use `mirror()` / `rotate()` when you want to change the object relative to its
own origin
- use `flip_across(...)`, `rotate_around(...)`, or container-level transforms
when you want to move the object in its parent coordinate system
### Example: `Port`
Old behavior:
```python
port.mirror(0) # changed both offset and orientation
```
New behavior:
```python
port.mirror(0) # changes orientation only
port.flip_across(axis=0) # old "mirror in the parent pattern" behavior
```
### What to audit
Check code that calls:
- `Port.mirror(...)`
- `Ref.rotate(...)`
- `Ref.mirror(...)`
- `Label.rotate_around(...)` / `Label.mirror(...)`
If that code expected offsets or repetition grids to move automatically, it
needs updating. For whole-pattern transforms, prefer calling `Pattern.mirror()`
or `Pattern.rotate_around(...)` at the container level.
## Other user-facing changes
### DXF environments
If you install the DXF extra, the supported `ezdxf` baseline moved from
`~=1.0.2` to `~=1.4`. Any pinned environments should be updated accordingly.
### New exports
These are additive, but available now from `masque` and `masque.builder`:
- `PortPather`
- `SimpleTool`
- `AutoTool`
- `boolean`
## Minimal migration checklist
If your code uses the routing stack, do these first:
1. Replace `path`/`path_to`/`mpath`/`path_into` calls with
`trace`/`trace_to`/multi-port `trace`/`trace_into`.
2. Replace `BasicTool` with `SimpleTool` or `AutoTool`.
3. Fix imports that still reference `masque.builder.builder` or
`masque.builder.renderpather`.
4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`.
If your code only uses `Pattern`, `Library`, `place()`, and `plug()` without the
routing helpers, you may not need any changes beyond the transform audit and any
stale imports.

View file

@ -145,7 +145,7 @@ References are accomplished by listing the target's name, not its `Pattern` obje
in order to create a reference, but they also need to access the pattern's ports.
* One way to provide this data is through an `Abstract`, generated via
`Library.abstract()` or through a `Library.abstract_view()`.
* Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates
* Another way is use `Pather.place()` or `Pather.plug()`, which automatically creates
an `Abstract` from its internally-referenced `Library`.
@ -193,8 +193,8 @@ my_pattern.ref(new_name, ...) # instantiate the cell
# In practice, you may do lots of
my_pattern.ref(lib << make_tree(...), ...)
# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit:
my_builder = Builder(library=lib, ...)
# With a `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit:
my_builder = Pather(library=lib, ...)
...
my_builder.place(make_tree(...))
```
@ -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

@ -1,6 +1,12 @@
masque Tutorial
===============
These examples are meant to be read roughly in order.
- Start with `basic_shapes.py` for the core `Pattern` / GDS concepts.
- Then read `devices.py` and `library.py` for hierarchical composition and libraries.
- Read the `pather*` tutorials separately when you want routing helpers.
Contents
--------
@ -8,24 +14,30 @@ Contents
* Draw basic geometry
* Export to GDS
- [devices](devices.py)
* Build hierarchical photonic-crystal example devices
* Reference other patterns
* Add ports to a pattern
* Snap ports together to build a circuit
* Use `Pather` to snap ports together into a circuit
* Check for dangling references
- [library](library.py)
* Continue from `devices.py` using a lazy library
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
* 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 `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py),
* Use `AutoTool` to generate paths
* Use `AutoTool` to automatically transition between path types
- [renderpather](renderpather.py)
* Use `Pather(auto_render=False)` 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.
Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating
photonic-crystal lattices; it is support code rather than a step-by-step tutorial.
Running
@ -37,3 +49,6 @@ cd examples/tutorial
python3 basic_shapes.py
klayout -e basic_shapes.gds
```
Some tutorials depend on outputs from earlier ones. In particular, `library.py`
expects `circuit.gds`, which is generated by `devices.py`.

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

@ -1,11 +1,19 @@
"""
Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Pather`.
This file uses photonic-crystal components as the concrete example, so some of
the geometry-generation code is domain-specific. The tutorial value is in the
Masque patterns around it: creating reusable cells, annotating ports, composing
hierarchy with references, and snapping ports together to build a larger circuit.
"""
from collections.abc import Sequence, Mapping
import numpy
from numpy import pi
from masque import (
layer_t, Pattern, Ref, Label, Builder, Port, Polygon,
Library, ILibraryView,
layer_t, Pattern, Ref, Pather, Port, Polygon,
Library,
)
from masque.utils import ports2data
from masque.file.gdsii import writefile, check_valid_names
@ -64,9 +72,9 @@ def perturbed_l3(
Provided sequence should have same length as `shifts_a`.
xy_size: `(x, y)` number of mirror periods in each direction; total size is
`2 * n + 1` holes in each direction. Default (10, 10).
perturbed_radius: radius of holes perturbed to form an upwards-driected beam
perturbed_radius: radius of holes perturbed to form an upwards-directed beam
(multiplicative factor). Default 1.1.
trench width: Width of the undercut trenches. Default 1200.
trench_width: Width of the undercut trenches. Default 1200.
Returns:
`Pattern` object representing the L3 design.
@ -79,14 +87,15 @@ def perturbed_l3(
shifts_a=shifts_a,
shifts_r=shifts_r)
# Build L3 cavity, using references to the provided hole pattern
# Build the cavity by instancing the supplied `hole` pattern many times.
# Using references keeps the pattern compact even though it contains many holes.
pat = Pattern()
pat.refs[hole] += [
Ref(scale=r, offset=(lattice_constant * x,
lattice_constant * y))
for x, y, r in xyr]
# Add rectangular undercut aids
# Add rectangular undercut aids based on the referenced hole extents.
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
trench_dx = max_xy[0] - min_xy[0]
@ -95,7 +104,7 @@ def perturbed_l3(
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
]
# Ports are at outer extents of the device (with y=0)
# Define the interface in Masque terms: two ports at the left/right extents.
extent = lattice_constant * xy_size[0]
pat.ports = dict(
input=Port((-extent, 0), rotation=0, ptype='pcwg'),
@ -125,17 +134,17 @@ def waveguide(
Returns:
`Pattern` object representing the waveguide.
"""
# Generate hole locations
# Generate the normalized lattice locations for the line defect.
xy = pcgen.waveguide(length=length, num_mirror=mirror_periods)
# Build the pattern
# Build the pattern by placing repeated references to the same hole cell.
pat = Pattern()
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
# Ports are at outer edges, with y=0
# Publish the device interface as two ports at the outer edges.
extent = lattice_constant * length / 2
pat.ports = dict(
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
@ -164,17 +173,17 @@ def bend(
`Pattern` object representing the waveguide bend.
Ports are named 'left' (input) and 'right' (output).
"""
# Generate hole locations
# Generate the normalized lattice locations for the bend.
xy = pcgen.wgbend(num_mirror=mirror_periods)
# Build the pattern
pat= Pattern()
# Build the pattern by instancing the shared hole cell.
pat = Pattern()
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
# Figure out port locations.
# Publish the bend interface as two ports.
extent = lattice_constant * mirror_periods
pat.ports = dict(
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
@ -203,17 +212,17 @@ def y_splitter(
`Pattern` object representing the y-splitter.
Ports are named 'in', 'top', and 'bottom'.
"""
# Generate hole locations
# Generate the normalized lattice locations for the splitter.
xy = pcgen.y_splitter(num_mirror=mirror_periods)
# Build pattern
# Build the pattern by instancing the shared hole cell.
pat = Pattern()
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
# Determine port locations
# Publish the splitter interface as one input and two outputs.
extent = lattice_constant * mirror_periods
pat.ports = {
'in': Port((-extent, 0), rotation=0, ptype='pcwg'),
@ -227,13 +236,13 @@ def y_splitter(
def main(interactive: bool = True) -> None:
# Generate some basic hole patterns
# First make a couple of reusable primitive cells.
shape_lib = {
'smile': basic_shapes.smile(RADIUS),
'hole': basic_shapes.hole(RADIUS),
}
# Build some devices
# Then build a small library of higher-level devices from those primitives.
a = LATTICE_CONSTANT
devices = {}
@ -245,22 +254,23 @@ def main(interactive: bool = True) -> None:
devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5)
devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :)
# Turn our dict of devices into a Library.
# This provides some convenience functions in the future!
# Turn the device mapping into a `Library`.
# That gives us convenience helpers for hierarchy inspection and abstract views.
lib = Library(devices)
#
# Build a circuit
#
# Create a `Builder`, and add the circuit to our library as "my_circuit".
circ = Builder(library=lib, name='my_circuit')
# Create a `Pather`, and register the resulting top cell as "my_circuit".
circ = Pather(library=lib, name='my_circuit')
# Start by placing a waveguide. Call its ports "in" and "signal".
# Start by placing a waveguide and renaming its ports to match the circuit-level
# names we want to use while assembling the design.
circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
# Extend the signal path by attaching the "left" port of a waveguide.
# Since there is only one other port ("right") on the waveguide we
# are attaching (wg10), it automatically inherits the name "signal".
# Extend the signal path by attaching another waveguide.
# Because `wg10` only has one unattached port left after the plug, Masque can
# infer that it should keep the name `signal`.
circ.plug('wg10', {'signal': 'left'})
# We could have done the following instead:
@ -268,8 +278,8 @@ def main(interactive: bool = True) -> None:
# lib['my_circuit'] = circ_pat
# circ_pat.place(lib.abstract('wg10'), ...)
# circ_pat.plug(lib.abstract('wg10'), ...)
# but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar
# syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality.
# but `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps
# the assembly code focused on port-level intent.
# Attach a y-splitter to the signal path.
# Since the y-splitter has 3 ports total, we can't auto-inherit the
@ -281,13 +291,10 @@ def main(interactive: bool = True) -> None:
circ.plug('wg05', {'signal1': 'left'})
circ.plug('wg05', {'signal2': 'left'})
# Add a bend to both ports.
# Our bend's ports "left" and "right" refer to the original counterclockwise
# orientation. We want the bends to turn in opposite directions, so we attach
# the "right" port to "signal1" to bend clockwise, and the "left" port
# to "signal2" to bend counterclockwise.
# We could also use `mirrored=(True, False)` to mirror one of the devices
# and then use same device port on both paths.
# Add a bend to both branches.
# Our bend primitive is defined with a specific orientation, so choosing which
# port to plug determines whether the path turns clockwise or counterclockwise.
# We could also mirror one instance instead of using opposite ports.
circ.plug('bend0', {'signal1': 'right'})
circ.plug('bend0', {'signal2': 'left'})
@ -296,29 +303,26 @@ def main(interactive: bool = True) -> None:
circ.plug('l3cav', {'signal1': 'input'})
circ.plug('wg10', {'signal1': 'left'})
# "signal2" just gets a single of equivalent length
# `signal2` gets a single waveguide of equivalent overall length.
circ.plug('wg28', {'signal2': 'left'})
# Now we bend both waveguides back towards each other
# Now bend both branches back towards each other.
circ.plug('bend0', {'signal1': 'right'})
circ.plug('bend0', {'signal2': 'left'})
circ.plug('wg05', {'signal1': 'left'})
circ.plug('wg05', {'signal2': 'left'})
# To join the waveguides, we attach a second y-junction.
# We plug "signal1" into the "bot" port, and "signal2" into the "top" port.
# The remaining port gets named "signal_out".
# This operation would raise an exception if the ports did not line up
# correctly (i.e. they required different rotations or translations of the
# y-junction device).
# To join the branches, attach a second y-junction.
# This succeeds only if both chosen ports agree on the same translation and
# rotation for the inserted device; otherwise Masque raises an exception.
circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
# Finally, add some more waveguide to "signal_out".
circ.plug('wg10', {'signal_out': 'left'})
# We can also add text labels for our circuit's ports.
# They will appear at the uppermost hierarchy level, while the individual
# device ports will appear further down, in their respective cells.
# Bake the top-level port metadata into labels so it survives GDS export.
# These labels appear on the circuit cell; individual child devices keep their
# own port labels in their own cells.
ports_to_data(circ.pattern)
# Check if we forgot to include any patterns... ooops!
@ -330,12 +334,12 @@ def main(interactive: bool = True) -> None:
lib.add(shape_lib)
assert not lib.dangling_refs()
# We can visualize the design. Usually it's easier to just view the GDS.
# We can visualize the design directly, though opening the written GDS is often easier.
if interactive:
print('Visualizing... this step may be slow')
circ.pattern.visualize(lib)
#Write out to GDS, only keeping patterns referenced by our circuit (including itself)
# Write out only the subtree reachable from our top cell.
subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use
check_valid_names(subtree.keys())
writefile(subtree, 'circuit.gds', **GDS_OPTS)

View file

@ -1,23 +1,28 @@
"""
Tutorial: using `LazyLibrary` and `Pather.interface()`.
This example assumes you have already read `devices.py` and generated the
`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry
itself, but rather how Masque lets you mix lazily loaded GDS content with
python-generated devices inside one library.
"""
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 Pather, 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
def main() -> None:
# Define a `LazyLibrary`, which provides lazy evaluation for generating
# patterns and lazy-loading of GDS contents.
# A `LazyLibrary` delays work until a pattern is actually needed.
# That applies both to GDS cells we load from disk and to python callables
# that generate patterns on demand.
lib = LazyLibrary()
#
@ -27,9 +32,9 @@ def main() -> None:
# Scan circuit.gds and prepare to lazy-load its contents
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
# Add it into the device library by providing a way to read port info
# This maintains the lazy evaluation from above, so no patterns
# are actually read yet.
# Add those cells into our lazy library.
# Nothing is read yet; we are only registering how to fetch and postprocess
# each pattern when it is first requested.
lib.add(gds_lib)
print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys())))
@ -44,8 +49,8 @@ def main() -> None:
hole = 'triangle',
)
# Triangle-based variants. These are defined here, but they won't run until they're
# retrieved from the library.
# Triangle-based variants. These lambdas are only recipes for building the
# patterns; they do not execute until someone asks for the cell.
lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts)
lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts)
lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts)
@ -57,22 +62,22 @@ def main() -> None:
# Build a mixed waveguide with an L3 cavity in the middle
#
# Immediately start building from an instance of the L3 cavity
circ2 = Builder(library=lib, ports='tri_l3cav')
# Start a new design by copying the ports from an existing library cell.
# This gives `circ2` the same external interface as `tri_l3cav`.
circ2 = Pather(library=lib, ports='tri_l3cav')
# First way to get abstracts is `lib.abstract(name)`
# We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`.
# First way to specify what we are plugging in: request an explicit abstract.
# This works with `Pattern` methods directly as well as with `Pather`.
circ2.plug(lib.abstract('wg10'), {'input': 'right'})
# Second way to get abstracts is to use an AbstractView
# This also works directly with `Pattern.plug()` / `Pattern.place()`.
# Second way: use an `AbstractView`, which behaves like a mapping of names
# to abstracts.
abstracts = lib.abstract_view()
circ2.plug(abstracts['wg10'], {'output': 'left'})
# Third way to specify an abstract works by automatically getting
# it from the library already within the Builder object.
# This wouldn't work if we only had a `Pattern` (not a `Builder`).
# Just pass the pattern name!
# Third way: let `Pather` resolve a pattern name through its own library.
# This shorthand is convenient, but it is specific to helpers that already
# carry a library reference.
circ2.plug('tri_wg10', {'input': 'right'})
circ2.plug('tri_wg10', {'output': 'left'})
@ -81,13 +86,15 @@ def main() -> None:
#
# Build a device that could plug into our mixed_wg_cav and joins the two ports
# Build a second device that is explicitly designed to mate with `circ2`.
#
# We'll be designing against an existing device's interface...
circ3 = Builder.interface(source=circ2)
# `Pather.interface()` makes a new pattern whose ports mirror an existing
# design's external interface. That is useful when you want to design an
# adapter, continuation, or mating structure.
circ3 = Pather.interface(source=circ2)
# ... that lets us continue from where we left off.
# Continue routing outward from those inherited ports.
circ3.plug('tri_bend0', {'input': 'right'})
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
circ3.plug('tri_bend0', {'input': 'right'})

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,31 +106,29 @@ 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
#
# Now we can start building up our library (collection of static cells) and pathing tools.
#
# If any of the operations below are confusing, you can cross-reference against the `RenderPather`
# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others
# (e.g. geometry definition).
#
def main() -> None:
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',
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',
)
#
@ -140,53 +137,79 @@ def main() -> None:
# 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`
# 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 = 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
),
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': ( # For example, when we're attaching to a port with type 'm2wire'
('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 = BasicTool(
straight = (
M2_tool = AutoTool(
straights = [
# 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',
),
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': (
('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
#
# Now we can start building up our library (collection of static cells) and pathing tools.
#
# If any of the operations below are confusing, you can cross-reference against the deferred
# `Pather` tutorial, which handles some things more explicitly (e.g. via placement) and simplifies
# others (e.g. geometry definition).
#
def main() -> None:
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'])
# the next time we route GND (the `pather.ccw()` call below).
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,169 @@
"""
PortPather tutorial: Using .at() syntax
"""
from masque import Pather, 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 deferred Pather and place some initial pads (same as Pather tutorial)
rpather = Pather(library, tools=M2_tool, auto_render=False)
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, trace() moves South by default)
(rpather.at('VCC')
.trace(False, length=6_000) # Move South, turn West (Clockwise)
.trace_to(None, x=0) # Continue West to x=0
)
# Route GND: 5um South, then West to match VCC's x-coordinate.
rpather.at('GND').trace(False, length=5_000).trace_to(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 route them together.
# Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via).
(rpather.at(['GND', 'VCC'])
.trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
.retool(M1_tool) # Retools both GND and VCC
.trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension
.trace(False, emin=1_000, spacing=1_200) # U-turn back South
.trace(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']).trace(None, xmin=-28_000)
# Final segments to -50k
rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire')
with rpather.at('GND').toolctx(M2_tool):
rpather.at('GND').trace_to(None, x=-40_000)
rpather.at('GND').trace_to(None, x=-50_000)
#
# Branching with mark and fork
#
# .mark(new_name) creates a port copy and keeps the original selected.
# .fork(new_name) creates a port copy and selects the new one.
# Create a tap on GND
(rpather.at('GND')
.trace(None, length=5_000) # Move GND further West
.mark('GND_TAP') # Mark this location for a later branch
.jog(offset=-10_000, length=10_000) # Continue GND with an S-bend
)
# Branch VCC and follow the new branch
(rpather.at('VCC')
.trace(None, length=5_000)
.fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH'
.trace(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)
.trace(True, length=10_000) # Turn South
.rename('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.select('GND') # Now tracking 3 ports
pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND
pp.trace(None, each=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: trace_into
#
# trace_into routes FROM the selected port TO a target 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').trace_into('DEST_A')
# Connect VCC_BRANCH to DEST_B
rpather.at('VCC_BRANCH').trace_into('DEST_B')
#
# 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 deferred auto-rendering, 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
Manual wire routing tutorial: deferred Pather and PathTool
"""
from collections.abc import Callable
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque import Pather, Library
from masque.builder.tools import PathTool
from masque.file.gdsii import writefile
@ -12,9 +11,9 @@ 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)
# but when used with `RenderPather`, it can consolidate multiple routing steps into
# To illustrate deferred routing with `Pather`, we use `PathTool` instead
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
# but when used with `Pather(auto_render=False)`, it can consolidate multiple routing steps into
# a single `Path` shape.
#
# We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py`
@ -25,66 +24,68 @@ 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)
rpather = Pather(tools=M2_ptool, library=library, auto_render=False)
# 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
library['Deferred_Pather_and_PathTool'] = rpather.pattern
# Convert from text-based layers to numeric layers for GDS, and output the file

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,
@ -72,10 +73,8 @@ from .ports import (
)
from .abstract import Abstract as Abstract
from .builder import (
Builder as Builder,
Tool as Tool,
Pather as Pather,
RenderPather as RenderPather,
RenderStep as RenderStep,
SimpleTool as SimpleTool,
AutoTool as AutoTool,

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,7 @@
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,
)
from .utils import ell as ell
from .tools import (
Tool as Tool,
@ -9,4 +9,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 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 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 Pather 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

@ -1,10 +1,12 @@
"""
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
# TODO document all tools
Concrete tools may implement native planning/rendering for `L`, `S`, or `U` routes.
Any unimplemented planning method falls back to the corresponding `trace*()` method,
and `Pather` may further synthesize some routes from simpler primitives when needed.
"""
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
@ -23,8 +25,8 @@ from ..error import BuildError
@dataclass(frozen=True, slots=True)
class RenderStep:
"""
Representation of a single saved operation, used by `RenderPather` and passed
to `Tool.render()` when `RenderPather.render()` is called.
Representation of a single saved operation, used by deferred `Pather`
instances and passed to `Tool.render()` when `Pather.render()` is called.
"""
opcode: Literal['L', 'S', 'U', 'P']
""" What operation is being performed.
@ -47,16 +49,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.flip_across(axis=axis)
new_end.flip_across(axis=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,
@ -70,7 +128,7 @@ class Tool:
Create a wire or waveguide that travels exactly `length` distance along the axis
of its input port.
Used by `Pather` and `RenderPather`.
Used by `Pather`.
The output port must be exactly `length` away along the input port's axis, but
may be placed an additional (unspecified) distance away along the perpendicular
@ -99,9 +157,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,
@ -116,7 +174,7 @@ class Tool:
of its input port, and `jog` distance on the perpendicular axis.
`jog` is positive when moving left of the direction of travel (from input to ouput port).
Used by `Pather` and `RenderPather`.
Used by `Pather`.
The output port should be rotated to face the input port (i.e. plugging the device
into a port will move that port but keep its orientation).
@ -141,7 +199,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,
@ -156,7 +214,7 @@ class Tool:
Plan a wire or waveguide that travels exactly `length` distance along the axis
of its input port.
Used by `RenderPather`.
Used by `Pather` when `auto_render=False`.
The output port must be exactly `length` away along the input port's axis, but
may be placed an additional (unspecified) distance away along the perpendicular
@ -183,7 +241,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.pop('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,
@ -198,7 +266,7 @@ class Tool:
Plan a wire or waveguide that travels exactly `length` distance along the axis
of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend).
Used by `RenderPather`.
Used by `Pather` when `auto_render=False`.
The output port must have an orientation rotated by pi from the input port.
@ -208,8 +276,8 @@ class Tool:
Args:
length: The total distance from input to output, along the input's axis only.
jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a rightwards shift (i.e. clockwise bend followed
by a counterclockwise bend)
A positive number implies a leftward 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.
kwargs: Custom tool-specific parameters.
@ -221,7 +289,58 @@ 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.pop('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`. Tools may leave this unimplemented if they
do not support a native U-bend primitive.
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,
@ -232,12 +351,12 @@ class Tool:
**kwargs,
) -> tuple[Port, Any]:
"""
# NOTE: TODO: U-bend is WIP; this interface may change in the future.
Plan a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
Used by `RenderPather`.
Used by `Pather` when `auto_render=False`. This is an optional native-planning hook: tools may
implement it when they can represent a U-turn directly, otherwise they may rely
on `traceU()` or let `Pather` synthesize the route from simpler primitives.
The output port must have an orientation identical to the input port.
@ -246,11 +365,12 @@ 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.
kwargs: Custom tool-specific parameters.
kwargs: Custom tool-specific parameters. `length` may be supplied here to
request a U-turn whose final port is displaced along both axes.
Returns:
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
@ -259,14 +379,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 +412,50 @@ 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
seg_name = lib << seg_tree
pat.plug(lib[seg_name], {port_names[1]: port_names[0]}, append=True)
del lib[seg_name]
return lib
abstract_tuple_t = tuple[Abstract, str, str]
@ -390,7 +565,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 +582,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 +595,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 +672,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 +697,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 +768,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 +864,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 +911,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 +964,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 +981,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 +1037,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 +1046,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 +1102,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 +1118,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 +1196,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 +1204,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 +1240,40 @@ class PathTool(Tool, metaclass=ABCMeta):
# self.width = width
# self.ptype: str
def path(
def _check_out_ptype(self, out_ptype: str | None) -> None:
if out_ptype and out_ptype != self.ptype:
raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}')
def _bend_radius(self) -> float:
return self.width / 2
def _plan_l_vertices(self, length: float, bend_run: float) -> NDArray[numpy.float64]:
vertices = [(0.0, 0.0), (length, 0.0)]
if not numpy.isclose(bend_run, 0):
vertices.append((length, bend_run))
return numpy.array(vertices, dtype=float)
def _plan_s_vertices(self, length: float, jog: float) -> NDArray[numpy.float64]:
if numpy.isclose(jog, 0):
return numpy.array([(0.0, 0.0), (length, 0.0)], dtype=float)
if length < self.width:
raise BuildError(
f'Asked to draw S-path with total length {length:,g}, shorter than required bend: {self.width:,g}'
)
# Match AutoTool's straight-then-s-bend placement so the jog happens
# width/2 before the end while still allowing smaller lateral offsets.
jog_x = length - self._bend_radius()
vertices = [
(0.0, 0.0),
(jog_x, 0.0),
(jog_x, jog),
(length, jog),
]
return numpy.array(vertices, dtype=float)
def traceL(
self,
ccw: SupportsBool | None,
length: float,
@ -907,15 +1283,15 @@ 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')
pat.path(layer=self.layer, width=self.width, vertices=self._plan_l_vertices(length, float(out_port.y)))
if ccw is None:
out_rot = pi
@ -926,7 +1302,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
@ -942,11 +1318,10 @@ class PathTool(Tool, metaclass=ABCMeta):
) -> tuple[Port, NDArray[numpy.float64]]:
# TODO check all the math for L-shaped bends
if out_ptype and out_ptype != self.ptype:
raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}')
self._check_out_ptype(out_ptype)
if ccw is not None:
bend_dxy = numpy.array([1, -1]) * self.width / 2
bend_dxy = numpy.array([1, -1]) * self._bend_radius()
bend_angle = pi / 2
if bool(ccw):
@ -967,6 +1342,46 @@ class PathTool(Tool, metaclass=ABCMeta):
out_port = Port(data, rotation=bend_angle, ptype=self.ptype)
return out_port, data
def traceS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs, # noqa: ARG002 (unused)
) -> Library:
out_port, _data = self.planS(
length,
jog,
in_ptype=in_ptype,
out_ptype=out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS')
pat.path(layer=self.layer, width=self.width, vertices=self._plan_s_vertices(length, jog))
pat.ports = {
port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype),
port_names[1]: out_port,
}
return tree
def planS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None, # noqa: ARG002 (unused)
out_ptype: str | None = None,
**kwargs, # noqa: ARG002 (unused)
) -> tuple[Port, NDArray[numpy.float64]]:
self._check_out_ptype(out_ptype)
self._plan_s_vertices(length, jog)
data = numpy.array((length, jog))
out_port = Port((length, jog), rotation=pi, ptype=self.ptype)
return out_port, data
def render(
self,
batch: Sequence[RenderStep],
@ -975,29 +1390,43 @@ 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. Pather.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
transform = rotation_matrix_2d(port_rot + pi)
delta = step.end_port.offset - step.start_port.offset
local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta
if step.opcode == 'L':
length, bend_run = 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)
local_vertices = self._plan_l_vertices(float(local_end[0]), float(local_end[1]))
elif step.opcode == 'S':
local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1]))
else:
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
if (path_vertices[-1] != batch[-1].end_port.offset).any():
# If the path ends in a bend, we need to add the final vertex
path_vertices.append(batch[-1].end_port.offset)
for vertex in local_vertices[1:]:
path_vertices.append(step.start_port.offset + transform @ vertex)
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

@ -46,7 +46,7 @@ def ell(
ccw: Turn direction. `True` means counterclockwise, `False` means clockwise,
and `None` means no bend. If `None`, spacing must remain `None` or `0` (default),
Otherwise, spacing must be set to a non-`None` value.
bound_method: Method used for determining the travel distance; see diagram above.
bound_type: Method used for determining the travel distance; see diagram above.
Valid values are:
- 'min_extension' or 'emin':
The total extension value for the furthest-out port (B in the diagram).
@ -64,7 +64,7 @@ def ell(
the x- and y- axes. If specifying a position, it is projected onto
the extension direction.
bound_value: Value associated with `bound_type`, see above.
bound: Value associated with `bound_type`, see above.
spacing: Distance between adjacent channels. Can be scalar, resulting in evenly
spaced channels, or a vector with length one less than `ports`, allowing
non-uniform spacing.
@ -84,7 +84,7 @@ def ell(
raise BuildError('Empty port list passed to `ell()`')
if ccw is None:
if spacing is not None and not numpy.isclose(spacing, 0):
if spacing is not None and not numpy.allclose(spacing, 0):
raise BuildError('Spacing must be 0 or None when ccw=None')
spacing = 0
elif spacing is None:
@ -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)
@ -132,8 +132,17 @@ def ell(
if spacing is None:
ch_offsets = numpy.zeros_like(y_order)
else:
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
steps = numpy.zeros_like(y_order)
steps[1:] = spacing
if spacing_arr.size == 1:
steps[1:] = spacing_arr[0]
elif spacing_arr.size == len(ports) - 1:
steps[1:] = spacing_arr
else:
raise BuildError(
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
f'got length {spacing_arr.size}'
)
ch_offsets = numpy.cumsum(steps)[y_ind]
x_start = rot_offsets[:, 0]

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
@ -193,8 +192,37 @@ def read(
top_name, top_pat = _read_block(msp)
mlib = Library({top_name: top_pat})
blocks_by_name = {
bb.name: bb
for bb in lib.blocks
if not bb.is_any_layout
}
referenced: set[str] = set()
pending = [msp]
seen_blocks: set[str] = set()
while pending:
block = pending.pop()
block_name = getattr(block, 'name', None)
if block_name is not None and block_name in seen_blocks:
continue
if block_name is not None:
seen_blocks.add(block_name)
for element in block:
if not isinstance(element, Insert):
continue
target = element.dxfattribs().get('name')
if target is None or target in referenced:
continue
referenced.add(target)
if target in blocks_by_name:
pending.append(blocks_by_name[target])
for bb in lib.blocks:
if bb.name == '*Model_Space':
if bb.is_any_layout:
continue
if bb.name.startswith('_') and bb.name not in referenced:
continue
name, pat = _read_block(bb)
mlib[name] = pat
@ -213,32 +241,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],
@ -273,12 +329,57 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
)
if 'column_count' in attr:
args['repetition'] = Grid(
a_vector=(attr['column_spacing'], 0),
b_vector=(0, attr['row_spacing']),
a_count=attr['column_count'],
b_count=attr['row_count'],
col_spacing = attr['column_spacing']
row_spacing = attr['row_spacing']
col_count = attr['column_count']
row_count = attr['row_count']
local_x = numpy.array((col_spacing, 0.0))
local_y = numpy.array((0.0, row_spacing))
inv_rot = rotation_matrix_2d(-rotation)
candidates = (
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
)
repetition = None
for a_vector, b_vector, a_count, b_count in candidates:
rotated_a = rotation_matrix_2d(rotation) @ a_vector
rotated_b = rotation_matrix_2d(rotation) @ b_vector
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
and a_count == col_count
and b_count == row_count):
repetition = Grid(
a_vector=a_vector,
b_vector=b_vector,
a_count=a_count,
b_count=b_count,
)
break
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
and b_count == col_count
and a_count == row_count):
repetition = Grid(
a_vector=a_vector,
b_vector=b_vector,
a_count=a_count,
b_count=b_count,
)
break
if repetition is None:
repetition = Grid(
a_vector=inv_rot @ local_x,
b_vector=inv_rot @ local_y,
a_count=col_count,
b_count=row_count,
)
args['repetition'] = repetition
pat.ref(**args)
else:
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
@ -303,15 +404,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 +453,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 +479,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(
@ -632,6 +637,7 @@ def check_valid_names(
max_length: Max allowed length
"""
names = tuple(names)
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
bad_chars = [
@ -648,7 +654,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,33 +549,35 @@ 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):
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
half_width = rint_cast(shape.width / 2)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup
if path_type is None:
raise PatternError(f'OASIS writer does not support path cap {shape.cap}')
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 +585,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 +608,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 +624,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 +651,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):
@ -707,13 +716,9 @@ def properties_to_annotations(
string = repr(value)
logger.warning(f'Converting property value for key ({key}) to string ({string})')
values.append(string)
annotations[key] = values
annotations.setdefault(key, []).extend(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,47 @@ 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 _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]:
svg_ids: dict[str, str] = {}
seen_ids: set[str] = set()
for name in names:
base_id = mangle_name(name)
svg_id = base_id
suffix = 1
while svg_id in seen_ids:
suffix += 1
svg_id = f'{base_id}_{suffix}'
seen_ids.add(svg_id)
svg_ids[name] = svg_id
return svg_ids
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 +75,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]
@ -63,10 +96,11 @@ def writefile(
# Create file
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
debug=(not custom_attributes))
svg_ids = _make_svg_ids(library)
# Now create a group for each pattern and add in any Boundary and Use elements
for name, pat in library.items():
svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red')
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
for layer, shapes in pat.shapes.items():
for shape in shapes:
@ -79,16 +113,37 @@ 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})'
use = svg.use(href='#' + mangle_name(target), transform=transform)
transform = _ref_to_svg_transform(ref)
use = svg.use(href='#' + svg_ids[target], transform=transform)
svg_group.add(use)
svg.defs.add(svg_group)
svg.add(svg.use(href='#' + mangle_name(top)))
svg.add(svg.use(href='#' + svg_ids[top]))
svg.save()

View file

@ -33,6 +33,12 @@ def preflight(
Run a standard set of useful operations and checks, usually done immediately prior
to writing to a file (or immediately after reading).
Note that this helper is not copy-isolating. When `sort=True`, it constructs a new
`Library` wrapper around the same `Pattern` objects after sorting them in place, so
later mutating preflight steps such as `prune_empty_patterns` and
`wrap_repeated_shapes` may still mutate caller-owned patterns. Callers that need
isolation should deep-copy the library before calling `preflight()`.
Args:
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
Default True. Useful for reproducible builds.
@ -75,7 +81,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))
@ -144,7 +151,11 @@ def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]:
path = pathlib.Path(path)
suffixes = ''.join(path.suffixes)
with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream:
yield tmp_stream
try:
yield tmp_stream
except Exception:
pathlib.Path(tmp_stream.name).unlink(missing_ok=True)
raise
try:
shutil.move(tmp_stream.name, path)

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,15 @@ 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._repetition = copy.deepcopy(self._repetition, memo)
new._annotations = copy.deepcopy(self._annotations, memo)
return new
def __lt__(self, other: 'Label') -> bool:
@ -76,6 +79,8 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
return annotations_lt(self.annotations, other.annotations)
def __eq__(self, other: Any) -> bool:
if type(self) is not type(other):
return False
return (
self.string == other.string
and numpy.array_equal(self.offset, other.offset)
@ -96,10 +101,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

@ -22,7 +22,7 @@ import copy
from pprint import pformat
from collections import defaultdict
from abc import ABCMeta, abstractmethod
from graphlib import TopologicalSorter
from graphlib import TopologicalSorter, CycleError
import numpy
from numpy.typing import ArrayLike, NDArray
@ -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 = '_'
"""
@ -177,6 +180,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str):
tops = (tops,)
tops = set(tops)
skip |= tops # don't re-visit tops
# Get referenced patterns for all tops
targets = set()
@ -186,9 +191,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 +296,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 +309,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 +424,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 +493,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
@ -511,50 +540,99 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
raise LibraryError('visit_* functions returned a new `Pattern` object'
' but no top-level name was provided in `hierarchy`')
del cast('ILibrary', self)[name]
cast('ILibrary', self)[name] = pattern
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()))
try:
return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order()))
except CycleError as exc:
cycle = exc.args[1] if len(exc.args) > 1 else None
if cycle is None:
raise LibraryError('Cycle found while building child order') from exc
raise LibraryError(f'Cycle found while building child order: {cycle}') from exc
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 +645,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 +655,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 +679,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 +696,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 +726,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 +783,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,
@ -700,6 +828,11 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Returns:
self
"""
if old_name not in self:
raise LibraryError(f'"{old_name}" does not exist in the library.')
if old_name == new_name:
return self
self[new_name] = self[old_name]
del self[old_name]
if move_references:
@ -724,6 +857,9 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Returns:
self
"""
if old_target == new_target:
return self
for pattern in self.values():
if old_target in pattern.refs:
pattern.refs[new_target].extend(pattern.refs[old_target])
@ -763,7 +899,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
@ -797,18 +933,23 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
(default).
Returns:
A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged
names map to themselves.
A mapping of `{old_name: new_name}` for all names in `other` which were
renamed while being added. Unchanged names are omitted.
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:
for key in other:
self._merge(key, other, key)
if mutate_other:
temp = other
else:
temp = Library(copy.deepcopy(dict(other)))
for key in temp:
self._merge(key, temp, key)
return {}
if mutate_other:
@ -909,7 +1050,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 = ()
@ -918,6 +1059,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
def label2name(label: tuple) -> str: # noqa: ARG001
return self.get_name(SINGLE_USE_PREFIX + 'shape')
used_names = set(self.keys())
def reserve_target_name(label: tuple) -> str:
base_name = label2name(label)
name = base_name
ii = sum(1 for nn in used_names if nn.startswith(base_name)) if base_name in used_names else 0
while name in used_names or name == '':
name = base_name + b64suffix(ii)
ii += 1
used_names.add(name)
return name
shape_counts: MutableMapping[tuple, int] = defaultdict(int)
shape_funcs = {}
@ -934,6 +1087,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
shape_counts[label] += 1
shape_pats = {}
target_names = {}
for label, count in shape_counts.items():
if count < threshold:
continue
@ -942,6 +1096,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
shape_pat = Pattern()
shape_pat.shapes[label[-1]] += [shape_func()]
shape_pats[label] = shape_pat
target_names[label] = reserve_target_name(label)
# ## Second pass ##
for pat in tuple(self.values()):
@ -966,14 +1121,14 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# For repeated shapes, create a `Pattern` holding a normalized shape object,
# and add `pat.refs` entries for each occurrence in pat. Also, note down that
# we should delete the `pat.shapes` entries for which we made `Ref`s.
shapes_to_remove = []
for label, shape_entries in shape_table.items():
layer = label[-1]
target = label2name(label)
target = target_names[label]
shapes_to_remove = []
for ii, values in shape_entries:
offset, scale, rotation, mirror_x = values
pat.ref(target=target, offset=offset, scale=scale,
rotation=rotation, mirrored=(mirror_x, False))
rotation=rotation, mirrored=mirror_x)
shapes_to_remove.append(ii)
# Remove any shapes for which we have created refs.
@ -981,7 +1136,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
del pat.shapes[layer][ii]
for ll, pp in shape_pats.items():
self[label2name(ll)] = pp
self[target_names[ll]] = pp
return self
@ -1002,7 +1157,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 +1191,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 +1239,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`).