Compare commits

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

284 commits

Author SHA1 Message Date
890e7e5c0f [BuildLibrary] allow prefixing/renaming/postprocessing in BuildCellsView 2026-08-28 20:25:14 -07:00
b62879b410 [AutoTool] misc cleanup 2026-08-27 14:05:24 -07:00
4d9aaf2fd9 [pather / RouteError] improve error reporting 2026-08-27 13:02:29 -07:00
1b84c87d9b [RouteError] include callsite traceback in route errors 2026-08-27 11:22:37 -07:00
ce7463e57c [pather / planner] pass planner options via plan_options, not strategy/bend_policy 2026-08-27 10:52:43 -07:00
91a5d63b57 [pather / planner] add bend_policy, default to error on non-minimal number of bends 2026-08-27 10:34:35 -07:00
1da5ac550a [Pather] improve cost calculation for S-first 2026-07-16 16:28:56 -07:00
ac34108253 [library] auto-rename single-use names in add_source 2026-07-16 08:36:19 -07:00
4b0a899e17 [LayerMappedView] reject None layer mappings early 2026-07-16 08:36:14 -07:00
2b6782c93e [Pather / planner / ports] improve trace_into connection accuracy 2026-07-16 08:35:46 -07:00
e397b3f96e [utils] canonicalize rotation matrices before caching 2026-07-16 08:35:42 -07:00
93dd3503ca [library / gdsii] add materialize_detached and do some more cleanup 2026-07-14 22:18:17 -07:00
29947c6c01 [gdsii] cleanup 2026-07-14 21:24:34 -07:00
635f612630 [gdsii] enable lazy write-through 2026-07-14 19:55:14 -07:00
95e54acf96 [file.utils] add preflight_source_aware 2026-07-14 19:18:46 -07:00
cbf75319ad [PortLoadView / LayerMappedView] rename/add new views 2026-07-14 11:18:20 -07:00
412133533a [AutoTool] fix some masked errors 2026-07-13 23:07:37 -07:00
42d291b1b6 [Pather] add route completion callback 2026-07-13 22:21:05 -07:00
e7f7cae8b9 [library / gdsii] more cleanup 2026-07-13 21:40:10 -07:00
7b589e4f44 [library / gdsii] cleanup using new library rework 2026-07-13 21:37:52 -07:00
ddb7742493 misc doc updates 2026-07-13 14:58:09 -07:00
f9611933ac [Pather / Tool] more unification work and fixes 2026-07-13 14:44:39 -07:00
8712398990 [AutoTool | PrimitiveOffer] remove priority_bias in favor of cost_at 2026-07-13 12:25:08 -07:00
0edd735a11 [library] More library rework 2026-07-13 12:24:33 -07:00
67afee7704 [Pather / AutoTool] rework route kwargs into tool_options, and actualyl pass them through AutoTool 2026-07-12 20:32:59 -07:00
49713896c3 [Pather/Planner] Doc cleanup and more informative error enums 2026-07-12 17:25:13 -07:00
3a3cd854a5 [Pather/Planner] Improve error reporting 2026-07-12 15:27:14 -07:00
09b131026f [Tool] tighten constraints on tool parameters 2026-07-12 15:26:24 -07:00
306f0b4c83 [Pather] handle empty or duplicate port lists 2026-07-12 15:18:00 -07:00
fe4e2f760b [AutoTool] infer port info automatically 2026-07-10 16:30:44 -07:00
cf3f72b828 [Pather] add strategy arg 2026-07-10 14:20:04 -07:00
0230fb49a6 [AutoTool] enable custom sbend endpoint_at 2026-07-09 21:38:29 -07:00
7083edc7ca [PortsLibraryView] now accepts overrides 2026-07-09 19:47:26 -07:00
520f37aa29 [gdsii] misc cleanup 2026-07-09 14:21:05 -07:00
088c37e9d2 [file.gdsii] rework gdsii module hierarchy 2026-07-09 13:40:58 -07:00
02a3708b30 [library] Split library.py into multiple files 2026-07-09 13:17:36 -07:00
4c64352152 [OverlayLibrary / PortsLibraryView] move into masque.library 2026-07-09 13:05:01 -07:00
9a39a436b2 [Pather] move render check from __del__ to __exit__ and use render= enum instead of auto_render 2026-07-09 12:28:05 -07:00
84664303f1 [planner] early exit if rotation doesn't match 2026-07-09 11:27:29 -07:00
f864ebbeab [planner] make solver carry state across increasing bend counts 2026-07-09 11:09:52 -07:00
54c4cd9a4a [planner] try bends=2 before bends=(2, 4) 2026-07-09 11:00:05 -07:00
b6c222cdc2 [planner] cache primitive offers 2026-07-09 10:36:00 -07:00
583bd5bd77 [planner] avoid rotation_matrix_2d since float inaccuracy breaks caching 2026-07-09 10:35:45 -07:00
22e645e527 [builder] major Pather/Planner/Tool rework 2026-07-08 23:58:24 -07:00
0f39ce8816 [Arc] fix raw constructor angle_ref default 2026-06-21 16:59:56 -07:00
5e0cc0e20f [BuildLibrary] fix auto-rename during merge 2026-06-20 16:23:08 -07:00
1b83034b7e [BuildLibrary / OverlayLibrary] further simplifications 2 2026-06-20 16:23:08 -07:00
1723212424 [BuildLibrary / OverlayLibrary] further simplifications 2026-06-20 16:23:08 -07:00
c420ac8085 [BuildLibrary] misc simplifications 2026-06-20 16:23:08 -07:00
08bbb10827 [OverlayLibrary] enable renaming all cells during add_source with rename_when='always' 2026-06-20 16:23:08 -07:00
3dea61b05e [BuildLibrary] eliminate BuiltLibrary and BuiltOverlayLibrary 2026-06-20 16:23:08 -07:00
0fba187d8c [BuildLibrary] whitespace 2026-06-20 16:23:08 -07:00
6d494142fe [wip] introduce BuildLibrary and make overlay first-class 2026-06-20 16:23:08 -07:00
e108199bcd [wip] Rework load_libraryfile and LazyLibrary using overlays 2026-06-20 16:23:08 -07:00
151a7f846f [arrow] improve test coverage and error handling 2026-06-20 16:23:08 -07:00
f3a60da30b [gdsii_arrow] remove non-raw-mode arrow option; fix gzip wrapper 2026-06-20 16:23:08 -07:00
fd01e369e1 [svg] avoid mutating the original library 2026-06-20 16:23:08 -07:00
c83846afb3 [arrow] add lazy arrow reader 2026-06-20 16:23:08 -07:00
e009417eed [shapes] move to per-shape purpose-built _from_raw constructors 2026-06-20 16:23:08 -07:00
85aed4388e [gdsii_arrow] more performance work 2026-06-20 16:23:08 -07:00
f6ad86cdc2 [Polygon / PolyCollection] add raw constructors 2026-06-20 16:23:08 -07:00
a279ccec21 [RectCollection] add a RectCollection shape 2026-06-20 16:23:08 -07:00
b6ee0d4929 enable annotations=None by default 2026-06-20 16:23:08 -07:00
4aca8acd3d [gdsii_arrow] further improvements to speed 2026-06-20 16:23:08 -07:00
46a15e2f0f [Label / Ref / Grid] add raw constructors 2026-06-20 16:23:08 -07:00
8b7dd947fb [gdsii] add some profiling helpers 2026-06-20 16:23:08 -07:00
262f2dee3a [gdsii_arrow] misc correctness work 2026-06-20 16:23:08 -07:00
624d707123 add some missing deps 2026-06-20 16:23:08 -07:00
jan
29bd0f63e9 [gdsii_arrow] add gdsii_arrow 2026-06-20 16:23:08 -07:00
d36a1a53e6 [PortPather] add default_spacing (.at(..., spacing=...)) 2026-06-20 16:22:59 -07:00
833f5dd159 [utils] add explicit spiral and circular arc helpers, and allow non-90deg bends 2026-06-16 00:14:24 -07:00
f4df8e0553 [utils] remove_duplicate_vertices now takes a tolerance (default exact) 2026-06-16 00:12:16 -07:00
4d57936da8 [test] refactor tests 2026-06-15 20:05:11 -07:00
51c7fa9add [Arc] add angle_ref to enable focus-based arcs 2026-06-15 20:05:11 -07:00
jan
e4a52b2c90 [AutoTool] fix output transition offset 2026-05-28 21:45:06 -07:00
jan
e33a0f5ae1 [Tool / SimpleTool / AutoTool] Documentation updates 2026-05-27 21:19:37 -07:00
950d144ead [AutoTool] rework two-L routing to avoid some bugs with incorrect transitions 2026-04-17 20:41:37 -07:00
d95ddbb6b9 [Arc] return clearer errors when working with an invalid arclength 2026-04-16 19:39:42 -07:00
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
138 changed files with 30268 additions and 4790 deletions

856
MIGRATION.md Normal file
View file

@ -0,0 +1,856 @@
# Migration Guide
This guide covers changes between the `master` branch and the current tree.
Both `master` and the current tree report `masque.__version__ == '3.4'`; the
version string has not yet been bumped for these changes.
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(..., render='deferred').trace(...)` |
| `RenderPather.path_to(...)` | `Pather(..., render='deferred').trace_to(...)` |
| `RenderPather.mpath(...)` | `Pather(..., render='deferred').trace(...)` / `Pather(..., render='deferred').trace_to(...)` |
| `RenderPather.pathS(...)` | `Pather(..., render='deferred').jog(...)` |
| `RenderPather.pathU(...)` | `Pather(..., render='deferred').uturn(...)` |
| `RenderPather.path_into(...)` | `Pather(..., render='deferred').trace_into(...)` |
| `RenderPather.path_from(src, dst)` | `Pather(..., render='deferred').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(..., render='deferred')
```
The new `Pather` remains importable from both `masque` and `masque.builder`.
The removed `Builder` and `RenderPather` names are no longer exported from
either location.
`Pather` now defaults to `render='auto'`, so plain construction replaces the
old `Builder` behavior. Use `Pather(..., render='deferred')` where you
previously used `RenderPather`.
## `SimpleTool` was removed and `AutoTool` registration changed
`SimpleTool` is no longer exported. `AutoTool` remains, but its old public
descriptor classes and constructor-oriented configuration were replaced by
registration methods. Use it for generated straights and S-bends and reusable
bends, U-turns, and transitions.
### Old `AutoTool`
```python
from masque.builder import AutoTool
tool = AutoTool(
straights=[
AutoTool.Straight('m1wire', make_straight, 'input', 'output'),
],
bends=[
AutoTool.Bend(lib.abstract('bend'), 'input', 'output'),
],
sbends=[],
transitions={
('m2wire', 'm1wire'): AutoTool.Transition(
lib.abstract('via'), 'top', 'bottom'
),
},
default_out_ptype='m1wire',
)
```
### New `AutoTool`
```python
from masque.builder import AutoTool
tool = (
AutoTool()
.add_straight(make_straight, 'm1wire', 'input')
.add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True)
.add_transition(lib.abstract('via'), 'top', 'bottom')
)
```
The key differences are:
- `SimpleTool` was removed; use `AutoTool` or implement the new `Tool`
primitive-offer interface
- `AutoTool.Straight(...)` -> `add_straight(fn, ptype, in_name)`
- `AutoTool.Bend(...)` -> `add_bend(abstract, in_name, out_name)`
- `AutoTool.SBend(...)` -> `add_sbend(fn, ptype, in_name, out_name)`
- reusable native U-turns can be registered with `add_uturn(...)`
- transitions are registered with `add_transition(abstract, external_port, internal_port)`
- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter
Primitive costs are now explicit and independent of `AutoTool` registration
order. Every `add_*()` method accepts `cost=`, as do the concrete offer
factories. A numeric value scales the default geometric cost; for example,
`cost=2` makes a primitive twice as expensive. A callable receives the
canonical primitive parameter and local endpoint and returns the complete
cost:
```python
tool.add_straight(make_straight, 'm1wire', 'input', cost=1.5)
tool.add_sbend(
make_sbend,
'm1wire',
'input',
'output',
cost=lambda jog, endpoint: abs(jog) + 2 * abs(endpoint.x),
)
```
`PrimitiveOffer.priority_bias` was removed; custom offers should use `cost`
instead. Exact equal-cost candidates still use deterministic discovery order
as the final tie-break, but registration order no longer changes their
reported cost.
For two-port primitives, `AutoTool` can infer omitted port names and, for
generated straight/S-bend primitives, omitted ptype metadata by sampling an
in-domain example. Supply those values explicitly when generation requires
route-specific keyword arguments, because metadata inference does not receive
`tool_options`.
## Custom `Tool` subclasses
If you maintain your own `Tool` subclass, the interface changed:
- `primitive_offers()` is now the planning boundary
- `render()` consumes committed primitive render tokens
- `Tool.path(...)`, `traceL()`, `traceS()`, `traceU()`, `planL()`,
`planS()`, and `planU()` are no longer part of the public `Tool` API
In practice, a minimal old implementation like:
```python
class MyTool(Tool):
def path(self, ccw, length, **kwargs):
...
```
should now become:
```python
from collections.abc import Sequence
from typing import Any
from masque import Port
from masque.builder import RenderStep, StraightOffer, Tool
class MyTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs):
if kind != 'straight':
return ()
def endpoint(length):
ptype = out_ptype or in_ptype
return Port((length, 0), rotation=3.141592653589793, ptype=ptype)
def commit(length):
return {'length': length}
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype or in_ptype,
endpoint_planner=endpoint,
commit_planner=commit,
),)
def render(self, batch: Sequence[RenderStep]):
...
```
If a tool does not provide a primitive kind, return `()` for that kind. `Pather`
will compose available primitive offers where the route family allows it.
### Primitive offers
Tools describe legal routing primitives through `Tool.primitive_offers()`.
`Pather` composes those primitive offers to implement `trace()`, `jog()`,
`uturn()`, and `trace_into()`.
For custom tools, construct the concrete offer class that matches the primitive
you are exposing:
- `StraightOffer` for non-turning length-parameterized primitives
- `BendOffer` for single-turn length-parameterized primitives
- `SOffer` for S-like jog-parameterized primitives
- `UOffer` for U-like jog-parameterized primitives
`PrimitiveOffer` is the shared base type used for generic annotations and
common callback behavior. It is not the normal class users should instantiate.
The concrete offer classes carry the semantic fields (`length_domain`,
`jog_domain`, `ccw`) so tools do not need to encode primitive identity in
strings. Each concrete offer now also exposes a class-level canonical `kind`.
Tools must return the matching offer kind for the discovery query; for example,
`primitive_offers('s', ...)` must return only `SOffer` instances. Mismatches are
reported as fatal `ToolContractError`s rather than silently ignored.
`RenderStep` now stores that same canonical kind. Code that constructs render
steps directly must use the kind rather than a legacy opcode:
```python
# old
RenderStep('L', tool, start, end, data)
# new
RenderStep('straight', tool, start, end, data)
```
Use `'bend'`, `'s'`, `'u'`, or `'plug'` for the other step types. The read-only
`RenderStep.opcode` and `PrimitiveOffer.opcode` properties remain available for
code that only consumes steps, but opcodes are now derived rather than stored.
Minimal straight-only example:
```python
from collections.abc import Sequence
from typing import Literal
from masque import Port
from masque.builder import RenderStep, StraightOffer, Tool
class MyTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype=None,
out_ptype=None,
**kwargs,
):
if kind != 'straight':
return ()
def endpoint(length):
ptype = out_ptype or in_ptype
return Port((length, 0), rotation=3.141592653589793, ptype=ptype)
def commit(length):
return {'length': length}
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype or in_ptype,
endpoint_planner=endpoint,
commit_planner=commit,
),)
def render(self, batch: Sequence[RenderStep]):
...
```
Routing entry points now name every supported route argument explicitly.
Custom per-route Tool values must be placed under `tool_options`:
```python
pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'})
```
The mapping is unpacked only for `Tool.primitive_offers()`. Route arguments do
not leak into that namespace, and tool options are not forwarded to
`Tool.render()`. An offer that needs route-specific render behavior must capture
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
does this automatically for generated straight and S-bend primitives: the
mapping is deep-copied per offer discovery and passed to its generator as
keyword arguments during rendering. Consequently every AutoTool option value
must be deep-copyable, and later mutation of nested caller-owned values does
not affect a pending route. AutoTool options must not change generated
ports or endpoint geometry. `PathTool` defines no tool options and rejects
nonempty mappings.
AutoTool does not validate generator keyword signatures during planning. A bad
keyword therefore raises when the generator runs, normally during rendering.
Generators that require route options must provide explicit port metadata at
registration; S-bend generators must also provide an explicit `endpoint`.
Primitive offers are local planning objects:
- `endpoint_at(parameter)` returns the local output `Port`
- `cost_at(parameter)` returns an additive scalar route-selection cost
- `bbox_at(parameter)` returns local primitive bounds when a footprint hook is supplied
- `parameterized_bbox` may carry opaque future-router footprint metadata
- `commit(parameter)` returns opaque render data consumed later by `render()`
- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton
- domains are validated when an offer is constructed, not when it is first evaluated
- selected parameter values must be finite; domains may use infinite open bounds but not `NaN`
- straight/bend domains require a finite nonnegative minimum; fixed singleton domains must be finite
- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer
`AutoTool.add_straight(length_range=...)` and
`AutoTool.add_sbend(jog_range=...)` now validate their ranges before metadata
inference. `jog_range` is an absolute-magnitude range and must have a finite,
nonnegative lower bound; AutoTool creates the corresponding positive and
negative S offers itself. Invalid ranges now raise immediately instead of
registering no offers.
`ToolContractError` is exported from `masque` and `masque.builder`. It marks a
broken Tool contract—such as an endpoint ptype that disagrees with its offer,
an invalid evaluated cost, or rendered output that disagrees with the planned
endpoint—and is fatal to route fallback. Ordinary `BuildError` raised by a
planning callback remains a recoverable candidate rejection. Rendered output
rotations are compared modulo one full turn; a port facing exactly backward is
no longer accepted as equivalent. A `None` rotation remains an explicit
wildcard.
Custom Tool authors can exercise discovery, offer callbacks, committed data,
and one-step rendering without depending on pytest:
```python
from masque.builder import ToolContractCase, validate_tool_contract
validate_tool_contract(my_tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
ToolContractCase('bend', in_ptype='wire', ccw=False),
ToolContractCase('bend', in_ptype='wire', ccw=True),
ToolContractCase('s', in_ptype='wire', require_offers=False),
))
```
Cases derive representative parameters from each returned offer domain and
may add explicit probes. Empty discovery is an error unless
`require_offers=False`; bbox support is required only with `check_bbox=True`.
Validation returns normally on success and otherwise raises an
`ExceptionGroup` of contextual `ToolContractError`s.
Run this validation during Tool development, testing, or application startup.
It is the comprehensive semantic preflight for custom Tools; those checks are
intentionally not repeated for every offer evaluation in the routing hot path.
Positional routing bounds (`p`, `pos`, `position`, `x`, and `y`) now require a
nearly Manhattan input-port direction. Arbitrarily angled ports remain valid
for non-positional/extension routing.
Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype
adapters. Requested `out_ptype` constrains only the final route endpoint; any
intermediate ptypes are chosen by the route solver.
`Tool` subclasses must override `primitive_offers()` and return `()` themselves
for recognized unsupported kinds. There is no route-level `plan*()` fallback.
Omitted-length S/U behavior comes from direct `SOffer` and `UOffer` endpoint
domains or from composed straight/bend primitives.
Offer constructors accept split `endpoint_planner` and `commit_planner`
callbacks. Provide both callbacks or override the offer methods in a subclass;
partial callback configurations are rejected during offer construction.
When writing direct primitive offers, declare the actual endpoint ptype
produced by the offer if it can differ from the requested value; `Pather`
validates evaluated endpoints against the declared offer ptype.
Stable imports for custom tool authors live in `masque.builder`. The
`masque.builder.planner` module is an internal planner implementation; do not
import it from user code.
`trace_into()` uses the same primitive-offer route selection and defaults to
the minimal main-route bend count required by the endpoint relationship: zero
for a straight, one for a quarter-turn, and two for S- and U-like connections.
Ptype adapters do not consume this bend budget. Set
`plan_options={'bend_policy': 'flexible'}` to search bounded route topologies
with up to four bend roles, including dogleg and loop-like fallbacks.
Bend-family requests then search one-bend routes before three-bend routes;
other families search zero-to-two-bend routes before four-bend routes. The
first band with a legal route wins. Within that band, candidates are ordered
by total primitive-offer cost, adapter count, step count, and deterministic
discovery order. The default planner's `strategy` option affects only that
final discovery-order tie-break and is also supplied through `plan_options`.
`plan_options` is reserved for planner-specific per-route policy, while
`tool_options` is forwarded only to `Tool.primitive_offers()`. For example:
```python
pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'})
```
Explicit-length `jog()` routes may also be satisfied by composing a straight
primitive before or after an omitted-length native S primitive. `uturn()` routes
may compose a straight primitive before an omitted-length native U primitive.
These compositions are used when they are the lowest-cost legal route for the
explicit request.
`AutoTool` can attach `bbox_at()` hooks to its primitive offers by rendering the
selected primitive into a temporary pattern and measuring it. If the rendered
primitive contains reusable refs, pass the source library as `bbox_library=...`;
normal routing does not require this.
### Omitted-length routing
Single-port omitted-length calls now evaluate legal primitive routes at their
minimum legal length-like parameter, or at their intrinsic endpoint length when
the requested offset fixes the primitive geometry. Cost then selects among
those minimum-length candidates:
```python
pather.trace('A', None) # minimum straight-like route
pather.jog('A', offset=2) # minimum S-like route for that offset
pather.uturn('A', offset=4) # minimum U-like route for that offset
```
For U-turns, use explicit `length=0` to request the old zero-public-length
shape:
```python
pather.uturn('A', offset=4, length=0)
```
## 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(...)`
- `Abstract.mirror_port_offsets(...)` / `Abstract.mirror_ports(...)`
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.
`Abstract.mirror_port_offsets()` and `Abstract.mirror_ports()` were removed.
Use `Abstract.mirror(axis)` to mirror both port locations and orientations. If
you intentionally need only one half of that operation, update the individual
ports explicitly with `Port.flip_across(...)` or `Port.mirror(...)`.
## Library hierarchy and graph behavior
`masque/library.py` was split into the `masque.library` package. Imports from
the public module remain stable:
```python
from masque import Library, LazyLibrary
# or
from masque.library import Library, LazyLibrary
```
Code importing the implementation file itself must move to the public package;
do not depend on the new internal `base`, `mapping`, or `lazy` module paths.
Hierarchy helpers now handle dangling references explicitly. The following
methods accept `dangling='error' | 'ignore' | 'include'` and default to
`'error'`:
- `child_graph()`
- `parent_graph()`
- `child_order()`
- `find_refs_local()`
- `find_refs_global()`
- `prune_empty()`
On `master`, graph construction could expose missing targets implicitly or
fail later with a `KeyError`. If dangling refs are intentional, choose the
behavior explicitly, for example:
```python
graph = library.child_graph(dangling='include')
order = library.child_order(dangling='ignore')
```
Graph cycles and invalid hierarchy states are now reported as `LibraryError`
with context. Audit code that caught `KeyError` or `graphlib.CycleError` from
these helpers.
Invalid `dangling=` strings now raise `ValueError`; they no longer fall through
to the `include` behavior. Empty lists stored in `Pattern.refs` are consistently
treated as absent references by hierarchy and geometry traversal. Code that
uses a `defaultdict` lookup such as `pattern.refs[name]` without appending a
`Ref` will therefore not create an edge or force that target to be loaded.
`Library.add()` now resolves the full name plan and remaps references before it
starts inserting cells. Name-resolution and preparation failures no longer
leave partially-added cells behind. A `rename_theirs` callback now receives an
`INameView` containing both existing names and names reserved earlier in the
same addition. Use membership, iteration, `len()`, or `get_name()`; the callback
argument is not the destination object and does not support pattern lookup or
mapping helpers such as `keys()` and `items()`. Update callback annotations from
`ILibraryView` to `INameView`. Custom `_merge()` implementations remain
responsible for their own rollback if they fail during the final commit.
Reference transforms returned by `find_refs_local()` and
`find_refs_global()` are now Nx5 arrays. The fifth column is the cumulative
scale, so rows have the form `(x, y, rotation, mirrored, scale)` rather than the
old Nx4 form.
`BuildReport` mapping fields are now defensively copied and read-only. Copy a
field to a new `dict` before adding or removing report entries.
`PortLoadView`, `LayerMappedView`, `OverlayLibrary`, and source-backed outputs
returned by `LibraryBuilder.build()` borrow their sources. They do not close
source resources, and the views are not context managers. Keep each lazy
source open and unchanged until every borrowing view or overlay is finished,
then close the owning source explicitly. An eager `build(output='library')`
result is detached and does not need its sources afterward.
Lazy GDS sources no longer provide `with_ports_from_data()` or
`with_port_overrides()` convenience methods. Construct the generic view
directly instead: `PortLoadView(source, layers=...)` imports port data, and
`PortLoadView(source, ports=..., replace=...)` applies explicit overrides.
`LayerMappedView(source, map_layer)` lazily remaps shape and label layers while
leaving the source untouched. Its default `copy_through=False` forces mapped
serialization of every cell. Set `copy_through=True` only when source-aware
writers may copy untouched cells unchanged and intentionally skip their layer
mapping; persistently accessed cells are mapped and no longer copied through.
`preflight_source_aware(...)` preserves that per-cell provenance. It returns a
borrowing `OverlayLibrary`, skips source-backed cells completely, and applies
pattern sorting, named-layer validation, safe empty-cell pruning, and
repeated-shape wrapping only to cells without reusable source provenance.
Library name order is retained because sorting source-backed cells would
require loading them. Always use the returned overlay for writing, and keep the
original lazy source open until the write finishes. The comprehensive
`preflight(...)` operation continues to materialize every cell when sorting is
enabled.
Generic borrowing views no longer expose GDS-specific `raw_struct_bytes()`,
`can_copy_raw_struct()`, or forwarded `library_info` attributes. Keep the
owning GDS source or the metadata returned by `readfile()` when direct access is
needed. `IBorrowing.source_cell()` now provides format-neutral per-cell
provenance; GDS writers use it internally to preserve safe raw copy-through.
Read-only `subtree()` results are now borrowed lazy views rather than eager
`LibraryView` snapshots. Creating one no longer loads its reachable patterns,
and the view preserves source ordering, hierarchy metadata, and lazy GDS
copy-through. Mutable `Library`, `LazyLibrary`, and `OverlayLibrary` subtrees
return the same writable type as their source. Overlay subtrees retain their
source layers and lazy GDS capabilities. Mutable subtree containers are
structurally independent, but already-materialized patterns remain shared.
Keep borrowed sources open and structurally unchanged for the subtree's
lifetime.
`ILibrary` no longer inherits `collections.abc.MutableMapping`. It remains a
readable `Mapping` with explicit insert-only item assignment and deletion, but
generic mutation helpers such as `update()`, `setdefault()`, `pop()`,
`popitem()`, and `clear()` are no longer supplied. Use `add()`, `rename()`,
`delete()`, item insertion, and item deletion so library name and reference
invariants remain explicit.
Library-level `referenced_patterns()` and `dangling_refs()` now report only
named cell targets and return `set[str]`. Populated refs whose target is `None`
are ignored, matching `child_graph()` and recursive geometry behavior;
`Pattern.referenced_patterns()` continues to report `None` locally.
Port-importing views now always process detached patterns, including when the
raw source cell was already cached. Code can safely retain and compare raw and
processed views without port overrides leaking back into the raw pattern.
Once a processed cell is persistently materialized, lazy GDS writers no longer
copy the raw source structure for that cell, so later mutations to the returned
`Pattern` are serialized. Non-persistent materialization does not mark the cell
as changed.
Underscore-prefixed declarations work through the attribute authoring surface:
`builder.cells._helper = pattern` now declares `_helper`. Only the view's exact
internal `_library` attribute is reserved.
Recursive geometry operations now reject cyclic reference hierarchies with a
contextual `PatternError` instead of eventually leaking `RecursionError`. This
applies to bounds calculation, flattened layer polygon extraction, and
visualization as well as the existing flattening checks.
`LibraryBuilder.validate(names=...)` now accepts either one string or a sequence
of strings. Non-string roots raise `TypeError`, and duplicate roots are reduced
to their first occurrence. A recipe may build a different `LibraryBuilder`, but
calling `build()` or `validate()` recursively on its own active builder now
raises `BuildError` before starting another session.
`LibraryBuilder` is an authoring registry, not an `ILibraryView` or mapping.
Use membership, iteration, `keys()`, `get_name()`, assignment, and deletion to
manage declarations, then use the library returned by `build()` for reads and
hierarchy operations. Recipes that need an active library must receive the
builder-owned placeholder as a direct argument:
```python
def make_top(lib: ILibrary) -> Pattern:
return Pather(library=lib, ports='device').pattern
builder.cells.top = cell(make_top)(builder.library)
builder.cells.device = cell(factory)(hole_lib=builder.library)
```
The builder-owned `builder.library` placeholder is read-only. Only direct
positional and keyword values equal to it are substituted; placeholders nested
inside containers are not interpreted. A placeholder from another builder is
rejected when the recipe is assigned.
`IMaterializable` now identifies libraries which support explicit
`materialize()` and `materialize_many()` operations. `LibraryBuilder.add()`
borrows these marked inputs, while ordinary mappings and `ILibraryView`
instances are copied eagerly. Use `add_source()` to force borrowing of an
unmarked view. Wrapping a materializable library in `LibraryView` intentionally
erases the marker.
`IBorrowing` separately identifies composite views which retain direct source
libraries and expose them through `borrowed_sources()`. Keep those sources open
and unchanged for the lifetime of the borrowing view. Owner libraries such as
`LazyLibrary` and the lazy GDS readers are materializable but do not implement
`IBorrowing`. Borrowing views may expose unchanged per-cell layout provenance
through `source_cell()`; format writers decide whether that provenance permits
raw copy-through. Raw GDS structure access remains confined to GDS sources and
writers.
`LibraryBuilder`, `OverlayLibrary`, `PortLoadView`, and `LayerMappedView` are new
additive library implementations. `LibraryBuilder` supports declarative `@cell`
recipes and dependency-aware builds; `OverlayLibrary` composes source libraries
without eagerly copying all patterns; `PortLoadView` loads port metadata from
labels and/or explicit mappings; `LayerMappedView` remaps shape and label layers
on detached materialization.
Flattening with `flatten_ports=True` now rejects repeated refs whose target has
ports, because expanding them would create duplicate port names. Resolve the
repetition and assign unique port names before flattening, or use
`flatten_ports=False`.
## GDSII module and lazy-loading changes
`masque.file.gdsii` changed from a module into a package. The eager klamath
API remains available at the old import path, so ordinary `read`, `readfile`,
`write`, and `writefile` calls do not need to change:
```python
from masque.file import gdsii
library, info = gdsii.readfile('layout.gds')
```
The old `gdsii.load_library()` and `gdsii.load_libraryfile()` entry points were
removed. Use the source-backed lazy reader instead:
```python
# old
library, info = gdsii.load_libraryfile('layout.gds')
# new
from masque.file.gdsii import lazy
library, info = lazy.readfile('layout.gds')
try:
pattern = library['TOP']
finally:
library.close()
```
`lazy.read(stream)` and `lazy.readfile(path, use_mmap=...)` return a read-only
`GdsLibrarySource`. It owns file resources when it opens them and also supports
the context-manager protocol. The old `full_load` and `postprocess` arguments
are gone; materialize/copy the desired cells and post-process them explicitly.
An optional Arrow/native backend is available through
`masque.file.gdsii.arrow` and `masque.file.gdsii.lazy_arrow`; install the new
`arrow` extra to use it. These modules are additive and are not a transparent
replacement unless their additional dependencies and native library are
available.
GDS writing now has one entry point for eager and lazy libraries. Use
`masque.file.gdsii.write()` or `masque.file.gdsii.writefile()` regardless of
which reader produced the library. The `lazy` and `lazy_arrow` modules no
longer re-export writer functions. Source-backed libraries continue to infer
their header metadata and copy untouched structures directly.
## Shape construction and geometry additions
The public `raw=True` constructor shortcut was removed from `Arc`, `Circle`,
`Ellipse`, `Path`, `Polygon`, `PolyCollection`, and `Text`. Call their normal
constructors without `raw`; `_from_raw()` is an internal fast path and is not a
compatibility API.
```python
# old
polygon = Polygon(vertices, raw=True)
# new
polygon = Polygon(vertices)
```
`Arc` radii must now be strictly positive rather than merely non-negative.
`Arc.angle_ref` is additive and defaults to `Arc.AngleRef.Center`, preserving
the previous center-referenced angle interpretation.
`RectCollection` is a new shape for batches of axis-aligned rectangles and is
exported from both `masque` and `masque.shapes`. `Polygon.boolean()` and the
top-level `masque.boolean()` helper are also new; install the `boolean` extra
for their `pyclipper` dependency.
## Other user-facing changes
### File writers
SVG writing no longer polygonizes or flattens caller-owned patterns in place;
it works from detached copies. `svg.writefile(..., annotate_ports=True)` can
add port arrows. DXF writing now expands shape repetitions into individual DXF
entities, so callers no longer need to wrap repeated shapes solely for DXF
output.
### 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.
### Optional dependency names
The misspelled `manhatanize_slow` extra was corrected to
`manhattanize_slow`. A separate `manhattanize` extra now installs the
scikit-image implementation. The `arrow` and `boolean` extras are also new.
### New exports
These are additive, but available now from `masque` and `masque.builder`:
- from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`,
`PortLoadView`, `LayerMappedView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`,
`BuildReport`, `CellProvenance`, and `cell`
- from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete
primitive-offer classes, structured route error/status types,
`ToolContractCase`, and `validate_tool_contract`
## 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 `SimpleTool` and old `AutoTool` descriptor construction with the
new `AutoTool.add_*()` methods.
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`.
5. Move lazy GDS calls from `gdsii.load_library*()` to
`masque.file.gdsii.lazy`.
6. Remove `raw=True` from public shape constructors.
If your code only uses `Pattern`, `Library`, `place()`, and `plug()` without the
routing helpers, audit transforms, dangling-reference graph calls, raw shape
construction, 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(...))
```
@ -275,14 +275,6 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
```
## TODO
## Development
* 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
Project-level planned work is tracked in [TODO.md](TODO.md).

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

@ -0,0 +1,131 @@
from __future__ import annotations
import argparse
import importlib
import json
import time
from pathlib import Path
from typing import Any
from masque import LibraryError
READERS: dict[str, tuple[str, tuple[str, ...]]] = {
'gdsii': ('masque.file.gdsii', ('readfile',)),
'gdsii_arrow': ('masque.file.gdsii.arrow', ('readfile', 'arrow_import', 'arrow_convert')),
}
def _summarize_library(path: Path, elapsed_s: float, info: dict[str, object], lib: object) -> dict[str, object]:
assert hasattr(lib, '__len__')
assert hasattr(lib, 'tops')
tops = lib.tops() # type: ignore[no-any-return, attr-defined]
try:
unique_top = lib.top() # type: ignore[no-any-return, attr-defined]
except LibraryError:
unique_top = None
return {
'path': str(path),
'elapsed_s': elapsed_s,
'library_name': info['name'],
'cell_count': len(lib), # type: ignore[arg-type]
'topcells': tops,
'topcell': unique_top,
}
def _summarize_arrow_import(path: Path, elapsed_s: float, arrow_arr: Any) -> dict[str, object]:
libarr = arrow_arr[0]
return {
'path': str(path),
'elapsed_s': elapsed_s,
'arrow_rows': len(arrow_arr),
'library_name': libarr['lib_name'].as_py(),
'cell_count': len(libarr['cells']),
'layer_count': len(libarr['layers']),
}
def _profile_stage(module: Any, stage: str, path: Path) -> dict[str, object]:
start = time.perf_counter()
if stage == 'readfile':
lib, info = module.readfile(path)
elapsed_s = time.perf_counter() - start
return _summarize_library(path, elapsed_s, info, lib)
if stage == 'arrow_import':
if hasattr(module, 'readfile_arrow'):
libarr, _info = module.readfile_arrow(path)
elapsed_s = time.perf_counter() - start
return {
'path': str(path),
'elapsed_s': elapsed_s,
'arrow_rows': 1,
'library_name': libarr['lib_name'].as_py(),
'cell_count': len(libarr['cells']),
'layer_count': len(libarr['layers']),
}
arrow_arr = module._read_to_arrow(path)
elapsed_s = time.perf_counter() - start
return _summarize_arrow_import(path, elapsed_s, arrow_arr)
if stage == 'arrow_convert':
arrow_arr = module._read_to_arrow(path)
libarr = arrow_arr[0]
start = time.perf_counter()
lib, info = module.read_arrow(libarr)
elapsed_s = time.perf_counter() - start
return _summarize_library(path, elapsed_s, info, lib)
raise ValueError(f'Unsupported stage {stage!r}')
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description='Profile GDS readers with a stable end-to-end workload.')
parser.add_argument('--reader', choices=sorted(READERS), required=True)
parser.add_argument('--stage', default='readfile')
parser.add_argument('--path', type=Path, required=True)
parser.add_argument('--warmup', type=int, default=1)
parser.add_argument('--repeat', type=int, default=1)
parser.add_argument('--output-json', type=Path)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
module_name, stages = READERS[args.reader]
if args.stage not in stages:
parser.error(f'reader {args.reader!r} only supports stages: {", ".join(stages)}')
module = importlib.import_module(module_name)
path = args.path.expanduser().resolve()
for _ in range(args.warmup):
_profile_stage(module, args.stage, path)
runs = []
for _ in range(args.repeat):
runs.append(_profile_stage(module, args.stage, path))
payload = {
'reader': args.reader,
'stage': args.stage,
'warmup': args.warmup,
'repeat': args.repeat,
'runs': runs,
}
rendered = json.dumps(payload, indent=2, sort_keys=True)
if args.output_json is not None:
args.output_json.parent.mkdir(parents=True, exist_ok=True)
args.output_json.write_text(rendered + '\n')
print(rendered)
return 0
if __name__ == '__main__':
raise SystemExit(main())

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)
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
* Continue from `devices.py` by declaring a mixed library with `LibraryBuilder`
* Import source-backed GDS cells and register python-generated recipes together
* Call `build()` to produce a normal library and report for downstream `Pather` usage and writing
* 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),
* Define a custom `Tool` that exposes primitive routing offers
* Use primitive offers to automatically transition between path types
- [renderpather](renderpather.py)
* Use `Pather(render='deferred')` 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
# 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,135 +1,133 @@
"""
Tutorial: authoring a mixed library with `LibraryBuilder`.
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 combine imported GDS cells with
python-generated recipes, then turn that declaration set into a normal library
for downstream assembly and writing.
"""
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.file.gdsii import writefile, load_libraryfile
from masque import ILibrary, LibraryBuilder, Pather, Pattern, PortLoadView, cell
from masque.file.gdsii import writefile
from masque.file.gdsii.lazy import readfile
import pcgen
import basic_shapes
import devices
from devices import ports_to_data, data_to_ports
from basic_shapes import GDS_OPTS
def make_mixed_waveguide(lib: ILibrary) -> Pattern:
"""
Recipe which assembles imported and generated cells behind the builder API.
"""
circ = Pather(library=lib, ports='tri_l3cav')
# First way to specify what we are plugging in: request an explicit abstract.
circ.plug(lib.abstract('wg10'), {'input': 'right'})
# Second way: use an AbstractView, which behaves like a mapping of names
# to abstracts.
abstracts = lib.abstract_view()
circ.plug(abstracts['wg10'], {'output': 'left'})
# Third way: let Pather resolve a pattern name through its own library.
circ.plug('tri_wg10', {'input': 'right'})
circ.plug('tri_wg10', {'output': 'left'})
return circ.pattern
def main() -> None:
# Define a `LazyLibrary`, which provides lazy evaluation for generating
# patterns and lazy-loading of GDS contents.
lib = LazyLibrary()
builder = LibraryBuilder()
cells = builder.cells
# Naming and export policy can be bound once without changing individual
# declarations. For example, a foundry-specific group could use:
#
# cells = builder.cells.prefixed(
# 'foundry_',
# facade=lambda proxy: devices.ports_to_data(proxy),
# )
#
# Dynamic names use the same view: cells[generated_name] = cell(factory)(...)
#
# Load some devices from a GDS file
#
# Scan circuit.gds and prepare to lazy-load its contents
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
# imported on first materialization, but the raw source remains untouched
# until we build the final library.
gds_lib, _properties = readfile('circuit.gds')
builder.add_source(PortLoadView(gds_lib, layers=[(3, 0)], max_depth=1))
# 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.
lib.add(gds_lib)
print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys())))
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
#
# Add some new devices to the library, this time from python code rather than GDS
# Register some new devices, this time from python code rather than GDS.
#
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
cells.triangle = basic_shapes.triangle(devices.RADIUS)
opts: dict[str, Any] = dict(
lattice_constant=devices.LATTICE_CONSTANT,
hole='triangle',
)
# Triangle-based variants. These are defined here, but they won't run until they're
# retrieved from the library.
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)
lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts)
lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts)
lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib)
cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts)
cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts)
cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts)
cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts)
cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts)
cells.tri_l3cav = cell(devices.perturbed_l3)(
xy_size=(4, 10),
**opts,
hole_lib=builder.library,
)
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder.library)
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
#
# Build a mixed waveguide with an L3 cavity in the middle
# Build the declaration set into a normal library.
#
# Immediately start building from an instance of the L3 cavity
circ2 = Builder(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`.
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()`.
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!
circ2.plug('tri_wg10', {'input': 'right'})
circ2.plug('tri_wg10', {'output': 'left'})
# Add the circuit to the device library.
lib['mixed_wg_cav'] = circ2.pattern
built, report = builder.build()
print('Built library contains:\n' + pformat(list(built.keys())))
print('Build dependency graph:\n' + pformat(report.dependency_graph))
#
# Build a device that could plug into our mixed_wg_cav and joins the two ports
# Continue designing against the built library.
#
# We'll be designing against an existing device's interface...
circ3 = Builder.interface(source=circ2)
# ... that lets us continue from where we left off.
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'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('tri_wg10', {'input': 'right'})
circ3.plug('tri_wg28', {'input': 'right'})
circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'})
lib['loop_segment'] = circ3.pattern
# The built result behaves like a normal mutable library, so downstream code
# can use Pather, abstract views, and writing without going back through the
# builder interface.
circ = Pather.interface(source='mixed_wg_cav', library=built)
circ.plug('tri_bend0', {'input': 'right'})
circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
circ.plug('tri_bend0', {'input': 'right'})
circ.plug('bend0', {'output': 'left'})
circ.plug('bend0', {'output': 'left'})
circ.plug('bend0', {'output': 'left'})
circ.plug('tri_wg10', {'input': 'right'})
circ.plug('tri_wg28', {'input': 'right'})
circ.plug('tri_wg10', {'input': 'right', 'output': 'left'})
built['loop_segment'] = circ.pattern
#
# Write all devices into a GDS file
# Write all devices into a GDS file.
#
print('Writing library to file...')
writefile(lib, 'library.gds', **GDS_OPTS)
writefile(built, 'library.gds', **GDS_OPTS)
# The default build output is an overlay which borrows its lazy sources.
# Close the owning GDS source only after the overlay is no longer needed.
gds_lib.close()
if __name__ == '__main__':
main()
#
#class prout:
# def place(
# self,
# other: Pattern,
# label_layer: layer_t = 'WATLAYER',
# *,
# port_map: Dict[str, str | None] | None = None,
# **kwargs,
# ) -> 'prout':
#
# Pattern.place(self, other, port_map=port_map, **kwargs)
# name: str | None
# for name in other.ports:
# if port_map:
# assert(name is not None)
# name = port_map.get(name, name)
# if name is None:
# continue
# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer)
# return self
#

View file

@ -1,11 +1,20 @@
"""
Manual wire routing tutorial: Pather and BasicTool
Manual wire routing tutorial: Pather and primitive offers
"""
from collections.abc import Callable
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal
import numpy
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.abstract import Abstract
from masque.builder import (
BendOffer, RenderStep, StraightOffer, Tool, ToolContractCase, validate_tool_contract,
)
from masque.error import BuildError
from masque.file.gdsii import writefile
from masque.library import ILibrary, SINGLE_USE_PREFIX
from basic_shapes import GDS_OPTS
@ -107,17 +116,265 @@ def map_layer(layer: layer_t) -> layer_t:
'M2': (20, 0),
'V1': (30, 0),
}
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:
@dataclass(frozen=True, slots=True)
class WireStraightData:
length: float
out_transition: 'WireTransitionSpec | None' = None
@dataclass(frozen=True, slots=True)
class WireBendData:
straight_length: float
ccw: bool
@dataclass(frozen=True, slots=True)
class WireTransitionSpec:
abstract: Abstract
in_port_name: str
out_port_name: str
@property
def in_port(self) -> Port:
return self.abstract.ports[self.in_port_name]
@property
def out_port(self) -> Port:
return self.abstract.ports[self.out_port_name]
@dataclass(frozen=True, slots=True)
class WireTransitionData:
spec: WireTransitionSpec
@dataclass
class PrimitiveWireTool(Tool):
"""
Minimal routing tool that exposes local routing primitives directly.
The high-level `Pather` methods below still decide how to compose straights,
bends, and ptype transitions. This tool only describes which one-step
primitives it can draw and how selected primitives should be rendered.
"""
layer: layer_t
width: float
ptype: str
bend: Abstract
transitions: Sequence[WireTransitionSpec]
def _straight_pattern(self, length: float) -> Pattern:
return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length)
@staticmethod
def _transition_length(spec: WireTransitionSpec) -> float | None:
dxy, angle = spec.in_port.measure_travel(spec.out_port)
if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0):
return None
return float(dxy[0])
def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]:
offers: list[StraightOffer] = []
for spec in self.transitions:
if spec.out_port.ptype != self.ptype:
continue
if in_ptype not in (None, 'unk', spec.in_port.ptype):
continue
length = self._transition_length(spec)
if length is None:
continue
def endpoint_planner(
parameter: float,
*,
spec: WireTransitionSpec = spec,
length: float = length,
) -> Port:
_ = parameter
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
def commit_planner(
parameter: float,
*,
spec: WireTransitionSpec = spec,
) -> WireTransitionData:
_ = parameter
return WireTransitionData(spec)
offers.append(StraightOffer(
in_ptype = spec.in_port.ptype,
out_ptype = spec.out_port.ptype,
length_domain = (length, length),
endpoint_planner = endpoint_planner,
commit_planner = commit_planner,
))
return tuple(offers)
def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]:
if out_ptype in ('unk', self.ptype):
return ()
offers: list[StraightOffer] = []
for spec in self.transitions:
if spec.in_port.ptype != self.ptype:
continue
if out_ptype is not None and spec.out_port.ptype != out_ptype:
continue
transition_length = self._transition_length(spec)
if transition_length is None:
continue
def endpoint_planner(
length: float,
*,
spec: WireTransitionSpec = spec,
transition_length: float = transition_length,
) -> Port:
straight_length = length - transition_length
if straight_length < 0:
raise BuildError(
f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}'
)
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
def commit_planner(
length: float,
*,
spec: WireTransitionSpec = spec,
transition_length: float = transition_length,
) -> WireStraightData:
endpoint_planner(length)
return WireStraightData(length - transition_length, spec)
offers.append(StraightOffer(
in_ptype = self.ptype,
out_ptype = spec.out_port.ptype,
length_domain = (transition_length, numpy.inf),
endpoint_planner = endpoint_planner,
commit_planner = commit_planner,
))
return tuple(offers)
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes)
**kwargs: Any,
) -> tuple[StraightOffer | BendOffer, ...]:
if kind == 'straight':
tool_options = dict(kwargs)
def endpoint_planner(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=self.ptype)
def commit_planner(length: float) -> WireStraightData:
_ = tool_options
return WireStraightData(length)
native_offer = StraightOffer(
in_ptype = self.ptype,
out_ptype = self.ptype,
endpoint_planner = endpoint_planner,
commit_planner = commit_planner,
)
return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype))
if kind == 'bend':
ccw = bool(kwargs.pop('ccw'))
bend_forward = self.width / 2
bend_run = bend_forward if ccw else -bend_forward
bend_rotation = -pi / 2 if ccw else pi / 2
def endpoint_planner(length: float) -> Port:
straight_length = length - bend_forward
if straight_length < 0:
raise BuildError(
f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}'
)
return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype)
def commit_planner(length: float) -> WireBendData:
endpoint_planner(length)
return WireBendData(straight_length=length - bend_forward, ccw=ccw)
return (BendOffer(
in_ptype = self.ptype,
out_ptype = self.ptype,
ccw = ccw,
length_domain = (bend_forward, numpy.inf),
endpoint_planner = endpoint_planner,
commit_planner = commit_planner,
),)
if kind in ('s', 'u'):
return ()
raise BuildError(f'Unrecognized primitive offer kind {kind!r}')
def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None:
if numpy.isclose(data.length, 0) and data.out_transition is None:
return
if not numpy.isclose(data.length, 0):
tree.top_pattern().plug(
self._straight_pattern(data.length),
{port_names[1]: 'input'},
append=True,
)
if data.out_transition is not None:
self._render_transition(tree, port_names, WireTransitionData(data.out_transition))
def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None:
self._render_straight(tree, port_names, WireStraightData(data.straight_length))
tree.top_pattern().plug(
self.bend,
{port_names[1]: 'input'},
mirrored=data.ccw,
)
@staticmethod
def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None:
tree.top_pattern().plug(
data.spec.abstract,
{port_names[1]: data.spec.in_port_name},
)
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype)
for step in batch:
assert step.tool == self
if isinstance(step.data, WireTransitionData):
self._render_transition(tree, port_names, step.data)
elif isinstance(step.data, WireStraightData):
self._render_straight(tree, port_names, step.data)
elif isinstance(step.data, WireBendData):
self._render_bend(tree, port_names, step.data)
else:
raise BuildError(f'Unexpected primitive render data {type(step.data)}')
return tree
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()
@ -136,58 +393,59 @@ def main() -> None:
#
# Now, define two tools.
# M1_tool will route on M1, using wires with M1_WIDTH
# M2_tool will route on M2, using wires with M2_WIDTH
# Both tools are able to automatically transition from the other wire type (with a via)
# M1_tool will route on M1, using wires with M1_WIDTH.
# M2_tool will route on M2, using wires with M2_WIDTH.
#
# Note that while we use BasicTool for this tutorial, you can define your own `Tool`
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
# transmission line geometry, or other features.
# Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers
# directly: it tells `Pather` about native straight/bend primitives and about
# via adapters that can transition between M1 and M2 port types.
#
M1_tool = BasicTool(
straight = (
# First, we need a function which takes in a length and spits out an M1 wire
lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
'output', # and use the port named 'output' as the output
),
bend = (
library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
'input', # To orient it clockwise, use the port named 'input' as the input
'output', # and 'output' as the output
),
transitions = { # We can automate transitions for different (normally incompatible) port types
'm2wire': ( # For example, when we're attaching to a port with type 'm2wire'
library.abstract('v1_via'), # we can place a V1 via
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
'bottom', # and using the port named 'bottom' as the output
),
},
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
via = library.abstract('v1_via')
via_transitions = (
WireTransitionSpec(via, 'top', 'bottom'),
WireTransitionSpec(via, 'bottom', 'top'),
)
M2_tool = BasicTool(
straight = (
# Again, we use make_straight_wire, but this time we set parameters for M2
lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
'input',
'output',
),
bend = (
library.abstract('m2_bend'), # and we use an M2 bend
'input',
'output',
),
transitions = {
'm1wire': (
library.abstract('v1_via'), # We still use the same via,
'bottom', # but the input port is now 'bottom'
'top', # and the output port is now 'top'
),
},
default_out_ptype = 'm2wire', # We default to trying to stay on M2
M1_tool = PrimitiveWireTool(
layer = 'M1',
width = M1_WIDTH,
ptype = 'm1wire',
bend = library.abstract('m1_bend'),
transitions = via_transitions,
)
M2_tool = PrimitiveWireTool(
layer = 'M2',
width = M2_WIDTH,
ptype = 'm2wire',
bend = library.abstract('m2_bend'),
transitions = via_transitions,
)
# Custom tools can be checked independently of Pather or pytest. Automatic
# probes cover each offer domain; explicit probes can target useful process
# dimensions. Unsupported primitive families opt out with require_offers=False.
for tool in (M1_tool, M2_tool):
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype=tool.ptype, probe_parameters=(10_000,)),
ToolContractCase('bend', in_ptype=tool.ptype, ccw=False),
ToolContractCase('bend', in_ptype=tool.ptype, ccw=True),
ToolContractCase('s', in_ptype=tool.ptype, require_offers=False),
ToolContractCase('u', in_ptype=tool.ptype, require_offers=False),
))
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.
# Then, place some pads and start routing wires!
@ -203,27 +461,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 +487,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 +497,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_PrimitiveOffers'] = 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,170 @@
"""
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, render='deferred')
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
.set_spacing(1_200) # Default bundle spacing for later bends
.trace(True, emax=50_000) # Turn East, moves 50um extension
.trace(False, emin=1_000) # U-turn back South
.trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing
)
# 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 routing is deferred, 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,9 +1,8 @@
"""
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.builder.tools import PathTool
from masque import Pather, Library
from masque.builder import PathTool
from masque.file.gdsii import writefile
from basic_shapes import GDS_OPTS
@ -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(render='deferred')`, 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`
@ -35,56 +34,58 @@ def main() -> None:
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, render='deferred')
# 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

@ -42,6 +42,7 @@ from .error import (
from .shapes import (
Shape as Shape,
Polygon as Polygon,
RectCollection as RectCollection,
Path as Path,
Circle as Circle,
Arc as Arc,
@ -55,16 +56,27 @@ from .pattern import (
map_targets as map_targets,
chain_elements as chain_elements,
)
from .utils.boolean import boolean as boolean
from .library import (
INameView as INameView,
ILibraryView as ILibraryView,
ILibrary as ILibrary,
IBorrowing as IBorrowing,
IMaterializable as IMaterializable,
LibraryView as LibraryView,
Library as Library,
OverlayLibrary as OverlayLibrary,
PortLoadView as PortLoadView,
LayerMappedView as LayerMappedView,
LibraryBuilder as LibraryBuilder,
BuildReport as BuildReport,
CellProvenance as CellProvenance,
LazyLibrary as LazyLibrary,
AbstractView as AbstractView,
TreeView as TreeView,
Tree as Tree,
cell as cell,
)
from .ports import (
Port as Port,
@ -72,12 +84,14 @@ from .ports import (
)
from .abstract import Abstract as Abstract
from .builder import (
Builder as Builder,
Tool as Tool,
ToolContractError as ToolContractError,
Pather as Pather,
RenderPather as RenderPather,
RouteError as RouteError,
RouteFailureDetails as RouteFailureDetails,
RouteFailurePolicy as RouteFailurePolicy,
MinimumStatus as MinimumStatus,
RenderStep as RenderStep,
SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool,
PortPather as PortPather,

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,12 +1,90 @@
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
"""
Builder helpers for port-based assembly and primitive-offer routing.
A routing `Tool` describes the primitive route families it can provide by
returning `PrimitiveOffer` objects. Each offer is a parameterized planning
candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost,
optional footprint metadata, and a commit hook for producing tool-specific
render data after a concrete parameter has been selected.
`Pather` owns user-facing route operations such as `trace()`, `jog()`,
`uturn()`, and `trace_into()`. The internal planner resolves each operation into
one or more `SolverRequest`s. This normalization is why the public routing API
can remain a convenient keyword-based interface without making the solver
stringly typed internally. A pure solver search selects a `Candidate`, and a
`RouteLeg` attaches that candidate to its copied source port and Tool. Only
after selection succeeds are the chosen offers materialized through
`offer.commit(parameter)` into a `PreparedRouteResult` containing
`RenderStep.data`. `Pather` then applies that prepared result to its live ports
and pending render queue.
Selection is pure with respect to caller-owned Pattern, Library, and Pather
state. Tool offer discovery and endpoint/cost/bbox callbacks must likewise be
deterministic and must not mutate that state. `commit()` is the first
selected-offer materialization hook, but it still must not mutate the live
layout. `Tool.render()` is the geometry mutation boundary: later,
`Pather.render()` batches compatible `RenderStep`s and inserts the resulting
geometry into the Pattern and Library.
`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract.
`RenderStep` is `Pather`'s deferred-render record, and
`masque.builder.planner` is an internal planner implementation rather than a
stable public API.
Custom Tool authors should run `validate_tool_contract()` as a development or
application-startup preflight. It performs the comprehensive semantic checks
that are intentionally not repeated during route selection, keeping the normal
routing path focused on search rather than contract verification.
The practical layering is:
- user code drives `Pather` and chooses Tools per port or by default,
- Tools describe local legal motion primitives without touching Pather state,
- the internal router composes those primitives into high-level route shapes,
- Pather applies the prepared result to ports, deferred render queues, and the
target pattern/library.
`Pather` intentionally remains a Pattern-oriented facade rather than exposing
separate assembly and routing objects: its user model is a working Pattern with
routing tools attached. The ownership phases above are internal boundaries,
not additional objects callers must coordinate.
Code outside the builder package should prefer the exports here over importing
from `masque.builder.planner`. The planner package is intentionally available
for tests and internal maintenance, but it is not the compatibility boundary
for custom Tools.
"""
from .pather import (
Pather as Pather,
PortPather as PortPather,
RouteCompletionCallback as RouteCompletionCallback,
)
from .error import (
ToolContractError as ToolContractError,
RouteError as RouteError,
RouteFailureDetails as RouteFailureDetails,
RouteOperation as RouteOperation,
RouteFailurePolicy as RouteFailurePolicy,
MinimumStatus as MinimumStatus,
)
from .utils import ell as ell
from .tool_testing import (
ToolContractCase as ToolContractCase,
validate_tool_contract as validate_tool_contract,
)
from .tools import (
Tool as Tool,
RenderStep as RenderStep,
SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool,
RenderStep as RenderStep,
RenderStepKind as RenderStepKind,
PrimitiveKind as PrimitiveKind,
CostCallable as CostCallable,
GeneratedEndpointFn as GeneratedEndpointFn,
PrimitiveOffer as PrimitiveOffer,
StraightOffer as StraightOffer,
BendOffer as BendOffer,
SOffer as SOffer,
UOffer as UOffer,
circular_arc_sbend_endpoint as circular_arc_sbend_endpoint,
)

View file

@ -0,0 +1,48 @@
"""Shared numeric tolerances for builder geometry and parameter comparisons."""
from math import isclose, remainder, tau
from typing import Any
import numpy
GEOMETRY_RTOL = 1e-5
GEOMETRY_ATOL = 1e-8
DOMAIN_RTOL = 1e-9
DOMAIN_ATOL = 1e-12
MANHATTAN_ANGLE_RTOL = 1e-9
MANHATTAN_ANGLE_ATOL = 1e-9
def scalar_close(a: float, b: float) -> bool:
"""Match the solver's existing scalar-comparison behavior."""
return isclose(float(a), float(b), rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL)
def array_close(a: Any, b: Any) -> bool:
"""Match NumPy's historical builder geometry-comparison behavior."""
return bool(numpy.allclose(a, b, rtol=GEOMETRY_RTOL, atol=GEOMETRY_ATOL))
def angles_equal(a: float, b: float) -> bool:
"""Return true when two rotations are equal modulo one full turn."""
delta = remainder(float(a) - float(b), tau)
return isclose(delta, 0.0, rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL)
def manhattan_axis(rotation: float) -> int | None:
"""Return 0 for horizontal, 1 for vertical, or None for a non-cardinal angle."""
angle = float(rotation) % (numpy.pi / 2)
if isclose(
angle,
0.0,
rel_tol=MANHATTAN_ANGLE_RTOL,
abs_tol=MANHATTAN_ANGLE_ATOL,
) or isclose(
angle,
numpy.pi / 2,
rel_tol=MANHATTAN_ANGLE_RTOL,
abs_tol=MANHATTAN_ANGLE_ATOL,
):
quarter_turn = round(float(rotation) / (numpy.pi / 2))
return quarter_turn % 2
return None

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

109
masque/builder/error.py Normal file
View file

@ -0,0 +1,109 @@
"""Public routing failure diagnostics."""
from typing import Any, Literal
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum, auto
from pprint import pformat
from types import MappingProxyType
import traceback
from ..error import BuildError
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn']
class ToolContractError(BuildError):
"""A Tool returned data inconsistent with its routing contract."""
class RouteFailurePolicy(Enum):
"""Whether route failure may be recovered through alternate/dead planning.
`RECOVERABLE` means a caller-controlled fallback may try another planning
branch. `FATAL` marks an invalid request or broken planning contract that
must be reported directly.
"""
RECOVERABLE = auto()
FATAL = auto()
class MinimumStatus(Enum):
"""Outcome of preferred-minimum-length diagnosis.
`NOT_EVALUATED` is used when diagnosis is inapplicable, notably for an
invalid resolved length. `FOUND` carries `minimum_length`; `NO_ROUTE` means
exhaustive planning found no legal unconstrained route; `FAILED` means the
secondary diagnostic calculation itself raised a recoverable error.
"""
NOT_EVALUATED = auto()
FOUND = auto()
NO_ROUTE = auto()
FAILED = auto()
@dataclass(frozen=True, slots=True)
class RouteFailureDetails:
"""Structured context for a failed Pather routing request."""
operation: RouteOperation
portspec: str
in_ptype: str | None
out_ptype: str | None
request: Mapping[str, Any]
resolved_length: float | None
resolved_jog: float | None
minimum_length: float | None
minimum_status: MinimumStatus
cause: str
minimum_cause: str | None = None
def __post_init__(self) -> None:
if self.minimum_status is MinimumStatus.FOUND and self.minimum_length is None:
raise BuildError('MinimumStatus.FOUND requires minimum_length')
if self.minimum_status is not MinimumStatus.FOUND and self.minimum_length is not None:
raise BuildError(f'{self.minimum_status} requires minimum_length=None')
object.__setattr__(self, 'request', MappingProxyType(dict(self.request)))
class RouteError(BuildError):
"""A route-selection failure with structured request and saved call-stack diagnostics."""
details: RouteFailureDetails
policy: RouteFailurePolicy
_call_stack: tuple[traceback.FrameSummary, ...]
def __init__(
self,
details: RouteFailureDetails,
*,
policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE,
) -> None:
self.details = details
self.policy = policy
if details.minimum_status is MinimumStatus.NOT_EVALUATED:
minimum = 'not evaluated'
elif details.minimum_status is MinimumStatus.FOUND:
assert details.minimum_length is not None
minimum = f'{details.minimum_length:g}'
elif details.minimum_status is MinimumStatus.NO_ROUTE:
minimum = 'unavailable (no legal route exists at any length)'
else:
minimum = 'unavailable (minimum-length calculation failed)'
lines = [
f'Unable to plan {details.operation} route for port {details.portspec!r}:',
f' in_ptype: {details.in_ptype!r}',
f' out_ptype: {details.out_ptype!r}',
f' request: {pformat(dict(details.request), compact=True)}',
f' resolved_length: {details.resolved_length!r}',
f' resolved_jog: {details.resolved_jog!r}',
f' preferred_minimum_length: {minimum}',
f' cause: {details.cause}',
]
if details.minimum_cause is not None:
lines.append(f' minimum_failure: {details.minimum_cause}')
self._call_stack = tuple(traceback.extract_stack()[:-1])
super().__init__('\n'.join(lines))

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

@ -0,0 +1,80 @@
"""Logging helpers for Pather."""
from typing import TYPE_CHECKING, Any
from collections.abc import Iterator, Sequence
import logging
import numpy
from contextlib import contextmanager
if TYPE_CHECKING:
from .pather import Pather
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

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

@ -0,0 +1,17 @@
"""
Simplified primitive-offer route planner used by `Pather`.
This package is the Pather-facing route-selection implementation. It keeps
the public Tool contract narrow: offers are evaluated during planning, and
offer commits are deferred until after a complete route is selected.
"""
from .interface import (
PreparedRouteAction as PreparedRouteAction,
PreparedRouteResult as PreparedRouteResult,
RoutePlanningError as RoutePlanningError,
RoutePortContext as RoutePortContext,
route_failure_policy as route_failure_policy,
)
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
from .planner import TraceIntoBendPolicy as TraceIntoBendPolicy
from .planner import RoutingPlanner as RoutingPlanner

View file

@ -0,0 +1,284 @@
"""
Argument validation and bound resolution for Pather routing calls.
This module keeps user-facing mode validation outside the solver. It converts
single-port positional bounds into local travel lengths and derives multi-port
S/U bundle specs before primitive offers are considered.
The solver expects one coherent route intent at a time. This module enforces
that public routing modes are not mixed: explicit length, per-port `each`,
positional bounds, and bundle bounds are mutually constrained before any Tool
offers are queried. Multi-port S/U bundles are also normalized here into exact
per-port public lengths and offsets.
"""
from __future__ import annotations
# ruff: noqa: TC001,TC002,TC003
from typing import Any
from collections.abc import Mapping, Sequence
from pprint import pformat
import numpy
from numpy import pi
from numpy.typing import ArrayLike, NDArray
from ...error import BuildError, PortError
from ...ports import Port
from ...utils import rotation_matrix_2d
from .._tolerances import manhattan_axis
from .interface import RoutePortContext
POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position')
BUNDLE_BOUND_KEYS: tuple[str, ...] = (
'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest',
)
def finite_scalar(value: Any, name: str, *, nonnegative: bool = False) -> float:
"""Return a finite scalar, preserving duck-typed numeric inputs."""
try:
array = numpy.asarray(value, dtype=float)
except (TypeError, ValueError) as err:
raise BuildError(f'{name} must be a finite numeric scalar') from err
if array.size != 1:
raise BuildError(f'{name} must be a scalar; got {array.size} values')
result = float(array.reshape(-1)[0])
if not numpy.isfinite(result):
raise BuildError(f'{name} must be finite')
if nonnegative and result < 0:
raise BuildError(f'{name} must be nonnegative')
return result
def resolved_position_bound(
port: Port,
bounds: Mapping[str, Any],
*,
allow_length: bool,
) -> tuple[str, Any, float] | None:
"""Resolve a single positional bound for a single port into a travel length."""
present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None]
if not present:
return None
if len(present) > 1:
keys = ', '.join(key for key, _value in present)
raise BuildError(f'Provide exactly one positional bound; got {keys}')
if not allow_length and bounds.get('length') is not None:
raise BuildError('length cannot be combined with a positional bound')
key, raw_value = present[0]
value = finite_scalar(raw_value, f'{key} positional bound')
if port.rotation is None:
raise BuildError('Ports must have rotation')
axis = manhattan_axis(port.rotation)
if axis is None:
raise BuildError(
'Positional bounds require a nearly Manhattan port direction; '
f'got rotation {port.rotation:g}'
)
if axis == 0:
if key == 'y':
raise BuildError('Port is horizontal')
target = Port((value, port.offset[1]), rotation=None)
else:
if key == 'x':
raise BuildError('Port is vertical')
target = Port((port.offset[0], value), rotation=None)
(travel, _jog), _ = port.measure_travel(target)
return key, value, -float(travel)
def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]:
"""Return keys whose bound value is explicitly present and non-None."""
return [key for key in keys if bounds.get(key) is not None]
def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]:
"""Return active multi-port trace bound keys."""
return present_keys(bounds, BUNDLE_BOUND_KEYS)
def validate_trace_args(
portspec: Sequence[str],
*,
length: float | None,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""
Validate mutually-exclusive `trace()` routing modes.
A trace request is either an explicit single-port length, an `each` length
for all ports, a single-port omitted-length solve, or a bundle solve with
exactly one bundle bound.
"""
bundle_bounds = present_bundle_bounds(bounds)
if len(bundle_bounds) > 1:
args = ', '.join(bundle_bounds)
raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}')
invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds
invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds
if length is not None:
if len(portspec) > 1:
raise BuildError('length only allowed with a single port')
if spacing is not None:
invalid_with_length.append('spacing')
if invalid_with_length:
args = ', '.join(invalid_with_length)
raise BuildError(f'length cannot be combined with other routing bounds: {args}')
return
if bounds.get('each') is not None:
if spacing is not None:
invalid_with_each.append('spacing')
if invalid_with_each:
args = ', '.join(invalid_with_each)
raise BuildError(f'each cannot be combined with other routing bounds: {args}')
return
if not bundle_bounds and len(portspec) == 1:
if spacing is not None:
raise BuildError('spacing cannot be combined with omitted-length single-port trace()')
invalid = present_keys(bounds, ('set_rotation',))
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}')
return
if not bundle_bounds:
raise BuildError('No bound type specified for trace()')
def validate_trace_to_positional_args(
*,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""Reject bound combinations that cannot be mixed with a single positional `trace_to()` target."""
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
if spacing is not None:
invalid.append('spacing')
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}')
def validate_jog_args(
portspec: Sequence[str],
*,
length: float | None,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""
Validate `jog()` mode constraints before S-route planning.
Single-port jogs may derive length from a positional bound. Multi-port jogs
require spacing and cannot combine omitted length with positional bounds.
"""
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
if len(portspec) == 1 and spacing is not None:
invalid.append('spacing')
if len(portspec) > 1 and length is None:
invalid += present_keys(bounds, POSITION_KEYS)
if length is not None:
invalid = present_keys(bounds, POSITION_KEYS) + invalid
if invalid:
args = ', '.join(invalid)
raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}')
return
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Unsupported routing bounds for jog(): {args}')
def validate_uturn_args(
portspec: Sequence[str],
*,
spacing: float | ArrayLike | None,
bounds: Mapping[str, Any],
) -> None:
"""Validate `uturn()` arguments, which do not support positional or bundle-bound keywords."""
invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds)
if len(portspec) == 1 and spacing is not None:
invalid.append('spacing')
if invalid:
args = ', '.join(invalid)
raise BuildError(f'Unsupported routing bounds for uturn(): {args}')
def su_bundle_specs(
contexts: Sequence[RoutePortContext],
offset: float,
length: float,
spacing: float | ArrayLike | None,
*,
route_name: str,
) -> tuple[tuple[str, float, float], ...]:
"""
Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs.
Ports are ordered from the inside of the first bend outward. The first spec
receives the requested base route; later specs add cumulative spacing to
both route length and lateral offset so the bundle keeps the requested
separation.
"""
if spacing is None:
raise BuildError(f'Must provide spacing for multi-port {route_name}()')
finite_scalar(offset, 'offset')
finite_scalar(length, 'length', nonnegative=True)
ports = {context.portspec: context.port for context in contexts}
has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool)
if not has_rotation.all():
raise PortError(f'Ports must have rotation for multi-port {route_name}()')
rotations = numpy.array([port.rotation for port in ports.values()], dtype=float)
if not numpy.allclose(rotations[0], rotations):
port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()}
raise BuildError(
f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n'
+ pformat(port_rotations)
)
direction = rotations[0] + pi
rot_matrix = rotation_matrix_2d(-direction)
orig_offsets = numpy.array([port.offset for port in ports.values()])
rot_offsets = (rot_matrix @ orig_offsets.T).T
first_ccw = bool(offset > 0)
y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
if numpy.any(spacing_arr < 0):
raise BuildError('spacing must be nonnegative')
steps: NDArray[numpy.float64] = numpy.zeros(len(ports), dtype=float)
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}'
)
if not numpy.all(numpy.isfinite(steps)):
raise BuildError('spacing must contain only finite values')
names = tuple(ports.keys())
ordered_spacings = numpy.cumsum(steps)
anchor_y = float(rot_offsets[y_order[0], 1])
specs: list[tuple[str, float, float]] = []
for order_index, port_index in enumerate(y_order):
spacing_offset = float(ordered_spacings[order_index])
start_y = float(rot_offsets[port_index, 1])
specs.append((
names[port_index],
float(length) + spacing_offset,
float(offset) - start_y + anchor_y + spacing_offset,
))
return tuple(specs)

View file

@ -0,0 +1,96 @@
"""
Planner/Pather exchange types.
`Pather` snapshots live routing state into these records before calling the
planner. The planner returns prepared actions that `Pather` can apply without
needing to know solver internals.
"""
from __future__ import annotations
# ruff: noqa: TC001
from dataclasses import dataclass
from ...error import BuildError
from ...ports import Port
from ..tools import RenderStep, Tool
from ..error import RouteError, RouteFailurePolicy, ToolContractError
class RoutePlanningError(BuildError):
"""Route-planning error with fallback policy metadata."""
policy: RouteFailurePolicy
def __init__(
self,
*args: object,
policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE,
) -> None:
super().__init__(*args)
self.policy = policy
def route_failure_policy(err: Exception) -> RouteFailurePolicy:
"""Return typed route recovery policy, defaulting generic errors to recoverable."""
if isinstance(err, ToolContractError):
return RouteFailurePolicy.FATAL
if isinstance(err, RoutePlanningError):
return err.policy
if isinstance(err, RouteError):
return err.policy
return RouteFailurePolicy.RECOVERABLE
@dataclass(frozen=True, slots=True)
class RoutePortContext:
"""
Immutable planning view of one live Pather port.
`port` is a copy of the live port so failed route selection leaves Pather
state unchanged. `tool` is the already-resolved routing Tool for this
portspec.
"""
portspec: str
"""Live Pather port name being planned."""
port: Port
"""Copied live port used as immutable route input."""
tool: Tool
"""Resolved Tool for this port."""
@dataclass(frozen=True, slots=True)
class PreparedRouteAction:
"""
Prepared mutation for one routed Pather port.
Pure selection has already completed, and the planner has materialized the
selected primitive offers into `render_steps` and computed the final live
port. `plug_into`, when set, names the destination port to consume after the
route endpoint is applied.
"""
portspec: str
"""Live Pather port name to update."""
render_steps: tuple[RenderStep, ...]
"""Committed route steps to append to Pather's pending render queue."""
final_port: Port
"""Final live port value after all route steps."""
plug_into: str | None = None
"""Optional destination port to consume after the final port is applied."""
@dataclass(frozen=True, slots=True)
class PreparedRouteResult:
"""
Complete prepared result for one Pather routing operation.
`actions` contain materialized, committed render data and are applied first.
`renames` are deferred until after all route actions so trace-into/thru
behavior can be represented without exposing the solver's selected
primitive sequence to Pather.
"""
actions: tuple[PreparedRouteAction, ...]
"""Prepared per-port route mutations."""
renames: tuple[tuple[str, str], ...] = ()
"""Deferred `(old_name, new_name)` port renames applied after actions."""

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,368 @@
"""Pytest-independent contract checks for custom routing Tools."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from math import isfinite
from types import MappingProxyType
from typing import TYPE_CHECKING, Any
import numpy
from numpy import pi
from ..library import ILibrary, SINGLE_USE_PREFIX
from ..ports import Port
from ..utils import ptypes_compatible
from ._tolerances import angles_equal, array_close, scalar_close
from .error import ToolContractError
from .tools import (
BendOffer, PrimitiveKind, PrimitiveOffer, RenderStep, SOffer, StraightOffer, Tool, UOffer,
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
_RESERVED_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
@dataclass(frozen=True, slots=True)
class ToolContractCase:
"""One primitive-discovery query to exercise against a custom Tool."""
kind: PrimitiveKind
in_ptype: str | None = None
out_ptype: str | None = None
ccw: bool | None = None
tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
probe_parameters: tuple[float, ...] = ()
require_offers: bool = True
check_bbox: bool = False
label: str | None = None
def __post_init__(self) -> None:
if self.kind not in ('straight', 'bend', 's', 'u'):
raise ValueError(f'Unrecognized primitive kind {self.kind!r}')
if self.kind == 'bend':
if self.ccw is None:
raise ValueError('Bend ToolContractCase requires ccw')
elif self.ccw is not None:
raise ValueError('ccw is only valid for bend ToolContractCase')
try:
options = deepcopy(dict(self.tool_options))
except Exception as err:
raise ValueError('ToolContractCase.tool_options must be a deep-copyable mapping') from err
nonstring = [key for key in options if not isinstance(key, str)]
if nonstring:
raise ValueError(f'ToolContractCase.tool_options keys must be strings; got {nonstring!r}')
collisions = sorted(_RESERVED_OPTION_KEYS & options.keys())
if collisions:
raise ValueError(f'ToolContractCase.tool_options contains reserved keys: {", ".join(collisions)}')
try:
probes = tuple(float(value) for value in self.probe_parameters)
except (TypeError, ValueError, OverflowError) as err:
raise ValueError('ToolContractCase.probe_parameters must contain numeric scalars') from err
if not all(isfinite(value) for value in probes):
raise ValueError('ToolContractCase.probe_parameters must be finite')
object.__setattr__(self, 'ccw', None if self.ccw is None else bool(self.ccw))
object.__setattr__(self, 'tool_options', MappingProxyType(options))
object.__setattr__(self, 'probe_parameters', probes)
def _automatic_probes(offer: PrimitiveOffer) -> tuple[float, ...]:
"""Choose deterministic representative parameters inside one offer domain."""
lower, upper = (float(value) for value in offer.parameter_domain)
if lower == upper:
return (lower,)
if numpy.isfinite(lower) and numpy.isfinite(upper):
midpoint = lower / 2 + upper / 2
if midpoint == upper:
midpoint = float(numpy.nextafter(upper, lower))
return (lower, midpoint)
if numpy.isfinite(lower):
step = max(1.0, abs(lower) * 0.1)
return (lower, lower + step)
if numpy.isfinite(upper):
step = max(1.0, abs(upper) * 0.1)
return (upper - step, upper - 2 * step)
return (-1.0, 1.0)
def _offer_probes(offer: PrimitiveOffer, extras: Sequence[float]) -> tuple[float, ...]:
"""Combine automatic and applicable explicit probes without duplicates."""
probes: list[float] = []
for parameter in (*_automatic_probes(offer), *extras):
try:
selected = offer.canonicalize_parameter(parameter)
except Exception:
continue
if not any(scalar_close(selected, previous) for previous in probes):
probes.append(selected)
return tuple(probes)
def _offer_metadata(offer: PrimitiveOffer) -> tuple[Any, ...]:
"""Return discovery metadata that must remain stable across repeated queries."""
cost_policy: tuple[str, Any]
if callable(offer.cost):
cost_policy = ('callable', type(offer.cost).__qualname__)
else:
cost_policy = ('factor', float(offer.cost))
return (
type(offer),
offer.kind,
offer.in_ptype,
offer.out_ptype,
tuple(float(value) for value in offer.parameter_domain),
getattr(offer, 'ccw', None),
cost_policy,
)
def _evaluated_cost(offer: PrimitiveOffer, parameter: float, endpoint: Port) -> float:
"""Mirror the solver's one-endpoint base-cost path while honoring overrides."""
if type(offer).cost_at is PrimitiveOffer.cost_at:
return PrimitiveOffer._cost_for_endpoint(offer, parameter, endpoint)
return float(offer.cost_at(parameter))
def validate_tool_contract(tool: Tool, cases: Sequence[ToolContractCase]) -> None:
"""Validate Tool discovery, offer callbacks, and one-step rendering.
All independent violations are collected and raised as one
`ExceptionGroup` containing contextual `ToolContractError` instances.
"""
cases = tuple(cases)
if not cases:
raise ValueError('validate_tool_contract() requires at least one case')
errors: list[ToolContractError] = []
def violation(context: str, message: str, cause: Exception | None = None) -> None:
err = ToolContractError(f'{context}: {message}')
if cause is not None:
err.__cause__ = cause
errors.append(err)
def discover(case: ToolContractCase, context: str, repetition: str) -> tuple[PrimitiveOffer, ...] | None:
expected_offer_type = {
'straight': StraightOffer,
'bend': BendOffer,
's': SOffer,
'u': UOffer,
}[case.kind]
try:
kwargs = deepcopy(dict(case.tool_options))
if case.kind == 'bend':
kwargs['ccw'] = case.ccw
offers = tool.primitive_offers(
case.kind,
in_ptype=case.in_ptype,
out_ptype=case.out_ptype,
**kwargs,
)
except Exception as err:
violation(context, f'{repetition} discovery raised {type(err).__name__}: {err}', err)
return None
if not isinstance(offers, tuple):
violation(context, f'{repetition} discovery returned {type(offers).__name__}, expected tuple')
return None
valid = True
for offer_index, offer in enumerate(offers):
if not isinstance(offer, PrimitiveOffer):
violation(
context,
f'{repetition} discovery item {offer_index} is {type(offer).__name__}, expected PrimitiveOffer',
)
valid = False
elif offer.kind != case.kind:
violation(
context,
f'{repetition} discovery item {offer_index} has kind {offer.kind!r}, expected {case.kind!r}',
)
valid = False
elif not isinstance(offer, expected_offer_type):
violation(
context,
f'{repetition} discovery item {offer_index} is {type(offer).__name__}, '
f'expected {expected_offer_type.__name__}',
)
valid = False
return offers if valid else None
for case_index, case in enumerate(cases):
context = case.label or f'case {case_index} ({case.kind})'
first = discover(case, context, 'first')
second = discover(case, context, 'repeated')
if first is None or second is None:
continue
if case.require_offers and not first:
violation(context, 'discovery returned no offers')
if len(first) != len(second):
violation(context, f'discovery count changed from {len(first)} to {len(second)}')
matched_explicit = [False] * len(case.probe_parameters)
for offer_index, offer in enumerate(first):
offer_context = f'{context}, offer {offer_index}'
repeated = second[offer_index] if offer_index < len(second) else None
if repeated is not None and _offer_metadata(offer) != _offer_metadata(repeated):
violation(offer_context, 'discovery metadata changed between repeated queries')
probes = _offer_probes(offer, case.probe_parameters)
for explicit_index, parameter in enumerate(case.probe_parameters):
try:
offer.canonicalize_parameter(parameter)
except Exception:
continue
matched_explicit[explicit_index] = True
stable_ptype: str | None = None
stable_rotation: float | None = None
has_stable_endpoint = False
for parameter in probes:
probe_context = f'{offer_context}, parameter {parameter:g}'
try:
endpoint = offer.endpoint_at(parameter)
except Exception as err:
violation(probe_context, f'endpoint_at() raised {type(err).__name__}: {err}', err)
continue
if not isinstance(endpoint, Port):
violation(probe_context, f'endpoint_at() returned {type(endpoint).__name__}, expected Port')
continue
if not numpy.all(numpy.isfinite(endpoint.offset)):
violation(probe_context, 'endpoint offset must be finite')
if endpoint.rotation is None or not numpy.isfinite(endpoint.rotation):
violation(probe_context, 'endpoint rotation must be finite and specified')
if not ptypes_compatible(endpoint.ptype, offer.out_ptype):
violation(probe_context, 'endpoint ptype does not match declared out_ptype')
if offer.kind in ('straight', 'bend') and not scalar_close(endpoint.x, parameter):
violation(probe_context, 'straight/bend endpoint x must equal its parameter')
if offer.kind in ('s', 'u') and not scalar_close(endpoint.y, parameter):
violation(probe_context, 'S/U endpoint y must equal its parameter')
expected_rotation = {
'straight': pi,
'bend': -pi / 2 if isinstance(offer, BendOffer) and offer.ccw else pi / 2,
's': pi,
'u': 0.0,
}[offer.kind]
if endpoint.rotation is not None and not angles_equal(endpoint.rotation, expected_rotation):
violation(
probe_context,
f'endpoint rotation does not match {offer.kind!r} geometry',
)
if has_stable_endpoint:
if endpoint.ptype != stable_ptype:
violation(probe_context, 'endpoint ptype changes across the offer domain')
if (
endpoint.rotation is None
or stable_rotation is None
or not angles_equal(endpoint.rotation, stable_rotation)
):
violation(probe_context, 'endpoint rotation changes across the offer domain')
else:
stable_ptype = endpoint.ptype
stable_rotation = endpoint.rotation
has_stable_endpoint = True
try:
cost = float(_evaluated_cost(offer, parameter, endpoint))
if not numpy.isfinite(cost) or cost < 0:
violation(probe_context, f'cost must be finite and nonnegative, got {cost!r}')
except Exception as err:
violation(probe_context, f'cost_at() raised {type(err).__name__}: {err}', err)
cost = None
if repeated is not None:
try:
repeated_endpoint = repeated.endpoint_at(parameter)
repeated_cost = float(_evaluated_cost(repeated, parameter, repeated_endpoint))
if (
not isinstance(repeated_endpoint, Port)
or not array_close(repeated_endpoint.offset, endpoint.offset)
or repeated_endpoint.ptype != endpoint.ptype
or repeated_endpoint.rotation is None
or endpoint.rotation is None
or not angles_equal(repeated_endpoint.rotation, endpoint.rotation)
):
violation(probe_context, 'endpoint result changed after repeated discovery')
if cost is not None and not scalar_close(repeated_cost, cost):
violation(probe_context, 'cost result changed after repeated discovery')
except Exception as err:
violation(
probe_context,
f'repeated offer evaluation raised {type(err).__name__}: {err}',
err,
)
if case.check_bbox:
try:
offer.bbox_at(parameter)
except Exception as err:
violation(probe_context, f'bbox_at() raised {type(err).__name__}: {err}', err)
try:
data = offer.commit(parameter)
except Exception as err:
violation(probe_context, f'commit() raised {type(err).__name__}: {err}', err)
continue
try:
start = Port((0, 0), rotation=pi, ptype=offer.in_ptype or 'unk')
tree = tool.render((RenderStep(offer.kind, tool, start, endpoint.copy(), data),))
except Exception as err:
violation(probe_context, f'render() raised {type(err).__name__}: {err}', err)
continue
if not isinstance(tree, ILibrary):
violation(probe_context, f'render() returned {type(tree).__name__}, expected ILibrary')
continue
try:
top_name = tree.top()
pattern = tree.top_pattern()
except Exception as err:
violation(probe_context, f'rendered tree has no valid top cell: {err}', err)
continue
missing = sorted(
name
for name in tree.dangling_refs(top_name)
if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX)
)
if missing:
violation(probe_context, f'rendered tree has missing single-use refs: {missing}')
missing_ports = [name for name in ('A', 'B') if name not in pattern.ports]
if missing_ports:
violation(probe_context, f'rendered top cell is missing ports: {missing_ports}')
continue
input_port, output_port = pattern.ports['A'], pattern.ports['B']
if not ptypes_compatible(input_port.ptype, offer.in_ptype):
violation(probe_context, 'rendered input ptype does not match offer in_ptype')
try:
rendered_offset, rendered_rotation = input_port.measure_travel(output_port)
except Exception as err:
violation(probe_context, f'unable to measure rendered endpoint: {err}', err)
continue
if not array_close(rendered_offset, endpoint.offset):
violation(probe_context, 'rendered output offset does not match planned endpoint')
if (
rendered_rotation is None
or endpoint.rotation is None
or not angles_equal(rendered_rotation, endpoint.rotation)
):
violation(probe_context, 'rendered output rotation does not match planned endpoint')
if not ptypes_compatible(output_port.ptype, endpoint.ptype):
violation(probe_context, 'rendered output ptype does not match planned endpoint')
for parameter, matched in zip(case.probe_parameters, matched_explicit, strict=True):
if not matched:
violation(context, f'explicit probe {parameter:g} is outside every discovered offer domain')
if errors:
raise ExceptionGroup(
f'{type(tool).__name__} failed Tool contract validation with {len(errors)} violation(s)',
errors,
)

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
from typing import SupportsFloat, cast, TYPE_CHECKING
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING
from collections.abc import Mapping
from pprint import pformat
import numpy
@ -8,11 +8,23 @@ from numpy.typing import ArrayLike, NDArray
from ..utils import rotation_matrix_2d, SupportsBool
from ..error import BuildError
from ._tolerances import manhattan_axis
if TYPE_CHECKING:
from ..ports import Port
_EXTENSION_BOUND_TYPES = (
'emin', 'min_extension',
'emax', 'max_extension',
'min_past_furthest',
)
_POSITION_MIN_BOUND_TYPES = ('pmin', 'min_position', 'xmin', 'ymin')
_POSITION_MAX_BOUND_TYPES = ('pmax', 'max_position', 'xmax', 'ymax')
_POSITION_BOUND_TYPES = _POSITION_MIN_BOUND_TYPES + _POSITION_MAX_BOUND_TYPES
_BOUND_TYPES = _EXTENSION_BOUND_TYPES + _POSITION_BOUND_TYPES
def ell(
ports: Mapping[str, 'Port'],
ccw: SupportsBool | None,
@ -46,7 +58,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 +76,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.
@ -82,9 +94,21 @@ def ell(
"""
if not ports:
raise BuildError('Empty port list passed to `ell()`')
if bound_type not in _BOUND_TYPES:
raise BuildError(f'Invalid bound type {bound_type!r}; expected one of {_BOUND_TYPES}')
try:
bound_arr = numpy.asarray(bound, dtype=float)
except (TypeError, ValueError) as err:
raise BuildError('bound must be a numeric scalar or length-2 vector') from err
if bound_arr.size not in (1, 2):
raise BuildError(f'bound must be scalar or have length 2; got {bound_arr.size} values')
if not numpy.all(numpy.isfinite(bound_arr)):
raise BuildError('bound must contain only finite values')
bound_values = bound_arr.reshape(-1)
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,11 +130,19 @@ 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!')
if not numpy.isfinite(set_rotation):
raise BuildError('set_rotation must be finite')
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
is_horizontal = numpy.isclose(rotations[0] % pi, 0)
axis = manhattan_axis(float(rotations[0]))
if bound_type in _POSITION_BOUND_TYPES and axis is None:
raise BuildError(
'Positional bounds require a nearly Manhattan port direction; '
f'got rotation {rotations[0]:g}'
)
is_horizontal = axis == 0
if bound_type in ('ymin', 'ymax') and is_horizontal:
raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!')
if bound_type in ('xmin', 'xmax') and not is_horizontal:
@ -132,8 +164,21 @@ def ell(
if spacing is None:
ch_offsets = numpy.zeros_like(y_order)
else:
steps = numpy.zeros_like(y_order)
steps[1:] = spacing
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
if not numpy.all(numpy.isfinite(spacing_arr)):
raise BuildError('spacing must contain only finite values')
if numpy.any(spacing_arr < 0):
raise BuildError('spacing must be nonnegative')
steps: NDArray[numpy.float64] = numpy.zeros(len(y_order), dtype=float)
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]
@ -164,38 +209,34 @@ def ell(
travel = d_to_align - (ch_offsets.max() - ch_offsets)
offsets = travel - travel.min().clip(max=0)
rot_bound: SupportsFloat
if bound_type in ('emin', 'min_extension',
'emax', 'max_extension',
'min_past_furthest',):
if numpy.size(bound) == 2:
bound = cast('Sequence[float]', bound)
rot_bound = (rot_matrix @ ((bound[0], 0),
(0, bound[1])))[0, :]
else:
bound = cast('float', bound)
rot_bound = numpy.array(bound)
if bound_type in _EXTENSION_BOUND_TYPES:
if numpy.any(bound_values < 0):
raise BuildError(f'Got negative bound for extension: {bound_values}')
if rot_bound < 0:
raise BuildError(f'Got negative bound for extension: {rot_bound}')
if bound_values.size == 2:
horizontal_weight = abs(float(numpy.cos(direction)))
vertical_weight = abs(float(numpy.sin(direction)))
use_x = horizontal_weight > vertical_weight or numpy.isclose(horizontal_weight, vertical_weight)
rot_bound = float(bound_values[0 if use_x else 1])
else:
rot_bound = float(bound_values[0])
if bound_type in ('emin', 'min_extension', 'min_past_furthest'):
offsets += rot_bound.max()
offsets += rot_bound
elif bound_type in ('emax', 'max_extension'):
offsets += rot_bound.min() - offsets.max()
offsets += rot_bound - offsets.max()
else:
if numpy.size(bound) == 2:
bound = cast('Sequence[float]', bound)
rot_bound = (rot_matrix @ bound)[0]
if bound_values.size == 2:
rot_bound = float((rot_matrix @ bound_values)[0])
else:
bound = cast('float', bound)
neg = (direction + pi / 4) % (2 * pi) > pi
rot_bound = -bound if neg else bound
bound_scalar = float(bound_values[0])
rot_bound = -bound_scalar if neg else bound_scalar
min_possible = x_start + offsets
if bound_type in ('pmax', 'max_position', 'xmax', 'ymax'):
if bound_type in _POSITION_MAX_BOUND_TYPES:
extension = rot_bound - min_possible.max()
elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'):
else:
extension = rot_bound - min_possible.min()
offsets += extension

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?')
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!)')
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 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])
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 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
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
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
if label.repetition is None:
block.add_text(
label.string,
dxfattribs=attribs
).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
).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

@ -0,0 +1,8 @@
"""
GDSII file format readers and writers.
"""
from .klamath import check_valid_names as check_valid_names
from .klamath import read as read
from .klamath import readfile as readfile
from .writer import write as write
from .writer import writefile as writefile

840
masque/file/gdsii/arrow.py Normal file
View file

@ -0,0 +1,840 @@
# ruff: noqa: ARG001
"""
GDSII file format readers and writers using the `TODO` library.
Note that GDSII references follow the same convention as `masque`,
with this order of operations:
1. Mirroring
2. Rotation
3. Scaling
4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)
Scaling, rotation, and mirroring apply to individual instances, not grid
vectors or offsets.
Notes:
* absolute positioning is not supported
* PLEX is not supported
* ELFLAGS are not supported
* GDS does not support library- or structure-level annotations
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
TODO writing
TODO warn on boxes, nodes
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from functools import cache
from importlib.machinery import EXTENSION_SUFFIXES
import importlib.util
import logging
import os
import pathlib
import gzip
import sys
import tempfile
from klamath.basic import KlamathError
import numpy
import pyarrow
from pyarrow.cffi import ffi
from ..utils import is_gzipped
from ... import Pattern, Ref, PatternError, Label
from ...shapes import Polygon, Path, PolyCollection, RectCollection
from ...repetition import Grid
from ...library import Library
if TYPE_CHECKING:
from collections.abc import Callable
import mmap
from numpy.typing import NDArray
from ...utils import annotations_t
logger = logging.getLogger(__name__)
ffi.cdef(
"""
const char* last_error_message(void);
int read_path(const char* path, struct ArrowArray* array, struct ArrowSchema* schema);
int scan_bytes(uint8_t* data, size_t size, struct ArrowArray* array, struct ArrowSchema* schema);
int read_cells_bytes(
uint8_t* data,
size_t size,
uint64_t* ranges,
size_t range_count,
struct ArrowArray* array,
struct ArrowSchema* schema
);
"""
)
_PATH_CAP_MAP = {
0: Path.Cap.Flush,
1: Path.Cap.Circle,
2: Path.Cap.Square,
4: Path.Cap.SquareCustom,
}
def _packed_layer_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int16]:
layer = (values >> numpy.uint32(16)).astype(numpy.uint16).view(numpy.int16)
dtype = (values & numpy.uint32(0xffff)).astype(numpy.uint16).view(numpy.int16)
return numpy.stack((layer, dtype), axis=-1)
def _packed_counts_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int64]:
a_count = (values >> numpy.uint32(16)).astype(numpy.uint16).astype(numpy.int64)
b_count = (values & numpy.uint32(0xffff)).astype(numpy.uint16).astype(numpy.int64)
return numpy.stack((a_count, b_count), axis=-1)
def _packed_xy_u64_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int32]:
xx = (values >> numpy.uint64(32)).astype(numpy.uint32).view(numpy.int32)
yy = (values & numpy.uint64(0xffff_ffff)).astype(numpy.uint32).view(numpy.int32)
return numpy.stack((xx, yy), axis=-1)
def _local_library_filename() -> str:
if sys.platform.startswith('linux'):
return 'libklamath_rs_ext.so'
if sys.platform == 'darwin':
return 'libklamath_rs_ext.dylib'
if sys.platform == 'win32':
return 'klamath_rs_ext.dll'
raise OSError(f'Unsupported platform for klamath_rs_ext: {sys.platform!r}')
def _installed_library_candidates() -> list[pathlib.Path]:
candidates: list[pathlib.Path] = []
try:
spec = importlib.util.find_spec('klamath_rs_ext.klamath_rs_ext')
except ModuleNotFoundError:
spec = None
if spec is not None and spec.origin is not None:
candidates.append(pathlib.Path(spec.origin))
try:
pkg_spec = importlib.util.find_spec('klamath_rs_ext')
except ModuleNotFoundError:
pkg_spec = None
if pkg_spec is not None and pkg_spec.submodule_search_locations is not None:
for location in pkg_spec.submodule_search_locations:
pkg_dir = pathlib.Path(location)
for suffix in EXTENSION_SUFFIXES:
candidates.extend(sorted(pkg_dir.glob(f'klamath_rs_ext*{suffix}')))
return candidates
def _repo_library_candidates() -> list[pathlib.Path]:
repo_root = pathlib.Path(__file__).resolve().parents[3]
library_name = _local_library_filename()
return [
repo_root / 'klamath-rs' / 'target' / 'release' / library_name,
repo_root / 'klamath-rs' / 'target' / 'debug' / library_name,
]
def _find_klamath_rs_library() -> pathlib.Path | None:
env_path = os.environ.get('KLAMATH_RS_EXT_LIB')
if env_path:
candidate = pathlib.Path(env_path).expanduser()
if candidate.exists():
return candidate.resolve()
seen: set[pathlib.Path] = set()
for candidate in _installed_library_candidates() + _repo_library_candidates():
resolved = candidate.expanduser()
if resolved in seen:
continue
seen.add(resolved)
if resolved.exists():
return resolved.resolve()
return None
def is_available() -> bool:
return _find_klamath_rs_library() is not None
@cache
def _get_clib() -> Any:
lib_path = _find_klamath_rs_library()
if lib_path is None:
raise ImportError(
'Could not locate klamath_rs_ext shared library. '
'Build klamath-rs with `cargo build --release --manifest-path klamath-rs/Cargo.toml` '
'or set KLAMATH_RS_EXT_LIB to the built library path.'
)
return ffi.dlopen(str(lib_path))
def _read_annotations(
prop_offs: NDArray[numpy.integer[Any]],
prop_key: NDArray[numpy.integer[Any]],
prop_val: list[str],
ee: int,
) -> annotations_t:
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
if prop_ii >= prop_ff:
return None
return {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}
def _read_to_arrow(
filename: str | pathlib.Path,
) -> pyarrow.Array:
path = pathlib.Path(filename).expanduser().resolve()
ptr_array = ffi.new('struct ArrowArray[]', 1)
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
if is_gzipped(path):
with gzip.open(path, mode='rb') as src:
data = src.read()
with tempfile.NamedTemporaryFile(suffix='.gds', delete=False) as tmp_stream:
tmp_stream.write(data)
tmp_name = tmp_stream.name
try:
_call_native(_get_clib().read_path(tmp_name.encode(), ptr_array, ptr_schema), 'read_path')
finally:
pathlib.Path(tmp_name).unlink(missing_ok=True)
else:
_call_native(_get_clib().read_path(str(path).encode(), ptr_array, ptr_schema), 'read_path')
return _import_arrow_array(ptr_array, ptr_schema)
def _import_arrow_array(ptr_array: Any, ptr_schema: Any) -> pyarrow.Array:
iptr_schema = int(ffi.cast('uintptr_t', ptr_schema))
iptr_array = int(ffi.cast('uintptr_t', ptr_array))
return pyarrow.Array._import_from_c(iptr_array, iptr_schema)
def _call_native(status: int, action: str) -> None:
if status == 0:
return
err_ptr = _get_clib().last_error_message()
if err_ptr == ffi.NULL:
raise KlamathError(f'{action} failed')
message = ffi.string(err_ptr).decode(errors='replace')
raise KlamathError(message)
def _scan_buffer_to_arrow(buffer: bytes | mmap.mmap | memoryview) -> pyarrow.Array:
ptr_array = ffi.new('struct ArrowArray[]', 1)
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
buf_view = memoryview(buffer)
cbuf = ffi.from_buffer('uint8_t[]', buf_view)
_call_native(_get_clib().scan_bytes(cbuf, len(buf_view), ptr_array, ptr_schema), 'scan_bytes')
return _import_arrow_array(ptr_array, ptr_schema)
def _read_selected_cells_to_arrow(
buffer: bytes | mmap.mmap | memoryview,
ranges: NDArray[numpy.uint64],
) -> pyarrow.Array:
ptr_array = ffi.new('struct ArrowArray[]', 1)
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
buf_view = memoryview(buffer)
cbuf = ffi.from_buffer('uint8_t[]', buf_view)
flat_ranges = numpy.require(ranges, dtype=numpy.uint64, requirements=('C_CONTIGUOUS', 'ALIGNED'))
cranges = ffi.from_buffer('uint64_t[]', flat_ranges)
_call_native(
_get_clib().read_cells_bytes(cbuf, len(buf_view), cranges, int(flat_ranges.shape[0]), ptr_array, ptr_schema),
'read_cells_bytes',
)
return _import_arrow_array(ptr_array, ptr_schema)
def readfile(
filename: str | pathlib.Path,
) -> tuple[Library, dict[str, Any]]:
"""
Read a GDSII file from a path into `masque.Library` / `Pattern` objects.
Will automatically decompress gzipped files.
Args:
filename: Filename to read.
For callers that can consume Arrow directly, prefer `readfile_arrow()`
to skip Python `Pattern` construction entirely.
"""
arrow_arr = _read_to_arrow(filename)
assert len(arrow_arr) == 1
results = read_arrow(arrow_arr[0])
return results
def readfile_arrow(
filename: str | pathlib.Path,
) -> tuple[pyarrow.StructScalar, dict[str, Any]]:
"""
Read a GDSII file into the native Arrow representation without converting
it into `masque.Library` / `Pattern` objects.
This is the lowest-overhead public read path exposed by this module.
Args:
filename: Filename to read.
Returns:
- Arrow struct scalar for the library payload
- dict of GDSII library info
"""
arrow_arr = _read_to_arrow(filename)
assert len(arrow_arr) == 1
libarr = arrow_arr[0]
return libarr, _read_header(libarr)
def read_arrow(
libarr: pyarrow.Array,
) -> tuple[Library, dict[str, Any]]:
"""
# TODO check GDSII file for cycles!
Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
are translated into Ref objects.
Additional library info is returned in a dict, containing:
'name': name of the library
'meters_per_unit': number of meters per database unit (all values are in database units)
'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
per database unit
Args:
libarr: Arrow library payload as returned by `readfile_arrow()`.
Returns:
- dict of pattern_name:Patterns generated from GDSII structures
- dict of GDSII library info
"""
library_info = _read_header(libarr)
layer_names_np = _packed_layer_u32_to_pairs(libarr['layers'].values.to_numpy())
layer_tups = [(int(pair[0]), int(pair[1])) for pair in layer_names_np]
cell_ids = libarr['cells'].values.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]:
el = libarr['cells'].values.field(geom_type)
elem = dict(
offsets = el.offsets.to_numpy(),
xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)),
xy_off = el.values.field('xy').offsets.to_numpy() // 2,
layer_inds = el.values.field('layer').to_numpy(),
prop_off = el.values.field('properties').offsets.to_numpy(),
prop_key = el.values.field('properties').values.field('key').to_numpy(),
prop_val = el.values.field('properties').values.field('value').to_pylist(),
)
return elem
def get_boundary_batches(libarr: pyarrow.Array) -> dict[str, Any]:
batches = libarr['cells'].values.field('boundary_batches')
return dict(
offsets = batches.offsets.to_numpy(),
layer_inds = batches.values.field('layer').to_numpy(),
vert_arr = batches.values.field('vertices').values.to_numpy().reshape((-1, 2)),
vert_off = batches.values.field('vertices').offsets.to_numpy() // 2,
poly_off = batches.values.field('vertex_offsets').offsets.to_numpy(),
poly_offsets = batches.values.field('vertex_offsets').values.to_numpy(),
)
def get_rect_batches(libarr: pyarrow.Array) -> dict[str, Any]:
batches = libarr['cells'].values.field('rect_batches')
return dict(
offsets = batches.offsets.to_numpy(),
layer_inds = batches.values.field('layer').to_numpy(),
rect_arr = batches.values.field('rects').values.to_numpy().reshape((-1, 4)),
rect_off = batches.values.field('rects').offsets.to_numpy() // 4,
)
def get_boundary_props(libarr: pyarrow.Array) -> dict[str, Any]:
boundaries = libarr['cells'].values.field('boundary_props')
return dict(
offsets = boundaries.offsets.to_numpy(),
layer_inds = boundaries.values.field('layer').to_numpy(),
vert_arr = boundaries.values.field('vertices').values.to_numpy().reshape((-1, 2)),
vert_off = boundaries.values.field('vertices').offsets.to_numpy() // 2,
prop_off = boundaries.values.field('properties').offsets.to_numpy(),
prop_key = boundaries.values.field('properties').values.field('key').to_numpy(),
prop_val = boundaries.values.field('properties').values.field('value').to_pylist(),
)
def get_refs(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]:
refs = libarr['cells'].values.field(geom_type)
values = refs.values
elem = dict(
offsets = refs.offsets.to_numpy(),
targets = values.field('target').to_numpy(),
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()),
invert_y = values.field('invert_y').to_numpy(zero_copy_only=False),
angle_rad = values.field('angle_rad').to_numpy(),
scale = values.field('scale').to_numpy(),
)
if has_repetition:
elem.update(dict(
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()),
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()),
))
return elem
def get_ref_props(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]:
refs = libarr['cells'].values.field(geom_type)
values = refs.values
elem = dict(
offsets = refs.offsets.to_numpy(),
targets = values.field('target').to_numpy(),
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()),
invert_y = values.field('invert_y').to_numpy(zero_copy_only=False),
angle_rad = values.field('angle_rad').to_numpy(),
scale = values.field('scale').to_numpy(),
prop_off = values.field('properties').offsets.to_numpy(),
prop_key = values.field('properties').values.field('key').to_numpy(),
prop_val = values.field('properties').values.field('value').to_pylist(),
)
if has_repetition:
elem.update(dict(
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()),
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()),
))
return elem
txt = libarr['cells'].values.field('texts')
texts = dict(
offsets = txt.offsets.to_numpy(),
layer_inds = txt.values.field('layer').to_numpy(),
xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()),
string = txt.values.field('string').to_pylist(),
prop_off = txt.values.field('properties').offsets.to_numpy(),
prop_key = txt.values.field('properties').values.field('key').to_numpy(),
prop_val = txt.values.field('properties').values.field('value').to_pylist(),
)
elements = dict(
srefs = get_refs(libarr, 'srefs', has_repetition=False),
arefs = get_refs(libarr, 'arefs', has_repetition=True),
sref_props = get_ref_props(libarr, 'sref_props', has_repetition=False),
aref_props = get_ref_props(libarr, 'aref_props', has_repetition=True),
rect_batches = get_rect_batches(libarr),
boundary_batches = get_boundary_batches(libarr),
boundary_props = get_boundary_props(libarr),
paths = get_geom(libarr, 'paths'),
texts = texts,
)
paths = libarr['cells'].values.field('paths')
elements['paths'].update(dict(
width = paths.values.field('width').fill_null(0).to_numpy(),
path_type = paths.values.field('path_type').fill_null(0).to_numpy(),
extensions = numpy.stack((
paths.values.field('extension_start').fill_null(0).to_numpy(),
paths.values.field('extension_end').fill_null(0).to_numpy(),
), axis=-1),
))
global_args = dict(
cell_names = cell_names,
layer_tups = layer_tups,
)
mlib = Library()
for cc in range(len(libarr['cells'])):
name = cell_names[int(cell_ids[cc])]
pat = Pattern()
_rect_batches_to_rectcollections(pat, global_args, elements['rect_batches'], cc)
_boundary_batches_to_polygons(pat, global_args, elements['boundary_batches'], cc)
_boundary_props_to_polygons(pat, global_args, elements['boundary_props'], cc)
_gpaths_to_mpaths(pat, global_args, elements['paths'], cc)
_srefs_to_mrefs(pat, global_args, elements['srefs'], cc)
_arefs_to_mrefs(pat, global_args, elements['arefs'], cc)
_sref_props_to_mrefs(pat, global_args, elements['sref_props'], cc)
_aref_props_to_mrefs(pat, global_args, elements['aref_props'], cc)
_texts_to_labels(pat, global_args, elements['texts'], cc)
mlib[name] = pat
return mlib, library_info
def _read_header(libarr: pyarrow.Array) -> dict[str, Any]:
"""
Read the file header and create the library_info dict.
"""
library_info = dict(
name = libarr['lib_name'].as_py(),
meters_per_unit = libarr['meters_per_db_unit'].as_py(),
logical_units_per_unit = libarr['user_units_per_db_unit'].as_py(),
)
return library_info
def _srefs_to_mrefs(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
cell_names = global_args['cell_names']
elem_off = elem['offsets']
elem_count = elem_off[cc + 1] - elem_off[cc]
if elem_count == 0:
return
start = elem_off[cc]
stop = elem_off[cc + 1]
elem_targets = elem['targets'][start:stop]
elem_xy = elem['xy'][start:stop]
elem_invert_y = elem['invert_y'][start:stop]
elem_angle_rad = elem['angle_rad'][start:stop]
elem_scale = elem['scale'][start:stop]
_append_plain_refs_sorted(
pat=pat,
cell_names=cell_names,
elem_targets=elem_targets,
elem_xy=elem_xy,
elem_invert_y=elem_invert_y,
elem_angle_rad=elem_angle_rad,
elem_scale=elem_scale,
)
def _append_plain_refs_sorted(
*,
pat: Pattern,
cell_names: list[str],
elem_targets: NDArray[numpy.integer[Any]],
elem_xy: NDArray[numpy.integer[Any]],
elem_invert_y: NDArray[numpy.bool_ | numpy.bool],
elem_angle_rad: NDArray[numpy.floating[Any]],
elem_scale: NDArray[numpy.floating[Any]],
) -> None:
elem_count = len(elem_targets)
if elem_count == 0:
return
target_start = 0
while target_start < elem_count:
target_id = int(elem_targets[target_start])
target_stop = target_start + 1
while target_stop < elem_count and elem_targets[target_stop] == target_id:
target_stop += 1
append_refs = pat.refs[cell_names[target_id]].extend
append_refs(
Ref._from_raw(
offset=elem_xy[ee],
mirrored=elem_invert_y[ee],
rotation=elem_angle_rad[ee],
scale=elem_scale[ee],
repetition=None,
annotations=None,
)
for ee in range(target_start, target_stop)
)
target_start = target_stop
def _arefs_to_mrefs(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
cell_names = global_args['cell_names']
elem_off = elem['offsets']
elem_count = elem_off[cc + 1] - elem_off[cc]
if elem_count == 0:
return
start = elem_off[cc]
stop = elem_off[cc + 1]
elem_targets = elem['targets'][start:stop]
elem_xy = elem['xy'][start:stop]
elem_invert_y = elem['invert_y'][start:stop]
elem_angle_rad = elem['angle_rad'][start:stop]
elem_scale = elem['scale'][start:stop]
elem_xy0 = elem['xy0'][start:stop]
elem_xy1 = elem['xy1'][start:stop]
elem_counts = elem['counts'][start:stop]
if len(elem_targets) == 0:
return
target = None
append_ref: Callable[[Ref], Any] | None = None
for ee in range(len(elem_targets)):
target_id = int(elem_targets[ee])
if target != target_id:
target = target_id
append_ref = pat.refs[cell_names[target_id]].append
assert append_ref is not None
a_count, b_count = elem_counts[ee]
append_ref(Ref._from_raw(
offset=elem_xy[ee],
mirrored=elem_invert_y[ee],
rotation=elem_angle_rad[ee],
scale=elem_scale[ee],
repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count),
annotations=None,
))
def _sref_props_to_mrefs(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
cell_names = global_args['cell_names']
elem_off = elem['offsets']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
if elem_count == 0:
return
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)
prop_offs = elem['prop_off'][elem_slc]
elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]]
elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]]
elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]]
elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]]
elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]]
for ee in range(elem_count):
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
ref = Ref._from_raw(
offset=elem_xy[ee],
mirrored=elem_invert_y[ee],
rotation=elem_angle_rad[ee],
scale=elem_scale[ee],
repetition=None,
annotations=annotations,
)
pat.refs[cell_names[int(elem_targets[ee])]].append(ref)
def _aref_props_to_mrefs(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
cell_names = global_args['cell_names']
elem_off = elem['offsets']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
if elem_count == 0:
return
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)
prop_offs = elem['prop_off'][elem_slc]
elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]]
elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]]
elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]]
elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]]
elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]]
elem_xy0 = elem['xy0'][elem_off[cc]:elem_off[cc + 1]]
elem_xy1 = elem['xy1'][elem_off[cc]:elem_off[cc + 1]]
elem_counts = elem['counts'][elem_off[cc]:elem_off[cc + 1]]
for ee in range(elem_count):
a_count, b_count = elem_counts[ee]
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
ref = Ref._from_raw(
offset=elem_xy[ee],
mirrored=elem_invert_y[ee],
rotation=elem_angle_rad[ee],
scale=elem_scale[ee],
repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count),
annotations=annotations,
)
pat.refs[cell_names[int(elem_targets[ee])]].append(ref)
def _texts_to_labels(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets'] # which elements belong to each cell
xy = elem['xy']
layer_tups = global_args['layer_tups']
layer_inds = elem['layer_inds']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
elem_xy = xy[elem_slc][:elem_count]
elem_layer_inds = layer_inds[elem_slc][:elem_count]
elem_strings = elem['string'][elem_slc][:elem_count]
for ee in range(elem_count):
layer = layer_tups[int(elem_layer_inds[ee])]
offset = elem_xy[ee]
string = elem_strings[ee]
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
mlabel = Label._from_raw(string=string, offset=offset, annotations=annotations)
pat.labels[layer].append(mlabel)
def _gpaths_to_mpaths(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets'] # which elements belong to each cell
xy_val = elem['xy_arr']
layer_tups = global_args['layer_tups']
layer_inds = elem['layer_inds']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
xy_offs = elem['xy_off'][elem_slc] # which xy coords belong to each element
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
elem_layer_inds = layer_inds[elem_slc][:elem_count]
elem_widths = elem['width'][elem_slc][:elem_count]
elem_path_types = elem['path_type'][elem_slc][:elem_count]
elem_extensions = elem['extensions'][elem_slc][:elem_count]
for ee in range(elem_count):
layer = layer_tups[int(elem_layer_inds[ee])]
vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]]
width = elem_widths[ee]
cap_int = int(elem_path_types[ee])
if cap_int not in _PATH_CAP_MAP:
raise PatternError(f'Unrecognized path type: {cap_int}')
cap = _PATH_CAP_MAP[cap_int]
if cap_int == 4:
cap_extensions = elem_extensions[ee]
else:
cap_extensions = None
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
path = Path._from_raw(
vertices=vertices,
width=width,
cap=cap,
cap_extensions=cap_extensions,
annotations=annotations,
)
pat.shapes[layer].append(path)
def _boundary_batches_to_polygons(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets'] # which elements belong to each cell
vert_arr = elem['vert_arr']
vert_off = elem['vert_off']
layer_inds = elem['layer_inds']
layer_tups = global_args['layer_tups']
poly_off = elem['poly_off']
poly_offsets = elem['poly_offsets']
batch_count = elem_off[cc + 1] - elem_off[cc]
if batch_count == 0:
return
elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1) # +1 to capture ending location for last elem
elem_vert_off = vert_off[elem_slc]
elem_poly_off = poly_off[elem_slc]
elem_layer_inds = layer_inds[elem_slc][:batch_count]
for bb in range(batch_count):
layer = layer_tups[int(elem_layer_inds[bb])]
vertices = vert_arr[elem_vert_off[bb]:elem_vert_off[bb + 1]]
vertex_offsets = poly_offsets[elem_poly_off[bb]:elem_poly_off[bb + 1]]
if vertex_offsets.size == 1:
poly = Polygon._from_raw(vertices=vertices, annotations=None)
pat.shapes[layer].append(poly)
else:
polys = PolyCollection._from_raw(vertex_lists=vertices, vertex_offsets=vertex_offsets, annotations=None)
pat.shapes[layer].append(polys)
def _rect_batches_to_rectcollections(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets']
rect_arr = elem['rect_arr']
rect_off = elem['rect_off']
layer_inds = elem['layer_inds']
layer_tups = global_args['layer_tups']
batch_count = elem_off[cc + 1] - elem_off[cc]
if batch_count == 0:
return
elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1)
elem_rect_off = rect_off[elem_slc]
elem_layer_inds = layer_inds[elem_slc][:batch_count]
for bb in range(batch_count):
layer = layer_tups[int(elem_layer_inds[bb])]
rects = rect_arr[elem_rect_off[bb]:elem_rect_off[bb + 1]]
rect_collection = RectCollection._from_raw(rects=rects, annotations=None)
pat.shapes[layer].append(rect_collection)
def _boundary_props_to_polygons(
pat: Pattern,
global_args: dict[str, Any],
elem: dict[str, Any],
cc: int,
) -> None:
elem_off = elem['offsets']
vert_arr = elem['vert_arr']
vert_off = elem['vert_off']
layer_inds = elem['layer_inds']
layer_tups = global_args['layer_tups']
prop_key = elem['prop_key']
prop_val = elem['prop_val']
elem_count = elem_off[cc + 1] - elem_off[cc]
if elem_count == 0:
return
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)
elem_vert_off = vert_off[elem_slc]
prop_offs = elem['prop_off'][elem_slc]
elem_layer_inds = layer_inds[elem_slc][:elem_count]
for ee in range(elem_count):
layer = layer_tups[int(elem_layer_inds[ee])]
vertices = vert_arr[elem_vert_off[ee]:elem_vert_off[ee + 1]]
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
pat.shapes[layer].append(poly)

View file

@ -19,11 +19,9 @@ Notes:
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
"""
from typing import IO, cast, Any
from typing import IO, Any
from collections.abc import Iterable, Mapping, Callable
from types import MappingProxyType
import io
import mmap
import logging
import pathlib
import gzip
@ -35,79 +33,37 @@ from numpy.typing import ArrayLike, NDArray
import klamath
from klamath import records
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path
from ..repetition import Grid
from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
from ..utils import is_gzipped
from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ...shapes import Polygon, Path, RectCollection
from ...repetition import Grid
from ...utils import layer_t, annotations_t
from ...library import Library
logger = logging.getLogger(__name__)
path_cap_map = {
_PATH_CAP_MAP = {
0: Path.Cap.Flush,
1: Path.Cap.Circle,
2: Path.Cap.Square,
4: Path.Cap.SquareCustom,
}
RO_EMPTY_DICT: Mapping[int, bytes] = MappingProxyType({})
_EMPTY_PROPERTIES: Mapping[int, bytes] = MappingProxyType({})
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
def _rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
return numpy.rint(val).astype(numpy.int32)
def write(
library: Mapping[str, Pattern],
def _write_header(
stream: IO[bytes],
meters_per_unit: float,
logical_units_per_unit: float = 1,
library_name: str = 'masque-klamath',
logical_units_per_unit: float,
library_name: str,
) -> None:
"""
Convert a library to a GDSII stream, mapping data as follows:
Pattern -> GDSII structure
Ref -> GDSII SREF or AREF
Path -> GSDII path
Shape (other than path) -> GDSII boundary/ies
Label -> GDSII text
annnotations -> properties, where possible
For each shape,
layer is chosen to be equal to `shape.layer` if it is an int,
or `shape.layer[0]` if it is a tuple
datatype is chosen to be `shape.layer[1]` if available,
otherwise `0`
GDS does not support shape repetition (only cell repeptition). Please call
`library.wrap_repeated_shapes()` before writing to file.
Other functions you may want to call:
- `masque.file.gdsii.check_valid_names(library.keys())` to check for invalid names
- `library.dangling_refs()` to check for references to missing patterns
- `pattern.polygonize()` for any patterns with shapes other
than `masque.shapes.Polygon` or `masque.shapes.Path`
Args:
library: A {name: Pattern} mapping of patterns to write.
meters_per_unit: Written into the GDSII file, meters per (database) length unit.
All distances are assumed to be an integer multiple of this unit, and are stored as such.
logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
"logical" unit which is different from the "database" unit, for display purposes.
Default `1`.
library_name: Library name written into the GDSII file.
Default 'masque-klamath'.
"""
if not isinstance(library, ILibrary):
if isinstance(library, dict):
library = Library(library)
else:
library = Library(dict(library))
# Create library
header = klamath.library.FileHeader(
name=library_name.encode('ASCII'),
user_units_per_db_unit=logical_units_per_unit,
@ -115,51 +71,19 @@ def write(
)
header.write(stream)
# Now create a structure for each pattern, and add in any Boundary and SREF elements
for name, pat in library.items():
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
elements: list[klamath.elements.Element] = []
elements += _shapes_to_elements(pat.shapes)
elements += _labels_to_texts(pat.labels)
elements += _mrefs_to_grefs(pat.refs)
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
def _write_footer(stream: IO[bytes]) -> None:
records.ENDLIB.write(stream, None)
def writefile(
library: Mapping[str, Pattern],
filename: str | pathlib.Path,
*args,
**kwargs,
) -> None:
"""
Wrapper for `write()` that takes a filename or path instead of a stream.
Will automatically compress the file if it has a .gz suffix.
Args:
library: {name: Pattern} pairs to save.
filename: Filename to save to.
*args: passed to `write()`
**kwargs: passed to `write()`
"""
path = pathlib.Path(filename)
with tmpfile(path) as base_stream:
streams: tuple[Any, ...] = (base_stream,)
if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
try:
write(library, stream, *args, **kwargs)
finally:
for ss in streams:
ss.close()
def readfile(
filename: str | pathlib.Path,
*args,
@ -216,7 +140,7 @@ def read(
found_struct = records.BGNSTR.skip_past(stream)
while found_struct:
name = records.STRNAME.skip_and_read(stream)
pat = read_elements(stream, raw_mode=raw_mode)
pat = _read_elements(stream, raw_mode=raw_mode)
mlib[name.decode('ASCII')] = pat
found_struct = records.BGNSTR.skip_past(stream)
@ -236,7 +160,7 @@ def _read_header(stream: IO[bytes]) -> dict[str, Any]:
return library_info
def read_elements(
def _read_elements(
stream: IO[bytes],
raw_mode: bool = True,
) -> Pattern:
@ -318,31 +242,45 @@ def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]:
if gpath.path_type in path_cap_map:
cap = path_cap_map[gpath.path_type]
if gpath.path_type in _PATH_CAP_MAP:
cap = _PATH_CAP_MAP[gpath.path_type]
else:
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
mpath = Path(
vertices=gpath.xy.astype(float),
vertices = gpath.xy.astype(float)
annotations = _properties_to_annotations(gpath.properties)
cap_extensions = None
if cap == Path.Cap.SquareCustom:
cap_extensions = numpy.asarray(gpath.extension, dtype=float)
if raw_mode:
mpath = Path._from_raw(
vertices=vertices,
width=gpath.width,
cap=cap,
cap_extensions=cap_extensions,
annotations=annotations,
)
else:
mpath = Path(
vertices=vertices,
width=gpath.width,
cap=cap,
cap_extensions=cap_extensions,
offset=numpy.zeros(2),
annotations=_properties_to_annotations(gpath.properties),
raw=raw_mode,
annotations=annotations,
)
if cap == Path.Cap.SquareCustom:
mpath.cap_extensions = gpath.extension
return gpath.layer, mpath
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
return boundary.layer, Polygon(
vertices=boundary.xy[:-1].astype(float),
offset=numpy.zeros(2),
annotations=_properties_to_annotations(boundary.properties),
raw=raw_mode,
)
vertices = boundary.xy[:-1].astype(float)
annotations = _properties_to_annotations(boundary.properties)
if raw_mode:
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
else:
poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations)
return boundary.layer, poly
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
@ -367,7 +305,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
])
aref = klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast(xy),
xy=_rint_cast(xy),
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
angle_deg=angle_deg,
invert_y=ref.mirrored,
@ -378,7 +316,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
elif rep is None:
sref = klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset]),
xy=_rint_cast([ref.offset]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
@ -390,7 +328,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
new_srefs = [
klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset + dd]),
xy=_rint_cast([ref.offset + dd]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
@ -410,7 +348,7 @@ def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]:
if annotations is None:
return RO_EMPTY_DICT
return _EMPTY_PROPERTIES
cum_len = 0
props = {}
for key, vals in annotations.items():
@ -447,13 +385,13 @@ def _shapes_to_elements(
properties = _annotations_to_properties(shape.annotations, 128)
if isinstance(shape, Path) and not polygonize_paths:
xy = rint_cast(shape.vertices + shape.offset)
width = rint_cast(shape.width)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
xy = _rint_cast(shape.vertices + shape.offset)
width = _rint_cast(shape.width)
path_type = next(k for k, v in _PATH_CAP_MAP.items() if v == shape.cap) # reverse lookup
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)
@ -466,6 +404,20 @@ def _shapes_to_elements(
properties=properties,
)
elements.append(path)
elif isinstance(shape, RectCollection):
for rect in shape.rects:
xy_closed = numpy.empty((5, 2), dtype=numpy.int32)
xy_closed[0] = _rint_cast((rect[0], rect[1]))
xy_closed[1] = _rint_cast((rect[0], rect[3]))
xy_closed[2] = _rint_cast((rect[2], rect[3]))
xy_closed[3] = _rint_cast((rect[2], rect[1]))
xy_closed[4] = xy_closed[0]
boundary = klamath.elements.Boundary(
layer=(layer, data_type),
xy=xy_closed,
properties=properties,
)
elements.append(boundary)
elif isinstance(shape, Polygon):
polygon = shape
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
@ -497,7 +449,7 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
layer, text_type = _mlayer2gds(mlayer)
for label in lseq:
properties = _annotations_to_properties(label.annotations, 128)
xy = rint_cast([label.offset])
xy = _rint_cast([label.offset])
text = klamath.elements.Text(
layer=(layer, text_type),
xy=xy,
@ -514,112 +466,6 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
return texts
def load_library(
stream: IO[bytes],
*,
full_load: bool = False,
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Scan a GDSII stream to determine what structures are present, and create
a library from them. This enables deferred reading of structures
on an as-needed basis.
All structures are loaded as secondary
Args:
stream: Seekable stream. Position 0 should be the start of the file.
The caller should leave the stream open while the library
is still in use, since the library will need to access it
in order to read the structure contents.
full_load: If True, force all structures to be read immediately rather
than as-needed. Since data is read sequentially from the file, this
will be faster than using the resulting library's `precache` method.
postprocess: If given, this function is used to post-process each
pattern *upon first load only*.
Returns:
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
stream.seek(0)
lib = LazyLibrary()
if full_load:
# Full load approach (immediately load everything)
patterns, library_info = read(stream)
for name, pattern in patterns.items():
if postprocess is not None:
lib[name] = postprocess(lib, name, pattern)
else:
lib[name] = pattern
return lib, library_info
# Normal approach (scan and defer load)
library_info = _read_header(stream)
structs = klamath.library.scan_structs(stream)
for name_bytes, pos in structs.items():
name = name_bytes.decode('ASCII')
def mkstruct(pos: int = pos, name: str = name) -> Pattern:
stream.seek(pos)
pat = read_elements(stream, raw_mode=True)
if postprocess is not None:
pat = postprocess(lib, name, pat)
return pat
lib[name] = mkstruct
return lib, library_info
def load_libraryfile(
filename: str | pathlib.Path,
*,
use_mmap: bool = True,
full_load: bool = False,
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Wrapper for `load_library()` that takes a filename or path instead of a stream.
Will automatically decompress the file if it is gzipped.
NOTE that any streams/mmaps opened will remain open until ALL of the
`PatternGenerator` objects in the library are garbage collected.
Args:
path: filename or path to read from
use_mmap: If `True`, will attempt to memory-map the file instead
of buffering. In the case of gzipped files, the file
is decompressed into a python `bytes` object in memory
and reopened as an `io.BytesIO` stream.
full_load: If `True`, immediately loads all data. See `load_library`.
postprocess: Passed to `load_library`
Returns:
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
path = pathlib.Path(filename)
stream: IO[bytes]
if is_gzipped(path):
if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BytesIO(gz_stream.read()) # type: ignore
else:
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BufferedReader(gz_stream) # type: ignore
else: # noqa: PLR5501
if use_mmap:
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
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)
def check_valid_names(
names: Iterable[str],
max_length: int = 32,
@ -632,6 +478,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 +495,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')

288
masque/file/gdsii/lazy.py Normal file
View file

@ -0,0 +1,288 @@
"""
Classic source-backed lazy GDSII reader built on the pure-python klamath path.
This module provides the non-Arrow half of Masque's lazy GDS architecture:
- `GdsLibrarySource` scans a GDS stream once to discover library metadata,
struct order, and child edges without materializing every cell.
- cells are materialized on demand through the classic `gdsii` decoder
whenever a caller indexes the lazy view
- untouched cells can be copied directly to another GDS file without
materializing them
- the source can be wrapped in `PortLoadView` or merged through
`OverlayLibrary`
The public surface intentionally parallels `gdsii.lazy_arrow` closely so that
callers can swap between the classic and Arrow-backed implementations with
minimal changes.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import IO, TYPE_CHECKING, Any, cast
import gzip
import io
import logging
import mmap
import pathlib
import klamath
from klamath import records
from . import klamath as gdsii_klamath
from ..utils import is_gzipped
from ...error import LibraryError
from ...library import (
ILibraryView,
IMaterializable,
LibraryView,
)
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
import numpy
from numpy.typing import NDArray
from ...pattern import Pattern
logger = logging.getLogger(__name__)
@dataclass
class _SourceHandle:
""" Owns the underlying stream and any companion file handle for a source. """
path: pathlib.Path | None
stream: IO[bytes]
handle: IO[bytes] | None = None
def close(self) -> None:
self.stream.close()
if self.handle is not None and self.handle is not self.stream:
self.handle.close()
self.handle = None
@dataclass(frozen=True)
class _CellScan:
""" Scan-time metadata for one cell in the source stream. """
offset: int
struct_start: int
struct_end: int
children: set[str]
def _open_source_stream(
filename: str | pathlib.Path,
*,
use_mmap: bool,
) -> _SourceHandle:
path = pathlib.Path(filename).expanduser().resolve()
if is_gzipped(path):
if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
with gzip.open(path, mode='rb') as gzip_stream:
data = gzip_stream.read()
return _SourceHandle(path=path, stream=io.BytesIO(data))
source_stream = cast('IO[bytes]', gzip.open(path, mode='rb')) # noqa: SIM115
return _SourceHandle(path=path, stream=source_stream)
if use_mmap:
handle = path.open(mode='rb', buffering=0)
mapped = cast('IO[bytes]', mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ))
return _SourceHandle(path=path, stream=mapped, handle=handle)
source_stream = path.open(mode='rb')
return _SourceHandle(path=path, stream=source_stream)
def _scan_library(
stream: IO[bytes],
) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]:
library_info = gdsii_klamath._read_header(stream)
order: list[str] = []
cells: dict[str, _CellScan] = {}
while True:
struct_start = stream.tell()
if not records.BGNSTR.skip_past(stream):
break
name = records.STRNAME.skip_and_read(stream).decode('ASCII')
offset = stream.tell()
elements = klamath.library.read_elements(stream)
struct_end = stream.tell()
children = {
element.struct_name.decode('ASCII')
for element in elements
if isinstance(element, klamath.elements.Reference)
}
order.append(name)
cells[name] = _CellScan(
offset=offset,
struct_start=struct_start,
struct_end=struct_end,
children=children,
)
return library_info, order, cells
class GdsLibrarySource(ILibraryView, IMaterializable):
"""
Read-only library backed by a seekable GDS stream.
Cells are scanned once up front to discover order, byte ranges, and child
edges. Untouched structures can be copied directly, while accessed cells
are materialized through the classic GDS decoder.
The source owns the stream lifetime, preserves on-disk ordering through
`source_order()`, and answers graph queries from scan metadata whenever
possible so callers can inspect hierarchy without forcing a full load.
"""
def __init__(
self,
*,
source: _SourceHandle,
library_info: dict[str, Any],
cell_order: Sequence[str],
cells: dict[str, _CellScan],
) -> None:
self.path = source.path
self.library_info = library_info
self._source = source
self._cell_order = tuple(cell_order)
self._cells = cells
self._cache: dict[str, Pattern] = {}
self._lookups_in_progress: list[str] = []
@classmethod
def from_file(
cls,
filename: str | pathlib.Path,
*,
use_mmap: bool = True,
) -> GdsLibrarySource:
source = _open_source_stream(filename, use_mmap=use_mmap)
source.stream.seek(0)
library_info, cell_order, cells = _scan_library(source.stream)
return cls(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
def __getitem__(self, key: str) -> Pattern:
return self.materialize(key, persist=True)
def __iter__(self) -> Iterator[str]:
return iter(self._cell_order)
def __len__(self) -> int:
return len(self._cell_order)
def __contains__(self, key: object) -> bool:
return key in self._cells
def source_order(self) -> tuple[str, ...]:
return self._cell_order
def can_copy_raw_struct(self, name: str) -> bool:
"""Return whether `name` still matches its original GDS structure."""
return name in self._cells and name not in self._cache
def raw_struct_bytes(self, name: str) -> bytes:
"""Read the original complete GDS structure for `name`."""
cell = self._cells[name]
stream = self._source.stream
stream.seek(cell.struct_start)
data = stream.read(cell.struct_end - cell.struct_start)
if len(data) != cell.struct_end - cell.struct_start:
raise LibraryError(f'Unexpected end of GDS source while copying structure {name!r}')
return data
def _decode_pattern(self, name: str) -> Pattern:
if name not in self._cells:
raise KeyError(name)
if name in self._lookups_in_progress:
chain = ' -> '.join(self._lookups_in_progress + [name])
raise LibraryError(
f'Detected circular reference or recursive lookup of "{name}".\n'
f'Lookup chain: {chain}\n'
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
)
self._lookups_in_progress.append(name)
try:
self._source.stream.seek(self._cells[name].offset)
pat = gdsii_klamath._read_elements(self._source.stream, raw_mode=True)
finally:
self._lookups_in_progress.pop()
return pat
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
if name in self._cache:
return self._cache[name]
pat = self._decode_pattern(name)
if persist:
self._cache[name] = pat
return pat
def materialize_detached(self, name: str) -> Pattern:
if name in self._cache:
return self._cache[name].deepcopy()
return self._decode_pattern(name)
def materialize_many_detached(
self,
names: Sequence[str],
) -> LibraryView:
return LibraryView({
name: self.materialize_detached(name)
for name in dict.fromkeys(names)
})
def _raw_children(self, name: str) -> set[str]:
if name in self._cache:
return super()._raw_children(name)
return set(self._cells[name].children)
def _raw_ref_transforms(
self,
parent: str,
target: str,
) -> list[NDArray[numpy.float64]]:
if parent in self._cache:
return super()._raw_ref_transforms(parent, target)
pat = self.materialize(parent, persist=False)
return [ref.as_transforms() for ref in pat.refs.get(target, ())]
def close(self) -> None:
self._source.close()
def __enter__(self) -> GdsLibrarySource:
return self
def __exit__(self, *_args: object) -> None:
self.close()
def read(
stream: IO[bytes],
) -> tuple[GdsLibrarySource, dict[str, Any]]:
source = _SourceHandle(path=None, stream=stream)
stream.seek(0)
library_info, cell_order, cells = _scan_library(stream)
lib = GdsLibrarySource(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
return lib, library_info
def readfile(
filename: str | pathlib.Path,
*,
use_mmap: bool = True,
) -> tuple[GdsLibrarySource, dict[str, Any]]:
lib = GdsLibrarySource.from_file(filename, use_mmap=use_mmap)
return lib, lib.library_info

View file

@ -0,0 +1,381 @@
"""
Lazy GDSII readers and writers backed by native Arrow scan/materialize paths.
This module is intentionally separate from `gdsii.arrow` so the eager read path
keeps its current behavior and performance profile.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import IO, TYPE_CHECKING, Any
import gzip
import logging
import mmap
import pathlib
import numpy
from . import arrow
from ..utils import is_gzipped
from ...library import (
ILibraryView,
IMaterializable,
LibraryView,
)
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from numpy.typing import NDArray
import pyarrow
from ...pattern import Pattern
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _StructRange:
start: int
end: int
@dataclass
class _SourceBuffer:
path: pathlib.Path
data: bytes | mmap.mmap
handle: IO[bytes] | None = None
def raw_slice(self, start: int, end: int) -> bytes:
return self.data[start:end]
@dataclass
class _ScanRefs:
offsets: NDArray[numpy.integer[Any]]
targets: NDArray[numpy.integer[Any]]
xy: NDArray[numpy.int32]
xy0: NDArray[numpy.int32]
xy1: NDArray[numpy.int32]
counts: NDArray[numpy.int64]
invert_y: NDArray[numpy.bool_ | numpy.bool]
angle_rad: NDArray[numpy.floating[Any]]
scale: NDArray[numpy.floating[Any]]
@dataclass(frozen=True)
class _CellScan:
cell_id: int
struct_range: _StructRange
ref_start: int
ref_stop: int
children: set[str]
@dataclass
class _ScanPayload:
libarr: pyarrow.StructScalar
library_info: dict[str, Any]
cell_names: list[str]
cell_order: list[str]
cells: dict[str, _CellScan]
refs: _ScanRefs
def _open_source_buffer(path: pathlib.Path) -> _SourceBuffer:
if is_gzipped(path):
with gzip.open(path, mode='rb') as stream:
data = stream.read()
return _SourceBuffer(path=path, data=data)
handle = path.open(mode='rb', buffering=0)
mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)
return _SourceBuffer(path=path, data=mapped, handle=handle)
def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload:
library_info = arrow._read_header(libarr)
cell_names = libarr['cell_names'].as_py()
cells = libarr['cells']
cell_values = cells.values
cell_ids = cell_values.field('id').to_numpy()
struct_starts = cell_values.field('struct_start_offset').to_numpy()
struct_ends = cell_values.field('struct_end_offset').to_numpy()
refs = cell_values.field('refs')
ref_values = refs.values
ref_offsets = refs.offsets.to_numpy()
targets = ref_values.field('target').to_numpy()
xy = arrow._packed_xy_u64_to_pairs(ref_values.field('xy').to_numpy())
xy0 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy0').to_numpy())
xy1 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy1').to_numpy())
counts = arrow._packed_counts_u32_to_pairs(ref_values.field('counts').to_numpy())
invert_y = ref_values.field('invert_y').to_numpy(zero_copy_only=False)
angle_rad = ref_values.field('angle_rad').to_numpy()
scale = ref_values.field('scale').to_numpy()
ref_payload = _ScanRefs(
offsets=ref_offsets,
targets=targets,
xy=xy,
xy0=xy0,
xy1=xy1,
counts=counts,
invert_y=invert_y,
angle_rad=angle_rad,
scale=scale,
)
cell_order = [cell_names[int(cell_id)] for cell_id in cell_ids]
cell_scan: dict[str, _CellScan] = {}
for cc, name in enumerate(cell_order):
ref_start = int(ref_offsets[cc])
ref_stop = int(ref_offsets[cc + 1])
children = {
cell_names[int(target)]
for target in targets[ref_start:ref_stop]
}
cell_scan[name] = _CellScan(
cell_id=int(cell_ids[cc]),
struct_range=_StructRange(int(struct_starts[cc]), int(struct_ends[cc])),
ref_start=ref_start,
ref_stop=ref_stop,
children=children,
)
return _ScanPayload(
libarr=libarr,
library_info=library_info,
cell_names=cell_names,
cell_order=cell_order,
cells=cell_scan,
refs=ref_payload,
)
def _make_ref_rows(
xy: NDArray[numpy.integer[Any]],
angle_rad: NDArray[numpy.floating[Any]],
invert_y: NDArray[numpy.bool_ | numpy.bool],
scale: NDArray[numpy.floating[Any]],
) -> NDArray[numpy.float64]:
rows = numpy.empty((len(xy), 5), dtype=float)
rows[:, :2] = xy
rows[:, 2] = angle_rad
rows[:, 3] = invert_y.astype(float)
rows[:, 4] = scale
return rows
def _expand_aref_row(
xy: NDArray[numpy.integer[Any]],
xy0: NDArray[numpy.integer[Any]],
xy1: NDArray[numpy.integer[Any]],
counts: NDArray[numpy.integer[Any]],
angle_rad: float,
invert_y: bool,
scale: float,
) -> NDArray[numpy.float64]:
a_count = int(counts[0])
b_count = int(counts[1])
aa, bb = numpy.meshgrid(numpy.arange(a_count), numpy.arange(b_count), indexing='ij')
displacements = aa.reshape(-1, 1) * xy0[None, :] + bb.reshape(-1, 1) * xy1[None, :]
rows = numpy.empty((displacements.shape[0], 5), dtype=float)
rows[:, :2] = xy + displacements
rows[:, 2] = angle_rad
rows[:, 3] = float(invert_y)
rows[:, 4] = scale
return rows
class ArrowLibrary(ILibraryView, IMaterializable):
"""
Read-only library backed by the native lazy Arrow scan schema.
Materializing a cell via `__getitem__` caches a real `Pattern` for that cell.
Cached cells are treated as edited for future writes from this module.
"""
path: pathlib.Path
library_info: dict[str, Any]
def __init__(
self,
*,
path: pathlib.Path,
payload: _ScanPayload,
source: _SourceBuffer,
) -> None:
self.path = path
self.library_info = payload.library_info
self._payload = payload
self._source = source
self._cache: dict[str, Pattern] = {}
@classmethod
def from_file(cls, filename: str | pathlib.Path) -> ArrowLibrary:
path = pathlib.Path(filename).expanduser().resolve()
source = _open_source_buffer(path)
scan_arr = arrow._scan_buffer_to_arrow(source.data)
assert len(scan_arr) == 1
payload = _extract_scan_payload(scan_arr[0])
return cls(path=path, payload=payload, source=source)
def __getitem__(self, key: str) -> Pattern:
return self.materialize(key, persist=True)
def __iter__(self) -> Iterator[str]:
return iter(self._payload.cell_order)
def __len__(self) -> int:
return len(self._payload.cell_order)
def __contains__(self, key: object) -> bool:
return key in self._payload.cells
def source_order(self) -> tuple[str, ...]:
return tuple(self._payload.cell_order)
def raw_struct_bytes(self, name: str) -> bytes:
struct_range = self._payload.cells[name].struct_range
return self._source.raw_slice(struct_range.start, struct_range.end)
def can_copy_raw_struct(self, name: str) -> bool:
return name not in self._cache
def materialize_many(
self,
names: Sequence[str],
*,
persist: bool = True,
) -> LibraryView:
mats = self._materialize_patterns(names, persist=persist, detached=False)
return LibraryView(mats)
def materialize_many_detached(
self,
names: Sequence[str],
) -> LibraryView:
mats = self._materialize_patterns(names, persist=False, detached=True)
return LibraryView(mats)
def _materialize_patterns(
self,
names: Sequence[str],
*,
persist: bool,
detached: bool,
) -> dict[str, Pattern]:
ordered_names = list(dict.fromkeys(names))
missing = [name for name in ordered_names if name not in self._payload.cells]
if missing:
raise KeyError(missing[0])
materialized: dict[str, Pattern] = {}
uncached = [name for name in ordered_names if name not in self._cache]
if uncached:
ranges = numpy.asarray(
[
[
self._payload.cells[name].struct_range.start,
self._payload.cells[name].struct_range.end,
]
for name in uncached
],
dtype=numpy.uint64,
)
arrow_arr = arrow._read_selected_cells_to_arrow(self._source.data, ranges)
assert len(arrow_arr) == 1
selected_lib, _info = arrow.read_arrow(arrow_arr[0])
for name in uncached:
pat = selected_lib[name]
materialized[name] = pat
if persist:
self._cache[name] = pat
for name in ordered_names:
if name not in materialized:
cached = self._cache[name]
materialized[name] = cached.deepcopy() if detached else cached
return materialized
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
return self._materialize_patterns((name,), persist=persist, detached=False)[name]
def materialize_detached(self, name: str) -> Pattern:
return self._materialize_patterns((name,), persist=False, detached=True)[name]
def _raw_children(self, name: str) -> set[str]:
if name in self._cache:
return super()._raw_children(name)
return set(self._payload.cells[name].children)
def _collect_raw_transforms(self, cell: _CellScan, target_id: int) -> list[NDArray[numpy.float64]]:
refs = self._payload.refs
start = cell.ref_start
stop = cell.ref_stop
if stop <= start:
return []
targets = refs.targets[start:stop]
mask = targets == target_id
if not mask.any():
return []
rows: list[NDArray[numpy.float64]] = []
counts = refs.counts[start:stop]
unit_mask = mask & (counts[:, 0] == 1) & (counts[:, 1] == 1)
if unit_mask.any():
rows.append(_make_ref_rows(
refs.xy[start:stop][unit_mask],
refs.angle_rad[start:stop][unit_mask],
refs.invert_y[start:stop][unit_mask],
refs.scale[start:stop][unit_mask],
))
aref_indices = numpy.nonzero(mask & ~unit_mask)[0]
for idx in aref_indices:
abs_idx = start + int(idx)
rows.append(_expand_aref_row(
xy=refs.xy[abs_idx],
xy0=refs.xy0[abs_idx],
xy1=refs.xy1[abs_idx],
counts=refs.counts[abs_idx],
angle_rad=float(refs.angle_rad[abs_idx]),
invert_y=bool(refs.invert_y[abs_idx]),
scale=float(refs.scale[abs_idx]),
))
return rows
def close(self) -> None:
data = self._source.data
if isinstance(data, mmap.mmap):
data.close()
if self._source.handle is not None:
self._source.handle.close()
self._source.handle = None
def __enter__(self) -> ArrowLibrary:
return self
def __exit__(self, *_args: object) -> None:
self.close()
def _raw_ref_transforms(
self,
parent: str,
target: str,
) -> list[NDArray[numpy.float64]]:
if parent in self._cache:
return super()._raw_ref_transforms(parent, target)
target_cell = self._payload.cells.get(target)
if target_cell is None or parent not in self._payload.cells:
return []
return self._collect_raw_transforms(self._payload.cells[parent], target_cell.cell_id)
def readfile(
filename: str | pathlib.Path,
) -> tuple[ArrowLibrary, dict[str, Any]]:
lib = ArrowLibrary.from_file(filename)
return lib, lib.library_info

174
masque/file/gdsii/writer.py Normal file
View file

@ -0,0 +1,174 @@
"""
GDSII writer for eager and source-backed libraries.
The generic mutable overlay and ports-importing view live in `masque.library`.
This module preserves source-backed GDS copy-through behavior where possible,
falling back to normal pattern serialization when a cell has been materialized
or remapped.
"""
from __future__ import annotations
from typing import IO, TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
import gzip
import logging
import pathlib
from . import klamath
from ..utils import tmpfile
from ...error import LibraryError
from ...library import IBorrowing, ILibraryView, IMaterializable
if TYPE_CHECKING:
from collections.abc import Mapping
from ...pattern import Pattern
logger = logging.getLogger(__name__)
@runtime_checkable
class _GdsInfoSource(Protocol):
"""Structural capability for propagating GDS header metadata."""
library_info: dict[str, Any]
@runtime_checkable
class _GdsRawCellSource(Protocol):
"""GDS-specific raw-structure copy-through capability."""
def can_copy_raw_struct(self, name: str) -> bool: ...
def raw_struct_bytes(self, name: str) -> bytes: ...
def _resolve_raw_struct(
library: ILibraryView,
name: str,
) -> tuple[_GdsRawCellSource, str] | None:
"""Resolve an unchanged visible cell to a copyable raw GDS structure."""
current = library
current_name = name
seen: set[tuple[int, str]] = set()
while True:
key = (id(current), current_name)
if key in seen:
return None
seen.add(key)
if isinstance(current, _GdsRawCellSource):
if current.can_copy_raw_struct(current_name):
return current, current_name
return None
if not isinstance(current, IBorrowing):
return None
source_cell = current.source_cell(current_name)
if source_cell is None:
return None
source, source_name = source_cell
if source_name != current_name:
return None
current = source
current_name = source_name
def _get_write_info(
library: Mapping[str, Pattern] | ILibraryView,
*,
meters_per_unit: float | None,
logical_units_per_unit: float | None,
library_name: str | None,
) -> tuple[float, float, str]:
if meters_per_unit is not None and logical_units_per_unit is not None and library_name is not None:
return meters_per_unit, logical_units_per_unit, library_name
infos: list[dict[str, Any]] = []
stack: list[Mapping[str, Pattern] | ILibraryView] = [library]
seen: set[int] = set()
while stack:
current = stack.pop()
if id(current) in seen:
continue
seen.add(id(current))
if isinstance(current, _GdsInfoSource) and isinstance(current.library_info, dict):
infos.append(current.library_info)
if isinstance(current, IBorrowing):
stack.extend(reversed(current.borrowed_sources()))
if infos:
unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos}
if len(unit_pairs) > 1:
raise LibraryError('Merged lazy GDS sources must have identical units before writing')
info = infos[0]
meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit
logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit
name = info['name'] if library_name is None else library_name
return meters, logical, name
if meters_per_unit is None:
raise LibraryError('meters_per_unit is required when writing a library without GDS metadata')
logical = 1 if logical_units_per_unit is None else logical_units_per_unit
name = 'masque-klamath' if library_name is None else library_name
return meters_per_unit, logical, name
def write(
library: Mapping[str, Pattern] | ILibraryView,
stream: IO[bytes],
meters_per_unit: float | None = None,
logical_units_per_unit: float | None = None,
library_name: str | None = None,
) -> None:
"""Write an eager or source-backed library to a GDSII stream."""
meters_per_unit, logical_units_per_unit, library_name = _get_write_info(
library,
meters_per_unit=meters_per_unit,
logical_units_per_unit=logical_units_per_unit,
library_name=library_name,
)
klamath._write_header(stream, meters_per_unit, logical_units_per_unit, library_name)
names = library.source_order() if isinstance(library, ILibraryView) else tuple(library)
for name in names:
if isinstance(library, ILibraryView):
raw_struct = _resolve_raw_struct(library, name)
if raw_struct is not None:
raw_source, source_name = raw_struct
stream.write(raw_source.raw_struct_bytes(source_name))
continue
if isinstance(library, IMaterializable):
pat = library.materialize(name, persist=False)
else:
pat = library[name]
klamath._write_pattern_struct(stream, name, pat)
klamath._write_footer(stream)
def writefile(
library: Mapping[str, Pattern] | ILibraryView,
filename: str | pathlib.Path,
meters_per_unit: float | None = None,
logical_units_per_unit: float | None = None,
library_name: str | None = None,
) -> None:
"""Write an eager or source-backed library to a path, compressing `.gz` files."""
path = pathlib.Path(filename)
with tmpfile(path) as base_stream:
if path.suffix == '.gz':
stream = cast('IO[bytes]', gzip.GzipFile(
filename='',
mtime=0,
fileobj=base_stream,
mode='wb',
compresslevel=6,
))
try:
write(library, stream, meters_per_unit, logical_units_per_unit, library_name)
finally:
stream.close()
else:
write(library, base_stream, meters_per_unit, logical_units_per_unit, library_name)

View file

@ -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):
@ -562,7 +562,9 @@ def _shapes_to_elements(
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(
@ -622,10 +624,12 @@ def repetition_fata2masq(
) -> Repetition | None:
mrep: Repetition | None
if isinstance(rep, fatamorgana.GridRepetition):
mrep = Grid(a_vector=rep.a_vector,
mrep = Grid(
a_vector = rep.a_vector,
b_vector = rep.b_vector,
a_count = rep.a_count,
b_count=rep.b_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

@ -9,26 +9,64 @@ from numpy.typing import ArrayLike
import svgwrite # type: ignore
from .utils import mangle_name
from .. import Pattern
from .. import Pattern, Ref
from ..library import IMaterializable
from ..utils import rotation_matrix_2d
logger = logging.getLogger(__name__)
def _ref_to_svg_transform(ref: 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 _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]:
if isinstance(library, IMaterializable):
detached = library.materialize_many_detached(tuple(library))
return dict(detached.items())
return {name: pat.deepcopy() for name, pat in library.items()}
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
Write a Pattern to an SVG file, by first calling .polygonize() on a detached
materialized copy
to change the shapes into polygons, and then writing patterns as SVG
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
as <use> elements.
Note that this function modifies the Pattern.
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
is written to the relevant elements.
@ -40,17 +78,21 @@ def writefile(
prior to calling this function.
Args:
pattern: Pattern to write to file. Modified by this function.
library: Mapping of pattern names to patterns.
top: Name of the top-level pattern to render.
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]
detached = _detached_library(library)
pattern = detached[top]
# Polygonize pattern
pattern.polygonize()
bounds = pattern.get_bounds(library=library)
bounds = pattern.get_bounds(library=detached)
if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
@ -63,10 +105,11 @@ def writefile(
# Create file
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
debug=(not custom_attributes))
svg_ids = _make_svg_ids(detached)
# 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')
for name, pat in detached.items():
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 +122,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()
@ -103,21 +167,21 @@ def writefile_inverted(
box and drawing the polygons with reverse vertex order inside it, all within
one `<path>` element.
Note that this function modifies the Pattern.
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
prior to calling this function.
Args:
pattern: Pattern to write to file. Modified by this function.
library: Mapping of pattern names to patterns.
top: Name of the top-level pattern to render.
filename: Filename to write to.
"""
pattern = library[top]
detached = _detached_library(library)
pattern = detached[top]
# Polygonize and flatten pattern
pattern.polygonize().flatten(library)
pattern.polygonize().flatten(detached)
bounds = pattern.get_bounds(library=library)
bounds = pattern.get_bounds(library=detached)
if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)

View file

@ -14,12 +14,188 @@ from pprint import pformat
from itertools import chain
from .. import Pattern, PatternError, Library, LibraryError
from ..library import (
IBorrowing, ILibraryView, OverlayLibrary, SINGLE_USE_PREFIX,
dangling_mode_t,
)
from ..shapes import Polygon, Path
logger = logging.getLogger(__name__)
def _has_source_provenance(library: ILibraryView, name: str) -> bool:
"""Return whether `name` has an uninterrupted borrowing provenance chain."""
current = library
current_name = name
seen: set[tuple[int, str]] = set()
followed_source = False
while isinstance(current, IBorrowing):
key = (id(current), current_name)
if key in seen:
return False
seen.add(key)
source_cell = current.source_cell(current_name)
if source_cell is None:
return False
followed_source = True
current, current_name = source_cell
return followed_source
def _prune_checked_empty(
library: OverlayLibrary,
checked_names: set[str],
*,
dangling: dangling_mode_t,
) -> set[str]:
"""Prune checked empty cells without modifying source-backed parents."""
parent_graph = library.parent_graph(dangling=dangling)
source_backed = set(library) - checked_names
def safely_empty(name: str) -> bool:
return (
name in library
and name in checked_names
and not (parent_graph.get(name, set()) & source_backed)
and library[name].is_empty()
)
empty = {name for name in checked_names if safely_empty(name)}
pruned: set[str] = set()
while empty:
parents: set[str] = set()
for name in empty:
name_parents = parent_graph.get(name, set())
del library[name]
checked_names.discard(name)
for parent in name_parents & checked_names:
if parent in library and name in library[parent].refs:
del library[parent].refs[name]
parents |= name_parents
pruned |= empty
empty = {parent for parent in parents if safely_empty(parent)}
return pruned
def _wrap_checked_repeated_shapes(
library: OverlayLibrary,
checked_names: set[str],
) -> None:
"""Wrap repetitions in checked cells while leaving source-backed cells untouched."""
for pattern_name in tuple(checked_names):
if pattern_name not in library:
continue
pattern = library[pattern_name]
for layer in pattern.shapes:
new_shapes = []
for shape in pattern.shapes[layer]:
if shape.repetition is None:
new_shapes.append(shape)
continue
name = library.get_name(SINGLE_USE_PREFIX + 'rep')
library[name] = Pattern(shapes={layer: [shape]})
checked_names.add(name)
pattern.ref(name, repetition=shape.repetition)
shape.repetition = None
pattern.shapes[layer] = new_shapes
for layer in pattern.labels:
new_labels = []
for label in pattern.labels[layer]:
if label.repetition is None:
new_labels.append(label)
continue
name = library.get_name(SINGLE_USE_PREFIX + 'rep')
library[name] = Pattern(labels={layer: [label]})
checked_names.add(name)
pattern.ref(name, repetition=label.repetition)
label.repetition = None
pattern.labels[layer] = new_labels
def preflight_source_aware(
lib: ILibraryView,
sort: bool = True,
sort_elements: bool = False,
allow_dangling_refs: bool | None = None,
allow_named_layers: bool = True,
prune_empty_patterns: bool = False,
wrap_repeated_shapes: bool = False,
) -> OverlayLibrary:
"""
Preflight cells without reusable source provenance.
Returns a borrowing `OverlayLibrary`. Cells with uninterrupted
`IBorrowing.source_cell()` provenance remain source-backed and receive no
per-pattern checks. Other cells are detached into the overlay and checked.
Keep `lib` and its borrowed sources open for the result's lifetime.
Args:
sort: Whether to sort checked pattern contents. Library name order is
retained because sorting source-backed cells would require loading them.
sort_elements: Whether to sort elements within checked patterns.
allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not
in the provided library. If `True`, no check is performed; if `False`, a `LibraryError`
is raised instead.
allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by
a string in a checked pattern instead of a number (or tuple).
prune_empty_patterns: Recursively delete checked empty patterns when
doing so does not require modifying a source-backed parent.
wrap_repeated_shapes: Turn repeated shapes in checked patterns into
repeated refs containing non-repeated shapes.
Returns:
A borrowing overlay containing checked patterns and source-backed cells.
"""
checked_names = {
name
for name in lib
if not _has_source_provenance(lib, name)
}
overlay = OverlayLibrary()
overlay.add_source(lib)
if sort:
for name in sorted(checked_names):
overlay[name].sort(sort_elements=sort_elements)
if not allow_dangling_refs:
refs = overlay.referenced_patterns()
dangling = refs - set(overlay.keys())
if dangling:
msg = 'Dangling refs found: ' + pformat(dangling)
if allow_dangling_refs is None:
logger.warning(msg)
else:
raise LibraryError(msg)
if not allow_named_layers:
checked_named_layers: Mapping[str, set] = defaultdict(set)
for name in checked_names:
pattern = overlay[name]
for layer in chain(pattern.shapes.keys(), pattern.labels.keys()):
if isinstance(layer, str):
checked_named_layers[name].add(layer)
checked_named_layers = dict(checked_named_layers)
if checked_named_layers:
raise PatternError('Non-numeric layers found:' + pformat(checked_named_layers))
if prune_empty_patterns:
prune_dangling: dangling_mode_t = 'error' if allow_dangling_refs is False else 'ignore'
pruned = _prune_checked_empty(overlay, checked_names, dangling=prune_dangling)
if pruned:
logger.info(f'Preflight pruned {len(pruned)} checked empty patterns')
logger.debug('Pruned: ' + pformat(pruned))
else:
logger.debug('Preflight found no safely prunable checked patterns')
if wrap_repeated_shapes:
_wrap_checked_repeated_shapes(overlay, checked_names)
return overlay
def preflight(
lib: Library,
sort: bool = True,
@ -30,33 +206,35 @@ def preflight(
wrap_repeated_shapes: bool = False,
) -> Library:
"""
Run a standard set of useful operations and checks, usually done immediately prior
to writing to a file (or immediately after reading).
Run a standard set of useful operations and checks on an entire library.
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. Later mutating steps may still mutate caller-owned patterns. Deep-copy
the library first when isolation is required.
Args:
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
Default True. Useful for reproducible builds.
sort_elements: Whether to sort the pattern contents. Requires sort=True to run.
allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not
in the provided library. If `True`, no check is performed; if `False`, a `LibraryError`
is raised instead.
allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by
a string instead of a number (or tuple).
prune_empty_patterns: Runs `Library.prune_empty()`, recursively deleting any empty patterns.
wrap_repeated_shapes: Runs `Library.wrap_repeated_shapes()`, turning repeated shapes into
repeated refs containing non-repeated shapes.
sort: Whether to sort patterns by name and sort each pattern's contents.
sort_elements: Whether to sort elements within each pattern. Requires
`sort=True`.
allow_dangling_refs: If `None`, warn about missing targets. If `True`,
skip the check. If `False`, raise `LibraryError`.
allow_named_layers: If `False`, raise `PatternError` for string layers.
prune_empty_patterns: Recursively delete empty patterns.
wrap_repeated_shapes: Move shape and label repetitions onto wrapping refs.
Returns:
`lib` or an equivalent sorted library
`lib`, or an equivalent name-sorted `Library` when `sort=True`.
"""
mutable_lib = lib
if sort:
lib = Library(dict(sorted(
(nn, pp.sort(sort_elements=sort_elements)) for nn, pp in lib.items()
mutable_lib = Library(dict(sorted(
(nn, pp.sort(sort_elements=sort_elements)) for nn, pp in mutable_lib.items()
)))
if not allow_dangling_refs:
refs = lib.referenced_patterns()
dangling = refs - set(lib.keys())
refs = mutable_lib.referenced_patterns()
dangling = refs - set(mutable_lib.keys())
if dangling:
msg = 'Dangling refs found: ' + pformat(dangling)
if allow_dangling_refs is None:
@ -66,7 +244,7 @@ def preflight(
if not allow_named_layers:
named_layers: Mapping[str, set] = defaultdict(set)
for name, pat in lib.items():
for name, pat in mutable_lib.items():
for layer in chain(pat.shapes.keys(), pat.labels.keys()):
if isinstance(layer, str):
named_layers[name].add(layer)
@ -75,7 +253,8 @@ def preflight(
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
if prune_empty_patterns:
pruned = lib.prune_empty()
prune_dangling: dangling_mode_t = 'error' if allow_dangling_refs is False else 'ignore'
pruned = mutable_lib.prune_empty(dangling=prune_dangling)
if pruned:
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
logger.debug('Pruned: ' + pformat(pruned))
@ -83,9 +262,9 @@ def preflight(
logger.debug('Preflight found no empty patterns')
if wrap_repeated_shapes:
lib.wrap_repeated_shapes()
mutable_lib.wrap_repeated_shapes()
return lib
return mutable_lib
def mangle_name(name: str) -> str:
@ -144,7 +323,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:
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)
"""
@ -53,17 +53,36 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
@classmethod
def _from_raw(
cls,
string: str,
*,
offset: NDArray[numpy.float64],
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
) -> Self:
new = cls.__new__(cls)
new._string = string
new._offset = offset
new._repetition = repetition
new._annotations = annotations
return new
def __copy__(self) -> Self:
return type(self)(
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 +95,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 +117,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

@ -0,0 +1,35 @@
"""Library classes for managing name-to-pattern mappings."""
from .utils import (
INameView as INameView,
SINGLE_USE_PREFIX as SINGLE_USE_PREFIX,
Tree as Tree,
TreeView as TreeView,
b64suffix as b64suffix,
dangling_mode_t as dangling_mode_t,
visitor_function_t as visitor_function_t,
)
from .base import (
AbstractView as AbstractView,
ILibrary as ILibrary,
ILibraryView as ILibraryView,
)
from .capabilities import (
IBorrowing as IBorrowing,
IMaterializable as IMaterializable,
)
from .mapping import (
Library as Library,
LibraryView as LibraryView,
)
from .overlay import (
OverlayLibrary as OverlayLibrary,
PortLoadView as PortLoadView,
LayerMappedView as LayerMappedView,
)
from .build import (
LibraryBuilder as LibraryBuilder,
BuildReport as BuildReport,
CellProvenance as CellProvenance,
cell as cell,
)
from .lazy import LazyLibrary as LazyLibrary

File diff suppressed because it is too large Load diff

1052
masque/library/build.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
"""Optional capabilities implemented by lazy and borrowing libraries."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from ..pattern import Pattern
from .base import ILibraryView
from .mapping import LibraryView
class IMaterializable(ABC):
"""Capability for libraries which support explicit pattern materialization."""
@abstractmethod
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
"""Materialize one pattern, optionally retaining it in the library's cache."""
def materialize_many(
self,
names: Sequence[str],
*,
persist: bool = True,
) -> LibraryView:
"""Materialize a de-duplicated sequence into a plain read-only view."""
from .mapping import LibraryView # noqa: PLC0415
return LibraryView({
name: self.materialize(name, persist=persist)
for name in dict.fromkeys(names)
})
def materialize_detached(self, name: str) -> Pattern:
"""Materialize a caller-owned pattern which is safe to mutate."""
return self.materialize(name, persist=False).deepcopy()
def materialize_many_detached(
self,
names: Sequence[str],
) -> LibraryView:
"""Materialize caller-owned patterns without retaining them in this library."""
from .mapping import LibraryView # noqa: PLC0415
materialized = self.materialize_many(names, persist=False)
return LibraryView({
name: materialized[name].deepcopy()
for name in dict.fromkeys(names)
})
class IBorrowing(ABC):
"""Capability for library views which directly borrow other libraries."""
@abstractmethod
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
"""Return the source views directly borrowed by this library."""
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: # noqa: ARG002
"""
Return a direct source cell with unchanged layout data, if available.
The source may use a different name, which is returned alongside it.
Port metadata may differ because ports are not layout-file content.
The result is not recursively resolved: consumers must follow further
borrowing views themselves and enforce format-specific constraints such
as whether the visible and source names must match. `None` means the
source cannot be reused safely or no source provenance is available.
"""
return None

183
masque/library/lazy.py Normal file
View file

@ -0,0 +1,183 @@
"""Closure-backed lazy library implementation."""
from __future__ import annotations
from pprint import pformat
from typing import TYPE_CHECKING, Self, cast
import logging
from ..error import LibraryError
from .base import ILibrary
from .capabilities import IMaterializable
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Mapping, Sequence
from ..pattern import Pattern
logger = logging.getLogger(__name__)
class LazyLibrary(ILibrary, IMaterializable):
"""
This class is usually used to create a library of Patterns by mapping names to
functions which generate or load the relevant `Pattern` object as-needed.
TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid?
"""
mapping: dict[str, Callable[[], Pattern]]
cache: dict[str, Pattern]
_lookups_in_progress: list[str]
def __init__(self) -> None:
self.mapping = {}
self.cache = {}
self._lookups_in_progress = []
def __setitem__(
self,
key: str,
value: Pattern | Callable[[], Pattern],
) -> None:
if key in self.mapping:
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
if callable(value):
value_func = value
else:
value_func = lambda: cast('Pattern', value) # noqa: E731
self.mapping[key] = value_func
if key in self.cache:
del self.cache[key]
def __delitem__(self, key: str) -> None:
del self.mapping[key]
if key in self.cache:
del self.cache[key]
def __getitem__(self, key: str) -> Pattern:
return self.materialize(key, persist=True)
def materialize(self, key: str, *, persist: bool = True) -> Pattern:
logger.debug(f'loading {key}')
if key in self.cache:
logger.debug(f'found {key} in cache')
return self.cache[key]
if key in self._lookups_in_progress:
chain = ' -> '.join(self._lookups_in_progress + [key])
raise LibraryError(
f'Detected circular reference or recursive lookup of "{key}".\n'
f'Lookup chain: {chain}\n'
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
)
self._lookups_in_progress.append(key)
try:
func = self.mapping[key]
pat = func()
finally:
self._lookups_in_progress.pop()
if persist:
self.cache[key] = pat
return pat
def __iter__(self) -> Iterator[str]:
return iter(self.mapping)
def __len__(self) -> int:
return len(self.mapping)
def __contains__(self, key: object) -> bool:
return key in self.mapping
def referenced_patterns(
self,
tops: str | Sequence[str] | None = None,
skip: set[str] | None = None,
) -> set[str]:
# Closure-backed cells do not have hierarchy metadata. Preserve laziness
# by loading only patterns reached from the requested roots.
return self._referenced_patterns_by_lookup(tops=tops, skip=skip)
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
if isinstance(other, LazyLibrary):
self.mapping[key_self] = other.mapping[key_other]
if key_other in other.cache:
self.cache[key_self] = other.cache[key_other]
else:
self[key_self] = other[key_other]
def __repr__(self) -> str:
return '<LazyLibrary with keys\n' + pformat(list(self.keys())) + '>'
def rename(
self,
old_name: str,
new_name: str,
move_references: bool = False,
) -> Self:
"""
Rename a pattern.
Args:
old_name: Current name for the pattern
new_name: New name for the pattern
move_references: Whether to scan all refs in the pattern and
move them to point to `new_name` as necessary.
Default `False`.
Returns:
self
"""
if old_name not in self.mapping:
raise LibraryError(f'"{old_name}" does not exist in the library.')
if old_name == new_name:
return self
self[new_name] = self.mapping[old_name] # copy over function
if old_name in self.cache:
self.cache[new_name] = self.cache[old_name]
del self[old_name]
if move_references:
self.move_references(old_name, new_name)
return self
def move_references(self, old_target: str, new_target: str) -> Self:
"""
Change all references pointing at `old_target` into references pointing at `new_target`.
Args:
old_target: Current reference target
new_target: New target for the reference
Returns:
self
"""
if old_target == new_target:
return self
self.precache()
for pattern in self.cache.values():
if old_target in pattern.refs:
pattern.refs[new_target].extend(pattern.refs[old_target])
del pattern.refs[old_target]
return self
def precache(self) -> Self:
"""
Force all patterns into the cache
Returns:
self
"""
for key in self.mapping:
_ = self[key] # want to trigger our own __getitem__
return self
def __deepcopy__(self, memo: dict | None = None) -> LazyLibrary:
raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)')

225
masque/library/mapping.py Normal file
View file

@ -0,0 +1,225 @@
"""Concrete mapping-backed library implementations."""
from __future__ import annotations
from pprint import pformat
from typing import TYPE_CHECKING, Self
from ..error import LibraryError
from .base import ILibrary, ILibraryView
from .capabilities import IBorrowing, IMaterializable
from .utils import dangling_mode_t, _validate_dangling_mode
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
import numpy
from numpy.typing import NDArray
from ..pattern import Pattern
class LibraryView(ILibraryView):
"""
Default implementation for a read-only library.
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
This library is backed by an arbitrary python object which implements the `Mapping` interface.
"""
mapping: Mapping[str, Pattern]
def __init__(
self,
mapping: Mapping[str, Pattern],
) -> None:
self.mapping = mapping
def __getitem__(self, key: str) -> Pattern:
return self.mapping[key]
def __iter__(self) -> Iterator[str]:
return iter(self.mapping)
def __len__(self) -> int:
return len(self.mapping)
def __contains__(self, key: object) -> bool:
return key in self.mapping
def __repr__(self) -> str:
return f'<LibraryView ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
"""Borrowed subtree view with snapshotted membership and hierarchy."""
def __init__(
self,
source: ILibraryView,
*,
names: set[str],
child_graph: Mapping[str, set[str]],
) -> None:
self._source = source
source_order = source.source_order()
ordered = list(dict.fromkeys(name for name in source_order if name in names))
seen = set(ordered)
ordered.extend(name for name in source if name in names and name not in seen)
self._order = tuple(ordered)
self._names = frozenset(self._order)
self._child_graph = {
name: set(child_graph.get(name, set()))
for name in self._order
}
def __getitem__(self, key: str) -> Pattern:
if key not in self._names:
raise KeyError(key)
return self._source[key]
def __iter__(self) -> Iterator[str]:
return iter(self._order)
def __len__(self) -> int:
return len(self._order)
def __contains__(self, key: object) -> bool:
return key in self._names
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
return (self._source,)
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
if name not in self._names:
return None
return self._source, name
def source_order(self) -> tuple[str, ...]:
return self._order
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
if name not in self._names:
raise KeyError(name)
if isinstance(self._source, IMaterializable):
return self._source.materialize(name, persist=persist)
return self._source[name]
def materialize_detached(self, name: str) -> Pattern:
if name not in self._names:
raise KeyError(name)
if isinstance(self._source, IMaterializable):
return self._source.materialize_detached(name)
return self._source[name].deepcopy()
def materialize_many_detached(
self,
names: Sequence[str],
) -> LibraryView:
ordered_names = tuple(dict.fromkeys(names))
missing = next((name for name in ordered_names if name not in self._names), None)
if missing is not None:
raise KeyError(missing)
if isinstance(self._source, IMaterializable):
return self._source.materialize_many_detached(ordered_names)
return LibraryView({name: self._source[name].deepcopy() for name in ordered_names})
def child_graph(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
_validate_dangling_mode(dangling)
graph = {name: set(children) for name, children in self._child_graph.items()}
existing = set(graph)
dangling_refs = set().union(*(children - existing for children in graph.values())) if graph else set()
if dangling == 'error':
if dangling_refs:
raise self._dangling_refs_error(dangling_refs, 'building child graph')
return graph
if dangling == 'ignore':
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 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]]]:
_validate_dangling_mode(dangling)
if parent_graph is None:
graph_mode: dangling_mode_t = 'ignore' if dangling == 'ignore' else 'include'
parent_graph = self.parent_graph(dangling=graph_mode)
refs = self._source.find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
return {parent: transforms for parent, transforms in refs.items() if parent in self._names}
class Library(ILibrary):
"""
Default implementation for a writeable library.
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
This library is backed by an arbitrary python object which implements the `MutableMapping` interface.
"""
mapping: MutableMapping[str, Pattern]
def __init__(
self,
mapping: MutableMapping[str, Pattern] | None = None,
) -> None:
if mapping is None:
self.mapping = {}
else:
self.mapping = mapping
def __getitem__(self, key: str) -> Pattern:
return self.mapping[key]
def __iter__(self) -> Iterator[str]:
return iter(self.mapping)
def __len__(self) -> int:
return len(self.mapping)
def __contains__(self, key: object) -> bool:
return key in self.mapping
def __setitem__(
self,
key: str,
value: Pattern | Callable[[], Pattern],
) -> None:
if key in self.mapping:
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
value = value() if callable(value) else value
self.mapping[key] = value
def __delitem__(self, key: str) -> None:
del self.mapping[key]
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
self[key_self] = other[key_other]
def __repr__(self) -> str:
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
@classmethod
def mktree(cls: type[Self], name: str) -> tuple[Self, Pattern]:
"""
Create a new Library and immediately add a pattern
Args:
name: The name for the new pattern (usually the name of the topcell).
Returns:
The newly created `Library` and the newly created `Pattern`
"""
from ..pattern import Pattern # noqa: PLC0415
tree = cls()
pat = Pattern()
tree[name] = pat
return tree, pat

574
masque/library/overlay.py Normal file
View file

@ -0,0 +1,574 @@
"""Overlay and lazily processed library views."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Self, cast
import copy
from ..error import LibraryError
from ..pattern import Pattern, map_layers, map_targets
from .base import ILibrary, ILibraryView
from .capabilities import IBorrowing, IMaterializable
from .utils import (
INameView,
dangling_mode_t,
_plan_source_names,
_rename_patterns,
_source_rename_map,
_validate_dangling_mode,
)
from .mapping import LibraryView
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Mapping, Sequence
import numpy
from numpy.typing import NDArray
from ..ports import Port
from ..utils import layer_t
@dataclass
class _SourceLayer:
""" One imported source layer tracked by an `OverlayLibrary`. """
library: ILibraryView
source_target_map: dict[str, str]
child_graph: dict[str, set[str]]
@dataclass(frozen=True)
class _SourceEntry:
""" Reference to a single visible source-backed cell in an overlay. """
layer_index: int
source_name: str
def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern:
if isinstance(view, IMaterializable):
return view.materialize_detached(name)
return view[name].deepcopy()
class _ProcessedLibraryView(ILibraryView, IMaterializable, IBorrowing):
"""Shared detached-materialization behavior for read-only processing views."""
def __init__(
self,
source: ILibraryView,
*,
copy_through: bool,
) -> None:
self._source = source
self._copy_through = copy_through
self._cache: dict[str, Pattern] = {}
self._lookups_in_progress: list[str] = []
def __getitem__(self, key: str) -> Pattern:
return self.materialize(key, persist=True)
def __iter__(self) -> Iterator[str]:
return iter(self._source)
def __len__(self) -> int:
return len(self._source)
def __contains__(self, key: object) -> bool:
return key in self._source
def _process_pattern(self, name: str, pattern: Pattern) -> Pattern:
"""Apply this view's processing to one detached source pattern."""
raise NotImplementedError
def _materialize_uncached_detached(self, name: str) -> Pattern:
if name in self._lookups_in_progress:
chain = ' -> '.join(self._lookups_in_progress + [name])
raise LibraryError(
f'Detected circular reference or recursive lookup of "{name}".\n'
f'Lookup chain: {chain}\n'
'This may be caused by an invalid (cyclical) reference, or buggy code.'
)
self._lookups_in_progress.append(name)
try:
pattern = _materialize_detached_pattern(self._source, name)
pattern = self._process_pattern(name, pattern)
finally:
self._lookups_in_progress.pop()
return pattern
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
if name in self._cache:
return self._cache[name]
pattern = self._materialize_uncached_detached(name)
if persist:
self._cache[name] = pattern
return pattern
def materialize_detached(self, name: str) -> Pattern:
if name in self._cache:
return self._cache[name].deepcopy()
return self._materialize_uncached_detached(name)
def materialize_many_detached(
self,
names: Sequence[str],
) -> LibraryView:
ordered_names = tuple(dict.fromkeys(names))
result: dict[str, Pattern] = {}
uncached = [name for name in ordered_names if name not in self._cache]
for name in ordered_names:
if name in self._cache:
result[name] = self._cache[name].deepcopy()
if uncached:
if isinstance(self._source, IMaterializable):
source_patterns = self._source.materialize_many_detached(uncached)
else:
source_patterns = LibraryView({name: self._source[name].deepcopy() for name in uncached})
for name in uncached:
if name in self._lookups_in_progress:
chain = ' -> '.join(self._lookups_in_progress + [name])
raise LibraryError(f'Detected circular reference or recursive lookup of "{name}".\nLookup chain: {chain}')
self._lookups_in_progress.append(name)
try:
result[name] = self._process_pattern(name, source_patterns[name])
finally:
self._lookups_in_progress.pop()
return LibraryView({name: result[name] for name in ordered_names})
def source_order(self) -> tuple[str, ...]:
return self._source.source_order()
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
return (self._source,)
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
if not self._copy_through or name not in self._source or name in self._cache:
return None
return self._source, name
def child_graph(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
_validate_dangling_mode(dangling)
return self._source.child_graph(dangling=dangling)
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]]]:
_validate_dangling_mode(dangling)
finder = getattr(self._source, 'find_refs_local', None)
if callable(finder):
return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling))
return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
class PortLoadView(_ProcessedLibraryView):
"""
Read-only view which loads or applies ports on first materialization.
The wrapped source remains untouched; this view owns a separate processed
cache so direct-copy workflows can continue to use the raw source view.
The view borrows its source: callers must keep the source open for the
lifetime of the view and close the source themselves.
Graph queries and source ordering are delegated to the wrapped source,
while `source_cell()` exposes unchanged layout provenance and `__getitem__`
and `materialize_many()` return port-imported patterns.
"""
def __init__(
self,
source: ILibraryView,
*,
layers: Sequence[layer_t] = (),
max_depth: int = 0,
skip_subcells: bool = True,
ports: Mapping[str, Mapping[str, Port]] | None = None,
replace: bool = False,
) -> None:
super().__init__(source, copy_through=True)
self._layers = tuple(layers)
self._max_depth = max_depth
self._skip_subcells = skip_subcells
self._ports = {
name: copy.deepcopy(dict(cell_ports))
for name, cell_ports in (ports or {}).items()
}
self._replace = replace
def _process_pattern(self, name: str, pattern: Pattern) -> Pattern:
from ..utils.ports2data import data_to_ports # noqa: PLC0415
if self._layers:
pattern = data_to_ports(
layers=self._layers,
library=self,
pattern=pattern,
name=name,
max_depth=self._max_depth,
skip_subcells=self._skip_subcells,
)
if name in self._ports:
ports = copy.deepcopy(self._ports[name])
if self._replace:
pattern.ports = ports
else:
pattern.ports.update(ports)
return pattern
class LayerMappedView(_ProcessedLibraryView):
"""
Read-only view which remaps shape and label layers on materialization.
The wrapped source remains untouched. By default, source-aware writers
must materialize and serialize every mapped cell. With `copy_through=True`,
unmaterialized cells may instead be copied unchanged from their source;
persistent access maps and caches a cell, disabling copy-through for it.
"""
def __init__(
self,
source: ILibraryView,
map_layer: Callable[[layer_t], layer_t],
*,
copy_through: bool = False,
) -> None:
super().__init__(source, copy_through=copy_through)
self._map_layer = map_layer
def _process_pattern(self, name: str, pattern: Pattern) -> Pattern:
_ = name
pattern.shapes = map_layers(pattern.shapes, self._map_layer)
pattern.labels = map_layers(pattern.labels, self._map_layer)
return pattern
class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
"""
Mutable overlay over one or more source libraries.
Source-backed cells remain lazy until accessed through `__getitem__`, which
persistently materializes a detached, overlay-owned `Pattern`.
Source libraries must remain open and must not be mutated after they are
added. The overlay borrows each source and snapshots its names, hierarchy,
and initial visible-name mapping while retaining the source itself for lazy
pattern materialization.
"""
def __init__(self) -> None:
self._layers: list[_SourceLayer] = []
self._entries: dict[str, Pattern | _SourceEntry] = {}
self._order: list[str] = []
self._target_remap: dict[str, str] = {}
def __iter__(self) -> Iterator[str]:
return (name for name in self._order if name in self._entries)
def __len__(self) -> int:
return len(self._entries)
def __contains__(self, key: object) -> bool:
return key in self._entries
def __getitem__(self, key: str) -> Pattern:
return self.materialize(key, persist=True)
def __setitem__(
self,
key: str,
value: Pattern | Callable[[], Pattern],
) -> None:
if key in self._entries:
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
pattern = value() if callable(value) else value
self._entries[key] = pattern
if key not in self._order:
self._order.append(key)
def __delitem__(self, key: str) -> None:
if key not in self._entries:
raise KeyError(key)
del self._entries[key]
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
self[key_self] = copy.deepcopy(other[key_other])
def add_source(
self,
source: Mapping[str, Pattern] | ILibraryView,
*,
rename_theirs: Callable[[INameView, str], str] | None = _rename_patterns,
rename_when: Literal['conflict', 'always'] = 'conflict',
) -> dict[str, str]:
"""
Add a source-backed library layer.
The source must remain open, and its names, hierarchy, and pattern
contents must remain unchanged for the lifetime of this overlay.
Args:
rename_theirs: Function used to choose visible names for imported
source cells. Its `INameView` argument contains existing and
previously reserved names, but does not support pattern lookup.
By default, conflicting single-use names are made unique;
pass `None` to reject every conflict.
rename_when: If `'conflict'`, only conflicting names are renamed.
If `'always'`, every imported source name is passed through
`rename_theirs`.
"""
view = source if isinstance(source, ILibraryView) else LibraryView(source)
source_order = list(view.source_order())
child_graph = view.child_graph(dangling='include')
source_to_visible = _plan_source_names(
self,
source_order,
rename_theirs = rename_theirs,
rename_when = rename_when,
)
layer = _SourceLayer(
library=view,
source_target_map=dict(source_to_visible),
child_graph=child_graph,
)
layer_index = len(self._layers)
self._layers.append(layer)
for source_name, visible_name in source_to_visible.items():
self._entries[visible_name] = _SourceEntry(layer_index=layer_index, source_name=source_name)
if visible_name not in self._order:
self._order.append(visible_name)
return _source_rename_map(source_to_visible)
def rename(
self,
old_name: str,
new_name: str,
move_references: bool = False,
) -> OverlayLibrary:
if old_name not in self._entries:
raise LibraryError(f'"{old_name}" does not exist in the library.')
if old_name == new_name:
return self
if new_name in self._entries:
raise LibraryError(f'"{new_name}" already exists in the library.')
entry = self._entries.pop(old_name)
self._entries[new_name] = entry
idx = self._order.index(old_name)
self._order[idx] = new_name
if move_references:
self.move_references(old_name, new_name)
return self
def _resolve_target(self, target: str) -> str:
seen: set[str] = set()
current = target
while current in self._target_remap:
if current in seen:
raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}')
seen.add(current)
current = self._target_remap[current]
return current
def _set_target_remap(self, old_target: str, new_target: str) -> None:
resolved_new = self._resolve_target(new_target)
if resolved_new == old_target:
raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}')
self._target_remap[old_target] = resolved_new
for key in list(self._target_remap):
self._target_remap[key] = self._resolve_target(self._target_remap[key])
def move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
if old_target == new_target:
return self
self._set_target_remap(old_target, new_target)
for entry in list(self._entries.values()):
if isinstance(entry, Pattern) and old_target in entry.refs:
entry.refs[new_target].extend(entry.refs[old_target])
del entry.refs[old_target]
return self
def _effective_target(self, layer: _SourceLayer, target: str) -> str:
visible = layer.source_target_map.get(target, target)
return self._resolve_target(visible)
def _remap_source_pattern(self, layer: _SourceLayer, source_pat: Pattern) -> Pattern:
def remap(target: str | None) -> str | None:
return None if target is None else self._effective_target(layer, target)
if source_pat.refs:
source_pat.refs = map_targets(source_pat.refs, remap)
return source_pat
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
if name not in self._entries:
raise KeyError(name)
entry = self._entries[name]
if isinstance(entry, Pattern):
return entry
layer = self._layers[entry.layer_index]
source_pat = _materialize_detached_pattern(layer.library, entry.source_name)
pat = self._remap_source_pattern(layer, source_pat)
if persist:
self._entries[name] = pat
return pat
def materialize_detached(self, name: str) -> Pattern:
if name not in self._entries:
raise KeyError(name)
entry = self._entries[name]
if isinstance(entry, Pattern):
return entry.deepcopy()
layer = self._layers[entry.layer_index]
source_pat = _materialize_detached_pattern(layer.library, entry.source_name)
return self._remap_source_pattern(layer, source_pat)
def materialize_many_detached(
self,
names: Sequence[str],
) -> LibraryView:
ordered_names = tuple(dict.fromkeys(names))
missing = next((name for name in ordered_names if name not in self._entries), None)
if missing is not None:
raise KeyError(missing)
result: dict[str, Pattern] = {}
grouped: dict[int, list[tuple[str, str]]] = defaultdict(list)
for name in ordered_names:
entry = self._entries[name]
if isinstance(entry, Pattern):
result[name] = entry.deepcopy()
else:
grouped[entry.layer_index].append((name, entry.source_name))
for layer_index, cells in grouped.items():
layer = self._layers[layer_index]
source_names = [source_name for _name, source_name in cells]
if isinstance(layer.library, IMaterializable):
source_patterns = layer.library.materialize_many_detached(source_names)
else:
source_patterns = LibraryView({
source_name: layer.library[source_name].deepcopy()
for source_name in source_names
})
for name, source_name in cells:
result[name] = self._remap_source_pattern(layer, source_patterns[source_name])
return LibraryView({name: result[name] for name in ordered_names})
def child_graph(
self,
dangling: dangling_mode_t = 'error',
) -> dict[str, set[str]]:
_validate_dangling_mode(dangling)
graph: dict[str, set[str]] = {}
for name in self._order:
if name not in self._entries:
continue
entry = self._entries[name]
if isinstance(entry, Pattern):
graph[name] = {child for child, refs in entry.refs.items() if child is not None and refs}
continue
layer = self._layers[entry.layer_index]
children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())}
graph[name] = children
existing = set(graph)
dangling_refs = set().union(*(children - existing for children in graph.values()))
if dangling == 'error':
if dangling_refs:
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
return graph
if dangling == 'ignore':
return {name: {child for child in children if child in existing} for name, children in graph.items()}
for child in dangling_refs:
graph.setdefault(cast('str', child), set())
return graph
def subtree(
self,
tops: str | Sequence[str],
) -> Self:
if isinstance(tops, str):
tops = (tops,)
graph = self.child_graph(dangling='include')
keep = self._referenced_patterns_from_graph(graph, tops=tops)
keep &= set(self)
keep |= set(tops)
new = type(self)()
new._layers = [
_SourceLayer(
library=layer.library,
source_target_map=dict(layer.source_target_map),
child_graph={name: set(children) for name, children in layer.child_graph.items()},
)
for layer in self._layers
]
new._order = [name for name in self._order if name in keep and name in self._entries]
new._entries = {name: self._entries[name] for name in new._order}
new._target_remap = dict(self._target_remap)
return new
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]]]:
_validate_dangling_mode(dangling)
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
if parent_graph is None:
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()):
pat = self.materialize(parent, persist=False)
for ref in pat.refs.get(name, []):
instances[parent].append(ref.as_transforms())
return instances
def source_order(self) -> tuple[str, ...]:
return tuple(name for name in self._order if name in self._entries)
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
return tuple(layer.library for layer in self._layers)
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
entry = self._entries.get(name)
if not isinstance(entry, _SourceEntry):
return None
layer = self._layers[entry.layer_index]
children = layer.child_graph.get(entry.source_name, set())
if any(self._effective_target(layer, child) != child for child in children):
return None
return layer.library, entry.source_name

223
masque/library/utils.py Normal file
View file

@ -0,0 +1,223 @@
"""Shared types and helpers for library implementations."""
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
from collections.abc import Callable, Collection, Iterator, Mapping, MutableMapping, Sequence
import logging
import re
from ..error import LibraryError
if TYPE_CHECKING:
import numpy
from numpy.typing import NDArray
from ..pattern import Pattern
logger = logging.getLogger(__name__)
SINGLE_USE_PREFIX = '_'
"""
Names starting with this prefix are assumed to refer to single-use patterns,
which may be renamed automatically by `ILibrary.add()` (via
`rename_theirs=_rename_patterns()` )
"""
# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
class INameView(Collection[str], ABC):
"""
Read-only collection of reserved names with a shared name allocator.
Name views support membership, iteration, `len()`, and `get_name()`. They
do not provide pattern lookup or the other operations of a library mapping.
"""
def get_name(
self,
name: str = SINGLE_USE_PREFIX * 2,
sanitize: bool = True,
max_length: int = 32,
quiet: bool | None = None,
) -> str:
"""
Find a unique name.
This function may be overridden in a subclass or monkey-patched to fit
the caller's requirements.
Args:
name: Preferred name. Default is `SINGLE_USE_PREFIX * 2`.
sanitize: Allow only alphanumeric characters and _?$, replacing
invalid characters with underscores.
max_length: Truncate names longer than this.
quiet: Suppress log messages when `True`. The default suppresses
messages only when `name` starts with `SINGLE_USE_PREFIX`.
Returns:
A name unique within this view.
"""
if quiet is None:
quiet = name.startswith(SINGLE_USE_PREFIX)
if sanitize:
sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', name)
else:
sanitized_name = name
suffixed_name = sanitized_name
if sanitized_name in self:
ii = sum(1 for nn in self if nn.startswith(sanitized_name))
else:
ii = 0
while suffixed_name in self or suffixed_name == '':
suffixed_name = sanitized_name + b64suffix(ii)
ii += 1
if len(suffixed_name) > max_length:
if name == '':
raise LibraryError(f'No valid pattern names remaining within the specified {max_length=}')
cropped_name = self.get_name(sanitized_name[:-1], sanitize=sanitize, max_length=max_length, quiet=True)
else:
cropped_name = suffixed_name
if not quiet:
logger.info(f'Requested name "{name}" changed to "{cropped_name}"')
return cropped_name
class visitor_function_t(Protocol):
""" Signature for `Library.dfs()` visitor functions. """
def __call__(
self,
pattern: Pattern,
hierarchy: tuple[str | None, ...],
memo: dict,
transform: NDArray[numpy.float64] | Literal[False],
) -> Pattern:
...
TreeView: TypeAlias = Mapping[str, 'Pattern']
""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """
Tree: TypeAlias = MutableMapping[str, 'Pattern']
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
""" How helpers should handle refs whose targets are not present in the library. """
def _rename_patterns(lib: INameView, name: str) -> str:
"""
The default `rename_theirs` function for `ILibrary.add`.
Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as
"one-offs" for which name conflicts should be automatically resolved.
Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)`
where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`.
Names lacking the prefix are directly returned (not renamed).
Args:
lib: The library into which `name` is to be added (but is presumed to conflict)
name: The original name, to be modified
Returns:
The new name, not guaranteed to be conflict-free!
"""
if not name.startswith(SINGLE_USE_PREFIX):
return name
stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
return lib.get_name(SINGLE_USE_PREFIX + stem)
def _validate_dangling_mode(dangling: dangling_mode_t) -> None:
if dangling not in ('error', 'ignore', 'include'):
raise ValueError(
f'Unknown dangling-reference mode {dangling!r}; '
'expected one of "error", "ignore", or "include"'
)
class _ProspectiveNames(INameView):
"""Target names plus names reserved earlier in an addition plan."""
def __init__(
self,
target: INameView,
reserved: set[str],
) -> None:
self._target = target
self._reserved = reserved
def __iter__(self) -> Iterator[str]:
yield from self._target
yield from self._reserved
def __len__(self) -> int:
return len(self._target) + len(self._reserved)
def __contains__(self, key: object) -> bool:
return key in self._reserved or key in self._target
def _plan_source_names(
target: INameView,
source_order: Sequence[str],
*,
rename_theirs: Callable[[INameView, str], str] | None = None,
rename_when: Literal['conflict', 'always'] = 'conflict',
) -> dict[str, str]:
if rename_when not in ('conflict', 'always'):
raise ValueError(f'Unknown source rename mode: {rename_when!r}')
if rename_when == 'always' and rename_theirs is None:
raise TypeError('rename_theirs is required when rename_when="always"')
source_to_visible: dict[str, str] = {}
reserved: set[str] = set()
prospective = _ProspectiveNames(target, reserved)
for name in source_order:
visible = name
if rename_when == 'always':
assert rename_theirs is not None
visible = rename_theirs(prospective, name)
elif visible in prospective:
if rename_theirs is None:
raise LibraryError(f'Conflicting name while adding source: {name!r}')
visible = rename_theirs(prospective, name)
if visible in prospective:
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
source_to_visible[name] = visible
reserved.add(visible)
return source_to_visible
def _source_rename_map(source_to_visible: Mapping[str, str]) -> dict[str, str]:
return {
source_name: visible_name
for source_name, visible_name in source_to_visible.items()
if source_name != visible_name
}
def b64suffix(ii: int) -> str:
"""
Turn an integer into a base64-equivalent suffix.
This could be done with base64.b64encode, but this way is faster for many small `ii`.
"""
def i2a(nn: int) -> str:
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]
parts = ['$', i2a(ii % 64)]
ii >>= 6
while ii:
parts.append(i2a(ii % 64))
ii >>= 6
return ''.join(parts)

View file

@ -26,9 +26,45 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab
from .ports import Port, PortList
logger = logging.getLogger(__name__)
def _check_ref_cycles(
pattern: 'Pattern',
library: Mapping[str, 'Pattern'],
*,
operation: str,
) -> None:
visited: set[str] = set()
active: list[str] = []
active_set: set[str] = set()
def visit(current: 'Pattern') -> None:
for target, refs in current.refs.items():
if target is None or not refs:
continue
if target in active_set:
cycle_start = active.index(target)
cycle = active[cycle_start:] + [target]
raise PatternError(
f'Circular reference while {operation}: {" -> ".join(cycle)}'
)
if target in visited:
continue
active.append(target)
active_set.add(target)
try:
visit(library[target])
finally:
active.pop()
active_set.remove(target)
visited.add(target)
visit(pattern)
@functools.total_ordering
class Pattern(PortList, AnnotatableImpl, Mirrorable):
"""
@ -37,8 +73,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
or provide equivalent functions.
`Pattern` also stores a dict of `Port`s, which can be used to "snap" together points.
See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes
`builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`.
See `Pattern.plug()` and `Pattern.place()`, as well as `builder.Pather`
and `ports.PortsList`.
For convenience, ports can be read out using square brackets:
- `pattern['A'] == Port((0, 0), 0)`
@ -171,7 +207,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
return s
def __copy__(self) -> 'Pattern':
logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!')
logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! '
'Consider using .deepcopy() if this was not intended.')
new = Pattern(
annotations=copy.deepcopy(self.annotations),
ports=copy.deepcopy(self.ports),
@ -198,7 +235,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
def __lt__(self, other: 'Pattern') -> bool:
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
@ -212,7 +249,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
return refs_ours < refs_theirs
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
@ -221,21 +258,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for _, _, layer in self_layerkeys:
shapes_ours = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(other.shapes[layer]))
if shapes_ours != shapes_theirs:
return shapes_ours < shapes_theirs
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
if self_txtlayerkeys != other_txtlayerkeys:
return self_txtlayerkeys < other_txtlayerkeys
for _, _, layer in self_layerkeys:
for _, _, layer in self_txtlayerkeys:
labels_ours = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(other.labels[layer]))
if labels_ours != labels_theirs:
return labels_ours < labels_theirs
@ -252,7 +289,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
return False
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
@ -266,7 +303,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
return False
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
@ -275,21 +312,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for _, _, layer in self_layerkeys:
shapes_ours = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(self.shapes[layer]))
shapes_theirs = tuple(sorted(other.shapes[layer]))
if shapes_ours != shapes_theirs:
return False
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
if self_txtlayerkeys != other_txtlayerkeys:
return False
for _, _, layer in self_layerkeys:
for _, _, layer in self_txtlayerkeys:
labels_ours = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(self.labels[layer]))
labels_theirs = tuple(sorted(other.labels[layer]))
if labels_ours != labels_theirs:
return False
@ -347,6 +384,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
self
"""
annotation_conflicts: set[str] = set()
if other_pattern.annotations is not None and self.annotations is not None:
annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys())
if port_conflicts:
raise PatternError(f'Port names overlap: {port_conflicts}')
for target, rseq in other_pattern.refs.items():
self.refs[target].extend(rseq)
for layer, sseq in other_pattern.shapes.items():
@ -357,14 +404,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if other_pattern.annotations is not None:
if self.annotations is None:
self.annotations = {}
annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
self.annotations.update(other_pattern.annotations)
port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys())
if port_conflicts:
raise PatternError(f'Port names overlap: {port_conflicts}')
self.ports.update(other_pattern.ports)
return self
@ -499,6 +539,74 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
]
return polys
def layer_as_polygons(
self,
layer: layer_t,
flatten: bool = True,
library: Mapping[str, 'Pattern'] | None = None,
*,
_cycle_checked: bool = False,
) -> list[Polygon]:
"""
Collect all geometry effectively on a given layer as a list of polygons.
If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`.
`Repetition` objects are expanded, and non-polygon shapes are converted
to `Polygon` approximations.
Args:
layer: The layer to collect geometry from.
flatten: If `True`, include geometry from referenced patterns.
library: Required if `flatten=True` to resolve references.
Returns:
A list of `Polygon` objects.
Raises:
PatternError: If `flatten=True` and the referenced hierarchy contains a cycle.
"""
if flatten and self.has_refs() and library is None:
raise PatternError("Must provide a library to layer_as_polygons() when flatten=True")
if flatten and self.has_refs() and not _cycle_checked:
assert library is not None
_check_ref_cycles(self, library, operation='collecting layer polygons')
polys: list[Polygon] = []
# Local shapes
for shape in self.shapes.get(layer, []):
for p in shape.to_polygons():
# expand repetitions
if p.repetition is not None:
for offset in p.repetition.displacements:
polys.append(p.deepcopy().translate(offset).set_repetition(None))
else:
polys.append(p.deepcopy())
if flatten and self.has_refs():
assert library is not None
for target, refs in self.refs.items():
if target is None or not refs:
continue
target_pat = library[target]
for ref in refs:
# Get polygons from target pattern on the same layer
ref_polys = target_pat.layer_as_polygons(
layer,
flatten=True,
library=library,
_cycle_checked=True,
)
# Apply ref transformations
for p in ref_polys:
p_pat = ref.as_pattern(Pattern(shapes={layer: [p]}))
# as_pattern expands repetition of the ref itself
# but we need to pull the polygons back out
for p_transformed in p_pat.shapes[layer]:
polys.append(cast('Polygon', p_transformed))
return polys
def referenced_patterns(self) -> set[str | None]:
"""
Get all pattern namers referenced by this pattern. Non-recursive.
@ -506,13 +614,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
A set of all pattern names referenced by this pattern.
"""
return set(self.refs.keys())
return {target for target, refs in self.refs.items() if refs}
def get_bounds(
self,
library: Mapping[str, 'Pattern'] | None = None,
recurse: bool = True,
cache: MutableMapping[str, NDArray[numpy.float64] | None] | None = None,
*,
_cycle_checked: bool = False,
) -> NDArray[numpy.float64] | None:
"""
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
@ -529,7 +639,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
Raises:
PatternError: If recursive bounds are requested for a cyclic hierarchy.
"""
if recurse and self.has_refs() and library is not None and not _cycle_checked:
_check_ref_cycles(self, library, operation='calculating bounds')
if self.is_empty():
return None
@ -568,7 +684,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if target in cache:
unrot_bounds = cache[target]
elif any(numpy.isclose(ref.rotation % (pi / 2), 0) for ref in refs):
unrot_bounds = library[target].get_bounds(library=library, recurse=recurse, cache=cache)
unrot_bounds = library[target].get_bounds(
library=library,
recurse=recurse,
cache=cache,
_cycle_checked=True,
)
cache[target] = unrot_bounds
for ref in refs:
@ -635,6 +756,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
"""
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast('Positionable', entry).translate(offset)
self._log_bulk_update(f"translate({offset!r})")
return self
def scale_elements(self, c: float) -> Self:
@ -688,7 +810,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
"""
Rotate the Pattern around the a location.
Extrinsic transformation: Rotate the Pattern around the a location in the
container's coordinate system. This affects all elements' offsets and
their repetition grids.
Args:
pivot: (x, y) location to rotate around
@ -702,11 +826,14 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.rotate_elements(rotation)
self.rotate_element_centers(rotation)
self.translate_elements(+pivot)
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
return self
def rotate_element_centers(self, rotation: float) -> Self:
"""
Rotate the offsets of all shapes, labels, refs, and ports around (0, 0)
Extrinsic transformation part: Rotate the offsets and repetition grids of all
shapes, labels, refs, and ports around (0, 0) in the container's
coordinate system.
Args:
rotation: Angle to rotate by (counter-clockwise, radians)
@ -717,11 +844,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
old_offset = cast('Positionable', entry).offset
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
if isinstance(entry, Repeatable) and entry.repetition is not None:
entry.repetition.rotate(rotation)
return self
def rotate_elements(self, rotation: float) -> Self:
"""
Rotate each shape, ref, and port around its origin (offset)
Intrinsic transformation part: Rotate each shape, ref, label, and port around its
origin (offset) in the container's coordinate system. This does NOT
affect their repetition grids.
Args:
rotation: Angle to rotate by (counter-clockwise, radians)
@ -729,54 +860,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Rotatable', entry).rotate(rotation)
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
if isinstance(entry, Rotatable):
entry.rotate(rotation)
return self
def mirror_element_centers(self, across_axis: int = 0) -> Self:
def mirror_element_centers(self, axis: int = 0) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
Extrinsic transformation part: Mirror the offsets and repetition grids of all
shapes, labels, refs, and ports relative to the container's origin.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across (0: x-axis, 1: y-axis)
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
cast('Positionable', entry).offset[1 - across_axis] *= -1
cast('Positionable', entry).offset[1 - axis] *= -1
if isinstance(entry, Repeatable) and entry.repetition is not None:
entry.repetition.mirror(axis)
return self
def mirror_elements(self, across_axis: int = 0) -> Self:
def mirror_elements(self, axis: int = 0) -> Self:
"""
Mirror each shape, ref, and pattern across an axis, relative
to its offset
Intrinsic transformation part: Mirror each shape, ref, label, and port relative
to its offset. This does NOT affect their repetition grids.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across
0: mirror across x axis (flip y),
1: mirror across y axis (flip x)
Returns:
self
"""
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
cast('Mirrorable', entry).mirror(across_axis)
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
if isinstance(entry, Mirrorable):
entry.mirror(axis=axis)
self._log_bulk_update(f"mirror_elements({axis})")
return self
def mirror(self, across_axis: int = 0) -> Self:
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis
Extrinsic transformation: Mirror the Pattern across an axis through its origin.
This affects all elements' offsets and their internal orientations.
Args:
across_axis: Axis to mirror across
(0: mirror across x axis, 1: mirror across y axis)
axis: Axis to mirror across (0: x-axis, 1: y-axis).
Returns:
self
"""
self.mirror_elements(across_axis)
self.mirror_element_centers(across_axis)
self.mirror_elements(axis=axis)
self.mirror_element_centers(axis=axis)
self._log_bulk_update(f"mirror({axis})")
return self
def copy(self) -> Self:
@ -787,7 +925,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
A deep copy of the current Pattern.
"""
return copy.deepcopy(self)
return self.deepcopy()
def deepcopy(self) -> Self:
"""
@ -930,6 +1068,28 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
del self.labels[layer]
return self
def resolve_repeated_refs(self) -> Self:
"""
Expand all repeated references into multiple individual references.
Alters the current pattern in-place.
Returns:
self
"""
new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list)
for target, rseq in self.refs.items():
for ref in rseq:
if ref.repetition is None:
new_refs[target].append(ref)
else:
for dd in ref.repetition.displacements:
new_ref = ref.deepcopy()
new_ref.offset = ref.offset + dd
new_ref.repetition = None
new_refs[target].append(new_ref)
self.refs = new_refs
return self
def prune_refs(self) -> Self:
"""
Remove empty ref lists in `self.refs`.
@ -981,10 +1141,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if target_pat is None:
raise PatternError(f'Circular reference in {name} to {target}')
if target_pat.is_empty(): # avoid some extra allocations
ports_only = flatten_ports and bool(target_pat.ports)
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
continue
for ref in refs:
if flatten_ports and ref.repetition is not None and target_pat.ports:
raise PatternError(
f'Cannot flatten ports from repeated ref to {target!r}; '
'flatten with flatten_ports=False or expand/rename the ports manually first.'
)
p = ref.as_pattern(pattern=target_pat)
if not flatten_ports:
p.ports.clear()
@ -1003,6 +1169,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
line_color: str = 'k',
fill_color: str = 'none',
overdraw: bool = False,
filename: str | None = None,
ports: bool = False,
) -> None:
"""
Draw a picture of the Pattern and wait for the user to inspect it
@ -1013,65 +1181,181 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
klayout or a different GDS viewer!
Args:
offset: Coordinates to offset by before drawing
line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
overdraw: Whether to create a new figure or draw on a pre-existing one
library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`.
offset: Coordinates to offset by before drawing.
line_color: Outlines are drawn with this color.
fill_color: Interiors are drawn with this color.
overdraw: Whether to create a new figure or draw on a pre-existing one.
filename: If provided, save the figure to this file instead of showing it.
ports: If True, annotate the plot with arrows representing the ports.
Raises:
PatternError: If the referenced hierarchy contains a cycle.
"""
# TODO: add text labels to visualize()
if self.has_refs() and library is None:
raise PatternError('Must provide a library when visualizing a pattern with refs')
if self.has_refs():
assert library is not None
_check_ref_cycles(self, library, operation='visualizing pattern hierarchy')
try:
from matplotlib import pyplot # type: ignore
import matplotlib.collections # type: ignore
from matplotlib import pyplot # type: ignore #noqa: PLC0415
import matplotlib.collections # type: ignore #noqa: PLC0415
except ImportError:
logger.exception('Pattern.visualize() depends on matplotlib!\n'
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
raise
if self.has_refs() and library is None:
raise PatternError('Must provide a library when visualizing a pattern with refs')
# Cache for {Pattern object ID: List of local polygon vertex arrays}
# Polygons are stored relative to the pattern's origin (offset included)
poly_cache: dict[int, list[NDArray[numpy.float64]]] = {}
offset = numpy.asarray(offset, dtype=float)
def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]:
pid = id(pat)
if pid not in poly_cache:
polys = []
for shape in chain.from_iterable(pat.shapes.values()):
for ss in shape.to_polygons():
# Shape.to_polygons() returns Polygons with their own offsets and vertices.
# We need to expand any shape-level repetition here.
v_base = ss.vertices + ss.offset
if ss.repetition is not None:
for disp in ss.repetition.displacements:
polys.append(v_base + disp)
else:
polys.append(v_base)
poly_cache[pid] = polys
return poly_cache[pid]
all_polygons: list[NDArray[numpy.float64]] = []
port_info: list[tuple[str, NDArray[numpy.float64], float]] = []
def collect_polys_recursive(
pat: 'Pattern',
c_offset: NDArray[numpy.float64],
c_rotation: float,
c_mirrored: bool,
c_scale: float,
) -> None:
# Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale)
# 1. Transform and collect local polygons
local_polys = get_local_polys(pat)
if local_polys:
rot_mat = rotation_matrix_2d(c_rotation)
for v in local_polys:
vt = v * c_scale
if c_mirrored:
vt = vt.copy()
vt[:, 1] *= -1
vt = (rot_mat @ vt.T).T + c_offset
all_polygons.append(vt)
# 2. Collect ports if requested
if ports:
for name, p in pat.ports.items():
pt_v = p.offset * c_scale
if c_mirrored:
pt_v = pt_v.copy()
pt_v[1] *= -1
pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset
if p.rotation is not None:
pt_rot = p.rotation
if c_mirrored:
pt_rot = -pt_rot
pt_rot += c_rotation
port_info.append((name, pt_v, pt_rot))
# 3. Recurse into refs
for target, refs in pat.refs.items():
if target is None or not refs:
continue
assert library is not None
target_pat = library[target]
for ref in refs:
# Ref order of operations: mirror, rotate, scale, translate, repeat
# Combined scale and mirror
r_scale = c_scale * ref.scale
r_mirrored = c_mirrored ^ ref.mirrored
# Combined rotation: push c_mirrored and c_rotation through ref.rotation
r_rot_relative = -ref.rotation if c_mirrored else ref.rotation
r_rotation = c_rotation + r_rot_relative
# Offset composition helper
def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
o = rel_offset * c_scale
if c_mirrored:
o = o.copy()
o[1] *= -1
return rotation_matrix_2d(c_rotation) @ o + c_offset
if ref.repetition is not None:
for disp in ref.repetition.displacements:
collect_polys_recursive(
target_pat,
get_full_offset(ref.offset + disp),
r_rotation,
r_mirrored,
r_scale
)
else:
collect_polys_recursive(
target_pat,
get_full_offset(ref.offset),
r_rotation,
r_mirrored,
r_scale
)
# Start recursive collection
collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0)
# Plotting
if not overdraw:
figure = pyplot.figure()
pyplot.axis('equal')
else:
figure = pyplot.gcf()
axes = figure.gca()
polygons = []
for shape in chain.from_iterable(self.shapes.values()):
polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
if all_polygons:
mpl_poly_collection = matplotlib.collections.PolyCollection(
polygons,
all_polygons,
facecolors = fill_color,
edgecolors = line_color,
)
axes.add_collection(mpl_poly_collection)
pyplot.axis('equal')
for target, refs in self.refs.items():
if target is None:
continue
if not refs:
continue
assert library is not None
target_pat = library[target]
for ref in refs:
ref.as_pattern(target_pat).visualize(
library=library,
offset=offset,
overdraw=True,
line_color=line_color,
fill_color=fill_color,
if ports:
for port_name, pt_v, pt_rot in port_info:
p1 = pt_v
angle = pt_rot
size = 1.0 # arrow size
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
axes.annotate(
port_name,
xy = tuple(p1),
xytext = tuple(p2),
arrowprops = dict(arrowstyle="->", color='g', linewidth=1),
color = 'g',
fontsize = 8,
)
axes.autoscale_view()
axes.set_aspect('equal')
if not overdraw:
pyplot.xlabel('x')
pyplot.ylabel('y')
pyplot.show()
axes.set_xlabel('x')
axes.set_ylabel('y')
if filename:
figure.savefig(filename)
else:
figure.show()
# @overload
# def place(
@ -1114,6 +1398,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
skip_geometry: bool = False,
) -> Self:
"""
Instantiate or append the pattern `other` into the current pattern, adding its
@ -1145,6 +1430,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
skip_geometry: If `True`, the operation only updates the port list and
skips adding any geometry (shapes, labels, or references). This
allows the pattern assembly to proceed for port-tracking purposes
even when layout generation is suppressed.
Returns:
self
@ -1159,7 +1448,26 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map = {}
if not skip_port_check:
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
port_map, overwrite_targets = self._resolve_insert_mapping(
other.ports.keys(),
map_in=None,
map_out=port_map,
allow_conflicts=skip_geometry,
)
for target in overwrite_targets:
self.ports.pop(target, None)
if not skip_geometry:
if append:
if isinstance(other, Abstract):
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
if other.annotations is not None and self.annotations is not None:
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
elif isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
ports = {}
for name, port in other.ports.items():
@ -1176,10 +1484,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
pp.rotate_around(pivot, rotation)
pp.translate(offset)
self.ports[name] = pp
self._log_port_update(name)
if skip_geometry:
return self
if append:
if isinstance(other, Abstract):
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
other_copy = other.deepcopy()
other_copy.ports.clear()
if mirrored:
@ -1188,7 +1498,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
other_copy.translate_elements(offset)
self.append(other_copy)
else:
assert not isinstance(other, Pattern)
ref = Ref(mirrored=mirrored)
ref.rotate_around(pivot, rotation)
ref.translate(offset)
@ -1234,6 +1543,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation: bool | None = None,
append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (),
skip_geometry: bool = False,
) -> Self:
"""
Instantiate or append a pattern into the current pattern, connecting
@ -1283,11 +1593,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
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)`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will emit a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
skip_geometry: If `True`, only ports are updated and geometry is
skipped. If a valid transform cannot be found (e.g. due to
misaligned ports), a 'best-effort' dummy transform is used
to ensure new ports are still added at approximate locations,
allowing downstream routing to continue.
Returns:
self
@ -1319,7 +1633,24 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
map_out = {out_port_name: next(iter(map_in.keys()))}
self.check_ports(other.ports.keys(), map_in, map_out)
map_out, overwrite_targets = self._resolve_insert_mapping(
other.ports.keys(),
map_in,
map_out,
allow_conflicts=skip_geometry,
)
if not skip_geometry:
if append:
if isinstance(other, Abstract):
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
if other.annotations is not None and self.annotations is not None:
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
elif isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
try:
translation, rotation, pivot = self.find_transform(
other,
map_in,
@ -1327,15 +1658,34 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation = set_rotation,
ok_connections = ok_connections,
)
except PortError:
if not skip_geometry:
raise
logger.warning("Port transform failed for dead device. Using dummy transform.")
if map_in:
ki, vi = next(iter(map_in.items()))
s_port = self.ports[ki]
o_port = other.ports[vi].deepcopy()
if mirrored:
o_port.mirror()
o_port.offset[1] *= -1
translation = s_port.offset - o_port.offset
rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0
pivot = o_port.offset
else:
translation = numpy.zeros(2)
rotation = 0.0
pivot = numpy.zeros(2)
for target in overwrite_targets:
self.ports.pop(target, None)
# get rid of plugged ports
for ki, vi in map_in.items():
del self.ports[ki]
self._log_port_removal(ki)
map_out[vi] = None
if isinstance(other, Pattern):
assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
self.place(
other,
offset = translation,
@ -1345,6 +1695,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map = map_out,
skip_port_check = True,
append = append,
skip_geometry = skip_geometry,
)
return self
@ -1378,7 +1729,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
current device.
Args:
source: A collection of ports (e.g. Pattern, Builder, or dict)
source: A collection of ports (e.g. Pattern, Pather, or dict)
from which to create the interface.
in_prefix: Prepended to port names for newly-created ports with
reversed directions compared to the current device.
@ -1406,9 +1757,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
else:
raise PatternError(f'Unable to get ports from {type(source)}: {source}')
if port_map:
if port_map is not None:
if isinstance(port_map, dict):
missing_inkeys = set(port_map.keys()) - set(orig_ports.keys())
port_targets = list(port_map.values())
duplicate_targets = {vv for vv in port_targets if port_targets.count(vv) > 1}
if duplicate_targets:
raise PortError(f'Duplicate targets in `port_map`: {duplicate_targets}')
mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map}
else:
port_set = set(port_map)
@ -1474,6 +1829,8 @@ def map_layers(
new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list)
for old_layer, seq in elements.items():
new_layer = map_layer(old_layer)
if new_layer is None:
raise PatternError(f'Layer mapping returned None for source layer {old_layer!r}')
new_elements[new_layer].extend(seq)
return new_elements

View file

@ -2,6 +2,7 @@ from typing import overload, Self, NoReturn, Any
from collections.abc import Iterable, KeysView, ValuesView, Mapping
import logging
import functools
import copy
from collections import Counter
from abc import ABCMeta, abstractmethod
from itertools import chain
@ -10,16 +11,17 @@ import numpy
from numpy import pi
from numpy.typing import ArrayLike, NDArray
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
from .utils import rotate_offsets_around, rotation_matrix_2d
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d
from .error import PortError, format_stacktrace
logger = logging.getLogger(__name__)
port_logger = logging.getLogger('masque.ports')
@functools.total_ordering
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
"""
A point at which a `Device` can be snapped to another `Device`.
@ -91,6 +93,12 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
def copy(self) -> Self:
return self.deepcopy()
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._offset = self._offset.copy()
return new
def get_bounds(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset, self.offset))
@ -99,6 +107,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
self.ptype = ptype
return self
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
"""
Mirror the object across a line in the container's coordinate system.
Note this operation is performed relative to the pattern's origin and modifies the port's offset.
Args:
axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0.
x: Vertical line x=val to mirror across.
y: Horizontal line y=val to mirror across.
Returns:
self
"""
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
self.translate(-pivot)
self.mirror(axis)
self.offset[1 - axis] *= -1
self.translate(+pivot)
return self
def mirror(self, axis: int = 0) -> Self:
if self.rotation is not None:
self.rotation *= -1
@ -114,6 +143,34 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
self.rotation = rotation
return self
def describe(self) -> str:
"""
Returns a human-readable description of the port's state including cardinal directions.
"""
deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None
cardinal = ""
travel_dir = ""
if self.rotation is not None:
dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"}
# normalize to [0, 360)
deg_norm = deg % 360
# Find closest cardinal
closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180))
if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3):
cardinal = f" ({dirs[closest]})"
# Travel direction (rotation + 180)
t_deg = (deg_norm + 180) % 360
closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180))
if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3):
travel_dir = f" (Travel -> {dirs[closest_t]})"
deg_text = 'any' if deg is None else f'{deg:g}'
return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}"
def __repr__(self) -> str:
if self.rotation is None:
rot = 'any'
@ -179,6 +236,19 @@ class PortList(metaclass=ABCMeta):
def ports(self, value: dict[str, Port]) -> None:
pass
def _log_port_update(self, name: str) -> None:
""" Log the current state of the named port """
port_logger.debug("Port %s: %s", name, self.ports[name].describe())
def _log_port_removal(self, name: str) -> None:
""" Log that the named port has been removed """
port_logger.debug("Port %s: removed", name)
def _log_bulk_update(self, label: str) -> None:
""" Log all current ports at DEBUG level """
for name, port in self.ports.items():
port_logger.debug("%s: Port %s: %s", label, name, port)
@overload
def __getitem__(self, key: str) -> Port:
pass
@ -203,6 +273,12 @@ class PortList(metaclass=ABCMeta):
else: # noqa: RET505
return {k: self.ports[k] for k in key}
def measure_travel(self, src: str, dst: str) -> tuple[NDArray[numpy.float64], float | None]:
"""
Convenience wrapper for measuring travel between two named ports.
"""
return self[src].measure_travel(self[dst])
def __contains__(self, key: str) -> NoReturn:
raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')
@ -232,6 +308,7 @@ class PortList(metaclass=ABCMeta):
raise PortError(f'Port {name} already exists.')
assert name not in self.ports
self.ports[name] = value
self._log_port_update(name)
return self
def rename_ports(
@ -253,17 +330,147 @@ class PortList(metaclass=ABCMeta):
Returns:
self
"""
self._rename_ports_impl(mapping, overwrite=overwrite)
return self
@staticmethod
def _normalize_target_mapping(
ordered_targets: Iterable[tuple[str, str | None]],
explicit_map: Mapping[str, str | None] | None = None,
) -> dict[str, str | None]:
ordered_targets = list(ordered_targets)
normalized = {} if explicit_map is None else copy.deepcopy(dict(explicit_map))
winners = {
target: source
for source, target in ordered_targets
if target is not None
}
for source, target in ordered_targets:
if target is not None and winners[target] != source:
normalized[source] = None
return normalized
def _resolve_insert_mapping(
self,
other_names: Iterable[str],
map_in: Mapping[str, str] | None = None,
map_out: Mapping[str, str | None] | None = None,
*,
allow_conflicts: bool = False,
) -> tuple[dict[str, str | None], set[str]]:
if map_in is None:
map_in = {}
normalized_map_out = {} if map_out is None else copy.deepcopy(dict(map_out))
other_names = list(other_names)
other = set(other_names)
missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
if missing_inkeys:
raise PortError(f'`map_in` keys not present in device: {missing_inkeys}')
missing_invals = set(map_in.values()) - other
if missing_invals:
raise PortError(f'`map_in` values not present in other device: {missing_invals}')
map_in_counts = Counter(map_in.values())
conflicts_in = {kk for kk, vv in map_in_counts.items() if vv > 1}
if conflicts_in:
raise PortError(f'Duplicate values in `map_in`: {conflicts_in}')
missing_outkeys = set(normalized_map_out.keys()) - other
if missing_outkeys:
raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}')
connected_outkeys = set(normalized_map_out.keys()) & set(map_in.values())
if connected_outkeys:
raise PortError(f'`map_out` keys conflict with connected ports: {connected_outkeys}')
orig_remaining = set(self.ports.keys()) - set(map_in.keys())
connected = set(map_in.values())
if allow_conflicts:
ordered_targets = [
(name, normalized_map_out.get(name, name))
for name in other_names
if name not in connected
]
normalized_map_out = self._normalize_target_mapping(ordered_targets, normalized_map_out)
final_targets = {
normalized_map_out.get(name, name)
for name in other_names
if name not in connected and normalized_map_out.get(name, name) is not None
}
overwrite_targets = {target for target in final_targets if target in orig_remaining}
return normalized_map_out, overwrite_targets
other_remaining = other - set(normalized_map_out.keys()) - connected
mapped_vals = set(normalized_map_out.values())
mapped_vals.discard(None)
conflicts_final = orig_remaining & (other_remaining | mapped_vals)
if conflicts_final:
raise PortError(f'Device ports conflict with existing ports: {conflicts_final}')
conflicts_partial = other_remaining & mapped_vals
if conflicts_partial:
raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}')
map_out_counts = Counter(normalized_map_out.values())
map_out_counts[None] = 0
conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1}
if conflicts_out:
raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')
return normalized_map_out, set()
def _rename_ports_impl(
self,
mapping: Mapping[str, str | None],
*,
overwrite: bool = False,
allow_collisions: bool = False,
) -> dict[str, str]:
if not overwrite:
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
missing = set(mapping) - set(self.ports)
if missing:
raise PortError(f'Ports to rename were not found: {missing}')
renamed_targets = [vv for vv in mapping.values() if vv is not None]
if not allow_collisions:
duplicate_targets = {vv for vv in renamed_targets if renamed_targets.count(vv) > 1}
if duplicate_targets:
raise PortError(f'Renamed ports would collide: {duplicate_targets}')
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
if None in renamed:
del renamed[None]
winners = {
target: source
for source, target in mapping.items()
if target is not None
}
overwritten = {
target
for target, source in winners.items()
if target in self.ports and target not in mapping and target != source
}
for kk, vv in mapping.items():
if vv is None or vv != kk:
self._log_port_removal(kk)
source_ports = {kk: self.ports.pop(kk) for kk in mapping}
for target in overwritten:
self.ports.pop(target, None)
renamed = {
vv: source_ports[kk]
for kk, vv in mapping.items()
if vv is not None and winners[vv] == kk
}
self.ports.update(renamed) # type: ignore
return self
for vv in winners:
self._log_port_update(vv)
return winners
def add_port_pair(
self,
@ -285,12 +492,16 @@ class PortList(metaclass=ABCMeta):
Returns:
self
"""
if names[0] == names[1]:
raise PortError(f'Port names must be distinct: {names[0]!r}')
new_ports = {
names[0]: Port(offset, rotation=rotation, ptype=ptype),
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
}
self.check_ports(names)
self.ports.update(new_ports)
self._log_port_update(names[0])
self._log_port_update(names[1])
return self
def plugged(
@ -313,13 +524,25 @@ class PortList(metaclass=ABCMeta):
Raises:
`PortError` if the ports are not properly aligned.
"""
if not connections:
raise PortError('Must provide at least one port connection')
missing_a = set(connections) - set(self.ports)
if missing_a:
raise PortError(f'Connection source ports were not found: {missing_a}')
missing_b = set(connections.values()) - set(self.ports)
if missing_b:
raise PortError(f'Connection destination ports were not found: {missing_b}')
a_names, b_names = list(zip(*connections.items(), strict=True))
used_names = list(chain(a_names, b_names))
duplicate_names = {name for name in used_names if used_names.count(name) > 1}
if duplicate_names:
raise PortError(f'Each port may appear in at most one connection: {duplicate_names}')
a_ports = [self.ports[pp] for pp in a_names]
b_ports = [self.ports[pp] for pp in b_names]
a_types = [pp.ptype for pp in a_ports]
b_types = [pp.ptype for pp in b_ports]
type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
type_conflicts = numpy.array([not ptypes_compatible(at, bt)
for at, bt in zip(a_types, b_types, strict=True)])
if type_conflicts.any():
@ -351,15 +574,16 @@ class PortList(metaclass=ABCMeta):
raise PortError(msg)
translations = a_offsets - b_offsets
if not numpy.allclose(translations, 0):
if not numpy.allclose(a_offsets, b_offsets):
msg = 'Port translations do not match:\n'
for nn, (kk, vv) in enumerate(connections.items()):
if not numpy.allclose(translations[nn], 0):
if not numpy.allclose(a_offsets[nn], b_offsets[nn]):
msg += f'{kk} | {translations[nn]} | {vv}\n'
raise PortError(msg)
for pp in chain(a_names, b_names):
del self.ports[pp]
self._log_port_removal(pp)
return self
def check_ports(
@ -390,45 +614,7 @@ class PortList(metaclass=ABCMeta):
`PortError` if there are any duplicate names after `map_in` and `map_out`
are applied.
"""
if map_in is None:
map_in = {}
if map_out is None:
map_out = {}
other = set(other_names)
missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
if missing_inkeys:
raise PortError(f'`map_in` keys not present in device: {missing_inkeys}')
missing_invals = set(map_in.values()) - other
if missing_invals:
raise PortError(f'`map_in` values not present in other device: {missing_invals}')
missing_outkeys = set(map_out.keys()) - other
if missing_outkeys:
raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}')
orig_remaining = set(self.ports.keys()) - set(map_in.keys())
other_remaining = other - set(map_out.keys()) - set(map_in.values())
mapped_vals = set(map_out.values())
mapped_vals.discard(None)
conflicts_final = orig_remaining & (other_remaining | mapped_vals)
if conflicts_final:
raise PortError(f'Device ports conflict with existing ports: {conflicts_final}')
conflicts_partial = other_remaining & mapped_vals
if conflicts_partial:
raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}')
map_out_counts = Counter(map_out.values())
map_out_counts[None] = 0
conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1}
if conflicts_out:
raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')
self._resolve_insert_mapping(other_names, map_in, map_out)
return self
def find_transform(
@ -455,11 +641,10 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `other` should be rotated. Otherwise,
`set_rotation` must remain `None`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will log a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
Returns:
- The (x, y) translation (performed last)
@ -468,6 +653,8 @@ class PortList(metaclass=ABCMeta):
The rotation should be performed before the translation.
"""
if not map_in:
raise PortError('Must provide at least one port connection')
s_ports = self[map_in.keys()]
o_ports = other[map_in.values()]
return self.find_port_transform(
@ -506,11 +693,10 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `o_ports` should be rotated. Otherwise,
`set_rotation` must remain `None`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will log a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
Returns:
- The (x, y) translation (performed last)
@ -519,6 +705,8 @@ class PortList(metaclass=ABCMeta):
The rotation should be performed before the translation.
"""
if not map_in:
raise PortError('Must provide at least one port connection')
s_offsets = numpy.array([p.offset for p in s_ports.values()])
o_offsets = numpy.array([p.offset for p in o_ports.values()])
s_types = [p.ptype for p in s_ports.values()]
@ -535,8 +723,10 @@ class PortList(metaclass=ABCMeta):
o_rotations *= -1
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs)
for st, ot in zip(s_types, o_types, strict=True)])
type_conflicts = numpy.array([
not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs
for st, ot in zip(s_types, o_types, strict=True)
])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (kk, vv) in enumerate(map_in.items()):
@ -548,7 +738,7 @@ class PortList(metaclass=ABCMeta):
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
if not has_rot.any():
if set_rotation is None:
PortError('Must provide set_rotation if rotation is indeterminate')
raise PortError('Must provide set_rotation if rotation is indeterminate')
rotations[:] = set_rotation
else:
rotations[~has_rot] = rotations[has_rot][0]
@ -573,4 +763,3 @@ class PortList(metaclass=ABCMeta):
raise PortError(msg)
return translations[0], rotations[0], o_offsets[0]

View file

@ -15,7 +15,8 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation
from .repetition import Repetition
from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl,
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
FlippableImpl,
)
@ -25,8 +26,9 @@ if TYPE_CHECKING:
@functools.total_ordering
class Ref(
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
PositionableImpl, RotatableImpl, ScalableImpl,
Copyable,
):
"""
`Ref` provides basic support for nesting Pattern objects within each other.
@ -42,7 +44,7 @@ class Ref(
__slots__ = (
'_mirrored',
# inherited
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
)
_mirrored: bool
@ -84,24 +86,48 @@ class Ref(
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
@classmethod
def _from_raw(
cls,
*,
offset: NDArray[numpy.float64],
rotation: float,
mirrored: bool,
scale: float,
repetition: Repetition | None,
annotations: annotations_t | None,
) -> Self:
new = cls.__new__(cls)
new._offset = offset
new._rotation = rotation % (2 * pi)
new._scale = scale
new._mirrored = mirrored
new._repetition = repetition
new._annotations = annotations
return new
def __copy__(self) -> 'Ref':
new = Ref(
offset=self.offset.copy(),
rotation=self.rotation,
scale=self.scale,
mirrored=self.mirrored,
repetition=copy.deepcopy(self.repetition),
annotations=copy.deepcopy(self.annotations),
repetition=self.repetition,
annotations=self.annotations,
)
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
memo = {} if memo is None else memo
new = copy.copy(self)
#new.repetition = copy.deepcopy(self.repetition, memo)
#new.annotations = copy.deepcopy(self.annotations, memo)
new._offset = self._offset.copy()
new.repetition = copy.deepcopy(self.repetition, memo)
new.annotations = copy.deepcopy(self.annotations, memo)
return new
def copy(self) -> 'Ref':
return self.deepcopy()
def __lt__(self, other: 'Ref') -> bool:
if (self.offset != other.offset).any():
return tuple(self.offset) < tuple(other.offset)
@ -116,6 +142,8 @@ class Ref(
return annotations_lt(self.annotations, other.annotations)
def __eq__(self, other: Any) -> bool:
if type(self) is not type(other):
return False
return (
numpy.array_equal(self.offset, other.offset)
and self.mirrored == other.mirrored
@ -160,16 +188,16 @@ class Ref(
return pattern
def rotate(self, rotation: float) -> Self:
"""
Intrinsic transformation: Rotate the target pattern relative to this Ref's
origin. This does NOT affect the repetition grid.
"""
self.rotation += rotation
if self.repetition is not None:
self.repetition.rotate(rotation)
return self
def mirror(self, axis: int = 0) -> Self:
self.mirror_target(axis)
self.rotation *= -1
if self.repetition is not None:
self.repetition.mirror(axis)
return self
def mirror_target(self, axis: int = 0) -> Self:
@ -187,10 +215,11 @@ class Ref(
xys = self.offset[None, :]
if self.repetition is not None:
xys = xys + self.repetition.displacements
transforms = numpy.empty((xys.shape[0], 4))
transforms = numpy.empty((xys.shape[0], 5))
transforms[:, :2] = xys
transforms[:, 2] = self.rotation
transforms[:, 3] = self.mirrored
transforms[:, 4] = self.scale
return transforms
def get_bounds_single(
@ -227,7 +256,10 @@ class Ref(
bounds = numpy.vstack((numpy.min(corners, axis=0),
numpy.max(corners, axis=0))) * self.scale + [self.offset]
return bounds
return self.as_pattern(pattern=pattern).get_bounds(library)
single_ref = self.deepcopy()
single_ref.repetition = None
return single_ref.as_pattern(pattern=pattern).get_bounds(library)
def __repr__(self) -> str:
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''

View file

@ -34,7 +34,7 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A
pass
@abstractmethod
def __le__(self, other: 'Repetition') -> bool:
def __lt__(self, other: 'Repetition') -> bool:
pass
@abstractmethod
@ -64,7 +64,7 @@ class Grid(Repetition):
_a_count: int
""" Number of instances along the direction specified by the `a_vector` """
_b_vector: NDArray[numpy.float64] | None
_b_vector: NDArray[numpy.float64]
""" Vector `[x, y]` specifying a second lattice vector for the grid.
Specifies center-to-center spacing between adjacent elements.
Can be `None` for a 1D array.
@ -113,6 +113,22 @@ class Grid(Repetition):
self.a_count = a_count
self.b_count = b_count
@classmethod
def _from_raw(
cls: type[GG],
*,
a_vector: NDArray[numpy.float64],
a_count: int,
b_vector: NDArray[numpy.float64],
b_count: int,
) -> GG:
new = cls.__new__(cls)
new._a_vector = a_vector
new._b_vector = b_vector
new._a_count = int(a_count)
new._b_count = int(b_count)
return new
@classmethod
def aligned(
cls: type[GG],
@ -184,6 +200,8 @@ class Grid(Repetition):
def a_count(self, val: int) -> None:
if val != int(val):
raise PatternError('a_count must be convertable to an int!')
if int(val) < 1:
raise PatternError(f'Repetition has too-small a_count: {val}')
self._a_count = int(val)
# b_count property
@ -195,13 +213,12 @@ class Grid(Repetition):
def b_count(self, val: int) -> None:
if val != int(val):
raise PatternError('b_count must be convertable to an int!')
if int(val) < 1:
raise PatternError(f'Repetition has too-small b_count: {val}')
self._b_count = int(val)
@property
def displacements(self) -> NDArray[numpy.float64]:
if self.b_vector is None:
return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :]
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
return (aa.flatten()[:, None] * self.a_vector[None, :]
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
@ -291,7 +308,7 @@ class Grid(Repetition):
return False
return True
def __le__(self, other: Repetition) -> bool:
def __lt__(self, other: Repetition) -> bool:
if type(self) is not type(other):
return repr(type(self)) < repr(type(other))
other = cast('Grid', other)
@ -301,12 +318,8 @@ class Grid(Repetition):
return self.b_count < other.b_count
if not numpy.array_equal(self.a_vector, other.a_vector):
return tuple(self.a_vector) < tuple(other.a_vector)
if self.b_vector is None:
return other.b_vector is not None
if other.b_vector is None:
return False
if not numpy.array_equal(self.b_vector, other.b_vector):
return tuple(self.a_vector) < tuple(other.a_vector)
return tuple(self.b_vector) < tuple(other.b_vector)
return False
@ -332,7 +345,22 @@ class Arbitrary(Repetition):
@displacements.setter
def displacements(self, val: ArrayLike) -> None:
try:
vala = numpy.array(val, dtype=float)
except (TypeError, ValueError) as exc:
raise PatternError('displacements must be convertible to an Nx2 ndarray') from exc
if vala.size == 0:
self._displacements = numpy.empty((0, 2), dtype=float)
return
if vala.ndim == 1:
if vala.size != 2:
raise PatternError('displacements must be convertible to an Nx2 ndarray')
vala = vala.reshape(1, 2)
elif vala.ndim != 2 or vala.shape[1] != 2:
raise PatternError('displacements must be convertible to an Nx2 ndarray')
order = numpy.lexsort(vala.T[::-1]) # sortrows
self._displacements = vala[order]
@ -350,11 +378,11 @@ class Arbitrary(Repetition):
return (f'<Arbitrary {len(self.displacements)}pts >')
def __eq__(self, other: Any) -> bool:
if not type(other) is not type(self):
if type(other) is not type(self):
return False
return numpy.array_equal(self.displacements, other.displacements)
def __le__(self, other: Repetition) -> bool:
def __lt__(self, other: Repetition) -> bool:
if type(self) is not type(other):
return repr(type(self)) < repr(type(other))
other = cast('Arbitrary', other)
@ -391,7 +419,9 @@ class Arbitrary(Repetition):
Returns:
self
"""
self.displacements[1 - axis] *= -1
new_displacements = self.displacements.copy()
new_displacements[:, 1 - axis] *= -1
self.displacements = new_displacements
return self
def get_bounds(self) -> NDArray[numpy.float64] | None:
@ -402,6 +432,8 @@ class Arbitrary(Repetition):
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
"""
if self.displacements.size == 0:
return None
xy_min = numpy.min(self.displacements, axis=0)
xy_max = numpy.max(self.displacements, axis=0)
return numpy.array((xy_min, xy_max))
@ -416,6 +448,5 @@ class Arbitrary(Repetition):
Returns:
self
"""
self.displacements *= c
self.displacements = self.displacements * c
return self

View file

@ -11,6 +11,7 @@ from .shape import (
from .polygon import Polygon as Polygon
from .poly_collection import PolyCollection as PolyCollection
from .rect_collection import RectCollection as RectCollection
from .circle import Circle as Circle
from .ellipse import Ellipse as Ellipse
from .arc import Arc as Arc

View file

@ -1,6 +1,7 @@
from typing import Any, cast
import copy
import functools
from enum import Enum
import numpy
from numpy import pi
@ -13,18 +14,37 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
from ..traits import PositionableImpl
@functools.total_ordering
class ArcAngleRef(Enum):
Center = 'center'
FocusPos = 'focus_pos'
FocusNeg = 'focus_neg'
def __lt__(self, other: Any) -> bool:
if self.__class__ is not other.__class__:
return self.__class__.__name__ < other.__class__.__name__
order = {
ArcAngleRef.Center: 0,
ArcAngleRef.FocusPos: 1,
ArcAngleRef.FocusNeg: 2,
}
return order[self] < order[other]
@functools.total_ordering
class Arc(PositionableImpl, Shape):
"""
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
An elliptical arc, formed by cutting off an elliptical ring with two rays.
By default the rays exit from its center, but they can optionally exit from one of the
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
a rotation, and a width.
The radii define an ellipse; the ring is formed with radii +/- width/2.
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
The start and stop angle are measured counterclockwise from the first (x) radius.
"""
__slots__ = (
'_radii', '_angles', '_width', '_rotation',
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
# Inherited
'_offset', '_repetition', '_annotations',
)
@ -41,6 +61,11 @@ class Arc(PositionableImpl, Shape):
_width: float
""" Width of the arc """
_angle_ref: ArcAngleRef
""" Origin used by start/stop rays """
AngleRef = ArcAngleRef
# radius properties
@property
def radii(self) -> NDArray[numpy.float64]:
@ -54,8 +79,8 @@ class Arc(PositionableImpl, Shape):
val = numpy.array(val, dtype=float).flatten()
if not val.size == 2:
raise PatternError('Radii must have length 2')
if not val.min() >= 0:
raise PatternError('Radii must be non-negative')
if not val.min() > 0:
raise PatternError('Radii must be positive')
self._radii = val
@property
@ -64,8 +89,8 @@ class Arc(PositionableImpl, Shape):
@radius_x.setter
def radius_x(self, val: float) -> None:
if not val >= 0:
raise PatternError('Radius must be non-negative')
if not val > 0:
raise PatternError('Radius must be positive')
self._radii[0] = val
@property
@ -74,8 +99,8 @@ class Arc(PositionableImpl, Shape):
@radius_y.setter
def radius_y(self, val: float) -> None:
if not val >= 0:
raise PatternError('Radius must be non-negative')
if not val > 0:
raise PatternError('Radius must be positive')
self._radii[1] = val
# arc start/stop angle properties
@ -113,6 +138,18 @@ class Arc(PositionableImpl, Shape):
def stop_angle(self, val: float) -> None:
self.angles = (self.angles[0], val)
# Angle reference property
@property
def angle_ref(self) -> ArcAngleRef:
"""
Origin used to interpret start and stop angle rays.
"""
return self._angle_ref
@angle_ref.setter
def angle_ref(self, val: ArcAngleRef | str) -> None:
self._angle_ref = ArcAngleRef(val)
# Rotation property
@property
def rotation(self) -> float:
@ -159,34 +196,48 @@ class Arc(PositionableImpl, Shape):
rotation: float = 0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
) -> None:
if raw:
assert isinstance(radii, numpy.ndarray)
assert isinstance(angles, numpy.ndarray)
assert isinstance(offset, numpy.ndarray)
self._radii = radii
self._angles = angles
self._width = width
self._offset = offset
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations
else:
self.radii = radii
self.angles = angles
self.width = width
self.offset = offset
self.rotation = rotation
self.angle_ref = angle_ref
self.repetition = repetition
self.annotations = annotations
@classmethod
def _from_raw(
cls,
*,
radii: NDArray[numpy.float64],
angles: NDArray[numpy.float64],
width: float,
offset: NDArray[numpy.float64],
rotation: float,
annotations: annotations_t = None,
repetition: Repetition | None = None,
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
) -> 'Arc':
new = cls.__new__(cls)
new._radii = radii
new._angles = angles
new._width = width
new._offset = offset
new._rotation = rotation % (2 * pi)
new._angle_ref = ArcAngleRef(angle_ref)
new._repetition = repetition
new._annotations = annotations
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
memo = {} if memo is None else memo
new = copy.copy(self)
new._offset = self._offset.copy()
new._radii = self._radii.copy()
new._angles = self._angles.copy()
new._repetition = copy.deepcopy(self._repetition, memo)
new._annotations = copy.deepcopy(self._annotations)
return new
@ -198,6 +249,7 @@ class Arc(PositionableImpl, Shape):
and numpy.array_equal(self.angles, other.angles)
and self.width == other.width
and self.rotation == other.rotation
and self.angle_ref == other.angle_ref
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
@ -214,6 +266,8 @@ class Arc(PositionableImpl, Shape):
return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.angles, other.angles):
return tuple(self.angles) < tuple(other.angles)
if self.angle_ref != other.angle_ref:
return self.angle_ref < other.angle_ref
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
@ -230,6 +284,8 @@ class Arc(PositionableImpl, Shape):
if (num_vertices is None) and (max_arclen is None):
raise PatternError('Max number of points and arclength left unspecified'
+ ' (default was also overridden)')
if max_arclen is not None and (numpy.isnan(max_arclen) or max_arclen <= 0):
raise PatternError('Max arclength must be positive and not NaN')
r0, r1 = self.radii
@ -256,29 +312,38 @@ class Arc(PositionableImpl, Shape):
return arc_lengths, tt
wh = self.width / 2.0
arclen_limits: list[float] = []
if max_arclen is not None:
arclen_limits.append(max_arclen)
if num_vertices is not None:
n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int)
perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum()
perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum()
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf)
assert max_arclen is not None
if not (numpy.isnan(implied_arclen) or implied_arclen <= 0):
arclen_limits.append(implied_arclen)
if not arclen_limits:
raise PatternError('Arc polygonization could not determine a valid max_arclen')
max_arclen = min(arclen_limits)
def get_thetas(inner: bool) -> NDArray[numpy.float64]:
""" Figure out the parameter values at which we should place vertices to meet the arclength constraint"""
dr = -wh if inner else wh
n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int)
n_pts = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen)))
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
keep = [0]
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
start = 1
start = 0
while start < arc_lengths.size:
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
if not removable.any():
next_to_keep = start + 1
else:
next_to_keep = start + numpy.where(removable)[0][-1] + 1
keep.append(next_to_keep)
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
start = next_to_keep + 1
start = next_to_keep
if keep[-1] != thetas.size - 1:
keep.append(thetas.size - 1)
@ -310,81 +375,59 @@ class Arc(PositionableImpl, Shape):
return [poly]
def get_bounds_single(self) -> NDArray[numpy.float64]:
"""
Equation for rotated ellipse is
`x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)`
`y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)`
where `t` is our parameter.
Differentiating and solving for 0 slope wrt. `t`, we find
`tan(t) = -+ b/a cot(phi)`
where -+ is for x, y cases, so that's where the extrema are.
If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
"""
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation)
mins = []
maxs = []
def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
return numpy.array((
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
))
def points_in_interval(rx: float, ry: float, a0: float, a1: float) -> list[NDArray[numpy.float64]]:
candidates = [a0, a1]
if rx != 0 and ry != 0:
tx = numpy.arctan2(-ry * sin_r, rx * cos_r)
ty = numpy.arctan2(ry * cos_r, rx * sin_r)
candidates.extend((tx, tx + pi, ty, ty + pi))
lo = min(a0, a1)
hi = max(a0, a1)
pts = []
for base in candidates:
k_min = int(numpy.floor((lo - base) / (2 * pi))) - 1
k_max = int(numpy.ceil((hi - base) / (2 * pi))) + 1
for kk in range(k_min, k_max + 1):
tt = base + kk * 2 * pi
if lo <= tt <= hi:
pts.append(point(rx, ry, tt))
return pts
pts = []
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2
rx = self.radius_x + wh
ry = self.radius_y + wh
if rx == 0 or ry == 0:
# Single point, at origin
mins.append([0, 0])
maxs.append([0, 0])
pts.append(numpy.zeros(2))
continue
pts.extend(points_in_interval(rx, ry, aa[0], aa[1]))
a0, a1 = aa
a0_offset = a0 - (a0 % (2 * pi))
sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation)
sin_a = numpy.sin(aa)
cos_a = numpy.cos(aa)
# Cutoff angles
xpt = (-self.rotation) % (2 * pi) + a0_offset
ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset
xnt = (xpt - pi) % (2 * pi) + a0_offset
ynt = (ypt - pi) % (2 * pi) + a0_offset
# Points along coordinate axes
rx2_inv = 1 / (rx * rx)
ry2_inv = 1 / (ry * ry)
xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5
yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5
# Arc endpoints
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
# If our arc subtends a coordinate axis, use the extremum along that axis
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
xp = xr
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
xn = -xr
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
yp = yr
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
yn = -yr
mins.append([xn, yn])
maxs.append([xp, yp])
return numpy.vstack((numpy.min(mins, axis=0) + self.offset,
numpy.max(maxs, axis=0) + self.offset))
all_pts = numpy.asarray(pts) + self.offset
return numpy.vstack((numpy.min(all_pts, axis=0),
numpy.max(all_pts, axis=0)))
def rotate(self, theta: float) -> 'Arc':
self.rotation += theta
return self
def mirror(self, axis: int = 0) -> 'Arc':
self.offset[axis - 1] *= -1
if self.angle_ref != ArcAngleRef.Center:
x_major = self.radius_x > self.radius_y
y_major = self.radius_y > self.radius_x
if (axis == 0 and y_major) or (axis == 1 and x_major):
self._swap_focus_ref()
self.rotation *= -1
self.rotation += axis * pi
self.angles *= -1
@ -396,6 +439,7 @@ class Arc(PositionableImpl, Shape):
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
angle_ref = self.angle_ref
if self.radius_x < self.radius_y:
radii = self.radii / self.radius_x
scale = self.radius_x
@ -406,23 +450,26 @@ class Arc(PositionableImpl, Shape):
scale = self.radius_y
rotation = self.rotation + pi / 2
angles = self.angles - pi / 2
angle_ref = _swapped_focus_ref(angle_ref)
delta_angle = angles[1] - angles[0]
start_angle = angles[0] % (2 * pi)
if start_angle >= pi:
start_angle -= pi
rotation += pi
angle_ref = _swapped_focus_ref(angle_ref)
norm_angles = (start_angle, start_angle + delta_angle)
rotation %= 2 * pi
width = self.width
return ((type(self), radii, norm_angles, width / norm_value),
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
(self.offset, scale / norm_value, rotation, False),
lambda: Arc(
radii=radii * norm_value,
angles=norm_angles,
width=width * norm_value,
angle_ref=angle_ref,
))
def get_cap_edges(self) -> NDArray[numpy.float64]:
@ -433,27 +480,16 @@ class Arc(PositionableImpl, Shape):
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
```
"""
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
a_ranges = self._angles_to_parameters()
mins = []
maxs = []
cuts = []
for index in range(2):
edge = []
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2
rx = self.radius_x + wh
ry = self.radius_y + wh
sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation)
sin_a = numpy.sin(aa)
cos_a = numpy.cos(aa)
# arc endpoints
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
mins.append([xn, yn])
maxs.append([xp, yp])
return numpy.array([mins, maxs]) + self.offset
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
cuts.append(edge)
return numpy.array(cuts) + self.offset
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
"""
@ -464,22 +500,111 @@ class Arc(PositionableImpl, Shape):
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
"""
aa = []
d_angle = self.angles[1] - self.angles[0]
if abs(d_angle) >= 2 * pi:
# Full ring
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
for sgn in (-1, +1):
wh = sgn * self.width / 2.0
rx = self.radius_x + wh
ry = self.radius_y + wh
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
sign = numpy.sign(self.angles[1] - self.angles[0])
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
sign = numpy.sign(d_angle)
if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi
aa.append((a0, a1))
return numpy.array(aa, dtype=float)
def _angle_to_parameter(self, angle: float, rx: float, ry: float) -> float:
"""
Convert an angle-reference ray to the ellipse parameter for one boundary edge.
Center-referenced arcs convert the ray angle from polar coordinates about the origin.
Focus-referenced arcs solve the forward ray/ellipse intersection from the selected
nominal focus and return the parameter `t` for `[rx*cos(t), ry*sin(t)]`.
"""
if self.angle_ref == ArcAngleRef.Center:
return numpy.arctan2(rx * numpy.sin(angle), ry * numpy.cos(angle))
focus = self._focus_point()
if rx <= 0 or ry <= 0:
raise PatternError('Focus-referenced arc boundary radii must be positive')
fx, fy = focus
origin_position = fx * fx / (rx * rx) + fy * fy / (ry * ry)
if origin_position >= 1:
raise PatternError('Focus-referenced arc ray origin must be inside both arc boundary ellipses')
dx = numpy.cos(angle)
dy = numpy.sin(angle)
aa = dx * dx / (rx * rx) + dy * dy / (ry * ry)
bb = 2 * (fx * dx / (rx * rx) + fy * dy / (ry * ry))
cc = origin_position - 1
determinant = bb * bb - 4 * aa * cc
if determinant < 0:
raise PatternError('Focus-referenced arc ray does not intersect boundary ellipse')
roots = numpy.array((
(-bb - numpy.sqrt(determinant)) / (2 * aa),
(-bb + numpy.sqrt(determinant)) / (2 * aa),
))
positive_roots = roots[roots > 0]
if positive_roots.size != 1:
raise PatternError('Focus-referenced arc ray must have exactly one forward boundary intersection')
point = focus + positive_roots[0] * numpy.array((dx, dy))
return numpy.arctan2(point[1] / ry, point[0] / rx)
def _focus_point(self) -> NDArray[numpy.float64]:
"""
Return the selected nominal focus in the arc's unrotated local coordinates.
`FocusPos` and `FocusNeg` select opposite directions along the major axis. Circles
have coincident foci, so both focus modes intentionally collapse to the center.
"""
if self.angle_ref == ArcAngleRef.Center or self.radius_x == self.radius_y:
return numpy.zeros(2)
sign = 1 if self.angle_ref == ArcAngleRef.FocusPos else -1
if self.radius_x > self.radius_y:
return numpy.array((sign * numpy.sqrt(self.radius_x * self.radius_x - self.radius_y * self.radius_y), 0.0))
return numpy.array((0.0, sign * numpy.sqrt(self.radius_y * self.radius_y - self.radius_x * self.radius_x)))
def _point_on_edge(self, rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
"""
Return a rotated local-space point on a boundary ellipse, before applying offset.
"""
sin_r = numpy.sin(self.rotation)
cos_r = numpy.cos(self.rotation)
return numpy.array((
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
))
def _swap_focus_ref(self) -> None:
"""
Swap `focus_pos` and `focus_neg`, leaving center-referenced arcs unchanged.
"""
self.angle_ref = _swapped_focus_ref(self.angle_ref)
def __repr__(self) -> str:
angles = f'{numpy.rad2deg(self.angles)}'
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
"""
Return the opposite focus reference, or center for center-referenced arcs.
"""
if angle_ref == ArcAngleRef.FocusPos:
return ArcAngleRef.FocusNeg
if angle_ref == ArcAngleRef.FocusNeg:
return ArcAngleRef.FocusPos
return angle_ref
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]

View file

@ -50,24 +50,33 @@ class Circle(PositionableImpl, Shape):
offset: ArrayLike = (0.0, 0.0),
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
if raw:
assert isinstance(offset, numpy.ndarray)
self._radius = radius
self._offset = offset
self._repetition = repetition
self._annotations = annotations
else:
self.radius = radius
self.offset = offset
self.repetition = repetition
self.annotations = annotations
@classmethod
def _from_raw(
cls,
*,
radius: float,
offset: NDArray[numpy.float64],
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> 'Circle':
new = cls.__new__(cls)
new._radius = radius
new._offset = offset
new._repetition = repetition
new._annotations = annotations
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
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)
return new
@ -108,7 +117,7 @@ class Circle(PositionableImpl, Shape):
n += [num_vertices]
if max_arclen is not None:
n += [2 * pi * self.radius / max_arclen]
num_vertices = int(round(max(n)))
num_vertices = max(3, int(round(max(n))))
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
xs = numpy.cos(thetas) * self.radius
ys = numpy.sin(thetas) * self.radius
@ -124,7 +133,6 @@ class Circle(PositionableImpl, Shape):
return self
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
self.offset[axis - 1] *= -1
return self
def scale_by(self, c: float) -> 'Circle':

View file

@ -42,7 +42,7 @@ class Ellipse(PositionableImpl, Shape):
@radii.setter
def radii(self, val: ArrayLike) -> None:
val = numpy.array(val).flatten()
val = numpy.array(val, dtype=float).flatten()
if not val.size == 2:
raise PatternError('Radii must have length 2')
if not val.min() >= 0:
@ -95,28 +95,37 @@ class Ellipse(PositionableImpl, Shape):
rotation: float = 0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
if raw:
assert isinstance(radii, numpy.ndarray)
assert isinstance(offset, numpy.ndarray)
self._radii = radii
self._offset = offset
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations
else:
self.radii = radii
self.offset = offset
self.rotation = rotation
self.repetition = repetition
self.annotations = annotations
@classmethod
def _from_raw(
cls,
*,
radii: NDArray[numpy.float64],
offset: NDArray[numpy.float64],
rotation: float,
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._radii = radii
new._offset = offset
new._rotation = rotation % pi
new._repetition = repetition
new._annotations = annotations
return new
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._radii = self._radii.copy()
new._repetition = copy.deepcopy(self._repetition, memo)
new._annotations = copy.deepcopy(self._annotations)
return new
@ -168,7 +177,7 @@ class Ellipse(PositionableImpl, Shape):
n += [num_vertices]
if max_arclen is not None:
n += [perimeter / max_arclen]
num_vertices = int(round(max(n)))
num_vertices = max(3, int(round(max(n))))
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
@ -180,16 +189,19 @@ class Ellipse(PositionableImpl, Shape):
return [poly]
def get_bounds_single(self) -> NDArray[numpy.float64]:
rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
return numpy.vstack((self.offset - rot_radii[0],
self.offset + rot_radii[1]))
cos_r = numpy.cos(self.rotation)
sin_r = numpy.sin(self.rotation)
x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2)
y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2)
extents = numpy.array((x_extent, y_extent))
return numpy.vstack((self.offset - extents,
self.offset + extents))
def rotate(self, theta: float) -> Self:
self.rotation += theta
return self
def mirror(self, axis: int = 0) -> Self:
self.offset[axis - 1] *= -1
self.rotation *= -1
self.rotation += axis * pi
return self
@ -207,7 +219,7 @@ class Ellipse(PositionableImpl, Shape):
radii = self.radii[::-1] / self.radius_y
scale = self.radius_y
angle = (self.rotation + pi / 2) % pi
return ((type(self), radii),
return ((type(self), tuple(radii.tolist())),
(self.offset, scale / norm_value, angle, False),
lambda: Ellipse(radii=radii * norm_value))

View file

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

View file

@ -34,7 +34,7 @@ class PolyCollection(Shape):
_vertex_lists: NDArray[numpy.float64]
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
_vertex_offsets: NDArray[numpy.intp]
_vertex_offsets: NDArray[numpy.integer[Any]]
""" 1D NDArray specifying the starting offset for each polygon """
@property
@ -45,7 +45,7 @@ class PolyCollection(Shape):
return self._vertex_lists
@property
def vertex_offsets(self) -> NDArray[numpy.intp]:
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
"""
Starting offset (in `vertex_lists`) for each polygon
"""
@ -56,12 +56,14 @@ class PolyCollection(Shape):
"""
Iterator which provides slices which index vertex_lists
"""
if self._vertex_offsets.size == 0:
return
for ii, ff in zip(
self._vertex_offsets,
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
strict=True,
):
yield slice(ii, ff)
yield slice(int(ii), int(ff))
@property
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
@ -82,7 +84,7 @@ class PolyCollection(Shape):
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('Path offset is forced to (0, 0)')
raise PatternError('PolyCollection offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
@ -98,16 +100,7 @@ class PolyCollection(Shape):
rotation: float = 0.0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
if raw:
assert isinstance(vertex_lists, numpy.ndarray)
assert isinstance(vertex_offsets, numpy.ndarray)
self._vertex_lists = vertex_lists
self._vertex_offsets = vertex_offsets
self._repetition = repetition
self._annotations = annotations
else:
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
self.repetition = repetition
@ -117,11 +110,28 @@ class PolyCollection(Shape):
if numpy.any(offset):
self.translate(offset)
@classmethod
def _from_raw(
cls,
*,
vertex_lists: NDArray[numpy.float64],
vertex_offsets: NDArray[numpy.integer[Any]],
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._vertex_lists = vertex_lists
new._vertex_offsets = vertex_offsets
new._repetition = repetition
new._annotations = annotations
return new
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._vertex_lists = self._vertex_lists.copy()
new._vertex_offsets = self._vertex_offsets.copy()
new._repetition = copy.deepcopy(self._repetition, memo)
new._annotations = copy.deepcopy(self._annotations)
return new
@ -129,7 +139,7 @@ class PolyCollection(Shape):
return (
type(self) is type(other)
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
and numpy.array_equal(self._vertex_offsets, other._vertex_offsets)
and numpy.array_equal(self.vertex_offsets, other.vertex_offsets)
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
@ -168,7 +178,9 @@ class PolyCollection(Shape):
annotations = copy.deepcopy(self.annotations),
) for vv in self.polygon_vertices]
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
if self._vertex_lists.size == 0:
return None
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
numpy.max(self._vertex_lists, axis=0)))
@ -179,7 +191,7 @@ class PolyCollection(Shape):
return self
def mirror(self, axis: int = 0) -> Self:
self._vertex_lists[:, axis - 1] *= -1
self._vertex_lists[:, 1 - axis] *= -1
return self
def scale_by(self, c: float) -> Self:
@ -210,11 +222,11 @@ class PolyCollection(Shape):
# TODO: normalize mirroring?
return ((type(self), rotated_vertices.data.tobytes() + self._vertex_offsets.tobytes()),
return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()),
(offset, scale / norm_value, rotation, False),
lambda: PolyCollection(
vertex_lists=rotated_vertices * norm_value,
vertex_offsets=self._vertex_offsets,
vertex_offsets=self.vertex_offsets.copy(),
),
)

View file

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

View file

@ -0,0 +1,249 @@
from typing import Any, cast, Self
from collections.abc import Iterator
import copy
import functools
import numpy
from numpy import pi
from numpy.typing import NDArray, ArrayLike
from . import Shape, normalized_shape_tuple
from .polygon import Polygon
from ..error import PatternError
from ..repetition import Repetition
from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t
def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]:
arr = numpy.asarray(rects, dtype=float)
if arr.ndim != 2 or arr.shape[1] != 4:
raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]')
if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]):
raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax')
if arr.shape[0] <= 1:
return arr
order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0]))
return arr[order]
def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None:
x0 = numpy.minimum(rects[:, 0], rects[:, 2])
x1 = numpy.maximum(rects[:, 0], rects[:, 2])
y0 = numpy.minimum(rects[:, 1], rects[:, 3])
y1 = numpy.maximum(rects[:, 1], rects[:, 3])
rects[:, 0] = x0
rects[:, 1] = y0
rects[:, 2] = x1
rects[:, 3] = y1
@functools.total_ordering
class RectCollection(Shape):
"""
A collection of axis-aligned rectangles, stored as an Nx4 array of
`[xmin, ymin, xmax, ymax]` rows.
"""
__slots__ = (
'_rects',
'_repetition', '_annotations',
)
_rects: NDArray[numpy.float64]
@property
def rects(self) -> NDArray[numpy.float64]:
return self._rects
@rects.setter
def rects(self, val: ArrayLike) -> None:
self._rects = _normalize_rects(val)
@property
def offset(self) -> NDArray[numpy.float64]:
return numpy.zeros(2)
@offset.setter
def offset(self, val: ArrayLike) -> None:
if numpy.any(val):
raise PatternError('RectCollection offset is forced to (0, 0)')
def set_offset(self, val: ArrayLike) -> Self:
if numpy.any(val):
raise PatternError('RectCollection offset is forced to (0, 0)')
return self
def translate(self, offset: ArrayLike) -> Self:
delta = numpy.asarray(offset, dtype=float).reshape(2)
self._rects[:, [0, 2]] += delta[0]
self._rects[:, [1, 3]] += delta[1]
return self
def __init__(
self,
rects: ArrayLike,
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
) -> None:
self.rects = rects
self.repetition = repetition
self.annotations = annotations
if rotation:
self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
@classmethod
def _from_raw(
cls,
*,
rects: NDArray[numpy.float64],
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._rects = rects
new._repetition = repetition
new._annotations = annotations
return new
@property
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
for rect in self._rects:
xmin, ymin, xmax, ymax = rect
yield numpy.array([
[xmin, ymin],
[xmin, ymax],
[xmax, ymax],
[xmax, ymin],
], dtype=float)
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._rects = self._rects.copy()
new._repetition = copy.deepcopy(self._repetition, memo)
new._annotations = copy.deepcopy(self._annotations)
return new
def _sorted_rects(self) -> NDArray[numpy.float64]:
if self._rects.shape[0] <= 1:
return self._rects
order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0]))
return self._rects[order]
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self._sorted_rects(), other._sorted_rects())
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast('RectCollection', other)
self_rects = self._sorted_rects()
other_rects = other._sorted_rects()
if not numpy.array_equal(self_rects, other_rects):
min_len = min(self_rects.shape[0], other_rects.shape[0])
eq_mask = self_rects[:min_len] != other_rects[:min_len]
eq_lt = self_rects[:min_len] < other_rects[:min_len]
eq_lt_masked = eq_lt[eq_mask]
if eq_lt_masked.size > 0:
return bool(eq_lt_masked.flat[0])
return self_rects.shape[0] < other_rects.shape[0]
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons(
self,
num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused # noqa: ARG002
) -> list[Polygon]:
return [
Polygon(
vertices=vertices,
repetition=copy.deepcopy(self.repetition),
annotations=copy.deepcopy(self.annotations),
)
for vertices in self.polygon_vertices
]
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
if self._rects.size == 0:
return None
mins = self._rects[:, :2].min(axis=0)
maxs = self._rects[:, 2:].max(axis=0)
return numpy.vstack((mins, maxs))
def rotate(self, theta: float) -> Self:
quarter_turns = int(numpy.rint(theta / (pi / 2)))
if not numpy.isclose(theta, quarter_turns * (pi / 2)):
raise PatternError('RectCollection only supports Manhattan rotations')
turns = quarter_turns % 4
if turns == 0 or self._rects.size == 0:
return self
corners = numpy.stack((
self._rects[:, [0, 1]],
self._rects[:, [0, 3]],
self._rects[:, [2, 3]],
self._rects[:, [2, 1]],
), axis=1)
flat = corners.reshape(-1, 2)
if turns == 1:
rotated = numpy.column_stack((-flat[:, 1], flat[:, 0]))
elif turns == 2:
rotated = -flat
else:
rotated = numpy.column_stack((flat[:, 1], -flat[:, 0]))
corners = rotated.reshape(corners.shape)
self._rects[:, 0] = corners[:, :, 0].min(axis=1)
self._rects[:, 1] = corners[:, :, 1].min(axis=1)
self._rects[:, 2] = corners[:, :, 0].max(axis=1)
self._rects[:, 3] = corners[:, :, 1].max(axis=1)
return self
def mirror(self, axis: int = 0) -> Self:
if axis not in (0, 1):
raise PatternError('Axis must be 0 or 1')
if axis == 0:
self._rects[:, [1, 3]] *= -1
else:
self._rects[:, [0, 2]] *= -1
_renormalize_rects_in_place(self._rects)
return self
def scale_by(self, c: float) -> Self:
self._rects *= c
_renormalize_rects_in_place(self._rects)
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
rects = self._sorted_rects()
centers = 0.5 * (rects[:, :2] + rects[:, 2:])
offset = centers.mean(axis=0)
zeroed = rects.copy()
zeroed[:, [0, 2]] -= offset[0]
zeroed[:, [1, 3]] -= offset[1]
normed = zeroed / norm_value
return (
(type(self), normed.data.tobytes()),
(offset, 1.0, 0.0, False),
lambda: RectCollection(rects=normed * norm_value),
)
def __repr__(self) -> str:
if self._rects.size == 0:
return '<RectCollection r0>'
centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:])
centroid = centers.mean(axis=0)
return f'<RectCollection centroid {centroid} r{self._rects.shape[0]}>'

View file

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

View file

@ -70,31 +70,48 @@ class Text(PositionableImpl, RotatableImpl, Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: bool = False,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
if raw:
assert isinstance(offset, numpy.ndarray)
self._offset = offset
self._string = string
self._height = height
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations
else:
self.offset = offset
self.string = string
self.height = height
self.rotation = rotation
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations
self.font_path = font_path
@classmethod
def _from_raw(
cls,
*,
string: str,
height: float,
font_path: str,
offset: NDArray[numpy.float64],
rotation: float,
mirrored: bool,
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._offset = offset
new._string = string
new._height = height
new._rotation = rotation % (2 * pi)
new._mirrored = mirrored
new._repetition = repetition
new._annotations = annotations
new.font_path = font_path
return new
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)
return new
@ -105,6 +122,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
and self.string == other.string
and self.height == other.height
and self.font_path == other.font_path
and self.mirrored == other.mirrored
and self.rotation == other.rotation
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
@ -124,6 +142,8 @@ class Text(PositionableImpl, RotatableImpl, Shape):
return self.font_path < other.font_path
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.mirrored != other.mirrored:
return self.mirrored < other.mirrored
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
@ -146,7 +166,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
if self.mirrored:
poly.mirror()
poly.scale_by(self.height)
poly.offset = self.offset + [total_advance, 0]
poly.translate(self.offset + [total_advance, 0])
poly.rotate_around(self.offset, self.rotation)
all_polygons += [poly]
@ -171,22 +191,25 @@ class Text(PositionableImpl, RotatableImpl, Shape):
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
lambda: Text(
string=self.string,
height=self.height * norm_value,
height=norm_value,
font_path=self.font_path,
rotation=rotation,
).mirror2d(across_x=self.mirrored),
)
def get_bounds_single(self) -> NDArray[numpy.float64]:
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
# just convert to polygons instead
polys = self.to_polygons()
if not polys:
return None
pbounds = numpy.full((len(polys), 2, 2), nan)
for pp, poly in enumerate(polys):
pbounds[pp] = poly.get_bounds_nonempty()
bounds = numpy.vstack((
numpy.min(pbounds[: 0, :], axis=0),
numpy.max(pbounds[: 1, :], axis=0),
numpy.min(pbounds[:, 0, :], axis=0),
numpy.max(pbounds[:, 1, :], axis=0),
))
return bounds
@ -202,8 +225,8 @@ def get_char_as_polygons(
char: str,
resolution: float = 48 * 64,
) -> tuple[list[NDArray[numpy.float64]], float]:
from freetype import Face # type: ignore
from matplotlib.path import Path # type: ignore
from freetype import Face # type: ignore #noqa: PLC0415
from matplotlib.path import Path # type: ignore #noqa: PLC0415
"""
Get a list of polygons representing a single character.

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

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

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

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

146
masque/test/helpers.py Normal file
View file

@ -0,0 +1,146 @@
from typing import Any
from collections.abc import Callable
from copy import deepcopy
import numpy
from numpy.typing import ArrayLike, NDArray
from numpy.testing import assert_allclose
from masque import Pather, Port
from masque.builder.tools import RenderStep
def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]:
"""
Return lengths for each edge of an implicitly closed vertex loop.
"""
vv = numpy.asarray(vertices, dtype=float)
return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1))
def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None:
"""
Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`.
"""
assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol)
def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None:
"""
Assert that an object's single-shape bounds match `expected`.
"""
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
def normalized_route_data(data: Any) -> Any:
"""
Return a deterministic, comparison-friendly representation of route data.
"""
if isinstance(data, dict):
return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0])))
if isinstance(data, list | tuple):
return tuple(normalized_route_data(value) for value in data)
if isinstance(data, numpy.ndarray):
return tuple(normalized_route_data(value) for value in data.tolist())
if isinstance(data, numpy.generic):
return data.item()
try:
hash(data)
except TypeError:
return repr(data)
return data
def route_step_signature(step: RenderStep) -> tuple[Any, ...]:
"""
Return the stable planning-relevant portion of one rendered route step.
"""
return (
step.opcode,
tuple(round(float(value), 9) for value in step.start_port.offset),
None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9),
step.start_port.ptype,
tuple(round(float(value), 9) for value in step.end_port.offset),
None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9),
step.end_port.ptype,
normalized_route_data(step.data),
)
def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]:
"""
Return a deterministic signature for a pather route.
"""
return tuple(route_step_signature(step) for step in pather._paths[portspec])
def route_endpoint(pather: Pather, portspec: str) -> Port:
"""
Return the endpoint of a routed port, falling back to the live port for empty routes.
"""
steps = pather._paths[portspec]
if not steps:
return pather.pattern[portspec]
return steps[-1].end_port
def assert_route_endpoint(
pather: Pather,
portspec: str,
expected: Port,
*,
atol: float = 1e-8,
) -> None:
"""
Assert that a route endpoint matches an expected port pose and ptype.
"""
actual = route_endpoint(pather, portspec)
assert_allclose(actual.offset, expected.offset, atol=atol)
if expected.rotation is None:
assert actual.rotation is None
else:
assert actual.rotation is not None
assert numpy.isclose(actual.rotation, expected.rotation, atol=atol)
assert actual.ptype == expected.ptype
def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None:
"""
Assert a simple render-step bend budget for route signatures.
"""
bend_count = sum(1 for step in pather._paths[portspec] if step.kind == 'bend')
assert bend_count <= max_bends
def assert_route_deterministic(
make_pather: Callable[[], Pather],
route: Callable[[Pather], None],
portspec: str,
) -> None:
"""
Assert that the same route operation produces the same route signature twice.
"""
first = make_pather()
route(first)
first_signature = route_signature(first, portspec)
second = make_pather()
route(second)
assert route_signature(second, portspec) == first_signature
def assert_route_failure_does_not_mutate(
pather: Pather,
route: Callable[[], None],
expected_exception: type[BaseException],
) -> BaseException:
"""
Assert that a failing route operation leaves pending route steps untouched.
"""
before = deepcopy(dict(pather._paths))
try:
route()
except expected_exception as err:
assert dict(pather._paths) == before
return err
raise AssertionError(f'Expected {expected_exception.__name__}')

View file

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

87
masque/test/test_arc.py Normal file
View file

@ -0,0 +1,87 @@
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_equal, assert_allclose
from ..error import PatternError
from ..shapes import Arc
from .helpers import assert_closed_edges_within
def test_arc_init() -> None:
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
assert_equal(a.radii, [10, 10])
assert_equal(a.angles, [0, pi / 2])
assert a.width == 2
def test_arc_to_polygons() -> None:
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
polys = a.to_polygons(num_vertices=32)
assert len(polys) == 1
# Quarter-circle ring section with outer radius 11 and inner radius 9.
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
def test_arc_focus_to_polygons() -> None:
a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos)
polys = a.to_polygons(num_vertices=32)
assert len(polys) == 1
focus = numpy.array([8.0, 0.0])
cuts = a.get_cap_edges()
for angle, cut in zip(a.angles, cuts, strict=True):
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
for point in cut:
delta = point - focus
assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10)
assert numpy.dot(direction, delta) > 0
def test_arc_circle_focus_matches_center() -> None:
center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos)
assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices,
center.to_polygons(num_vertices=32)[0].vertices,
atol=1e-10)
def test_arc_edge_cases() -> None:
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
a.to_polygons(num_vertices=64)
bounds = a.get_bounds_single()
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200))
bounds = arc.get_bounds_single()
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
assert_allclose(bounds, poly_bounds, atol=1e-3)
def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None:
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4,
offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos)
bounds = arc.get_bounds_single()
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
assert_allclose(bounds, poly_bounds, atol=1e-3)
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
with pytest.raises(PatternError, match='valid max_arclen'):
arc.to_polygons(num_vertices=24)
def test_focus_arc_rejects_focus_outside_inner_boundary() -> None:
arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos)
with pytest.raises(PatternError, match='inside both arc boundary ellipses'):
arc.to_polygons(num_vertices=24)
def test_focus_arc_max_arclen_limits_segments() -> None:
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg)
assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2)
def test_arc_rejects_zero_radii_up_front() -> None:
with pytest.raises(PatternError, match='Radii must be positive'):
Arc(radii=(0, 5), angles=(0, 1), width=1)
with pytest.raises(PatternError, match='Radii must be positive'):
Arc(radii=(5, 0), angles=(0, 1), width=1)
with pytest.raises(PatternError, match='Radii must be positive'):
Arc(radii=(0, 0), angles=(0, 1), width=1)

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,242 @@
# ruff: noqa: PLC0415
import pytest
import numpy
from numpy.testing import assert_allclose
from masque.pattern import Pattern
from masque.shapes.polygon import Polygon
from masque.repetition import Grid
from masque.library import Library
from masque.error import PatternError
def _poly_area(poly: Polygon) -> float:
verts = poly.vertices
x = verts[:, 0]
y = verts[:, 1]
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
def test_layer_as_polygons_basic() -> None:
pat = Pattern()
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
polys = pat.layer_as_polygons((1, 0), flatten=False)
assert len(polys) == 1
assert isinstance(polys[0], Polygon)
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
def test_layer_as_polygons_repetition() -> None:
pat = Pattern()
rep = Grid(a_vector=(2, 0), a_count=2)
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
polys = pat.layer_as_polygons((1, 0), flatten=False)
assert len(polys) == 2
# First polygon at (0,0)
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
# Second polygon at (2,0)
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
def test_layer_as_polygons_flatten() -> None:
lib = Library()
child = Pattern()
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
lib['child'] = child
parent = Pattern()
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
assert len(polys) == 1
# Child vertices are rotated by the ref and then translated by the ref offset.
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
assert_allclose(polys[0].vertices, expected, atol=1e-10)
def test_boolean_import_error() -> None:
from masque import boolean
# If pyclipper is not installed, this should raise ImportError
try:
import pyclipper # noqa: F401
pytest.skip("pyclipper is installed, cannot test ImportError")
except ImportError:
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
boolean([], [], operation='union')
def test_polygon_boolean_shortcut() -> None:
poly = Polygon([[0, 0], [1, 0], [1, 1]])
# This should also raise ImportError if pyclipper is missing
try:
import pyclipper # noqa: F401
pytest.skip("pyclipper is installed")
except ImportError:
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
poly.boolean(poly)
def test_boolean_intersection_with_pyclipper() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
result = boolean(
[Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])],
[Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])],
operation='intersection',
)
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
def test_polygon_boolean_shortcut_with_pyclipper() -> None:
pytest.importorskip("pyclipper")
poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
result = poly.boolean(
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
operation='intersection',
)
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
def test_boolean_union_difference_and_xor_with_pyclipper() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
union = boolean([rect_a], [rect_b], operation='union')
assert len(union) == 1
assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10)
assert_allclose(_poly_area(union[0]), 7, atol=1e-10)
difference = boolean([rect_a], [rect_b], operation='difference')
assert len(difference) == 1
assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10)
assert_allclose(_poly_area(difference[0]), 3, atol=1e-10)
xor = boolean([rect_a], [rect_b], operation='xor')
assert len(xor) == 2
assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10)
xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor)
assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))]
def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
raw_result = boolean(
[numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])],
numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]),
operation='intersection',
)
assert len(raw_result) == 1
assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10)
single_shape_result = boolean(
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
operation='intersection',
)
assert len(single_shape_result) == 1
assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
def test_boolean_handles_multi_polygon_inputs() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
result = boolean(
[
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]),
],
[
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]),
],
operation='intersection',
)
assert len(result) == 2
assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10)
result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result)
assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))]
def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
result = boolean([outer], [hole], operation='difference')
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
assert_allclose(_poly_area(result[0]), 64, atol=1e-10)
def test_boolean_nested_hole_and_island_case() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]])
result = boolean([outer, island], [hole], operation='union')
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
assert_allclose(_poly_area(result[0]), 100, atol=1e-10)
def test_boolean_empty_inputs_follow_set_semantics() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
union = boolean([], [rect], operation='union')
assert len(union) == 1
assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
intersection = boolean([], [rect], operation='intersection')
assert intersection == []
difference = boolean([], [rect], operation='difference')
assert difference == []
xor = boolean([], [rect], operation='xor')
assert len(xor) == 1
assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
clip_empty_union = boolean([rect], [], operation='union')
assert len(clip_empty_union) == 1
assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
clip_empty_intersection = boolean([rect], [], operation='intersection')
assert clip_empty_intersection == []
clip_empty_difference = boolean([rect], [], operation='difference')
assert len(clip_empty_difference) == 1
assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
clip_empty_xor = boolean([rect], [], operation='xor')
assert len(clip_empty_xor) == 1
assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
def test_boolean_invalid_inputs_raise_pattern_error() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
for bad in (123, object(), [123]):
with pytest.raises(PatternError, match='Unsupported type'):
boolean([rect], bad, operation='intersection')

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,254 @@
import numpy
import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import MinimumStatus, Pather, RouteFailureDetails
from ..builder.utils import ell
from ..error import BuildError
from ..library import Library
from ..pattern import Pattern
from ..ports import Port
def test_builder_public_imports() -> None:
from masque import PortPather as TopPortPather
from masque import RenderStep as TopRenderStep
from masque import RouteError as TopRouteError
from masque import RouteFailureDetails as TopRouteFailureDetails
from masque import RouteFailurePolicy as TopRouteFailurePolicy
from masque import MinimumStatus as TopMinimumStatus
from masque.builder import PortPather as BuilderPortPather
from masque.builder import RenderStep as BuilderRenderStep
from masque.builder import RouteError as BuilderRouteError
from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails
from masque.builder import RouteFailurePolicy as BuilderRouteFailurePolicy
from masque.builder import MinimumStatus as BuilderMinimumStatus
assert TopPortPather is BuilderPortPather
assert TopRenderStep is BuilderRenderStep
assert TopRouteError is BuilderRouteError
assert TopRouteFailureDetails is BuilderRouteFailureDetails
assert TopRouteFailurePolicy is BuilderRouteFailurePolicy
assert TopMinimumStatus is BuilderMinimumStatus
def test_route_failure_details_enforces_minimum_status_invariants() -> None:
common = {
'operation': 'trace',
'portspec': 'A',
'in_ptype': 'wire',
'out_ptype': 'wide',
'request': {},
'resolved_length': 1,
'resolved_jog': None,
'cause': 'no route',
}
with pytest.raises(BuildError, match='FOUND requires minimum_length'):
RouteFailureDetails(
**common,
minimum_length=None,
minimum_status=MinimumStatus.FOUND,
)
with pytest.raises(BuildError, match='requires minimum_length=None'):
RouteFailureDetails(
**common,
minimum_length=2,
minimum_status=MinimumStatus.NO_ROUTE,
)
def test_plain_build_error_does_not_include_stacktrace() -> None:
assert str(BuildError('plain builder failure')) == 'plain builder failure'
def test_builder_init() -> None:
lib = Library()
b = Pather(lib, name="mypat")
assert b.pattern is lib["mypat"]
assert b.library is lib
def test_builder_place() -> None:
lib = Library()
child = Pattern()
child.ports["A"] = Port((0, 0), 0)
lib["child"] = child
b = Pather(lib)
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
assert "child_A" in b.ports
assert_equal(b.ports["child_A"].offset, [10, 20])
assert "child" in b.pattern.refs
def test_builder_plug() -> None:
lib = Library()
wire = Pattern()
wire.ports["in"] = Port((0, 0), 0)
wire.ports["out"] = Port((10, 0), pi)
lib["wire"] = wire
b = Pather(lib)
b.ports["start"] = Port((100, 100), 0)
# Plug wire's "in" port into builder's "start" port
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
# builder start: (100, 100) rotation 0
# wire in: (0, 0) rotation 0
# wire out: (10, 0) rotation pi
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
b.plug("wire", map_in={"start": "in"})
assert "start" in b.ports
assert_equal(b.ports["start"].offset, [90, 100])
assert b.ports["start"].rotation is not None
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
def test_builder_interface() -> None:
lib = Library()
source = Pattern()
source.ports["P1"] = Port((0, 0), 0)
lib["source"] = source
b = Pather.interface("source", library=lib, name="iface")
assert "in_P1" in b.ports
assert "P1" in b.ports
assert b.pattern is lib["iface"]
def test_builder_set_dead() -> None:
lib = Library()
lib["sub"] = Pattern()
b = Pather(lib)
b.set_dead()
b.place("sub")
assert not b.pattern.has_refs()
def test_builder_dead_ports() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Pather(lib, pattern=pat)
b.set_dead()
# Attempt to plug a device where ports don't line up
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
# This should NOT raise PortError because b is dead
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
# Port A should be removed, and Port B (renamed from D) should be added
assert 'A' not in b.ports
assert 'B' in b.ports
# Verify geometry was not added
assert not b.pattern.has_refs()
assert not b.pattern.has_shapes()
def test_dead_plug_best_effort() -> None:
lib = Library()
pat = Pattern()
pat.ports['A'] = Port((0, 0), 0)
b = Pather(lib, pattern=pat)
b.set_dead()
# Device with multiple ports, none of which line up correctly
other = Pattern(ports={
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
})
# Try to plug. find_transform will fail.
# It should fall back to aligning the first pair ('A' and 'P1').
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
assert 'A' not in b.ports
assert 'B' in b.ports
# Dummy transform aligns A (0,0) with P1 (10,10)
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
# P2 (20,20) rotation pi:
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
# 2. Rotate (10,10) by -pi: (-10,-10)
# 3. Translate by s_port.offset (0,0): (-10,-10)
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
# P2 rot pi + transform rot -pi = 0
assert b.ports['B'].rotation is not None
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)
def test_ell_validates_spacing_length() -> None:
ports = {
'A': Port((0, 0), 0),
'B': Port((0, 1), 0),
'C': Port((0, 2), 0),
}
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3])
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
ell(ports, True, 'min_extension', 5, spacing=[])
def test_ell_handles_array_spacing_when_ccw_none() -> None:
ports = {
'A': Port((0, 0), 0),
'B': Port((0, 1), 0),
}
scalar = ell(ports, None, 'min_extension', 5, spacing=0)
array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0]))
assert scalar == array_zero
with pytest.raises(BuildError, match='Spacing must be 0 or None'):
ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0]))
@pytest.mark.parametrize('bound_type', ['emin', 'emax', 'min_past_furthest'])
@pytest.mark.parametrize(
('rotation', 'expected'),
[
(0, 5),
(pi / 2, 7),
(pi / 6, 5),
(pi / 3, 7),
(pi / 4, 5),
],
)
def test_ell_extension_vector_selects_dominant_route_axis(
bound_type: str,
rotation: float,
expected: float,
) -> None:
result = ell({'A': Port((0, 0), rotation)}, None, bound_type, (5, 7), spacing=0)
assert_allclose(result['A'], expected)
@pytest.mark.parametrize('bound', [(-1, 2), (1, -2), (numpy.nan, 2), (1, numpy.inf), (1, 2, 3)])
def test_ell_rejects_invalid_extension_vector(bound: tuple[float, ...]) -> None:
with pytest.raises(BuildError, match='bound|negative'):
ell({'A': Port((0, 0), 0)}, None, 'emin', bound, spacing=0)
def test_ell_position_vector_still_projects_onto_route_direction() -> None:
result = ell({'A': Port((0, 0), pi)}, None, 'pmax', (5, 7), spacing=0)
assert_allclose(result['A'], 5)
def test_ell_rejects_invalid_bound_type() -> None:
with pytest.raises(BuildError, match='Invalid bound type'):
ell({'A': Port((0, 0), 0)}, None, 'nearest', 5, spacing=0)

View file

@ -0,0 +1,17 @@
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Circle, Polygon
def test_circle_init() -> None:
c = Circle(radius=10, offset=(5, 5))
assert c.radius == 10
assert_equal(c.offset, [5, 5])
def test_circle_to_polygons() -> None:
c = Circle(radius=10)
polys = c.to_polygons(num_vertices=32)
assert len(polys) == 1
assert isinstance(polys[0], Polygon)
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)

View file

@ -0,0 +1,26 @@
from numpy import pi
from ..shapes import Arc, Circle, Ellipse
from .helpers import assert_closed_edges_within
def test_shape_arclen() -> None:
e = Ellipse(radii=(10, 5))
polys = e.to_polygons(max_arclen=5)
v = polys[0].vertices
assert_closed_edges_within(v, 5)
assert len(v) > 10
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
polys = a.to_polygons(max_arclen=2)
assert_closed_edges_within(polys[0].vertices, 2)
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
for shape in (
Circle(radius=10),
Ellipse(radii=(10, 20)),
Arc(radii=(10, 20), angles=(0, 1), width=2),
):
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
assert len(polys) == 1
assert len(polys[0].vertices) >= 3

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

@ -0,0 +1,168 @@
import io
import numpy
import ezdxf
from numpy.testing import assert_allclose
from pathlib import Path
from ..pattern import Pattern
from ..library import Library
from ..shapes import Path as MPath, Polygon
from ..repetition import Grid
from ..file import dxf
def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
return bool(
numpy.allclose(actual, expected)
or numpy.allclose(actual, expected[::-1])
)
def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()}
def test_dxf_roundtrip(tmp_path: Path):
lib = Library()
pat = Pattern()
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
pat.polygon("1", vertices=poly_verts)
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
pat.path("2", vertices=path_verts, width=2)
# Two-point paths remain paths rather than being polygonized.
path2_verts = numpy.array([[40, 0], [50, 10]])
pat.path("3", vertices=path2_verts, width=0)
subpat = Pattern()
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = subpat
pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3))
lib["top"] = pat
dxf_file = tmp_path / "test.dxf"
dxf.writefile(lib, "top", dxf_file)
read_lib, _ = dxf.readfile(dxf_file)
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
assert len(polys) >= 1
poly_read = polys[0]
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
assert len(paths) >= 1
path_read = paths[0]
assert _matches_open_path(path_read.vertices, path_verts)
assert path_read.width == 2
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
assert len(paths2) >= 1
path2_read = paths2[0]
assert _matches_open_path(path2_read.vertices, path2_verts)
assert path2_read.width == 0
assert "sub" in read_lib
found_grid = False
for target, reflist in top_pat.refs.items():
if target.upper() == "SUB":
for ref in reflist:
if isinstance(ref.repetition, Grid):
assert ref.repetition.a_count == 2
assert ref.repetition.b_count == 3
assert_allclose(ref.repetition.a_vector, (10, 0))
assert_allclose(ref.repetition.b_vector, (0, 10))
found_grid = True
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
def test_dxf_manhattan_precision(tmp_path: Path):
lib = Library()
sub = Pattern()
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = sub
top = Pattern()
angle = numpy.pi / 2 # 90 degrees
top.ref("sub", offset=(0, 0), rotation=angle,
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
lib["top"] = top
dxf_file = tmp_path / "precision.dxf"
dxf.writefile(lib, "top", dxf_file)
# Near-integer rotated basis vectors round-trip as a Manhattan Grid.
read_lib, _ = dxf.readfile(dxf_file)
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
ref = read_top.refs[target_name][0]
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"
def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path):
lib = Library()
sub = Pattern()
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = sub
top = Pattern()
top.ref(
"sub",
offset=(0, 0),
rotation=numpy.pi / 2,
repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2),
)
lib["top"] = top
dxf_file = tmp_path / "rotated_grid.dxf"
dxf.writefile(lib, "top", dxf_file)
read_lib, _ = dxf.readfile(dxf_file)
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
ref = read_top.refs[target_name][0]
assert isinstance(ref.repetition, Grid)
actual = ref.repetition.displacements
expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements
assert_allclose(
actual[numpy.lexsort((actual[:, 1], actual[:, 0]))],
expected[numpy.lexsort((expected[:, 1], expected[:, 0]))],
)
def test_dxf_read_legacy_polyline() -> None:
doc = ezdxf.new()
msp = doc.modelspace()
msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True)
stream = io.StringIO()
doc.write(stream)
stream.seek(0)
read_lib, _ = dxf.read(stream)
top_pat = read_lib.get("Model") or list(read_lib.values())[0]
polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)]
assert len(polys) == 1
assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]]))
def test_dxf_read_ignores_unreferenced_setup_blocks() -> None:
lib = Library({"top": Pattern()})
stream = io.StringIO()
dxf.write(lib, "top", stream)
stream.seek(0)
read_lib, _ = dxf.read(stream)
assert set(read_lib) == {"Model"}

View file

@ -0,0 +1,29 @@
from numpy import pi
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Ellipse
def test_ellipse_init() -> None:
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
assert_equal(e.radii, [10, 5])
assert_equal(e.offset, [1, 2])
assert e.rotation == pi / 4
def test_ellipse_to_polygons() -> None:
e = Ellipse(radii=(10, 5))
polys = e.to_polygons(num_vertices=64)
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
bounds = ellipse.get_bounds_single()
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
assert_allclose(bounds, poly_bounds, atol=1e-3)
def test_ellipse_integer_radii_scale_cleanly() -> None:
ellipse = Ellipse(radii=(10, 20))
ellipse.scale_by(0.5)
assert_allclose(ellipse.radii, [5, 10])

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

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

View file

@ -0,0 +1,138 @@
from pathlib import Path
from typing import cast
import pytest
from numpy.testing import assert_allclose
from ..pattern import Pattern
from ..library import Library
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
from ..repetition import Grid, Arbitrary
def create_test_library(for_gds: bool = False) -> Library:
lib = Library()
pat_poly = Pattern()
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
lib["polygons"] = pat_poly
pat_paths = Pattern()
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
if for_gds:
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
lib["paths"] = pat_paths
pat_circles = Pattern()
if for_gds:
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
else:
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
lib["circles"] = pat_circles
pat_refs = Pattern()
pat_refs.ref("polygons", offset=(0, 0))
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
lib["refs"] = pat_refs
pat_rep_shapes = Pattern()
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
lib["rep_shapes"] = pat_rep_shapes
if for_gds:
lib.wrap_repeated_shapes()
return lib
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
from ..file import gdsii
lib = create_test_library(for_gds=True)
gds_file = tmp_path / "full_test.gds"
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, _ = gdsii.readfile(gds_file)
for name in lib:
assert name in read_lib
read_paths = read_lib["paths"]
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
assert p_flush.cap == MPath.Cap.Flush
p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
assert p_square.cap == MPath.Cap.Square
p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
assert p_circle.cap == MPath.Cap.Circle
p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
assert p_custom.cap == MPath.Cap.SquareCustom
assert p_custom.cap_extensions is not None
assert_allclose(p_custom.cap_extensions, (1, 5))
read_refs = read_lib["refs"]
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
assert len(arefs) == 1
assert isinstance(arefs[0].repetition, Grid)
assert arefs[0].repetition.a_count == 3
assert arefs[0].repetition.b_count == 2
# GDS stores repeated shapes through refs created by wrap_repeated_shapes().
assert len(read_lib["rep_shapes"].refs) > 0
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
pytest.importorskip("fatamorgana")
from ..file import oasis
lib = create_test_library(for_gds=False)
oas_file = tmp_path / "full_test.oas"
oasis.writefile(lib, oas_file, units_per_micron=1000)
read_lib, _ = oasis.readfile(oas_file)
for name in lib:
assert name in read_lib
read_circles = read_lib["circles"]
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
assert read_circles.shapes[(3, 0)][0].radius == 5
read_paths = read_lib["paths"]
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
read_rep_shapes = read_lib["rep_shapes"]
poly = read_rep_shapes.shapes[(4, 0)][0]
assert poly.repetition is not None
assert isinstance(poly.repetition, Grid)
assert poly.repetition.a_count == 5
def test_gdsii_rect_collection_roundtrip(tmp_path: Path) -> None:
from ..file import gdsii
lib = Library()
pat = Pattern()
pat.shapes[(5, 0)].append(
RectCollection(
rects=[[0, 0, 10, 5], [20, -5, 30, 10]],
annotations={'1': ['rects']},
)
)
lib['rects'] = pat
gds_file = tmp_path / 'rect_collection.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
read_lib, _ = gdsii.readfile(gds_file)
polys = read_lib['rects'].shapes[(5, 0)]
assert len(polys) == 2
assert all(isinstance(poly, Polygon) for poly in polys)
assert_allclose(polys[0].vertices, [[0, 0], [0, 5], [10, 5], [10, 0]])
assert_allclose(polys[1].vertices, [[20, -5], [20, 10], [30, 10], [30, -5]])
assert polys[0].annotations == {'1': ['rects']}
assert polys[1].annotations == {'1': ['rects']}

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

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

View file

@ -0,0 +1,604 @@
from pathlib import Path
import subprocess
import sys
import textwrap
import klamath
import numpy
import pytest
pytest.importorskip('pyarrow')
from .. import Ref, Label, PatternError
from ..library import Library
from ..pattern import Pattern
from ..repetition import Grid
from ..shapes import Path as MPath, Polygon, PolyCollection, RectCollection
from ..file import gdsii
from ..file.gdsii import arrow as gdsii_arrow
from tools.generate_gds_perf import write_fixture
if not gdsii_arrow.is_available():
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None:
if not annotations:
return None
return tuple(sorted((key, tuple(values)) for key, values in annotations.items()))
def _coord_key(values: object) -> tuple[int, ...] | tuple[tuple[int, int], ...]:
arr = numpy.rint(numpy.asarray(values, dtype=float)).astype(int)
if arr.ndim == 1:
return tuple(arr.tolist())
return tuple(tuple(row.tolist()) for row in arr)
def _canonical_polygon_key(vertices: object) -> tuple[tuple[int, int], ...]:
arr = numpy.rint(numpy.asarray(vertices, dtype=float)).astype(int)
rows = [tuple(tuple(row.tolist()) for row in numpy.roll(arr, -shift, axis=0)) for shift in range(arr.shape[0])]
rev = arr[::-1]
rows.extend(tuple(tuple(row.tolist()) for row in numpy.roll(rev, -shift, axis=0)) for shift in range(rev.shape[0]))
return min(rows)
def _shape_key(shape: object, layer: tuple[int, int]) -> list[tuple[object, ...]]:
if isinstance(shape, MPath):
cap_extensions = None if shape.cap_extensions is None else _coord_key(shape.cap_extensions)
return [(
'path',
layer,
_coord_key(shape.vertices),
_coord_key(shape.offset),
int(round(float(shape.width))),
shape.cap.name,
cap_extensions,
_annotations_key(shape.annotations),
)]
keys = []
for poly in shape.to_polygons():
keys.append((
'polygon',
layer,
_canonical_polygon_key(poly.vertices),
_coord_key(poly.offset),
_annotations_key(poly.annotations),
))
return keys
def _ref_keys(target: str, ref: object) -> list[tuple[object, ...]]:
keys = []
for transform in ref.as_transforms():
keys.append((
target,
_coord_key(transform[:2]),
round(float(transform[2]), 8),
round(float(transform[4]), 8),
bool(int(round(float(transform[3])))),
_annotations_key(ref.annotations),
))
return keys
def _label_key(layer: tuple[int, int], label: object) -> tuple[object, ...]:
return (
layer,
label.string,
_coord_key(label.offset),
_annotations_key(label.annotations),
)
def _pattern_summary(pattern: Pattern) -> dict[str, object]:
shape_keys: list[tuple[object, ...]] = []
for layer, shapes in pattern.shapes.items():
for shape in shapes:
shape_keys.extend(_shape_key(shape, layer))
ref_keys: list[tuple[object, ...]] = []
for target, refs in pattern.refs.items():
for ref in refs:
ref_keys.extend(_ref_keys(target, ref))
label_keys = [
_label_key(layer, label)
for layer, labels in pattern.labels.items()
for label in labels
]
return {
'shapes': sorted(shape_keys),
'refs': sorted(ref_keys),
'labels': sorted(label_keys),
}
def _library_summary(lib: Library) -> dict[str, dict[str, object]]:
return {name: _pattern_summary(pattern) for name, pattern in lib.items()}
def _make_arrow_test_library() -> Library:
lib = Library()
leaf = Pattern()
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]], annotations={'1': ['leaf-poly']})
leaf.polygon((2, 0), vertices=[[40, 0], [50, 0], [50, 10], [40, 10]])
leaf.polygon((1, 0), vertices=[[20, 0], [30, 0], [30, 10], [20, 10]])
leaf.polygon((1, 0), vertices=[[80, 0], [90, 0], [90, 10], [80, 10]])
leaf.polygon((2, 0), vertices=[[60, 0], [70, 0], [70, 10], [60, 10]], annotations={'18': ['leaf-poly-2']})
leaf.label((10, 0), string='LEAF', offset=(3, 4), annotations={'10': ['leaf-label']})
lib['leaf'] = leaf
child = Pattern()
child.path(
(2, 0),
vertices=[[0, 0], [15, 5], [30, 5]],
width=6,
cap=MPath.Cap.SquareCustom,
cap_extensions=(2, 4),
annotations={'2': ['child-path']},
)
child.label((11, 0), string='CHILD', offset=(7, 8), annotations={'11': ['child-label']})
child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25, annotations={'12': ['child-ref']})
lib['child'] = child
sibling = Pattern()
sibling.polygon((3, 0), vertices=[[0, 0], [5, 0], [5, 6], [0, 6]])
sibling.label((12, 0), string='SIB', offset=(1, 2), annotations={'13': ['sib-label']})
sibling.ref(
'leaf',
offset=(-50, 60),
repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2),
annotations={'14': ['sib-ref']},
)
lib['sibling'] = sibling
fanout = Pattern()
fanout.ref('leaf', offset=(0, 0))
fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1)
fanout.ref('leaf', offset=(20, 0))
fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3))
fanout.ref('child', offset=(40, 0), mirrored=True, rotation=numpy.pi / 4, scale=1.2,
repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2))
fanout.ref('leaf', offset=(50, 0), repetition=Grid(a_vector=(6, 0), a_count=3, b_vector=(0, 8), b_count=2))
fanout.ref('leaf', offset=(60, 0), annotations={'19': ['fanout-sref']})
fanout.ref('child', offset=(70, 0), repetition=Grid(a_vector=(4, 0), a_count=2, b_vector=(0, 5), b_count=2),
annotations={'20': ['fanout-aref']})
lib['fanout'] = fanout
top = Pattern()
top.ref('child', offset=(500, 600), annotations={'15': ['top-child-ref']})
top.ref('sibling', offset=(-100, 50), rotation=numpy.pi, annotations={'16': ['top-sibling-ref']})
top.ref('fanout', offset=(250, -75))
top.label((13, 0), string='TOP', offset=(0, 0), annotations={'17': ['top-label']})
lib['top'] = top
return lib
def _write_invalid_path_type_fixture(path: Path) -> None:
with path.open('wb') as stream:
header = klamath.library.FileHeader(
name=b'test',
user_units_per_db_unit=1.0,
meters_per_db_unit=1e-9,
)
header.write(stream)
elem = klamath.elements.Path(
layer=(1, 0),
path_type=3,
width=10,
extension=(0, 0),
xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32),
properties={},
)
klamath.library.write_struct(stream, name=b'top', elements=[elem])
klamath.records.ENDLIB.write(stream, None)
def test_gdsii_arrow_matches_gdsii_readfile(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'arrow_roundtrip.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
canonical_lib, canonical_info = gdsii.readfile(gds_file)
arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file)
assert canonical_info == arrow_info
assert _library_summary(canonical_lib) == _library_summary(arrow_lib)
def test_gdsii_arrow_matches_gdsii_readfile_for_gzipped_file(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'arrow_roundtrip.gds.gz'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
canonical_lib, canonical_info = gdsii.readfile(gds_file)
arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file)
assert canonical_info == arrow_info
assert _library_summary(canonical_lib) == _library_summary(arrow_lib)
def test_gdsii_arrow_readfile_arrow_returns_native_payload(tmp_path: Path) -> None:
gds_file = tmp_path / 'many_cells_native.gds'
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
libarr, info = gdsii_arrow.readfile_arrow(gds_file)
assert info['name'] == manifest.library_name
assert libarr['lib_name'].as_py() == manifest.library_name
assert len(libarr['cells']) == manifest.cells
assert 0 < len(libarr['layers']) <= manifest.layers
def test_gdsii_arrow_readfile_arrow_reads_gzipped_file(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'native_payload.gds.gz'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
libarr, info = gdsii_arrow.readfile_arrow(gds_file)
assert info['name'] == 'masque-klamath'
assert libarr['lib_name'].as_py() == 'masque-klamath'
assert len(libarr['cells']) == len(lib)
assert len(libarr['layers']) > 0
def test_gdsii_arrow_removed_raw_mode_arg(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'removed_raw_mode.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
libarr, _ = gdsii_arrow.readfile_arrow(gds_file)
with pytest.raises(TypeError):
gdsii_arrow.readfile(gds_file, raw_mode=False)
with pytest.raises(TypeError):
gdsii_arrow.read_arrow(libarr, raw_mode=False)
def test_gdsii_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None:
gds_file = tmp_path / 'invalid.gds'
gds_file.write_bytes(b'not-a-gds')
script = textwrap.dedent(f"""
from masque.file.gdsii import arrow as gdsii_arrow
try:
gdsii_arrow.readfile({str(gds_file)!r})
except Exception as exc:
print(type(exc).__module__)
print(type(exc).__qualname__)
print(exc)
else:
raise SystemExit('expected gdsii_arrow.readfile() to fail')
""")
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
assert 'klamath.basic' in result.stdout
assert 'KlamathError' in result.stdout
def test_gdsii_arrow_reads_small_perf_fixture(tmp_path: Path) -> None:
gds_file = tmp_path / 'many_cells_smoke.gds'
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
lib, info = gdsii_arrow.readfile(gds_file)
assert info['name'] == manifest.library_name
assert len(lib) == manifest.cells
assert 'TOP' in lib
assert sum(len(refs) for refs in lib['TOP'].refs.values()) > 0
def test_gdsii_arrow_degenerate_aref_decodes_as_single_transform(tmp_path: Path) -> None:
lib = Library()
leaf = Pattern()
leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
lib['leaf'] = leaf
top = Pattern()
top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1))
lib['top'] = top
gds_file = tmp_path / 'degenerate_aref.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
canonical_lib, _ = gdsii.readfile(gds_file)
arrow_lib, _ = gdsii_arrow.readfile(gds_file)
assert _library_summary(arrow_lib) == _library_summary(canonical_lib)
decoded_ref = arrow_lib['top'].refs['leaf'][0]
assert decoded_ref.repetition is None
def test_gdsii_arrow_plain_srefs_decode_without_arbitrary(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'plain_srefs.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
arrow_lib, _ = gdsii_arrow.readfile(gds_file)
fanout = arrow_lib['fanout']
plain_leaf_refs = [
ref
for ref in fanout.refs['leaf']
if ref.annotations is None and ref.repetition is None
]
assert len(plain_leaf_refs) == 2
assert all(type(ref.repetition) is not Grid for ref in plain_leaf_refs)
def test_gdsii_arrow_degenerate_aref_schema_normalizes_to_sref(tmp_path: Path) -> None:
lib = Library()
leaf = Pattern()
leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
lib['leaf'] = leaf
top = Pattern()
top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1))
lib['top'] = top
gds_file = tmp_path / 'degenerate_aref_schema.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
cells = libarr['cells'].values
cell_ids = cells.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top')
srefs = cells.field('srefs')[top_index].as_py()
arefs = cells.field('arefs')[top_index].as_py()
assert len(srefs) == 1
assert len(arefs) == 0
assert cell_names[srefs[0]['target']] == 'leaf'
def test_gdsii_arrow_boundary_batch_schema(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'arrow_batches.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
cells = libarr['cells'].values
cell_ids = cells.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
layer_table = [
((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF)
for layer in libarr['layers'].values.to_numpy()
]
leaf_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'leaf')
rect_batches = cells.field('rect_batches')[leaf_index].as_py()
boundary_batches = cells.field('boundary_batches')[leaf_index].as_py()
boundary_props = cells.field('boundary_props')[leaf_index].as_py()
assert len(rect_batches) == 2
assert len(boundary_batches) == 0
assert len(boundary_props) == 2
rects_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in rect_batches}
assert rects_by_layer[(1, 0)]['rects'] == [20, 0, 30, 10, 80, 0, 90, 10]
assert rects_by_layer[(2, 0)]['rects'] == [40, 0, 50, 10]
props_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in boundary_props}
assert sorted(props_by_layer) == [(1, 0), (2, 0)]
assert props_by_layer[(1, 0)]['properties'][0]['value'] == 'leaf-poly'
assert props_by_layer[(2, 0)]['properties'][0]['value'] == 'leaf-poly-2'
def test_gdsii_arrow_rect_batch_schema_for_mixed_layer(tmp_path: Path) -> None:
lib = Library()
top = Pattern()
top.shapes[(1, 0)].append(RectCollection(rects=[[0, 0, 10, 10], [20, 0, 30, 10], [40, 0, 50, 10], [60, 0, 70, 10]]))
top.polygon((1, 0), vertices=[[80, 0], [85, 10], [90, 0]])
top.polygon((1, 0), vertices=[[100, 0], [105, 10], [110, 0]])
lib['top'] = top
gds_file = tmp_path / 'arrow_rect_batches.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
cells = libarr['cells'].values
cell_ids = cells.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
layer_table = [
((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF)
for layer in libarr['layers'].values.to_numpy()
]
top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top')
rect_batches = cells.field('rect_batches')[top_index].as_py()
boundary_batches = cells.field('boundary_batches')[top_index].as_py()
assert len(rect_batches) == 1
assert tuple(layer_table[rect_batches[0]['layer']]) == (1, 0)
assert rect_batches[0]['rects'] == [
0, 0, 10, 10,
20, 0, 30, 10,
40, 0, 50, 10,
60, 0, 70, 10,
]
assert len(boundary_batches) == 1
assert tuple(layer_table[boundary_batches[0]['layer']]) == (1, 0)
assert boundary_batches[0]['vertex_offsets'] == [0, 3]
def test_gdsii_arrow_ref_schema(tmp_path: Path) -> None:
lib = _make_arrow_test_library()
gds_file = tmp_path / 'arrow_ref_batches.gds'
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
cells = libarr['cells'].values
cell_ids = cells.field('id').to_numpy()
cell_names = libarr['cell_names'].as_py()
fanout_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'fanout')
srefs = cells.field('srefs')[fanout_index].as_py()
arefs = cells.field('arefs')[fanout_index].as_py()
sref_props = cells.field('sref_props')[fanout_index].as_py()
aref_props = cells.field('aref_props')[fanout_index].as_py()
sref_target_ids = [entry['target'] for entry in srefs]
sref_targets = [cell_names[target] for target in sref_target_ids]
assert sorted(sref_targets) == ['child', 'leaf', 'leaf']
assert sref_target_ids == sorted(sref_target_ids)
sref_by_target = {}
for entry in srefs:
sref_by_target.setdefault(cell_names[entry['target']], []).append(entry)
assert [entry['invert_y'] for entry in sref_by_target['child']] == [True]
assert [entry['scale'] for entry in sref_by_target['child']] == pytest.approx([1.1])
assert len(sref_by_target['leaf']) == 2
aref_target_ids = [entry['target'] for entry in arefs]
aref_targets = [cell_names[target] for target in aref_target_ids]
assert sorted(aref_targets) == ['child', 'leaf', 'leaf']
assert aref_target_ids == sorted(aref_target_ids)
aref_by_target = {}
for entry in arefs:
aref_by_target.setdefault(cell_names[entry['target']], []).append(entry)
assert [entry['invert_y'] for entry in aref_by_target['child']] == [True]
assert [entry['scale'] for entry in aref_by_target['child']] == pytest.approx([1.2])
assert len(aref_by_target['leaf']) == 2
assert len(sref_props) == 1
assert cell_names[sref_props[0]['target']] == 'leaf'
assert sref_props[0]['properties'][0]['value'] == 'fanout-sref'
assert len(aref_props) == 1
assert cell_names[aref_props[0]['target']] == 'child'
assert aref_props[0]['properties'][0]['value'] == 'fanout-aref'
def test_gdsii_arrow_invalid_path_type_matches_gdsii(tmp_path: Path) -> None:
gds_file = tmp_path / 'invalid_path_type.gds'
_write_invalid_path_type_fixture(gds_file)
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
gdsii.readfile(gds_file)
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
gdsii_arrow.readfile(gds_file)
def test_raw_ref_grid_label_constructors_match_public() -> None:
raw_grid = Grid._from_raw(
a_vector=numpy.array([20, 0]),
a_count=3,
b_vector=numpy.array([0, 30]),
b_count=2,
)
public_grid = Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2)
assert raw_grid == public_grid
raw_poly = Polygon._from_raw(
vertices=numpy.array([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]),
annotations={'1': ['poly']},
)
public_poly = Polygon(
vertices=[[0, 0], [5, 0], [5, 5], [0, 5]],
annotations={'1': ['poly']},
)
assert raw_poly == public_poly
raw_poly_collection = PolyCollection._from_raw(
vertex_lists=numpy.array([
[0.0, 0.0], [2.0, 0.0], [2.0, 2.0],
[10.0, 10.0], [12.0, 10.0], [12.0, 12.0],
]),
vertex_offsets=numpy.array([0, 3], dtype=numpy.uint32),
annotations={'2': ['pc']},
)
public_poly_collection = PolyCollection(
vertex_lists=[[0, 0], [2, 0], [2, 2], [10, 10], [12, 10], [12, 12]],
vertex_offsets=[0, 3],
annotations={'2': ['pc']},
)
assert raw_poly_collection == public_poly_collection
assert [tuple(s.indices(len(raw_poly_collection.vertex_lists))) for s in raw_poly_collection.vertex_slices] == [(0, 3, 1), (3, 6, 1)]
raw_rect_collection = RectCollection._from_raw(
rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]),
annotations={'3': ['rects']},
)
public_rect_collection = RectCollection(
rects=[[0, 0, 5, 5], [10, 10, 12, 12]],
annotations={'3': ['rects']},
)
assert raw_rect_collection == public_rect_collection
raw_ref_empty = Ref._from_raw(
offset=numpy.array([100, 200]),
rotation=numpy.pi / 2,
mirrored=False,
scale=1.0,
repetition=None,
annotations=None,
)
public_ref_empty = Ref(
offset=(100, 200),
rotation=numpy.pi / 2,
mirrored=False,
scale=1.0,
repetition=None,
annotations=None,
)
assert raw_ref_empty.annotations is None
assert raw_ref_empty == public_ref_empty
raw_ref = Ref._from_raw(
offset=numpy.array([100, 200]),
rotation=numpy.pi / 2,
mirrored=True,
scale=1.25,
repetition=raw_grid,
annotations={'12': ['child-ref']},
)
public_ref = Ref(
offset=(100, 200),
rotation=numpy.pi / 2,
mirrored=True,
scale=1.25,
repetition=public_grid,
annotations={'12': ['child-ref']},
)
assert raw_ref == public_ref
assert numpy.array_equal(raw_ref.as_transforms(), public_ref.as_transforms())
raw_label_empty = Label._from_raw(
'LEAF',
offset=numpy.array([3, 4]),
annotations=None,
)
public_label_empty = Label(
'LEAF',
offset=(3, 4),
annotations=None,
)
assert raw_label_empty.annotations is None
assert raw_label_empty == public_label_empty
raw_label = Label._from_raw(
'LEAF',
offset=numpy.array([3, 4]),
annotations={'10': ['leaf-label']},
)
public_label = Label(
'LEAF',
offset=(3, 4),
annotations={'10': ['leaf-label']},
)
assert raw_label == public_label
assert numpy.array_equal(raw_label.get_bounds_single(), public_label.get_bounds_single())

View file

@ -0,0 +1,580 @@
from pathlib import Path
import gzip
import io
import numpy
import pytest
from numpy.testing import assert_allclose
from ..file import gdsii
from ..file.gdsii import lazy as gdsii_lazy
from ..file.gdsii import writer as gdsii_writer
from ..file.utils import preflight_source_aware
from ..error import LibraryError
from ..pattern import Pattern
from ..ports import Port
from ..library import (
IBorrowing, IMaterializable, LayerMappedView, LazyLibrary, Library, LibraryView,
OverlayLibrary, PortLoadView,
)
def _make_lazy_port_library() -> Library:
lib = Library()
leaf = Pattern()
leaf.label(layer=(10, 0), string='A:type1 0', offset=(5, 0))
lib['leaf'] = leaf
child = Pattern()
child.ref('leaf', offset=(10, 20), rotation=numpy.pi / 2)
lib['child'] = child
top = Pattern()
top.ref('child', offset=(100, 200))
lib['top'] = top
return lib
def test_gdsii_write_requires_units_without_gds_metadata() -> None:
lib = Library({'top': Pattern()})
lib.library_info = None # type: ignore[attr-defined]
with pytest.raises(LibraryError, match='meters_per_unit is required'):
gdsii.write(lib, io.BytesIO())
def test_gdsii_write_plain_library_defaults() -> None:
lib = _make_lazy_port_library()
stream = io.BytesIO()
gdsii.write(lib, stream, 1e-9)
stream.seek(0)
_roundtrip, info = gdsii.read(stream)
assert info == {
'name': 'masque-klamath',
'meters_per_unit': 1e-9,
'logical_units_per_unit': 1,
}
def test_gdsii_lazy_write_materializes_transiently() -> None:
lib = LazyLibrary()
lib['top'] = Pattern()
stream = io.BytesIO()
gdsii.write(
lib,
stream,
meters_per_unit=1e-9,
logical_units_per_unit=1,
library_name='transient',
)
assert not lib.cache
stream.seek(0)
roundtrip, info = gdsii.read(stream)
assert set(roundtrip) == {'top'}
assert info['name'] == 'transient'
def test_gdsii_raw_copy_provenance_cycle_falls_back() -> None:
class SelfBorrowingView(LibraryView, IBorrowing):
def borrowed_sources(self) -> tuple['SelfBorrowingView', ...]:
return (self,)
def source_cell(self, name: str) -> tuple['SelfBorrowingView', str] | None:
return self, name
view = SelfBorrowingView({'top': Pattern()})
assert gdsii_writer._resolve_raw_struct(view, 'top') is None
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_source.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-lazy')
lib, info = gdsii_lazy.readfile(gds_file)
assert info['name'] == 'classic-lazy'
assert lib.source_order() == ('leaf', 'child', 'top')
assert lib.child_graph(dangling='ignore') == {
'leaf': set(),
'child': {'leaf'},
'top': {'child'},
}
assert lib.parent_graph() == {
'leaf': {'child'},
'child': {'top'},
'top': set(),
}
assert lib.tops() == ['top']
global_refs = lib.find_refs_global('leaf')
assert_allclose(global_refs[('top', 'child', 'leaf')], [[110, 220, numpy.pi / 2, 0, 1]])
assert not lib._cache
with pytest.raises(ValueError, match='dangling-reference mode'):
lib.child_graph(dangling='typo')
with pytest.raises(ValueError, match='dangling-reference mode'):
lib.find_refs_local('leaf', dangling='typo')
child = lib['child']
assert list(child.refs.keys()) == ['leaf']
assert set(lib._cache) == {'child'}
def test_gdsii_lazy_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_cached_graph.gds'
gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9)
lib, _ = gdsii_lazy.readfile(gds_file)
del lib['child'].refs['leaf']
assert lib.child_graph(dangling='ignore')['child'] == set()
assert lib.find_refs_local('leaf') == {}
def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_subtree_source.gds'
src = _make_lazy_port_library()
src['unused'] = Pattern()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-subtree')
raw, _ = gdsii_lazy.readfile(gds_file)
subtree = raw.subtree('top')
assert isinstance(raw, IMaterializable)
assert not isinstance(raw, IBorrowing)
assert isinstance(subtree, IMaterializable)
assert isinstance(subtree, IBorrowing)
assert subtree.source_order() == ('leaf', 'child', 'top')
assert not hasattr(subtree, 'library_info')
assert not raw._cache
out_file = tmp_path / 'lazy_subtree_out.gds'
gdsii.writefile(subtree, out_file)
assert not raw._cache
roundtrip, info = gdsii.readfile(out_file)
assert info['name'] == 'classic-subtree'
assert set(roundtrip) == {'leaf', 'child', 'top'}
def test_gdsii_lazy_borrowed_sources_require_matching_units(tmp_path: Path) -> None:
gds_a = tmp_path / 'units_a.gds'
gds_b = tmp_path / 'units_b.gds'
source = Library({'top': Pattern()})
gdsii.writefile(source, gds_a, meters_per_unit=1e-9, library_name='units-a')
gdsii.writefile(source, gds_b, meters_per_unit=2e-9, library_name='units-b')
lazy_a, _ = gdsii_lazy.readfile(gds_a)
lazy_b, _ = gdsii_lazy.readfile(gds_b)
overlay = OverlayLibrary()
overlay.add_source(lazy_a)
overlay.add_source(lazy_b, rename_theirs=lambda lib, name: lib.get_name(name))
with pytest.raises(LibraryError, match='identical units'):
gdsii.write(overlay, io.BytesIO())
def test_gdsii_lazy_overlay_subtree_preserves_source_laziness(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_overlay_subtree_source.gds'
src = _make_lazy_port_library()
src['unused'] = Pattern()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-subtree')
raw, _ = gdsii_lazy.readfile(gds_file)
overlay = OverlayLibrary()
overlay.add_source(raw)
subtree = overlay.subtree('top')
assert isinstance(subtree, OverlayLibrary)
assert subtree.borrowed_sources() == (raw,)
out_file = tmp_path / 'lazy_overlay_subtree_out.gds'
gdsii.writefile(subtree, out_file)
assert not raw._cache
roundtrip, info = gdsii.readfile(out_file)
assert info['name'] == 'overlay-subtree'
assert set(roundtrip) == {'leaf', 'child', 'top'}
def test_gdsii_lazy_port_load_view_keeps_raw_source_unmodified(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_ports.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
assert not hasattr(processed, 'library_info')
top = processed['top']
assert set(top.ports) == {'A'}
assert_allclose(top.ports['A'].offset, [110, 225], atol=1e-10)
assert not raw._cache
raw_top = raw['top']
assert not raw_top.ports
def test_gdsii_lazy_port_load_view_detaches_previously_cached_source(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_cached_ports.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-cached-ports')
raw, _ = gdsii_lazy.readfile(gds_file)
raw_top = raw['top']
processed = PortLoadView(raw, ports={
'top': {
'P': Port((1, 2), rotation=0, ptype='wire'),
},
})
processed_top = processed['top']
assert processed_top is not raw_top
assert not raw_top.ports
assert set(processed_top.ports) == {'P'}
assert raw['top'] is raw_top
assert not hasattr(processed, 'close')
def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_port_overrides.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(raw, ports={
'top': {
'P': Port((1, 2), rotation=0, ptype='wire'),
},
})
top = processed['top']
assert set(top.ports) == {'P'}
assert_allclose(top.ports['P'].offset, [1, 2], atol=1e-10)
assert top.ports['P'].rotation == 0
assert top.ports['P'].ptype == 'wire'
assert not raw._cache
raw_top = raw['top']
assert not raw_top.ports
def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_ports_override_extracted.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(
raw,
layers=[(10, 0)],
max_depth=2,
ports={
'top': {
'A': Port((1, 2), rotation=numpy.pi, ptype='manual'),
'B': Port((3, 4), rotation=None, ptype=None),
},
},
)
top = processed['top']
assert set(top.ports) == {'A', 'B'}
assert_allclose(top.ports['A'].offset, [1, 2], atol=1e-10)
assert top.ports['A'].rotation == numpy.pi
assert top.ports['A'].ptype == 'manual'
assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10)
assert top.ports['B'].rotation is None
assert top.ports['B'].ptype is None
assert not raw._cache
def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_ports_replace.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(
raw,
layers=[(10, 0)],
max_depth=2,
ports={
'top': {
'B': Port((3, 4), rotation=None, ptype=None),
},
},
replace=True,
)
top = processed['top']
assert set(top.ports) == {'B'}
assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10)
assert not raw._cache
def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_overlay.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
overlay = OverlayLibrary()
overlay.add_source(processed)
assert not raw._cache
assert not processed._cache
abstract = overlay.abstract('top')
assert set(abstract.ports) == {'A'}
def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_overlay_override.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(raw, ports={
'top': {
'P': Port((1, 2), rotation=0, ptype='wire'),
},
})
overlay = OverlayLibrary()
overlay.add_source(processed)
assert not raw._cache
assert not processed._cache
abstract = overlay.abstract('top')
assert set(abstract.ports) == {'P'}
assert_allclose(abstract.ports['P'].offset, [1, 2], atol=1e-10)
def test_gdsii_lazy_overlay_add_source_can_rename_every_source_cell() -> None:
src = _make_lazy_port_library()
overlay = OverlayLibrary()
rename_map = overlay.add_source(
src,
rename_theirs=lambda _lib, name: f'mapped_{name}',
rename_when='always',
)
assert rename_map == {
'leaf': 'mapped_leaf',
'child': 'mapped_child',
'top': 'mapped_top',
}
assert tuple(overlay.keys()) == ('mapped_leaf', 'mapped_child', 'mapped_top')
assert 'mapped_leaf' in overlay['mapped_child'].refs
def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None:
src = _make_lazy_port_library()
with pytest.raises(TypeError, match='rename_theirs'):
OverlayLibrary().add_source(src, rename_theirs=None, rename_when='always')
with pytest.raises(ValueError, match='rename mode'):
OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type]
def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_roundtrip.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip')
raw, _ = gdsii_lazy.readfile(gds_file)
processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
out_file = tmp_path / 'lazy_roundtrip_out.gds'
gdsii.writefile(processed, out_file)
assert out_file.read_bytes() == gds_file.read_bytes()
def test_gdsii_lazy_source_aware_write_copies_untouched_structures(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'classic_copy_source.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-copy')
raw, _ = gdsii_lazy.readfile(gds_file)
ports = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
mapped = LayerMappedView(ports, lambda layer: (20, 0) if layer == (10, 0) else layer, copy_through=True)
prepared = preflight_source_aware(mapped)
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
def fail_materialize(_name: str, *, persist: bool = True) -> Pattern: # noqa: ARG001
raise AssertionError('untouched cells must not be materialized')
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
monkeypatch.setattr(raw, 'materialize', fail_materialize)
out_file = tmp_path / 'classic_copy_out.gds'
gdsii.writefile(prepared, out_file)
assert copied == ['leaf', 'child', 'top']
assert not raw._cache
assert not ports._cache
assert not mapped._cache
assert out_file.read_bytes() == gds_file.read_bytes()
@pytest.mark.parametrize('use_mmap', [False, True])
def test_gdsii_lazy_raw_copy_stream_backends(tmp_path: Path, use_mmap: bool) -> None:
gds_file = tmp_path / 'classic_stream_source.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-stream')
raw, _ = gdsii_lazy.readfile(gds_file, use_mmap=use_mmap)
out_file = tmp_path / f'classic_stream_{use_mmap}.gds'
gdsii.writefile(raw, out_file)
assert not raw._cache
assert out_file.read_bytes() == gds_file.read_bytes()
def test_gdsii_lazy_raw_copy_gzipped_source(tmp_path: Path) -> None:
gds_file = tmp_path / 'classic_gzip_source.gds'
gz_file = tmp_path / 'classic_gzip_source.gds.gz'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-gzip')
with gzip.open(gz_file, 'wb') as stream:
stream.write(gds_file.read_bytes())
raw, _ = gdsii_lazy.readfile(gz_file, use_mmap=False)
for name in raw.source_order():
raw.raw_struct_bytes(name)
assert raw._source.stream.tell() == raw._cells[name].struct_end
out_file = tmp_path / 'classic_gzip_out.gds'
gdsii.writefile(raw, out_file)
assert not raw._cache
assert out_file.read_bytes() == gds_file.read_bytes()
def test_gdsii_lazy_cached_source_cell_disables_only_its_raw_copy(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'classic_cached_source.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-cached')
raw, _ = gdsii_lazy.readfile(gds_file)
assert raw.can_copy_raw_struct('leaf')
raw['leaf'].label((30, 0), string='cached', offset=(1, 2))
assert not raw.can_copy_raw_struct('leaf')
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
out_file = tmp_path / 'classic_cached_out.gds'
gdsii.writefile(raw, out_file)
assert copied == ['child', 'top']
roundtrip, _ = gdsii.readfile(out_file)
assert set(roundtrip['leaf'].labels) == {(10, 0), (30, 0)}
def test_gdsii_lazy_detached_processing_copies_only_cached_patterns(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'classic_detached_source.gds'
gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9)
raw, _ = gdsii_lazy.readfile(gds_file)
mapped = LayerMappedView(raw, lambda layer: layer)
original_deepcopy = Pattern.deepcopy
copied: list[Pattern] = []
def count_deepcopy(pattern: Pattern) -> Pattern:
copied.append(pattern)
return original_deepcopy(pattern)
monkeypatch.setattr(Pattern, 'deepcopy', count_deepcopy)
fresh = mapped.materialize_detached('top')
assert not copied
assert not raw._cache
assert not mapped._cache
cached = raw['top']
copied.clear()
detached = mapped.materialize_detached('top')
assert copied == [cached]
assert detached is not cached
assert detached is not fresh
def test_gdsii_lazy_materialized_cell_disables_only_its_raw_copy(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'classic_partial_copy_source.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-partial-copy')
raw, _ = gdsii_lazy.readfile(gds_file)
mapped = LayerMappedView(raw, lambda layer: (20, 0) if layer == (10, 0) else layer, copy_through=True)
assert set(mapped['leaf'].labels) == {(20, 0)}
prepared = preflight_source_aware(mapped)
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
out_file = tmp_path / 'classic_partial_copy_out.gds'
gdsii.writefile(prepared, out_file)
assert copied == ['child', 'top']
assert not raw._cache
roundtrip, _ = gdsii.readfile(out_file)
assert set(roundtrip['leaf'].labels) == {(20, 0)}
def test_gdsii_lazy_layer_mapped_write_materializes_without_source_cache(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_layer_source.gds'
src = _make_lazy_port_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-layers')
raw, _ = gdsii_lazy.readfile(gds_file)
mapped = LayerMappedView(raw, lambda layer: (20, 0) if layer == (10, 0) else layer)
out_file = tmp_path / 'lazy_layer_mapped.gds'
gdsii.writefile(mapped, out_file)
assert not raw._cache
roundtrip, info = gdsii.readfile(out_file)
assert info['name'] == 'classic-layers'
assert set(roundtrip['leaf'].labels) == {(20, 0)}
assert (10, 0) not in roundtrip['leaf'].labels
def test_gdsii_removed_closure_based_lazy_loader() -> None:
assert not hasattr(gdsii, 'load_library')
assert not hasattr(gdsii, 'load_libraryfile')
assert not hasattr(gdsii_lazy, 'write')
assert not hasattr(gdsii_lazy, 'writefile')

View file

@ -0,0 +1,547 @@
from pathlib import Path
import subprocess
import sys
import textwrap
import klamath
import numpy
import pytest
pytest.importorskip('pyarrow')
from .. import PatternError
from ..library import IBorrowing, IMaterializable, LayerMappedView, Library, OverlayLibrary, PortLoadView
from ..pattern import Pattern
from ..repetition import Grid
from ..file import gdsii
from ..file.utils import preflight_source_aware
from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow
from ..file.gdsii import arrow as gdsii_arrow
from ..file.gdsii import writer as gdsii_writer
from tools.generate_gds_perf import write_fixture
if not gdsii_arrow.is_available():
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
def test_gdsii_lazy_arrow_has_reader_only_surface() -> None:
assert not hasattr(gdsii_lazy_arrow, 'is_available')
assert not hasattr(gdsii_lazy_arrow, 'write')
assert not hasattr(gdsii_lazy_arrow, 'writefile')
def _make_small_library() -> Library:
lib = Library()
leaf = Pattern()
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 5], [0, 5]])
lib['leaf'] = leaf
mid = Pattern()
mid.ref('leaf', offset=(10, 20))
mid.ref('leaf', offset=(40, 0), repetition=Grid(a_vector=(12, 0), a_count=2, b_vector=(0, 9), b_count=2))
lib['mid'] = mid
top = Pattern()
top.ref('mid', offset=(100, 200))
lib['top'] = top
return lib
def _make_complex_ref_library() -> Library:
lib = Library()
leaf = Pattern()
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib['leaf'] = leaf
child = Pattern()
child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25)
lib['child'] = child
sibling = Pattern()
sibling.ref(
'leaf',
offset=(-50, 60),
repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2),
)
lib['sibling'] = sibling
fanout = Pattern()
fanout.ref('leaf', offset=(0, 0))
fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1)
fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3))
fanout.ref(
'child',
offset=(40, 0),
mirrored=True,
rotation=numpy.pi / 4,
scale=1.2,
repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2),
)
lib['fanout'] = fanout
top = Pattern()
top.ref('child', offset=(500, 600))
top.ref('sibling', offset=(-100, 50), rotation=numpy.pi)
top.ref('fanout', offset=(250, -75))
lib['top'] = top
return lib
def _write_invalid_path_type_fixture(path: Path) -> None:
with path.open('wb') as stream:
header = klamath.library.FileHeader(
name=b'test',
user_units_per_db_unit=1.0,
meters_per_db_unit=1e-9,
)
header.write(stream)
elem = klamath.elements.Path(
layer=(1, 0),
path_type=3,
width=10,
extension=(0, 0),
xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32),
properties={},
)
klamath.library.write_struct(stream, name=b'top', elements=[elem])
klamath.records.ENDLIB.write(stream, None)
def _transform_rows_key(values: numpy.ndarray) -> tuple[tuple[object, ...], ...]:
arr = numpy.asarray(values, dtype=float)
arr = numpy.atleast_2d(arr)
rows = [
(
round(float(row[0]), 8),
round(float(row[1]), 8),
round(float(row[2]), 8),
bool(int(round(float(row[3])))),
round(float(row[4]), 8),
)
for row in arr
]
return tuple(sorted(rows))
def _local_refs_key(refs: dict[str, list[numpy.ndarray]]) -> dict[str, tuple[tuple[object, ...], ...]]:
return {
parent: _transform_rows_key(numpy.concatenate(transforms))
for parent, transforms in refs.items()
}
def _global_refs_key(refs: dict[tuple[str, ...], numpy.ndarray]) -> dict[tuple[str, ...], tuple[tuple[object, ...], ...]]:
return {
path: _transform_rows_key(transforms)
for path, transforms in refs.items()
}
def test_gdsii_lazy_arrow_loads_perf_fixture(tmp_path: Path) -> None:
gds_file = tmp_path / 'many_cells_lazy.gds'
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
lib, info = gdsii_lazy_arrow.readfile(gds_file)
assert info['name'] == manifest.library_name
assert len(lib) == manifest.cells
assert lib.top() == 'TOP'
assert 'TOP' in lib.child_graph(dangling='ignore')
def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None:
gds_file = tmp_path / 'refs.gds'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-refs')
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
local = lib.find_refs_local('leaf')
assert set(local) == {'mid'}
assert sum(arr.shape[0] for arr in local['mid']) == 5
global_refs = lib.find_refs_global('leaf')
assert set(global_refs) == {('top', 'mid', 'leaf')}
assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5
def test_gdsii_lazy_arrow_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
gds_file = tmp_path / 'lazy_arrow_cached_graph.gds'
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9)
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
del lib['mid'].refs['leaf']
assert lib.child_graph(dangling='ignore')['mid'] == set()
assert lib.find_refs_local('leaf') == {}
def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None:
gds_file = tmp_path / 'complex_refs.gds'
src = _make_complex_ref_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-complex-refs')
eager, _ = gdsii.readfile(gds_file)
lazy, _ = gdsii_lazy_arrow.readfile(gds_file)
for name in ('leaf', 'child'):
assert _local_refs_key(lazy.find_refs_local(name)) == _local_refs_key(eager.find_refs_local(name))
assert _global_refs_key(lazy.find_refs_global(name)) == _global_refs_key(eager.find_refs_global(name))
def test_gdsii_lazy_arrow_detached_batch_preserves_native_batching(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'lazy_arrow_detached_batch.gds'
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9)
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
mapped = LayerMappedView(raw, lambda layer: layer)
original_read = gdsii_arrow._read_selected_cells_to_arrow
call_count = 0
def count_read(*args, **kwargs) -> object:
nonlocal call_count
call_count += 1
return original_read(*args, **kwargs)
monkeypatch.setattr(gdsii_arrow, '_read_selected_cells_to_arrow', count_read)
detached = mapped.materialize_many_detached(('leaf', 'mid', 'leaf'))
assert tuple(detached) == ('leaf', 'mid')
assert call_count == 1
assert not raw._cache
assert not mapped._cache
def test_gdsii_lazy_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None:
gds_file = tmp_path / 'invalid.gds'
gds_file.write_bytes(b'not-a-gds')
script = textwrap.dedent(f"""
from masque.file.gdsii import lazy_arrow as gdsii_lazy_arrow
try:
gdsii_lazy_arrow.readfile({str(gds_file)!r})
except Exception as exc:
print(type(exc).__module__)
print(type(exc).__qualname__)
print(exc)
else:
raise SystemExit('expected gdsii_lazy_arrow.readfile() to fail')
""")
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
assert 'klamath.basic' in result.stdout
assert 'KlamathError' in result.stdout
def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path) -> None:
gds_file = tmp_path / 'invalid_path_type.gds'
_write_invalid_path_type_fixture(gds_file)
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
lib['top']
def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> None:
gds_file = tmp_path / 'copy_source.gds'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through')
lib, info = gdsii_lazy_arrow.readfile(gds_file)
out_file = tmp_path / 'copy_out.gds'
gdsii.writefile(
lib,
out_file,
meters_per_unit=info['meters_per_unit'],
logical_units_per_unit=info['logical_units_per_unit'],
library_name=info['name'],
)
assert out_file.read_bytes() == gds_file.read_bytes()
def test_gdsii_raw_copy_resolves_generic_borrowing_views(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gds_file = tmp_path / 'provenance_source.gds'
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='provenance')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
ports = PortLoadView(raw)
subtree = ports.subtree('top')
overlay = OverlayLibrary()
overlay.add_source(subtree)
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
out_file = tmp_path / 'provenance_out.gds'
gdsii.writefile(overlay, out_file)
assert copied == ['leaf', 'mid', 'top']
assert out_file.read_bytes() == gds_file.read_bytes()
renamed = OverlayLibrary()
renamed.add_source(raw)
renamed.rename('top', 'renamed_top')
assert gdsii_writer._resolve_raw_struct(renamed, 'renamed_top') is None
remapped = OverlayLibrary()
remapped.add_source(raw)
remapped.rename('leaf', 'renamed_leaf', move_references=True)
assert gdsii_writer._resolve_raw_struct(remapped, 'mid') is None
def test_gdsii_layer_mapped_view_controls_raw_copy_through(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'layer_mapped_source.gds'
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='layer-mapped')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
def map_layer(layer): # noqa: ANN001,ANN202
return (20, 0) if layer == (1, 0) else layer
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
mapped = LayerMappedView(raw, map_layer)
mapped_file = tmp_path / 'layer_mapped_all.gds'
gdsii.writefile(mapped, mapped_file)
assert copied == []
assert not raw._cache
roundtrip, info = gdsii.readfile(mapped_file)
assert info['name'] == 'layer-mapped'
assert set(roundtrip['leaf'].shapes) == {(20, 0)}
passthrough = LayerMappedView(raw, map_layer, copy_through=True)
preflighted = preflight_source_aware(passthrough)
assert isinstance(preflighted, OverlayLibrary)
copied_file = tmp_path / 'layer_mapped_copied.gds'
gdsii.writefile(preflighted, copied_file)
assert copied == ['leaf', 'mid', 'top']
assert copied_file.read_bytes() == gds_file.read_bytes()
assert not raw._cache
copied.clear()
assert set(passthrough['leaf'].shapes) == {(20, 0)}
preflighted = preflight_source_aware(passthrough)
materialized_file = tmp_path / 'layer_mapped_materialized.gds'
gdsii.writefile(preflighted, materialized_file)
assert copied == ['mid', 'top']
assert not raw._cache
roundtrip, _ = gdsii.readfile(materialized_file)
assert set(roundtrip['leaf'].shapes) == {(20, 0)}
def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gds_file = tmp_path / 'processed_edit_source.gds'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='processed-edit')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
processed = PortLoadView(raw)
processed['top'].polygon((7, 0), vertices=[[0, 0], [4, 0], [0, 4]])
copied: list[str] = []
raw_reader = raw.raw_struct_bytes
def record_raw_read(name: str) -> bytes:
copied.append(name)
return raw_reader(name)
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
out_file = tmp_path / 'processed_edit_out.gds'
gdsii.writefile(processed, out_file)
assert 'top' not in copied
roundtrip, _ = gdsii.readfile(out_file)
assert len(roundtrip['top'].shapes[(7, 0)]) == 1
def test_gdsii_lazy_arrow_subtree_preserves_raw_copy_and_ref_queries(tmp_path: Path) -> None:
gds_file = tmp_path / 'subtree_copy_source.gds'
src = _make_small_library()
src['unused'] = Pattern()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='subtree-copy')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
subtree = raw.subtree('top')
assert isinstance(raw, IMaterializable)
assert not isinstance(raw, IBorrowing)
assert isinstance(subtree, IMaterializable)
assert isinstance(subtree, IBorrowing)
assert subtree.source_order() == ('leaf', 'mid', 'top')
assert _global_refs_key(subtree.find_refs_global('leaf')) == _global_refs_key(raw.find_refs_global('leaf'))
assert not raw._cache
out_file = tmp_path / 'subtree_copy_out.gds'
gdsii.writefile(subtree, out_file)
assert not raw._cache
roundtrip, info = gdsii.readfile(out_file)
assert info['name'] == 'subtree-copy'
assert set(roundtrip) == {'leaf', 'mid', 'top'}
def test_gdsii_lazy_arrow_overlay_subtree_preserves_raw_copy(tmp_path: Path) -> None:
gds_file = tmp_path / 'overlay_subtree_source.gds'
src = _make_small_library()
src['unused'] = Pattern()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-subtree-copy')
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
overlay = OverlayLibrary()
overlay.add_source(raw)
subtree = overlay.subtree('top')
assert isinstance(subtree, OverlayLibrary)
assert subtree.borrowed_sources() == (raw,)
assert not raw._cache
out_file = tmp_path / 'overlay_subtree_out.gds'
gdsii.writefile(subtree, out_file)
assert not raw._cache
roundtrip, info = gdsii.readfile(out_file)
assert info['name'] == 'overlay-subtree-copy'
assert set(roundtrip) == {'leaf', 'mid', 'top'}
def test_gdsii_lazy_arrow_gzipped_copy_through(tmp_path: Path) -> None:
gds_file = tmp_path / 'copy_source.gds.gz'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through-gz')
lib, info = gdsii_lazy_arrow.readfile(gds_file)
out_file = tmp_path / 'copy_out.gds.gz'
gdsii.writefile(
lib,
out_file,
meters_per_unit=info['meters_per_unit'],
logical_units_per_unit=info['logical_units_per_unit'],
library_name=info['name'],
)
assert out_file.read_bytes() == gds_file.read_bytes()
def test_gdsii_lazy_overlay_merge_and_write(tmp_path: Path) -> None:
base_a = Library()
leaf_a = Pattern()
leaf_a.polygon((1, 0), vertices=[[0, 0], [8, 0], [8, 8], [0, 8]])
base_a['leaf'] = leaf_a
top_a = Pattern()
top_a.ref('leaf', offset=(0, 0))
base_a['top_a'] = top_a
base_b = Library()
leaf_b = Pattern()
leaf_b.polygon((2, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
base_b['leaf'] = leaf_b
top_b = Pattern()
top_b.ref('leaf', offset=(20, 30))
base_b['top_b'] = top_b
gds_a = tmp_path / 'a.gds'
gds_b = tmp_path / 'b.gds'
gdsii.writefile(base_a, gds_a, meters_per_unit=1e-9, library_name='overlay')
gdsii.writefile(base_b, gds_b, meters_per_unit=1e-9, library_name='overlay')
lib_a, _ = gdsii_lazy_arrow.readfile(gds_a)
lib_b, _ = gdsii_lazy_arrow.readfile(gds_b)
overlay = OverlayLibrary()
overlay.add_source(lib_a)
rename_map = overlay.add_source(lib_b, rename_theirs=lambda lib, name: lib.get_name(name))
renamed_leaf = rename_map['leaf']
assert rename_map == {'leaf': renamed_leaf}
assert renamed_leaf != 'leaf'
assert len(lib_a._cache) == 0
assert len(lib_b._cache) == 0
overlay.move_references('leaf', renamed_leaf)
out_file = tmp_path / 'overlay_out.gds'
gdsii.writefile(overlay, out_file)
roundtrip, _ = gdsii.readfile(out_file)
assert set(roundtrip.keys()) == {'leaf', renamed_leaf, 'top_a', 'top_b'}
assert 'top_b' in roundtrip
assert list(roundtrip['top_b'].refs.keys()) == [renamed_leaf]
def test_gdsii_writer_accepts_overlay_library(tmp_path: Path) -> None:
gds_file = tmp_path / 'overlay_source.gds'
src = _make_small_library()
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-src')
lib, info = gdsii_lazy_arrow.readfile(gds_file)
overlay = OverlayLibrary()
overlay.add_source(lib)
overlay.rename('leaf', 'leaf_copy', move_references=True)
out_file = tmp_path / 'overlay_via_eager_writer.gds'
gdsii.writefile(
overlay,
out_file,
meters_per_unit=info['meters_per_unit'],
logical_units_per_unit=info['logical_units_per_unit'],
library_name=info['name'],
)
roundtrip, _ = gdsii.readfile(out_file)
assert set(roundtrip.keys()) == {'leaf_copy', 'mid', 'top'}
assert list(roundtrip['mid'].refs.keys()) == ['leaf_copy']
def test_svg_writer_uses_detached_materialized_copy(tmp_path: Path) -> None:
pytest.importorskip('svgwrite')
from ..file import svg
from ..shapes import Path as MPath
gds_file = tmp_path / 'svg_source.gds'
src = _make_small_library()
src['top'].path((3, 0), vertices=[[0, 0], [0, 20]], width=4)
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='svg-src')
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
top_pat = lib['top']
assert list(top_pat.refs.keys()) == ['mid']
assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)])
svg_path = tmp_path / 'lazy.svg'
svg.writefile(lib, 'top', str(svg_path))
assert svg_path.exists()
assert list(top_pat.refs.keys()) == ['mid']
assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)])

View file

@ -0,0 +1,24 @@
from dataclasses import asdict
import json
from pathlib import Path
from ..file import gdsii
from tools.generate_gds_perf import fixture_manifest, write_fixture
def test_gdsii_perf_fixture_smoke(tmp_path: Path) -> None:
output = tmp_path / 'many_cells.gds'
manifest = write_fixture(output, preset='many_cells', scale=0.002)
expected = fixture_manifest(output, preset='many_cells', scale=0.002)
assert output.exists()
assert manifest == expected
sidecar = json.loads(output.with_suffix('.gds.json').read_text())
assert sidecar == asdict(manifest)
read_lib, info = gdsii.readfile(output)
assert info['name'] == manifest.library_name
assert len(read_lib) == manifest.cells
assert 'TOP' in read_lib
assert len(read_lib['TOP'].refs) > 0

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

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

1174
masque/test/test_library.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
import pytest
import numpy
from ..shapes import Polygon
def test_manhattanize() -> None:
pytest.importorskip("float_raster")
pytest.importorskip("skimage.measure")
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
grid = numpy.arange(0, 11, 1)
manhattan_polys = poly.manhattanize(grid, grid)
assert len(manhattan_polys) >= 1
for mp in manhattan_polys:
dv = numpy.diff(mp.vertices, axis=0)
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))

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

@ -0,0 +1,60 @@
import io
from pathlib import Path
import pytest
from numpy.testing import assert_equal
from ..error import PatternError
from ..pattern import Pattern
from ..library import Library
from ..shapes import Path as MPath
def test_oasis_roundtrip(tmp_path: Path) -> None:
# Skip if fatamorgana is not installed
pytest.importorskip("fatamorgana")
from ..file import oasis
lib = Library()
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
lib["cell1"] = pat1
oas_file = tmp_path / "test.oas"
# OASIS needs units_per_micron
oasis.writefile(lib, oas_file, units_per_micron=1000)
read_lib, info = oasis.readfile(oas_file)
assert "cell1" in read_lib
# Check bounds
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])
def test_oasis_properties_to_annotations_merges_repeated_keys() -> None:
pytest.importorskip("fatamorgana")
import fatamorgana.records as fatrec
from ..file.oasis import properties_to_annotations
annotations = properties_to_annotations(
[
fatrec.Property("k", [1], is_standard=False),
fatrec.Property("k", [2, 3], is_standard=False),
],
{},
{},
)
assert annotations == {"k": [1, 2, 3]}
def test_oasis_write_rejects_circle_path_caps() -> None:
pytest.importorskip("fatamorgana")
from ..file import oasis
lib = Library()
pat = Pattern()
pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle)
lib["cell1"] = pat
with pytest.raises(PatternError, match="does not support path cap"):
oasis.write(lib, io.BytesIO(), units_per_micron=1000)

View file

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

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

@ -0,0 +1,101 @@
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Path, Path as MPath
def test_path_init() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
assert_equal(p.vertices, [[0, 0], [10, 0]])
assert p.width == 2
assert p.cap == Path.Cap.Flush
def test_path_to_polygons_flush() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[0, -1], [10, 1]])
def test_path_to_polygons_square() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
def test_path_to_polygons_circle() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
polys = p.to_polygons(num_vertices=32)
assert len(polys) >= 3
bounds = p.get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
def test_path_custom_cap() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-5, -1], [20, 1]])
def test_path_bend() -> None:
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[0, -1], [11, 10]])
def test_path_mirror() -> None:
p = Path(vertices=[[10, 5], [20, 10]], width=2)
p.mirror(0)
assert_equal(p.vertices, [[10, -5], [20, -10]])
def test_path_scale() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2)
p.scale_by(2)
assert_equal(p.vertices, [[0, 0], [20, 0]])
assert p.width == 4
def test_path_scale_custom_cap_extensions() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
p.scale_by(3)
assert_equal(p.vertices, [[0, 0], [30, 0]])
assert p.width == 6
assert p.cap_extensions is not None
assert_allclose(p.cap_extensions, [3, 6])
assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]])
def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
intrinsic, _extrinsic, ctor = p.normalized_form(5)
q = ctor()
assert intrinsic[-1] == (0.2, 0.4)
assert q.width == 2
assert q.cap_extensions is not None
assert_allclose(q.cap_extensions, [1, 2])
def test_path_normalized_form_distinguishes_custom_caps() -> None:
p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
def test_path_edge_cases() -> None:
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])

View file

@ -0,0 +1,107 @@
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import AutoTool
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
pat = Pattern()
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
return pat
def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern:
pat = Pattern()
# Rectangular approximation of a 90 degree bend.
if clockwise:
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype)
else:
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype)
return pat
@pytest.fixture
def multi_bend_tool() -> tuple[AutoTool, Library]:
lib = Library()
lib["b1"] = make_bend(2, ptype="wire")
b1_abs = lib.abstract("b1")
lib["b2"] = make_bend(5, ptype="wire")
b2_abs = lib.abstract("b2")
tool = (
AutoTool()
.add_straight(make_straight, "wire", "A", length_range=(0, 10))
.add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8))
.add_bend(b1_abs, "A", "B", clockwise=True, mirror=True)
.add_bend(b2_abs, "A", "B", clockwise=True, mirror=True)
)
return tool, lib
def test_autotool_uturn() -> None:
from masque.builder.tools import AutoTool
lib = Library()
def make_straight(length: float) -> Pattern:
pat = Pattern()
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
pat.ports['in'] = Port((0, 0), 0)
pat.ports['out'] = Port((length, 0), pi)
return pat
bend_pat = Pattern()
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
bend_pat.ports['in'] = Port((0, 0), 0)
bend_pat.ports['out'] = Port((500, -500), pi/2)
lib['bend'] = bend_pat
tool = (
AutoTool()
.add_straight(make_straight, 'wire', 'in')
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), 0)
p.at('A').uturn(offset=-2000, length=1000)
# U-turn plan output is transformed into the port extension frame.
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None:
tool, lib = multi_bend_tool
rp = Pather(lib, tools=tool)
rp.ports["A"] = Port((0,0), 0, ptype="wire")
rp.jog("A", 10, length=20)
assert_allclose(rp.ports["A"].offset, [-20, -10])
assert_allclose(rp.ports["A"].rotation, 0)
rp.render()
assert len(rp.pattern.refs) > 0
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
tool, lib = multi_bend_tool
p = Pather(lib, tools=tool)
p.ports["A"] = Port((0,0), 0, ptype="wire")
p.uturn("A", 10, length=5)
# Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames,
# yielding a global endpoint at (-5, -10).
assert_allclose(p.ports["A"].offset, [-5, -10])
assert_allclose(p.ports["A"].rotation, pi)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,614 @@
from typing import Any
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose, assert_equal
from masque import Pather, Library, Pattern, Port, RouteError
from masque.builder import PathTool, PrimitiveOffer, StraightOffer
from masque.builder.planner import RoutingPlanner
from masque.error import BuildError, PortError
@pytest.fixture
def pather_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
# Port rotation points into the device, so path extension moves in the opposite direction.
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return p, tool, lib
def test_builder_tool_symbol_exports() -> None:
import masque
import masque.builder
import masque.builder.tools as builder_tools
package_root_exports = (
'RenderStep',
'PortPather',
)
builder_tool_names = (
'PrimitiveOffer',
'StraightOffer',
'BendOffer',
'SOffer',
'UOffer',
)
internal_names = (
'PTypeMatch',
'canonicalize_domain_value',
'ptype_match',
'ptypes_compatible',
)
for name in (*package_root_exports, *builder_tool_names):
assert hasattr(masque.builder, name)
for name in package_root_exports:
assert hasattr(masque, name)
for name in (*builder_tool_names, *internal_names):
assert not hasattr(masque, name)
for name in internal_names:
assert not hasattr(masque.builder, name)
for name in builder_tool_names:
assert hasattr(masque.builder, name)
assert hasattr(builder_tools, name)
def test_pather_pending_render_steps_are_private() -> None:
p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1))
assert not hasattr(p, 'paths')
assert hasattr(p, '_paths')
def test_pather_accepts_and_reuses_planner_instance() -> None:
class CountingPlanner(RoutingPlanner):
def __init__(self) -> None:
self.trace_to_calls = 0
def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any:
self.trace_to_calls += 1
return super().plan_trace_to_route(*args, **kwargs)
planner = CountingPlanner()
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
render='deferred',
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 1)
p.straight('A', 2)
assert p.planner is planner
assert planner.trace_to_calls == 2
def test_pather_copies_and_forwards_plan_options_to_planner() -> None:
class RecordingPlanner(RoutingPlanner):
def __init__(self) -> None:
super().__init__()
self.received_plan_options: Any = None
def plan_trace_to_route(
self,
*args: Any,
plan_options: Any = None,
**kwargs: Any,
) -> Any:
self.received_plan_options = plan_options
return super().plan_trace_to_route(
*args,
plan_options=plan_options,
**kwargs,
)
planner = RecordingPlanner()
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
render='deferred',
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0)
supplied = {'strategy': 'turn_first'}
p.straight('A', 1, plan_options=supplied)
assert planner.received_plan_options == supplied
assert planner.received_plan_options is not supplied
def test_port_tool_policy_and_portpather_selection_follow_names() -> None:
default_tool = PathTool(layer=(1, 0), width=1, ptype='wire')
named_tool = PathTool(layer=(2, 0), width=1, ptype='wire')
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools={None: default_tool, 'A': named_tool},
render='deferred',
)
selected = p.at('A')
p.rename_ports({'A': 'B'})
assert selected.ports == ['A']
assert p.tools['A'] is named_tool
assert 'B' not in p.tools
p.straight('B', 1)
assert p._paths['B'][0].tool is default_tool
p.mkport('A', Port((10, 0), rotation=0, ptype='wire'))
p.straight('A', 1)
assert p._paths['A'][0].tool is named_tool
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.straight("start", 10)
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.cw("start", 10)
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.straight("start", y=-50)
assert_equal(p.ports["start"].offset, [0, -50])
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
p.straight(["A", "B"], ymin=-20)
assert_equal(p.ports["A"].offset, [0, -20])
assert_equal(p.ports["B"].offset, [10, -20])
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.at("start").straight(10).ccw(10)
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
assert p.ports["start"].rotation is not None
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
def test_pather_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
p.set_dead()
with pytest.raises(RouteError, match='finite and nonnegative'):
p.straight("in", -10)
assert_allclose(p.ports["in"].offset, [0, 0], atol=1e-10)
p.straight("in", 20)
assert_allclose(p.ports["in"].offset, [-20, 0], atol=1e-10)
assert not p.pattern.has_shapes()
def test_pather_trace_basic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
# Routing extends opposite the port's inward-facing rotation.
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').trace(None, 5000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
p.at('A').trace(True, 5000) # CCW bend
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2)
def test_pather_trace_to() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').trace_to(None, x=-10000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
p.at('A').trace_to(None, p=-20000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0))
def test_pather_bundle_trace() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
p.at(['A', 'B']).straight(xmin=-10000)
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
# The lower port is on the inner bend, so `xmin` applies to that route.
assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000)
def test_portpather_default_spacing_matches_explicit_spacing() -> None:
lib_default = Library()
tool_default = PathTool(layer='M1', width=1000)
p_default = Pather(lib_default, tools=tool_default)
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
lib_explicit = Library()
tool_explicit = PathTool(layer='M1', width=1000)
p_explicit = Pather(lib_explicit, tools=tool_explicit)
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_default.at(['A', 'B'], spacing=2000).ccw(xmin=-20000)
p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset)
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
def test_portpather_default_spacing_reused_and_overridden() -> None:
p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
pp_default = p_default.at(['A', 'B'], spacing=2000)
pp_default.ccw(xmin=-20000)
pp_default.cw(emin=1000)
p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000).cw(emin=1000, spacing=2000)
assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset)
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_override.pattern.ports['A'] = Port((0, 0), rotation=0)
p_override.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_override.at(['A', 'B'], spacing=2000).ccw(xmin=-20000, spacing=3000)
p_override_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=3000)
assert_allclose(p_override.pattern.ports['A'].offset, p_override_explicit.pattern.ports['A'].offset)
assert_allclose(p_override.pattern.ports['B'].offset, p_override_explicit.pattern.ports['B'].offset)
def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
p.at(['A', 'B'], spacing=2000).straight(xmin=-10000)
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
p.pattern.ports['C'] = Port((0, 4000), rotation=0)
pp = p.at(['A', 'B', 'C'], spacing=[2000, 3000])
pp.deselect('C')
with pytest.raises(BuildError, match='spacing must be scalar or have length 1'):
pp.ccw(xmin=-20000)
def test_pather_each_bound() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
p.at(['A', 'B']).trace(None, each=5000)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000))
def test_selection_management() -> None:
lib = Library()
p = Pather(lib)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 0), rotation=0)
pp = p.at('A')
assert pp.ports == ['A']
pp.select('B')
assert pp.ports == ['A', 'B']
pp.deselect('A')
assert pp.ports == ['B']
pp.select(['A'])
assert pp.ports == ['B', 'A']
pp.drop()
assert 'A' not in p.pattern.ports
assert 'B' not in p.pattern.ports
assert pp.ports == []
@pytest.mark.parametrize('action', ['plug', 'plugged', 'rename', 'mark', 'fork'])
def test_empty_selection_exact_one_operations_raise_build_error(action: str) -> None:
p = Pather(Library())
pp = p.at([])
operations = {
'plug': lambda: pp.plug('unused', 'A'),
'plugged': lambda: pp.plugged('A'),
'rename': lambda: pp.rename('new'),
'mark': lambda: pp.mark('new'),
'fork': lambda: pp.fork('new'),
}
with pytest.raises(BuildError, match='expected exactly one'):
operations[action]()
def test_mark_fork() -> None:
lib = Library()
p = Pather(lib)
p.pattern.ports['A'] = Port((100, 200), rotation=1)
pp = p.at('A')
pp.mark('B')
assert 'B' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200))
assert p.pattern.ports['B'].rotation == 1
assert pp.ports == ['A']
pp.fork('C')
assert 'C' in p.pattern.ports
assert pp.ports == ['C']
def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None:
lib = Library()
p_mark = Pather(lib, pattern=Pattern(ports={
'A': Port((0, 0), rotation=0),
'C': Port((2, 0), rotation=0),
}))
with pytest.raises(PortError, match='overwrite existing ports'):
p_mark.at('A').mark('C')
assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0))
p_fork = Pather(lib, pattern=Pattern(ports={
'A': Port((0, 0), rotation=0),
'B': Port((1, 0), rotation=0),
}))
pp = p_fork.at(['A', 'B'])
with pytest.raises(PortError, match='targets would collide'):
pp.fork({'A': 'X', 'B': 'X'})
assert set(p_fork.pattern.ports) == {'A', 'B'}
assert pp.ports == ['A', 'B']
def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None:
lib = Library()
p = Pather(lib, pattern=Pattern(ports={
'A': Port((0, 0), rotation=0),
'B': Port((1, 0), rotation=0),
'C': Port((2, 0), rotation=0),
}))
p.set_dead()
p.at('A').mark('C')
assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0))
pp = p.at(['A', 'B'])
pp.fork({'A': 'X', 'B': 'X'})
assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0))
assert pp.ports == ['X']
def test_mark_fork_reject_missing_sources() -> None:
lib = Library()
p = Pather(lib, pattern=Pattern(ports={
'A': Port((0, 0), rotation=0),
'B': Port((1, 0), rotation=0),
}))
with pytest.raises(PortError, match='selected ports'):
p.at(['A', 'B']).mark({'Z': 'C'})
with pytest.raises(PortError, match='selected ports'):
p.at(['A', 'B']).fork({'Z': 'C'})
def test_rename() -> None:
lib = Library()
p = Pather(lib)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').rename('B')
assert 'A' not in p.pattern.ports
assert 'B' in p.pattern.ports
p.pattern.ports['C'] = Port((0, 0), rotation=0)
pp = p.at(['B', 'C'])
pp.rename({'B': 'D', 'C': 'E'})
assert 'B' not in p.pattern.ports
assert 'C' not in p.pattern.ports
assert 'D' in p.pattern.ports
assert 'E' in p.pattern.ports
assert set(pp.ports) == {'D', 'E'}
def test_pather_dead_fallback_preserves_out_ptype() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
p.straight('A', 1000, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 0))
assert p.pattern.ports['A'].ptype == 'other'
assert len(p._paths['A']) == 0
def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None:
class MismatchedEndpointTool(PathTool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001,ARG002
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
return (StraightOffer(
in_ptype=in_ptype,
out_ptype='other',
endpoint_planner=endpoint,
commit_planner=lambda length: {'length': length},
),)
p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
p.straight('A', 1000)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None:
class PartlyMismatchedEndpointTool(PathTool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001,ARG002
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def mismatched_endpoint(length: float) -> Port:
ptype = 'wire' if numpy.isclose(length, 0) else 'other'
return Port((length, 0), rotation=pi, ptype=ptype)
def valid_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
return (
StraightOffer(
in_ptype=in_ptype,
out_ptype='wire',
endpoint_planner=mismatched_endpoint,
commit_planner=lambda length: {'kind': 'mismatched', 'length': length},
),
StraightOffer(
in_ptype=in_ptype,
out_ptype='wire',
endpoint_planner=valid_endpoint,
commit_planner=lambda length: {'kind': 'valid', 'length': length},
),
)
p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
p.straight('A', 1000)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
lib = Library()
p = Pather(lib, pattern=Pattern(ports={
'A': Port((5, 5), rotation=0),
'keep': Port((9, 9), rotation=0),
}))
p.set_dead()
other = Pattern()
other.ports['X'] = Port((1, 0), rotation=0)
other.ports['Y'] = Port((2, 0), rotation=pi / 2)
p.place(other, port_map={'X': 'A', 'Y': 'A'})
assert set(p.pattern.ports) == {'A', 'keep'}
assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2)
def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool, pattern=Pattern(ports={
'A': Port((0, 0), rotation=0, ptype='wire'),
'B': Port((99, 99), rotation=0, ptype='wire'),
}))
p.set_dead()
other = Pattern()
other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire')
other.ports['X'] = Port((10, 0), rotation=0, ptype='wire')
other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire')
p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'})
assert 'A' not in p.pattern.ports
assert 'B' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0))
assert p.pattern.ports['B'].rotation is not None
assert numpy.isclose(p.pattern.ports['B'].rotation, 0)
def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None:
p = Pather(Library(), pattern=Pattern(ports={
'A': Port((0, 0), rotation=0),
'B': Port((1, 0), rotation=0),
'C': Port((2, 0), rotation=0),
}))
p.set_dead()
p.rename_ports({'A': 'C', 'B': 'C'})
assert set(p.pattern.ports) == {'C'}
assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0))

View file

@ -0,0 +1,120 @@
import pytest
import numpy
from numpy import pi
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool
from masque.error import PortError, PatternError
def test_pather_place_treeview_resolves_once() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
p.place(tree)
assert len(lib) == 1
assert 'child' in lib
assert 'child' in p.pattern.refs
assert 'B' in p.pattern.ports
def test_pather_plug_treeview_resolves_once() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
p.plug(tree, {'A': 'B'})
assert len(lib) == 1
assert 'child' in lib
assert 'child' in p.pattern.refs
assert 'A' not in p.pattern.ports
def test_pather_failed_plug_does_not_add_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.annotations = {'k': [1]}
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').trace(None, 5000)
assert [step.opcode for step in p._paths['A']] == ['L']
other = Pattern(
annotations={'k': [2]},
ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)},
)
with pytest.raises(PatternError, match='Annotation keys overlap'):
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
assert [step.opcode for step in p._paths['A']] == ['L']
assert set(p.pattern.ports) == {'A'}
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
p.rename_ports({'A': None})
other = Pattern(ports={'X': Port((-5000, 0), rotation=0)})
p.place(other, port_map={'X': 'A'}, append=True)
p.at('A').straight(2000)
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
p.render()
assert p.pattern.has_shapes()
assert 'A' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
p.rename_ports({'A': None})
other = Pattern(
ports={
'X': Port((0, 0), rotation=pi),
'Y': Port((-5000, 0), rotation=0),
},
)
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
p.at('A').straight(2000)
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
p.render()
assert p.pattern.has_shapes()
assert 'A' in p.pattern.ports
assert 'B' not in p.pattern.ports
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
assert [step.opcode for step in p._paths['A']] == ['L']
with pytest.raises(PortError, match='Connection destination ports were not found'):
p.plugged({'A': 'missing'})
assert [step.opcode for step in p._paths['A']] == ['L']
assert set(p._paths) == {'A'}

View file

@ -0,0 +1,910 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Literal
import numpy
import pytest
from numpy import pi
from masque import Library, Path, Port, Pather, ToolContractError
from masque.builder.planner import RoutingPlanner
from masque.builder.planner.planner import Solver, SolverRequest
from masque.builder.tools import (
BendOffer,
PathTool,
PrimitiveOffer,
SOffer,
StraightOffer,
Tool,
UOffer,
)
from masque.error import BuildError
from masque.utils import PTypeMatch, ptype_match
def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]:
return {
'endpoint_planner': lambda parameter: planner(parameter)[0],
'commit_planner': lambda parameter: planner(parameter)[1],
}
class PlanningOnlyTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('planning_only_tool')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
return tree
def test_tool_contract_error_is_fatal_even_when_an_alternate_offer_exists() -> None:
class BrokenTool(PlanningOnlyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (
StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wrong'),
commit_planner=lambda length: length,
),
StraightOffer.generated('wire', lambda length: length),
)
pather = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=BrokenTool(),
render='deferred',
)
with pytest.raises(ToolContractError, match='declared offer out_ptype'):
pather.straight('A', 5)
def test_callback_build_error_remains_recoverable_candidate_rejection() -> None:
class RecoverableTool(PlanningOnlyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
def rejected(length: float) -> Port:
raise BuildError(f'unsupported length {length}')
return (
StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=rejected,
commit_planner=lambda length: length,
),
StraightOffer.generated('wire', lambda length: length),
)
pather = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=RecoverableTool(),
render='deferred',
)
pather.straight('A', 5)
assert numpy.allclose(pather.ports['A'].offset, (-5, 0))
def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None:
class CountingTool(PlanningOnlyTool):
calls = 0
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
self.calls += 1
assert kwargs == {'nested': []}
return ()
tool = CountingTool()
solver = Solver(SolverRequest(
family='straight',
tool=tool,
in_ptype='wire',
tool_options={'nested': []},
))
solver.primitive_offers('straight', 'wire')
solver.primitive_offers('straight', 'wire')
assert tool.calls == 1
def test_solver_finalize_chooses_cheapest_parameter_allocation() -> None:
tool = PlanningOnlyTool()
solver = Solver(SolverRequest(
family='straight',
tool=tool,
in_ptype='wire',
tool_options={},
length=10,
))
expensive_offer = StraightOffer.generated('wire', lambda length: length, cost=1)
cheap_offer = StraightOffer.generated('wire', lambda length: length, cost=0.3)
expensive = solver.evaluate(expensive_offer, 0, 'wire', out_ptype=None, role='main')
cheap = solver.evaluate(cheap_offer, 0, 'wire', out_ptype=None, role='main')
for steps in ((expensive, cheap), (cheap, expensive)):
candidate = solver.finalize(steps)
parameters = {step.offer: step.parameter for step in candidate.steps}
assert parameters[cheap_offer] == pytest.approx(10)
assert parameters[expensive_offer] == pytest.approx(0)
assert candidate.cost == pytest.approx(3)
def test_solver_strategy_rank_covers_all_main_steps_and_ignores_adapters() -> None:
tool = PlanningOnlyTool()
straight_first = Solver(SolverRequest(
family='s',
tool=tool,
in_ptype='wire',
tool_options={},
strategy='straight_first',
))
turn_first = Solver(SolverRequest(
family='s',
tool=tool,
in_ptype='wire',
tool_options={},
strategy='turn_first',
))
straight_offer = StraightOffer.generated('wire', lambda length: length)
bend_offer = BendOffer.prebuilt(
'wire',
'wire',
Port((1, 1), 3 * pi / 2, ptype='wire'),
None,
ccw=True,
)
adapter_offer = StraightOffer.prebuilt(
'wire',
'adapted',
Port((1, 0), pi, ptype='adapted'),
None,
)
straight = straight_first.evaluate(straight_offer, 0, 'wire', out_ptype=None, role='main')
turn = straight_first.evaluate(bend_offer, 1, 'wire', out_ptype=None, role='main')
adapter = straight_first.evaluate(adapter_offer, 1, 'wire', out_ptype=None, role='adapter')
alternating = (straight, turn, adapter, straight, turn)
delayed_straight = (straight, turn, adapter, turn, straight)
assert straight_first.strategy_rank(alternating) < straight_first.strategy_rank(delayed_straight)
assert turn_first.strategy_rank(delayed_straight) < turn_first.strategy_rank(alternating)
assert straight_first.strategy_rank(alternating) == straight_first.strategy_rank(
(straight, turn, straight, turn),
)
assert straight_first.strategy_rank((straight,)) < straight_first.strategy_rank((turn,))
assert turn_first.strategy_rank((turn,)) < turn_first.strategy_rank((straight,))
def test_tool_requires_primitive_offers_override() -> None:
class RenderOnlyTool(Tool):
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(TypeError):
RenderOnlyTool()
def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None:
class BaseCallingTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(NotImplementedError):
BaseCallingTool().primitive_offers('straight')
def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float:
offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
return offer.canonicalize_parameter(value)
def test_offer_canonicalize_parameter_half_open_and_singleton() -> None:
assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0
assert canonicalize_offer_parameter(3, (0, 10)) == 3
with pytest.raises(BuildError, match='outside half-open domain'):
canonicalize_offer_parameter(10, (0, 10))
assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5
with pytest.raises(BuildError, match='outside singleton domain'):
canonicalize_offer_parameter(5.1, (5, 5))
@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf])
def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None:
with pytest.raises(BuildError, match='must be finite'):
canonicalize_offer_parameter(value, (0, 10))
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=(10, 0))
@pytest.mark.parametrize('domain', [(numpy.nan, 1), (1, numpy.nan), (numpy.inf, numpy.inf)])
def test_offer_rejects_invalid_domain_at_construction(domain: tuple[float, float]) -> None:
with pytest.raises(BuildError, match='domain'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
@pytest.mark.parametrize('domain', [(-1, 2), (-numpy.inf, 2)])
def test_length_offer_requires_finite_nonnegative_minimum(domain: tuple[float, float]) -> None:
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None:
assert ptype_match('wire', 'wire') is PTypeMatch.EXACT
assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD
assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD
assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH
def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None:
endpoint_calls: list[float] = []
commit_calls: list[float] = []
def endpoint(length: float) -> Port:
endpoint_calls.append(length)
return Port((length, 2), rotation=pi, ptype='wire')
def commit(length: float) -> dict[str, float]:
commit_calls.append(length)
return {'length': length}
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
length_domain=(5, 5),
endpoint_planner=endpoint,
commit_planner=commit,
)
assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2))
assert offer.cost_at(5 + 1e-13) == 5 + pi
assert commit_calls == []
assert offer.commit(5 + 1e-13) == {'length': 5}
assert endpoint_calls == [5, 5]
assert commit_calls == [5]
def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None:
def data_at(length: float) -> dict[str, float]:
return {'length': length}
offer = StraightOffer.generated(
'wire',
data_at,
length_domain=(2, 8),
cost=3,
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]),
)
endpoint = offer.endpoint_at(4)
assert offer.length_domain == (2, 8)
assert offer.cost == 3
assert offer.cost_at(4) == 12
assert numpy.allclose(endpoint.offset, [4, 0])
assert endpoint.rotation == pi
assert endpoint.ptype == 'wire'
assert offer.commit(4) == {'length': 4}
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]])
def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None:
def endpoint_at(jog: float) -> Port:
return Port((10, jog), rotation=pi, ptype='wire')
def data_at(jog: float) -> dict[str, Any]:
return {'jog': abs(jog), 'mirrored': jog < 0}
offer = SOffer.generated(
'wire',
endpoint_at,
data_at,
jog_domain=(-5, 5),
bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]),
)
endpoint = offer.endpoint_at(-3)
assert numpy.allclose(endpoint.offset, [10, -3])
assert offer.commit(-3) == {'jog': 3, 'mirrored': True}
assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]])
def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
def endpoint_at(length: float) -> Port:
return Port((length, length), rotation=-pi / 2, ptype='wire')
def data_at(length: float) -> dict[str, float]:
return {'length': length}
offer = BendOffer.generated(
'wire',
endpoint_at,
data_at,
ccw=True,
length_domain=(4, 4),
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]),
)
endpoint = offer.endpoint_at(4)
assert offer.ccw
assert offer.length_domain == (4, 4)
assert numpy.allclose(endpoint.offset, [4, 4])
assert endpoint.rotation == 3 * pi / 2
assert offer.commit(4) == {'length': 4}
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]])
def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
def endpoint_at(jog: float) -> Port:
return Port((12, jog), rotation=0, ptype='wire')
def data_at(jog: float) -> dict[str, float]:
return {'jog': jog}
offer = UOffer.generated(
'wire',
endpoint_at,
data_at,
jog_domain=(-6, 6),
bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]),
)
endpoint = offer.endpoint_at(-4)
assert offer.jog_domain == (-6, 6)
assert numpy.allclose(endpoint.offset, [12, -4])
assert endpoint.rotation == 0
assert offer.commit(-4) == {'jog': -4}
assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]])
def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None:
data = {'kind': 'prebuilt'}
bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731
endpoint = Port((5, 2), rotation=pi / 2, ptype='out')
cases = [
(StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5),
(BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5),
(SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
(UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
]
for offer, parameter in cases:
first = offer.endpoint_at(parameter)
first.offset[:] = 99
second = offer.endpoint_at(parameter)
assert numpy.allclose(second.offset, [5, 2])
assert second.rotation == pi / 2
assert second.ptype == 'out'
assert offer.commit(parameter) is data
assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]])
@pytest.mark.parametrize('cost', [-1, numpy.inf, numpy.nan, 'expensive'])
def test_offer_rejects_invalid_cost_factor(cost: Any) -> None:
with pytest.raises(BuildError, match='cost factor'):
StraightOffer(in_ptype='wire', out_ptype='wire', cost=cost)
@pytest.mark.parametrize('result', [-1, numpy.inf, numpy.nan, 'expensive'])
def test_offer_rejects_invalid_callable_cost_result(result: Any) -> None:
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
cost=lambda _parameter, _endpoint: result,
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), None)),
)
with pytest.raises(BuildError, match='Primitive cost'):
offer.cost_at(5)
def test_offer_callable_cost_replaces_default_cost() -> None:
seen: list[tuple[float, Port]] = []
def cost(parameter: float, endpoint: Port) -> float:
seen.append((parameter, endpoint))
return parameter + endpoint.y
offer = SOffer(
in_ptype='wire',
out_ptype='wire',
cost=cost,
jog_domain=(2, 2),
**offer_callbacks(lambda jog: (Port((10, jog), rotation=pi, ptype='wire'), None)),
)
assert offer.cost_at(2 + 1e-13) == 4
assert seen[0][0] == 2
assert numpy.allclose(seen[0][1].offset, (10, 2))
def test_offer_rejects_one_sided_split_callbacks() -> None:
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='require both'):
StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint)
def test_offer_bbox_at_validates_bounds() -> None:
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]),
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
)
assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]])
bad = StraightOffer(
in_ptype='wire',
out_ptype='wire',
bbox_planner=lambda _length: numpy.array([0, 1]),
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
)
with pytest.raises(BuildError, match='shape'):
bad.bbox_at(3)
def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('straight', in_ptype='wire')[0]
bounds = offer.bbox_at(10)
expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single()
assert numpy.allclose(bounds, expected)
def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0]
bounds = offer.bbox_at(1)
expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single()
assert isinstance(offer, BendOffer)
assert offer.length_domain == (1, 1)
assert numpy.allclose(bounds, expected)
def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('s', in_ptype='wire')[0]
bounds = offer.bbox_at(3)
expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single()
assert isinstance(offer, SOffer)
assert numpy.allclose(bounds, expected)
def test_pathtool_u_offers_remain_unsupported() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
assert tool.primitive_offers('u', in_ptype='wire') == ()
@pytest.mark.parametrize(
('kind', 'kwargs'),
[
('straight', {}),
('bend', {'ccw': True}),
('s', {}),
],
)
def test_pathtool_out_ptype_unk_is_wildcard(
kind: Literal['straight', 'bend', 's'],
kwargs: dict[str, Any],
) -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs)
assert offers
assert offers[0].out_ptype == 'wire'
def test_pathtool_rejects_unsupported_planning_options() -> None:
tool = PathTool(layer='M1', width=1)
with pytest.raises(BuildError, match='does not support tool options: marker'):
tool.primitive_offers('straight', marker='sentinel')
def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
class NoOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
raise NotImplementedError
p = Pather(Library(), tools=NoOfferTool())
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
p.straight('A', 5)
@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError])
def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None:
class BrokenTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
raise err_type('offer query failed')
p = Pather(Library(), tools=BrokenTool())
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(err_type):
p.straight('A', 5)
def test_pather_selects_lowest_cost_offer() -> None:
class MultiOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def high(length: float) -> tuple[Port, dict[str, str | float]]:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'}
def low(length: float) -> tuple[Port, dict[str, str | float]]:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'}
return (
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, cost=10, **offer_callbacks(high)),
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, cost=1, **offer_callbacks(low)),
)
p = Pather(Library(), tools=MultiOfferTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'low'}
assert numpy.allclose(p.ports['A'].offset, (-7, 0))
def test_planner_deduplication_distinguishes_offer_costs() -> None:
@dataclass(frozen=True, slots=True)
class MarkedStraightOffer(StraightOffer):
marker: str = ''
def commit(self, parameter: float) -> dict[str, str | float]:
return {'kind': self.marker, 'length': self.canonicalize_parameter(parameter)}
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
def shared_commit(length: float) -> dict[str, float]:
return {'length': length}
class DuplicateGeometryTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
common = {
'in_ptype': in_ptype,
'out_ptype': out_ptype,
'endpoint_planner': endpoint,
'commit_planner': shared_commit,
}
return (
MarkedStraightOffer(cost=100, marker='expensive', **common),
MarkedStraightOffer(cost=1, marker='cheap', **common),
)
pather = Pather(Library(), tools=DuplicateGeometryTool(), render='deferred')
pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
pather.straight('A', 7)
assert pather._paths['A'][0].data == {'kind': 'cheap', 'length': 7}
class StrategyTieTool(PlanningOnlyTool):
def __init__(self) -> None:
self.seen_kwargs: list[dict[str, Any]] = []
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
self.seen_kwargs.append(dict(kwargs))
endpoint_ptype = out_ptype or in_ptype
marker = kwargs.get('marker')
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length, 'marker': marker},
)),
),)
if kind == 's':
return (SOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((3, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog, 'marker': marker},
)),
),)
return ()
def pather_with_strategy_tool(
planner: RoutingPlanner | None = None,
) -> tuple[Pather, StrategyTieTool]:
tool = StrategyTieTool()
pather = Pather(Library(), tools=tool, planner=planner, render='deferred')
pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
return pather, tool
def test_pather_route_strategy_defaults_to_straight_first() -> None:
pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10)
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
assert pather._paths['A'][0].data['length'] == 7
def test_pather_route_strategy_uses_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10)
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
assert pather._paths['A'][1].data['length'] == 7
def test_pather_route_strategy_per_route_overrides_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10, plan_options={'strategy': 'straight_first'})
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'})
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
def test_pather_plan_options_are_not_forwarded_to_tool() -> None:
pather, tool = pather_with_strategy_tool()
pather.jog(
'A',
4,
length=10,
plan_options={'strategy': 'turn_first'},
tool_options={'marker': 'sentinel'},
)
assert tool.seen_kwargs
assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs)
assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs)
assert all(step.data['marker'] == 'sentinel' for step in pather._paths['A'])
def test_pather_route_strategy_rejects_invalid_values() -> None:
with pytest.raises(BuildError, match='Invalid route strategy'):
RoutingPlanner(strategy='sideways')
pather, _tool = pather_with_strategy_tool()
with pytest.raises(BuildError, match='Invalid route strategy'):
pather.jog('A', 4, length=10, plan_options={'strategy': 'sideways'})
def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None:
invalid_parameters: list[float] = []
class RotationOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def invalid_endpoint(length: float) -> Port:
invalid_parameters.append(length)
return Port((length, 0), rotation=0, ptype=out_ptype or in_ptype)
def valid_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
return (
StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
endpoint_planner=invalid_endpoint,
commit_planner=lambda length: {'kind': 'invalid', 'length': length},
),
StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
endpoint_planner=valid_endpoint,
commit_planner=lambda length: {'kind': 'valid', 'length': length},
),
)
p = Pather(Library(), tools=RotationOfferTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7}
assert invalid_parameters == [0.0]
def test_pather_commits_only_selected_offer() -> None:
committed: list[str] = []
class RecordingTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def make(label: str, cost: float) -> StraightOffer:
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
def commit(length: float) -> dict[str, float | str]:
committed.append(label)
return {'kind': label, 'length': length}
return StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
cost=cost,
endpoint_planner=endpoint,
commit_planner=commit,
)
return (make('expensive', 100), make('cheap', 1))
p = Pather(Library(), tools=RecordingTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 5)
assert committed == ['cheap']
assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5}
def test_pather_routes_with_offer_only_s_and_u_tool() -> None:
class OfferOnlyTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
endpoint_ptype = out_ptype or in_ptype
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length},
)),
),)
if kind == 's':
return (SOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((5, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog},
)),
),)
if kind == 'u':
return (UOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((2, jog), rotation=0, ptype=endpoint_ptype),
{'kind': 'u', 'jog': jog},
)),
),)
return ()
p = Pather(Library(), tools=OfferOnlyTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 3)
p.uturn('A', 4)
assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u']

View file

@ -0,0 +1,627 @@
from typing import TYPE_CHECKING, cast
import logging
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose
from ..builder import Pather, RouteError, ToolContractError
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
from ..error import BuildError
from ..library import Library
from ..pattern import Pattern
from ..ports import Port
if TYPE_CHECKING:
from ..shapes import Path
@pytest.fixture
def deferred_render_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
rp = Pather(lib, tools=tool, render='deferred')
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return rp, tool, lib
def add_pending_straight(pather: Pather) -> None:
pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
pather.straight("start", 10)
def route_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
def fail_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
raise RuntimeError('body failed')
def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10).straight(10)
assert not rp.pattern.has_shapes()
assert len(rp._paths["start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
assert len(rp.pattern.shapes[(1, 0)]) == 1
# PathTool renders length steps in the port extension direction.
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert len(path_shape.vertices) == 3
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10).cw(10)
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
# The bend route is explicit straight run plus a fixed-size bend.
assert len(path_shape.vertices) == 5
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10)
def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").jog(4, length=10)
assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"]
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -8], [0, -9], [1, -9], [3, -9], [4, -9], [4, -10]], atol=1e-10)
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10).cw(10)
rp.mirror(0)
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10)
def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool1, lib = deferred_render_setup
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
rp.at("start").straight(10)
rp.retool(tool2, keys=["start"])
rp.at("start").straight(10)
rp.render()
assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 0)]) == 1
def test_deferred_render_batches_tools_by_identity() -> None:
class CountingTool(PathTool):
def __init__(self, *args, **kwargs) -> None: # noqa: ANN002,ANN003
super().__init__(*args, **kwargs)
self.render_calls = 0
def render(self, *args, **kwargs): # noqa: ANN002,ANN003,ANN202
self.render_calls += 1
return super().render(*args, **kwargs)
lib = Library()
tool1 = CountingTool(layer=(1, 0), width=2, ptype='wire')
tool2 = CountingTool(layer=(1, 0), width=2, ptype='wire')
assert tool1 == tool2
assert tool1 is not tool2
p = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool1,
render='deferred',
)
p.straight('A', 5)
p.retool(tool2, 'A')
p.straight('A', 5)
p.render()
assert tool1.render_calls == 1
assert tool2.render_calls == 1
assert len(p.pattern.shapes[(1, 0)]) == 2
def test_deleted_name_reuse_does_not_retarget_pending_render_steps() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
p = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
p.straight('A', 5)
original_step = p._paths['A'][0]
p.rename_ports({'A': None})
p.mkport('A', Port((100, 0), rotation=0, ptype='wire'))
p.straight('A', 5)
assert len(p._paths['A']) == 2
assert_allclose(original_step.start_port.offset, (0, 0))
assert_allclose(original_step.end_port.offset, (-5, 0))
assert_allclose(p._paths['A'][1].start_port.offset, (100, 0))
def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
pp = rp.at("start")
pp.straight(10)
pp.translate((5, 0))
pp.straight(10)
rp.render()
shapes = rp.pattern.shapes[(1, 0)]
assert len(shapes) == 2
assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10)
assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10)
assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10)
def test_deferred_render_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred')
rp.set_dead()
with pytest.raises(RouteError, match='finite and nonnegative'):
rp.straight("in", -10)
assert_allclose(rp.ports["in"].offset, [0, 0], atol=1e-10)
assert len(rp._paths["in"]) == 0
rp.render()
assert not rp.pattern.has_shapes()
def test_pather_default_auto_policy_renders_immediately_outside_context() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
p.straight("start", 10)
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with Pather(lib, tools=tool) as p:
add_pending_straight(p)
assert not p.pattern.has_shapes()
assert len(p._paths["start"]) == 1
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p:
add_pending_straight(p)
records = [
record
for record in caplog.records
if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage()
]
assert len(records) == 1
assert records[0].stack_info is None
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_error_policy_rejects_pending_paths() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(BuildError, match='1 pending render step on 1 port'):
route_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p:
add_pending_straight(p)
assert not caplog.records
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_policy_does_not_mask_body_exception() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(RuntimeError, match='body failed'):
fail_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_rejects_invalid_render_policy() -> None:
with pytest.raises(BuildError, match='Invalid render policy'):
Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type]
def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10)
rp.rename_ports({"start": "new_start"})
rp.at("new_start").straight(10)
assert "start" not in rp._paths
assert len(rp._paths["new_start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
assert len(rp.pattern.shapes[(1, 0)]) == 1
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
assert "new_start" in rp.ports
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10).drop()
assert "start" not in rp.ports
assert len(rp._paths["start"]) == 1
rp.render()
assert rp.pattern.has_shapes()
assert "start" not in rp.ports
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
def test_pathtool_bend_offer_render_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(1)
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(1)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert offer.length_domain == (1, 1)
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10)
def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
offer = tool.primitive_offers("s", in_ptype="wire")[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(4)
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(4)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10)
def test_deferred_render_uturn_fallback() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, render='deferred')
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').uturn(offset=10000, length=5000)
assert len(rp._paths['A']) == 4
assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L']
rp.render()
assert rp.pattern.ports['A'].rotation is not None
assert numpy.isclose(rp.pattern.ports['A'].rotation, pi)
def test_pather_render_auto_renames_single_use_tool_children() -> None:
class FullTreeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
child = Pattern(annotations={'batch': [len(batch)]})
top.ref('_seg')
tree['_top'] = top
tree['_seg'] = child
return tree
lib = Library()
p = Pather(lib, tools=FullTreeTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
p.render()
p.straight('A', 10)
p.render()
assert len(lib) == 2
assert set(lib.keys()) == set(p.pattern.refs.keys())
assert len(set(p.pattern.refs.keys())) == 2
assert all(name.startswith('_seg') for name in lib)
assert p.pattern.referenced_patterns() <= set(lib.keys())
def test_custom_tool_render_preserves_segment_subtrees() -> None:
class TraceTreeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: self._trace(length),
),)
def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
child = Pattern(annotations={'length': [length]})
top.ref('_seg')
tree['_top'] = top
tree['_seg'] = child
return tree
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
assert len(batch) == 1
assert isinstance(batch[0].data, Library)
return batch[0].data
lib = Library()
p = Pather(lib, tools=TraceTreeTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
p.render()
assert '_seg' in lib
assert '_seg' in p.pattern.refs
assert p.pattern.referenced_patterns() <= set(lib.keys())
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
class MissingSingleUseTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((1, 0), pi, ptype='wire'),
})
top.ref('_seg')
tree['_top'] = top
return tree
lib = Library()
lib['_seg'] = Pattern(annotations={'stale': [1]})
p = Pather(lib, tools=MissingSingleUseTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='missing single-use refs'):
p.render()
assert list(lib.keys()) == ['_seg']
assert not p.pattern.refs
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
class SharedRefTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
top.ref('shared')
tree['_top'] = top
return tree
lib = Library()
lib['shared'] = Pattern(annotations={'shared': [1]})
p = Pather(lib, tools=SharedRefTool())
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
p.render()
assert 'shared' in p.pattern.refs
assert p.pattern.referenced_patterns() <= set(lib.keys())
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None:
class WrongEndpointTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length + 1, 0), pi, ptype='wire'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongEndpointTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
def test_pather_render_rejects_opposite_output_rotation() -> None:
class WrongRotationTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer.generated('wire', lambda length: length),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
return tree
p = Pather(Library(), tools=WrongRotationTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(ToolContractError, match='does not match planned endpoint'):
p.render()
def test_pather_render_allows_unspecified_output_rotation() -> None:
class UnspecifiedRotationTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer.generated('wire', lambda length: length),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), None, ptype='wire'),
})
return tree
p = Pather(Library(), tools=UnspecifiedRotationTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
p.render()
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
class WrongPtypeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='metal'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongPtypeTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, render='deferred')
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').straight(5000)
rp.rename_ports({'A': None})
assert 'A' not in rp.pattern.ports
assert len(rp._paths['A']) == 1
rp.render()
assert rp.pattern.has_shapes()
assert 'A' not in rp.pattern.ports

View file

@ -0,0 +1,166 @@
from collections.abc import Mapping, Sequence
from typing import Any, Literal
import numpy
import pytest
from masque import Library, Pather, Port
from masque.builder import PathTool, RenderStep, RouteCompletionCallback, Tool
from masque.error import BuildError
def test_route_completion_can_label_before_automatic_render() -> None:
calls = 0
def label_endpoints(pather: Pather, endpoints: Mapping[str, Port]) -> None:
nonlocal calls
calls += 1
assert not pather.pattern.has_shapes()
assert numpy.allclose(pather.ports['A'].offset, endpoints['A'].offset)
for name, port in endpoints.items():
pather.label('LABELS', string=name, offset=port.offset)
callback: RouteCompletionCallback = label_endpoints
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
on_route_complete=callback,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.at('A').straight(5)
assert calls == 1
assert p.pattern.has_shapes()
assert len(p.pattern.labels['LABELS']) == 1
assert p.pattern.labels['LABELS'][0].string == 'A'
assert numpy.allclose(p.pattern.labels['LABELS'][0].offset, p.ports['A'].offset)
def test_bundle_route_completion_is_ordered_read_only_and_isolated() -> None:
calls: list[Mapping[str, Port]] = []
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
_ = pather
calls.append(endpoints)
with pytest.raises(TypeError):
endpoints['extra'] = Port((0, 0)) # type: ignore[index]
endpoints['B'].translate((100, 100))
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
on_route_complete=record,
)
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace(['A', 'B'], None, each=5)
assert len(calls) == 1
assert list(calls[0]) == ['A', 'B']
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
assert numpy.allclose(p.ports['B'].offset, (-5, 2))
def test_trace_into_completion_retains_consumed_source_endpoint() -> None:
calls: list[Mapping[str, Port]] = []
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
assert set(pather.ports) == {'src'}
assert numpy.allclose(pather.ports['src'].offset, (20, 0))
calls.append(endpoints)
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
on_route_complete=record,
)
p.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
p.ports['dst'] = Port((-10, 0), rotation=numpy.pi, ptype='wire')
p.ports['thru'] = Port((20, 0), rotation=0, ptype='wire')
p.trace_into('src', 'dst', thru='thru')
assert len(calls) == 1
assert list(calls[0]) == ['src']
assert numpy.allclose(calls[0]['src'].offset, (-10, 0))
def test_route_completion_exception_propagates_before_render() -> None:
def fail(pather: Pather, endpoints: Mapping[str, Port]) -> None:
_ = pather, endpoints
raise RuntimeError('completion failed')
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
on_route_complete=fail,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(RuntimeError, match='completion failed'):
p.straight('A', 5)
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
assert p._paths['A']
assert not p.pattern.has_shapes()
class NoRouteTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
**kwargs: Any,
) -> tuple[()]:
_ = kind, kwargs
return ()
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any,
) -> Library:
_ = batch, port_names, kwargs
return Library()
def test_dead_bundle_fallback_invokes_route_completion_once() -> None:
calls: list[Mapping[str, Port]] = []
p = Pather(
Library(),
tools=NoRouteTool(),
on_route_complete=lambda _pather, endpoints: calls.append(endpoints),
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
p.set_dead()
p.trace(['B', 'A'], None, each=5)
assert len(calls) == 1
assert list(calls[0]) == ['B', 'A']
assert numpy.allclose(calls[0]['B'].offset, (-5, 2))
assert numpy.allclose(calls[0]['A'].offset, (-5, 0))
assert not p.pattern.has_shapes()
def test_failed_route_does_not_invoke_route_completion() -> None:
calls = 0
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
nonlocal calls
_ = pather, endpoints
calls += 1
p = Pather(Library(), tools=NoRouteTool(), on_route_complete=record)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError):
p.straight('A', 5)
assert calls == 0

View file

@ -0,0 +1,573 @@
from typing import Any
import numpy
import pytest
from numpy import pi
from numpy.testing import assert_equal
from masque import Library, PathTool, Port, Pather, RouteFailurePolicy
from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import Candidate, SolverRequest
from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
from masque.error import BuildError, PortError
@pytest.fixture
def trace_into_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='immediate', render_append=False)
return p, tool, lib
def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.trace_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
assert len(p.pattern.refs) == 1
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
p.trace_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
assert len(p.pattern.refs) == 1
def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, -10), pi, ptype="wire")
p.trace_into("src", "dst")
assert "src" not in p.ports
assert "dst" not in p.ports
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
p.ports["other"] = Port((10, 10), 0)
p.trace_into("src", "dst", thru="other")
assert "src" in p.ports
assert_equal(p.ports["src"].offset, [10, 10])
assert "other" not in p.ports
def test_pather_trace_into_shapes() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
p.at('A').trace_into('B', plug_destination=False)
assert 'B' in p.pattern.ports
assert 'A' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
p.pattern.ports['C'] = Port((0, 0), rotation=0)
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2)
p.at('C').trace_into('D', plug_destination=False)
assert 'D' in p.pattern.ports
assert 'C' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000))
p.pattern.ports['E'] = Port((0, 0), rotation=0)
p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi)
p.at('E').trace_into('F', plug_destination=False)
assert 'F' in p.pattern.ports
assert 'E' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000))
p.pattern.ports['G'] = Port((0, 0), rotation=0)
p.pattern.ports['H'] = Port((-10000, 2000), rotation=0)
p.at('G').trace_into('H', plug_destination=False)
assert 'H' in p.pattern.ports
assert 'G' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000))
assert p.pattern.ports['G'].rotation is not None
assert numpy.isclose(p.pattern.ports['G'].rotation, pi)
p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2)
p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2)
p.at('I').trace_into('J', plug_destination=False)
assert 'J' in p.pattern.ports
assert 'I' in p.pattern.ports
assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000))
assert p.pattern.ports['I'].rotation is not None
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
@pytest.mark.parametrize(
'dst',
[
Port((-10_000, 0), rotation=pi),
Port((-10_000, 2_000), rotation=pi),
Port((-5_000, 5_000), rotation=pi / 2),
Port((-10_000, 2_000), rotation=0),
],
)
def test_pather_trace_into_minimal_policy_accepts_required_topologies(dst: Port) -> None:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1_000),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0)
pather.ports['dst'] = dst
pather.trace_into(
'src',
'dst',
plug_destination=False,
plan_options={'bend_policy': 'minimal'},
)
assert numpy.allclose(pather.ports['src'].offset, dst.offset)
assert pather.ports['src'].rotation is not None
assert numpy.isclose((pather.ports['src'].rotation - dst.rotation) % (2 * pi), pi)
def test_pather_trace_into_bend_policy_changes_real_solver_fallback() -> None:
def make_pather() -> Pather:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((2, 0), rotation=pi, ptype='wire')
return pather
flexible = make_pather()
flexible.at('src').trace_into(
'dst',
plug_destination=False,
plan_options={'bend_policy': 'flexible'},
)
assert_equal(flexible.ports['src'].offset, (2, 0))
assert flexible.ports['src'].rotation is not None
assert numpy.isclose(flexible.ports['src'].rotation, 0)
bend_roles = sum(
1 if step.kind == 'bend' else 2 if step.kind in ('s', 'u') else 0
for step in flexible._paths['src']
)
assert bend_roles == 4
minimal = make_pather()
with pytest.raises(BuildError):
minimal.at('src').trace_into(
'dst',
plug_destination=False,
)
assert set(minimal.ports) == {'src', 'dst'}
assert_equal(minimal.ports['src'].offset, (0, 0))
assert numpy.isclose(minimal.ports['src'].rotation, 0)
assert_equal(minimal.ports['dst'].offset, (2, 0))
assert numpy.isclose(minimal.ports['dst'].rotation, pi)
assert not minimal._paths
def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None:
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1000, ptype='wire'),
render='deferred',
)
p.ports['src'] = Port((123.25, 456.75), rotation=0, ptype='wire')
p.ports['dst'] = Port((-99_999_876.75, 20_000_456.75), rotation=0, ptype='wire')
p.trace_into('src', 'dst')
assert 'src' not in p.ports
assert 'dst' not in p.ports
assert [step.kind for step in p._paths['src']] == ['straight', 'bend', 'straight', 'bend', 'plug']
def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
class TransitionTool(Tool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = in_ptype
if kind == 'straight':
def native_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='m1wire')
native = StraightOffer(
in_ptype='m1wire',
out_ptype='m1wire',
endpoint_planner=native_endpoint,
commit_planner=lambda length: {'kind': 'straight', 'length': length},
)
if out_ptype in ('unk', 'm1wire'):
return (native,)
def transition_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='m2wire')
transition = StraightOffer(
in_ptype='m1wire',
out_ptype='m2wire',
length_domain=(2500, numpy.inf),
endpoint_planner=transition_endpoint,
commit_planner=lambda length: {'kind': 'transition', 'length': length},
)
return native, transition
if kind == 'bend':
ccw = bool(kwargs['ccw'])
def endpoint(length: float) -> Port:
return Port(
(length, 500 if ccw else -500),
rotation=-pi / 2 if ccw else pi / 2,
ptype='m1wire',
)
return (BendOffer(
in_ptype='m1wire',
out_ptype='m1wire',
ccw=ccw,
length_domain=(500, numpy.inf),
endpoint_planner=endpoint,
commit_planner=lambda length: {'kind': 'bend', 'length': length},
),)
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('transition_tool')
pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire')
return tree
p = Pather(Library(), tools=TransitionTool(), render='deferred')
p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire')
p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire')
p.trace_into('src', 'dst')
assert 'src' not in p.pattern.ports
assert 'dst' not in p.pattern.ports
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire')
p.set_dead()
p.trace_into('A', 'B', plug_destination=False)
assert set(p.pattern.ports) == {'A', 'B'}
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p._paths['A']) == 0
assert not p.pattern.has_shapes()
assert not p.pattern.has_refs()
def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire')
with pytest.raises(BuildError):
p.trace_into('A', 'B', plug_destination=False, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2)
assert len(p._paths['A']) == 0
def test_pather_trace_into_rename_failure_propagates() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire')
p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire')
with pytest.raises(PortError, match='overwritten'):
p.trace_into('A', 'B', plug_destination=False, thru='other')
@pytest.mark.parametrize(
('dst', 'kwargs', 'match'),
[
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'),
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'),
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'),
],
)
def test_pather_trace_into_rejects_reserved_route_kwargs(
dst: Port,
kwargs: dict[str, Any],
match: str,
) -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = dst
_ = match
with pytest.raises(TypeError, match='unexpected keyword argument'):
p.trace_into('A', 'B', plug_destination=False, **kwargs)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset)
assert dst.rotation is not None
assert p.pattern.ports['B'].rotation is not None
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
assert len(p._paths['A']) == 0
class TraceIntoBudgetSolver:
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
self.successes = successes
self.fatal_at = set() if fatal_at is None else fatal_at
self.attempts: list[tuple[int, int]] = []
def solve(
self,
*,
min_bends: int = 0,
max_bends: int | None = None,
) -> Candidate:
assert max_bends is not None
band = (min_bends, max_bends)
self.attempts.append(band)
if band in self.fatal_at:
raise RoutePlanningError('fatal', policy=RouteFailurePolicy.FATAL)
if band not in self.successes:
raise BuildError('try next budget')
return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0)
class TraceIntoBudgetPlanner(RoutingPlanner):
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
super().__init__()
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
self.solver_requests = 0
def solver_for_request(self, request: SolverRequest) -> Any:
_ = request
self.solver_requests += 1
return self.solver
def prepared_result_from_legs(
self,
legs: Any,
*,
renames: tuple[tuple[str, str], ...] = (),
) -> PreparedRouteResult:
_ = legs, renames
return PreparedRouteResult(())
@pytest.mark.parametrize(
('dst', 'successes', 'attempts'),
[
(Port((-10, 0), rotation=pi, ptype='wire'), {(0, 2)}, [(0, 2)]),
(Port((-10, 0), rotation=pi, ptype='wire'), {(4, 4)}, [(0, 2), (4, 4)]),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(1, 1)}, [(1, 1)]),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(3, 3)}, [(1, 1), (3, 3)]),
],
)
def test_trace_into_reuses_solver_across_staged_bend_bands(
dst: Port,
successes: set[tuple[int, int]],
attempts: list[tuple[int, int]],
) -> None:
planner = TraceIntoBudgetPlanner(successes)
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert planner.solver.attempts == attempts
assert planner.solver_requests == 1
def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None:
planner = TraceIntoBudgetPlanner({(4, 4)}, fatal_at={(0, 2)})
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
with pytest.raises(RoutePlanningError, match='fatal'):
planner.plan_trace_into(
context,
'dst',
Port((-10, 0), rotation=pi, ptype='wire'),
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert planner.solver.attempts == [(0, 2)]
assert planner.solver_requests == 1
def test_trace_into_bend_bands_respect_max_bends() -> None:
class OneBendPlanner(RoutingPlanner):
TRACE_INTO_MAX_BENDS = 1
planner = OneBendPlanner(bend_policy='flexible')
assert planner.trace_into_bend_bands('straight') == ((0, 0),)
assert planner.trace_into_bend_bands('s') == ((0, 0),)
assert planner.trace_into_bend_bands('bend') == ((1, 1),)
@pytest.mark.parametrize(
('family', 'expected'),
[
('straight', ((0, 0),)),
('bend', ((1, 1),)),
('s', ((2, 2),)),
('u', ((2, 2),)),
],
)
def test_trace_into_default_minimal_bend_bands(family: str, expected: tuple[tuple[int, int], ...]) -> None:
planner = RoutingPlanner()
assert planner.trace_into_bend_bands(family) == expected
assert planner.trace_into_bend_bands(family, bend_policy='flexible') == (
((1, 1), (3, 3)) if family == 'bend' else ((0, 2), (4, 4))
)
@pytest.mark.parametrize(
('dst', 'required_band'),
[
(Port((-10, 0), rotation=pi, ptype='wire'), (0, 0)),
(Port((-10, -5), rotation=pi, ptype='wire'), (2, 2)),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), (1, 1)),
(Port((-10, -5), rotation=0, ptype='wire'), (2, 2)),
],
)
def test_trace_into_minimal_policy_uses_orientation_required_band(
dst: Port,
required_band: tuple[int, int],
) -> None:
planner = TraceIntoBudgetPlanner({required_band})
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'minimal'},
)
assert planner.solver.attempts == [required_band]
def test_trace_into_minimal_policy_rejects_fallback_without_mutation() -> None:
planner = TraceIntoBudgetPlanner({(4, 4)})
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
planner=planner,
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='try next budget'):
pather.trace_into('src', 'dst', plan_options={'bend_policy': 'minimal'})
assert planner.solver.attempts == [(0, 0)]
assert set(pather.ports) == {'src', 'dst'}
assert_equal(pather.ports['src'].offset, (0, 0))
assert_equal(pather.ports['dst'].offset, (-10, 0))
assert not pather._paths
def test_trace_into_bend_policy_planner_default_and_route_override() -> None:
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
dst = Port((-10, 0), rotation=pi, ptype='wire')
minimal_planner = TraceIntoBudgetPlanner({(4, 4)})
with pytest.raises(BuildError, match='try next budget'):
minimal_planner.plan_trace_into(
context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None,
)
assert minimal_planner.solver.attempts == [(0, 0)]
flexible_planner = TraceIntoBudgetPlanner({(4, 4)})
flexible_planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert flexible_planner.solver.attempts == [(0, 2), (4, 4)]
def test_trace_into_rejects_invalid_bend_policy() -> None:
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
RoutingPlanner(bend_policy='sideways') # type: ignore[arg-type]
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
pather.trace_into('src', 'dst', plan_options={'bend_policy': 'sideways'})

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

@ -0,0 +1,310 @@
import pytest
import copy
from typing import cast
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..error import PatternError
from ..abstract import Abstract
from ..pattern import Pattern
from ..shapes import Polygon
from ..ref import Ref
from ..ports import Port, PortError
from ..label import Label
from ..repetition import Grid
def test_pattern_init() -> None:
pat = Pattern()
assert pat.is_empty()
assert not pat.has_shapes()
assert not pat.has_refs()
assert not pat.has_labels()
assert not pat.has_ports()
def test_pattern_with_elements() -> None:
poly = Polygon.square(10)
label = Label("test", offset=(5, 5))
ref = Ref(offset=(100, 100))
port = Port((0, 0), 0)
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
assert pat.has_shapes()
assert pat.has_labels()
assert pat.has_refs()
assert pat.has_ports()
assert not pat.is_empty()
assert pat.shapes[(1, 0)] == [poly]
assert pat.labels[(1, 2)] == [label]
assert pat.refs["sub"] == [ref]
assert pat.ports["P1"] == port
def test_pattern_append() -> None:
pat1 = Pattern()
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
pat2 = Pattern()
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
pat1.append(pat2)
assert len(pat1.shapes[(1, 0)]) == 1
assert len(pat1.shapes[(2, 0)]) == 1
def test_pattern_translate() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
pat.ports["P1"] = Port((5, 5), 0)
pat.translate_elements((10, 20))
# Polygon.translate adds to vertices, and offset is always (0,0)
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20])
assert_equal(pat.ports["P1"].offset, [15, 25])
def test_pattern_scale() -> None:
pat = Pattern()
# Polygon.rect sets an offset in its constructor which is immediately translated into vertices
pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
pat.scale_by(2)
# Vertices should be scaled
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
def test_pattern_rotate() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]])
# Rotate 90 degrees CCW around (0,0)
pat.rotate_around((0, 0), pi / 2)
# [10, 0] rotated 90 deg around (0,0) is [0, 10]
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10)
def test_pattern_mirror() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]])
# Mirror across X axis (y -> -y)
pat.mirror(0)
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5])
def test_pattern_get_bounds() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
bounds = pat.get_bounds()
assert_equal(bounds, [[-5, -5], [10, 10]])
def test_pattern_flatten_preserves_ports_only_child() -> None:
child = Pattern(ports={"P1": Port((1, 2), 0)})
parent = Pattern()
parent.ref("child", offset=(10, 10))
parent.flatten({"child": child}, flatten_ports=True)
assert set(parent.ports) == {"P1"}
assert parent.ports["P1"].rotation == 0
assert tuple(parent.ports["P1"].offset) == (11.0, 12.0)
def test_pattern_flatten_repeated_ref_with_ports_raises() -> None:
child = Pattern(ports={"P1": Port((1, 2), 0)})
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
parent = Pattern()
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
parent.flatten({"child": child}, flatten_ports=True)
def test_pattern_place_requires_abstract_for_reference() -> None:
parent = Pattern()
child = Pattern()
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
parent.place(child)
assert not parent.ports
def test_pattern_place_append_requires_pattern_atomically() -> None:
parent = Pattern()
child = Abstract("child", {"A": Port((1, 2), 0)})
with pytest.raises(PatternError, match='Must provide a full `Pattern`'):
parent.place(child, append=True)
assert not parent.ports
def test_pattern_place_append_annotation_conflict_is_atomic() -> None:
parent = Pattern(annotations={"k": [1]})
child = Pattern(annotations={"k": [2]}, ports={"A": Port((1, 2), 0)})
with pytest.raises(PatternError, match="Annotation keys overlap"):
parent.place(child, append=True)
assert not parent.ports
assert parent.annotations == {"k": [1]}
def test_pattern_place_skip_geometry_overwrites_colliding_ports_last_wins() -> None:
parent = Pattern(ports={
"A": Port((5, 5), 0),
"keep": Port((9, 9), 0),
})
child = Pattern(ports={
"X": Port((1, 0), 0),
"Y": Port((2, 0), pi / 2),
})
parent.place(child, port_map={"X": "A", "Y": "A"}, skip_geometry=True, append=True)
assert set(parent.ports) == {"A", "keep"}
assert_allclose(parent.ports["A"].offset, (2, 0))
assert parent.ports["A"].rotation is not None
assert_allclose(parent.ports["A"].rotation, pi / 2)
def test_pattern_interface() -> None:
source = Pattern()
source.ports["A"] = Port((10, 20), 0, ptype="test")
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
assert "in_A" in iface.ports
assert "out_A" in iface.ports
assert iface.ports["in_A"].rotation is not None
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
assert iface.ports["out_A"].rotation is not None
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
assert iface.ports["in_A"].ptype == "test"
assert iface.ports["out_A"].ptype == "test"
def test_pattern_interface_duplicate_port_map_targets_raise() -> None:
source = Pattern()
source.ports["A"] = Port((10, 20), 0)
source.ports["B"] = Port((30, 40), pi)
with pytest.raises(PortError, match='Duplicate targets in `port_map`'):
Pattern.interface(source, port_map={"A": "X", "B": "X"})
def test_pattern_interface_empty_port_map_copies_no_ports() -> None:
source = Pattern()
source.ports["A"] = Port((10, 20), 0)
source.ports["B"] = Port((30, 40), pi)
assert not Pattern.interface(source, port_map={}).ports
assert not Pattern.interface(source, port_map=[]).ports
def test_pattern_plug_requires_abstract_for_reference_atomically() -> None:
parent = Pattern(ports={"X": Port((0, 0), 0)})
child = Pattern(ports={"A": Port((0, 0), pi)})
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
parent.plug(child, {"X": "A"})
assert set(parent.ports) == {"X"}
def test_pattern_plug_append_annotation_conflict_is_atomic() -> None:
parent = Pattern(
annotations={"k": [1]},
ports={"X": Port((0, 0), 0), "Q": Port((9, 9), 0)},
)
child = Pattern(
annotations={"k": [2]},
ports={"A": Port((0, 0), pi), "B": Port((5, 0), 0)},
)
with pytest.raises(PatternError, match="Annotation keys overlap"):
parent.plug(child, {"X": "A"}, map_out={"B": "Y"}, append=True)
assert set(parent.ports) == {"X", "Q"}
assert_allclose(parent.ports["X"].offset, (0, 0))
assert_allclose(parent.ports["Q"].offset, (9, 9))
assert parent.annotations == {"k": [1]}
def test_pattern_plug_skip_geometry_overwrites_colliding_ports_last_wins() -> None:
parent = Pattern(ports={
"A": Port((0, 0), 0, ptype="wire"),
"B": Port((99, 99), 0, ptype="wire"),
})
child = Pattern(ports={
"in": Port((0, 0), pi, ptype="wire"),
"X": Port((10, 0), 0, ptype="wire"),
"Y": Port((20, 0), 0, ptype="wire"),
})
parent.plug(child, {"A": "in"}, map_out={"X": "B", "Y": "B"}, skip_geometry=True, append=True)
assert "A" not in parent.ports
assert "B" in parent.ports
assert_allclose(parent.ports["B"].offset, (20, 0))
assert parent.ports["B"].rotation is not None
assert_allclose(parent.ports["B"].rotation, 0)
def test_pattern_append_port_conflict_is_atomic() -> None:
pat1 = Pattern()
pat1.ports["A"] = Port((0, 0), 0)
pat2 = Pattern()
pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
pat2.ports["A"] = Port((1, 0), 0)
with pytest.raises(PatternError, match="Port names overlap"):
pat1.append(pat2)
assert not pat1.shapes
assert set(pat1.ports) == {"A"}
def test_pattern_append_annotation_conflict_is_atomic() -> None:
pat1 = Pattern(annotations={"k": [1]})
pat2 = Pattern(annotations={"k": [2]})
pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
with pytest.raises(PatternError, match="Annotation keys overlap"):
pat1.append(pat2)
assert not pat1.shapes
assert pat1.annotations == {"k": [1]}
def test_pattern_deepcopy_does_not_share_shape_repetitions() -> None:
pat = Pattern()
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2))
pat2 = copy.deepcopy(pat)
pat2.scale_by(2)
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).repetition.a_vector, [10, 0])
assert_allclose(cast("Polygon", pat2.shapes[(1, 0)][0]).repetition.a_vector, [20, 0])
def test_pattern_flatten_does_not_mutate_child_repetitions() -> None:
child = Pattern()
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2))
parent = Pattern()
parent.ref("child", scale=2)
parent.flatten({"child": child})
assert_allclose(cast("Polygon", child.shapes[(1, 0)][0]).repetition.a_vector, [10, 0])

View file

@ -0,0 +1,89 @@
import pytest
from numpy.testing import assert_equal
from ..error import PatternError
from ..shapes import Circle, Ellipse, Polygon, PolyCollection
def test_poly_collection_init() -> None:
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 4]
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
assert len(list(pc.polygon_vertices)) == 2
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
def test_poly_collection_to_polygons() -> None:
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 4]
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
polys = pc.to_polygons()
assert len(polys) == 2
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
def test_poly_collection_holes() -> None:
# PolyCollection represents separate polygon boundaries, including nested boundaries.
verts = [
[0, 0],
[10, 0],
[10, 10],
[0, 10], # Poly 1
[2, 2],
[2, 8],
[8, 8],
[8, 2], # Poly 2
]
offsets = [0, 4]
pc = PolyCollection(verts, offsets)
polys = pc.to_polygons()
assert len(polys) == 2
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
def test_poly_collection_constituent_empty() -> None:
# Duplicate offsets create an empty constituent slice between valid polygons.
verts = [
[0, 0],
[1, 0],
[0, 1], # Tri
[10, 10],
[11, 10],
[11, 11],
[10, 11], # Square
]
offsets = [0, 3, 3]
pc = PolyCollection(verts, offsets)
with pytest.raises(PatternError):
pc.to_polygons()
def test_poly_collection_valid() -> None:
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
offsets = [0, 3]
pc = PolyCollection(verts, offsets)
assert len(pc.to_polygons()) == 2
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
sorted_shapes = sorted(shapes)
assert len(sorted_shapes) == 4
assert sorted(sorted_shapes) == sorted_shapes
def test_poly_collection_normalized_form_reconstruction_is_independent() -> None:
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
clone = rebuild()
clone.vertex_offsets[:] = [5]
assert_equal(pc.vertex_offsets, [0])
assert_equal(clone.vertex_offsets, [5])
def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None:
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
first = rebuild()
second = rebuild()
first.vertex_offsets[:] = [7]
assert_equal(first.vertex_offsets, [7])
assert_equal(second.vertex_offsets, [0])
assert_equal(pc.vertex_offsets, [0])

Some files were not shown because too many files have changed in this diff Show more