Compare commits

..

301 Commits

Author SHA1 Message Date
jan
94a1b3d793 cleanup comment 2024-10-14 17:25:01 -07:00
jan
7c7a7e916c Fix offset handling in polygon normalized_form() 2024-10-14 17:24:49 -07:00
73193473df Fixup arclength calculation for wedges (or other thick arcs) 2024-10-05 11:24:40 -07:00
febaaeff0b add Library functions for finding instances and extracting hierarchy
added child_graph, parent_graph, child_order, find_refs_local and find_refs_global
2024-10-04 17:21:31 -07:00
a54ee5a26c bump klamath req 2024-08-01 00:41:01 -07:00
8d671ed709 bump version to v3.2
Highlights:
- Pather.path_into() for connecting into existing ports
- Pattern.plugged() for removing ports which were manually pathed into
  each other.
- Defined ordering/comparsions to enable sorting patterns and shapes
- numpy 2.0 compatibility
- Fix bounds calculation for arrays with manhattan rotations
- Bugfixes for DXF and OASIS
- Speed improvement for default Library.get_name() and GDS writing
2024-07-29 21:00:40 -07:00
a816a7db8e allow numpy v2.0 2024-07-29 18:24:31 -07:00
a8a42bba1d speed up b64suffix by using a simple array lookup instead of base64.b64encode 2024-07-29 11:30:31 -07:00
da7118f521 misc cleanup 2024-07-29 03:13:36 -07:00
ef6c5df386 be more consistent about when copies are made 2024-07-29 03:13:23 -07:00
ad0adec8e8 numpy.array(..., copy=False) -> numpy.asarray(...)
For numpy 2.0
2024-07-29 02:37:48 -07:00
8fd6896a71 set stacklevel=1 2024-07-28 20:41:17 -07:00
1ae3ffb9a2 linter cleanup 2024-07-28 20:35:37 -07:00
810a09f18b simplify comparison 2024-07-28 20:34:25 -07:00
97688ffae1 don't want to use context manager here 2024-07-28 20:32:55 -07:00
445c5690e1 use path.open 2024-07-28 20:32:55 -07:00
7e1f617274 fix bug where use_mmap was ignored 2024-07-28 20:32:53 -07:00
b10803efe9 pass along string arg 2024-07-28 20:31:41 -07:00
5f0a450ffa no need for string annotation 2024-07-28 20:31:41 -07:00
aa3636ebc6 flatten indent 2024-07-28 20:31:41 -07:00
48ffc9709e double quotes for docstrings 2024-07-28 20:31:41 -07:00
5cdafd580f don't need ABCMeta here 2024-07-28 20:31:41 -07:00
2cf187fdb8 def-in-loop needs assigments for vars 2024-07-28 20:31:41 -07:00
99e55f931c refactor to single-line conditional assignments 2024-07-28 20:31:41 -07:00
c48b427c77 mark some missing annotations as intentional 2024-07-28 20:31:41 -07:00
62fc64c344 iteration and collection simplifications 2024-07-28 20:31:41 -07:00
f304217d76 format is ok 2024-07-28 20:31:41 -07:00
ae21a2132e handle int-based cell references 2024-07-28 20:31:41 -07:00
e159c80b0c improve error generation and handling 2024-07-28 20:31:41 -07:00
38e9d5c250 use strict zip 2024-07-28 20:31:41 -07:00
5614eea3b4 Update DXF reading 2024-07-28 20:31:41 -07:00
8035daee7e mark intentionally unused args 2024-07-28 20:31:41 -07:00
4c69e773fd pass kwargs down into gen_straight() 2024-07-28 20:31:41 -07:00
39d9b88fa4 flatten indentation where it makes sense 2024-07-28 20:31:29 -07:00
9d5b1ef5e6 type annotation updates 2024-07-28 19:44:04 -07:00
3d50ff0070 add ruff config 2024-07-28 19:37:57 -07:00
01fe53dc79 fix final assignment and clarify what's going 2024-07-28 19:37:20 -07:00
d5adf57bc6 fix repr outside of class 2024-07-28 19:35:44 -07:00
4c721feaec re-exports: import x as x 2024-07-28 19:34:17 -07:00
6ec94fb3c3 import Sequence et al from collections.abc not typing 2024-07-28 19:33:16 -07:00
b1d78b9acb mkdir examples/layouts/ 2024-07-28 19:28:26 -07:00
dca918e63f notes for more todos 2024-07-28 19:28:05 -07:00
cda895a7d3 remove Builder.path() to avoid confusion with Pather.path() 2024-06-03 17:09:43 -07:00
jan
6db4bb96db Create an ordering for everything
In order to make layouts more reproducible
Also add pattern.sort() and file.utils.preflight_check()

optionally don't sort elements
elements aren't re-ordered that often, sorting them is slow, and the
sort criteria are arbitrary, so we might want to only sort stuff by name
2024-06-03 17:00:20 -07:00
jan
94aa853a49 add plugged() for manually-aligned ports 2024-06-03 16:57:07 -07:00
bb054b9eee port .copy() should deepcopy 2024-06-03 16:54:25 -07:00
5fb736eb74 add a more descriptive error message 2024-06-03 16:54:15 -07:00
4334d0d50b fix bounds calculation for arrays with manhattan rotation 2024-06-03 16:54:02 -07:00
31863c9799 reduce compression level to improve speed 2024-06-03 16:53:14 -07:00
30982d742b make sure kwargs get passed into gen_straight() 2024-06-03 16:53:03 -07:00
447d4ba35b improve path_into docs and error messages 2024-06-03 16:52:34 -07:00
70a51ed8ef path_into should use destination port's ptype by default 2024-06-03 16:26:12 -07:00
jan
b33c632569 cache base64encode calls since it's actually fairly slow 2024-03-09 18:38:29 -08:00
c115780bc7 bump version to v3.1 2024-03-30 18:02:40 -07:00
66d9a4eff8 add note about github mirror 2024-03-30 18:01:14 -07:00
3a0c49174b improve variable naming 2024-03-30 18:01:14 -07:00
8d122cbd2e add path_into() 2024-03-30 18:01:08 -07:00
383b5a0bef add plug_into arg 2023-11-24 23:55:39 -08:00
jan
24c77fd3c3 remove custom __copy__
no longer necessary now that we're not locking anything
2023-11-18 12:29:36 -08:00
jan
33529f5ed3 pattern shouldn't have an offset 2023-11-18 12:28:51 -08:00
jan
2516f06e40 add missing returns 2023-11-18 12:28:33 -08:00
1f6d78386c pass kwargs down into tool's path() calls 2023-11-12 02:30:11 -08:00
41d670eef3 Add missing f for f-strings 2023-11-12 02:29:52 -08:00
7f927c46b3 another arc fix 2023-10-27 23:31:22 -07:00
55e3066485 Wrap Pattern functions for label, ref, polygon, etc. 2023-10-27 21:59:48 -07:00
c7736a18c3 add missing arc endpoints 2023-10-27 21:55:17 -07:00
aefd79fb5d Pattern should be a forward reference 2023-10-23 10:24:49 -07:00
jan
7353617878 add .x and .y aliases for .offset 2023-10-20 23:19:28 -07:00
jan
f28c31fe29 = should have been + 2023-10-20 23:16:39 -07:00
jan
8ef5e2e852 improve docs 2023-10-20 23:16:02 -07:00
jan
ed433861e3 make sure transform is float-typed 2023-10-20 23:15:38 -07:00
jan
e710fa44b5 improve type annotations 2023-10-20 23:15:13 -07:00
jan
9a7a5583ed Add Tree/TreeView and allow Builder to ingest them 2023-10-20 23:14:47 -07:00
jan
b4d31903c1 update required python version 2023-10-15 23:55:41 -07:00
jan
d6ab8a1f34 Bump version to v3.0. Note that MAJOR BREAKING CHANGES were introduced almost everywhere in this version -- see the readme to understand how everything works now. 2023-10-15 23:12:33 -07:00
jan
83e82db5da doc typo 2023-10-15 23:10:58 -07:00
jan
73ce794fec import pack2d by default 2023-10-15 23:07:37 -07:00
jan
3a6807707b Add more docs 2023-10-15 23:07:28 -07:00
jan
1bdb998085 Generalize underscore into SINGLE_USE_PREFIX 2023-10-15 23:01:47 -07:00
jan
668d4b5d8b docstring updates 2023-10-15 18:31:58 -07:00
jan
2229ee5d25 surface BasicTool and PathTool at top level 2023-10-15 16:24:20 -07:00
jan
6ba44e375b remove todo 2023-10-15 16:21:51 -07:00
jan
f12f14e087 Add RenderPather tutorial, tutorial README, and some minor doc updates 2023-10-15 16:18:34 -07:00
jan
ef3bec01ce Replicate routing using paths 2023-10-15 16:18:34 -07:00
jan
5f5c78455b Add missing final vertex when the path ends in a bend 2023-10-15 16:18:34 -07:00
jan
1c7b0ce5e1 Start working on a pather tutorial 2023-10-15 16:18:34 -07:00
jan
8c14401788 add Library.map_layers 2023-10-15 16:18:34 -07:00
jan
4de82ab2ba fix transition calculation 2023-10-15 16:18:34 -07:00
jan
5a6826f8e5 stop taking in base_name -- tools can set their own cell names 2023-10-15 16:18:34 -07:00
jan
bfd81f777c Cleanup based on flake8 lint 2023-10-15 16:18:34 -07:00
jan
dec084818a some further work on Tool interface 2023-10-15 16:18:34 -07:00
jan
590b6b36bd No need for Builder 2023-10-15 16:18:34 -07:00
jan
80e0c5daa8 path() should return a tree 2023-10-15 16:18:34 -07:00
jan
5001664547 doc updates 2023-10-15 16:18:34 -07:00
jan
333b21ecf4 more design pattern docs 2023-10-15 16:18:34 -07:00
jan
0aa4a6ee7a doc updates 2023-10-15 16:18:34 -07:00
jan
fa7a850ec3 Add some notes on shorthand 2023-10-15 16:18:34 -07:00
jan
621f8420f8 comment grammar 2023-10-15 16:18:34 -07:00
jan
a3b356ac14 save new name on a separate line, for debugging convenience 2023-10-15 16:18:34 -07:00
jan
2f9c7e61ee add <= operator for library (returns an Abstract) 2023-10-15 16:18:34 -07:00
jan
3245de99b3 Add NoReturn __contains__ with a more descriptive error message 2023-10-15 16:18:34 -07:00
jan
c02c2f90ef add mkport() for safely making ports 2023-10-15 16:18:34 -07:00
jan
772e42ebf1 references to Pattern should be forward references 2023-10-15 16:18:34 -07:00
jan
8d2d1ffd50 Allow Pattern.ref() to take an Abstract 2023-10-15 16:18:34 -07:00
jan
ceaa4923ef fix broken import 2023-10-15 16:18:34 -07:00
jan
f40c74adb5 improve docs and variable names 2023-10-15 16:18:34 -07:00
jan
9de382b856 Fix major bugs in presort 2023-10-15 16:18:34 -07:00
169e5a1f12 Lots of doc updates 2023-10-15 16:18:34 -07:00
d79a0a6388 get rid of Pather.mk() 2023-10-15 16:18:34 -07:00
6975787717 remove unused import 2023-10-15 16:18:34 -07:00
c4ff53a0ba fix isinstance call arg order 2023-10-15 16:18:34 -07:00
3415a16cd1 Give a more explicit error message 2023-10-15 16:18:34 -07:00
0ea3b6625f add missing end condition 2023-10-15 16:18:34 -07:00
272cfb7e48 fix arclength calculations giving invalid values or non-integral steps 2023-10-15 16:18:34 -07:00
8fe7b14f4b repr updates 2023-10-15 16:18:34 -07:00
086d07a82d Add the option to use explicit x= or y= in path_to 2023-10-15 16:18:33 -07:00
d02ea400a0 Move plug/place/interface to Pattern
Since Pattern has ports already, these should live in Pattern and get
wrapped elsewhere. Builder becomes a context-holder (holding .library
and .dead) and some code duplication goes away.
2023-10-15 16:18:33 -07:00
4bca0e2638 clean some old code 2023-10-15 16:18:33 -07:00
33377df883 add notes about ports 2023-10-15 16:18:33 -07:00
jan
63e8f0b10e fix old variable name 2023-10-15 16:18:33 -07:00
jan
99f3b0871a missing import 2023-10-15 16:18:33 -07:00
jan
d5608786ea Remove more mentions of AutoSlots 2023-10-15 16:18:33 -07:00
jan
6866d44021 simplify imports and use new approach 2023-10-15 16:18:33 -07:00
jan
a2cc94794e don't need to deepcopy twice 2023-10-15 16:18:33 -07:00
jan
c2008f2719 Improve arc arclength estimation (untested) 2023-10-15 16:18:33 -07:00
jan
e2c7f8c8cc various doc updates 2023-10-15 16:18:33 -07:00
04e15f7c85 use retstep instead of subtracting 2023-10-15 16:18:33 -07:00
a5ddfc76ca speed up get_bounds when called on a manhattan ref 2023-10-15 16:18:33 -07:00
0c0012def0 find_ptransform -> find_port_transform 2023-10-15 16:18:33 -07:00
468322ceb9 add has_ports() 2023-10-15 16:18:33 -07:00
d4bb466ad9 add mutate_other arg 2023-10-15 16:18:33 -07:00
e6ff6daa32 move __repr__ higher 2023-10-15 16:18:33 -07:00
f7f5a62f54 Update comments 2023-10-15 16:18:33 -07:00
e47f9b76b1 remove TODO labels from mypy #3004 comments 2023-10-15 16:18:33 -07:00
b872e19dec Improve arclength calculation for elliptical arcs 2023-10-15 16:18:33 -07:00
jan
efac8efa90 update some examples 2023-10-15 16:18:33 -07:00
31d97d8df0 add retool() 2023-10-15 16:18:33 -07:00
3b2be804e2 Only remove existing ports 2023-10-15 16:18:33 -07:00
b443a2a41e add prune_layers and prune_refs 2023-10-15 16:18:33 -07:00
064c3803ed fix comment 2023-10-15 16:18:33 -07:00
jan
0618be91d4 delete some old code 2023-10-15 16:18:33 -07:00
jan
c55d95505c improve accuracy of manhattan rotations 2023-10-15 16:18:33 -07:00
jan
97ccd8c303 fix missing tools prop 2023-10-15 16:18:33 -07:00
jan
df4c867e5c fix bounds 2023-10-15 16:18:33 -07:00
jan
24fc97e7f5 update readme 2023-10-15 16:18:33 -07:00
jan
91465b7175 don't keep track of y-mirroring separately from x 2023-10-15 16:18:33 -07:00
jan
9bc8d29b85 renderbuilder fixes 2023-10-15 16:18:33 -07:00
jan
9a28e1617c renderpather, get_bounds includes repetitions, Boundable 2023-10-15 16:18:33 -07:00
jan
22e1c6ae1d fix bounds 2023-10-15 16:18:33 -07:00
jan
87be06dcbe pattern copy should be deep 2023-10-15 16:18:33 -07:00
jan
bbc61a2fcd wrong func name 2023-10-15 16:18:33 -07:00
jan
e3c7150e18 missing import 2023-10-15 16:18:33 -07:00
jan
976ca0a2da missing parens 2023-10-15 16:18:33 -07:00
jan
723d856915 repetitions affect bounds 2023-10-15 16:18:33 -07:00
jan
079250e665 wip get_bounds 2023-10-15 16:18:33 -07:00
jan
8959101162 faster get_bounds for manhattan refs 2023-10-15 16:18:33 -07:00
jan
234264c0af Make rotation matrix immutable and cache the value 2023-10-15 16:18:33 -07:00
jan
93ab0a942d misc fixes 2023-10-15 16:18:33 -07:00
jan
9a077ea2df move to dicty layers and targets 2023-10-15 16:18:33 -07:00
jan
6b240de268 delete FlatBuilder (Builder subsumes it) 2023-10-15 16:18:33 -07:00
jan
3028ea0941 pather fixes / type updates 2023-10-15 16:18:33 -07:00
jan
5f24ceb13f add RenderPather 2023-10-15 16:18:33 -07:00
jan
75821c4ff9 comment 2023-10-15 16:18:33 -07:00
jan
2ed868ec25 split out find_ptransform (static version, only need ports) 2023-10-15 16:18:33 -07:00
jan
cbe5c07f8f add todo about underscore 2023-10-15 16:18:33 -07:00
jan
b13d7286e5 shorten labels 2023-10-15 16:18:33 -07:00
jan
de0d35d3d7 cleanup 2023-10-15 16:18:33 -07:00
jan
1008b6aabd split pather into its own file 2023-10-15 16:18:33 -07:00
jan
bb3caf1ad7 comment updates 2023-10-15 16:18:33 -07:00
jan
c5c31a5f0f only mutable variant should have rename_top 2023-10-15 16:18:33 -07:00
jan
08291da167 fixes 2023-10-15 16:18:33 -07:00
jan
68318a1382 add functions for dealing with the topcell and its name 2023-10-15 16:18:33 -07:00
jan
31cf0047e7 add mktree 2023-10-15 16:18:33 -07:00
jan
f0a71bfb8b redo library class naming 2023-10-15 16:18:33 -07:00
jan
a07446808a should be union; we want to exclude dangling refs 2023-10-15 16:18:33 -07:00
jan
340fe7f656 fixes to subtree and lshift, as well as some cast() improvements 2023-10-15 16:18:33 -07:00
jan
45265faec4 oneshot available at toplevel 2023-10-15 16:18:33 -07:00
jan
46a7f60460 add @oneshot decorator 2023-10-15 16:18:33 -07:00
jan
d7e89ef5c8 lshift operator shouldn't special-case trees
Instead, just call .tops() if there are multiple cells, and fail if
there are multiple tops
2023-10-15 16:18:33 -07:00
jan
0efd9afd16 find_toplevel -> tops 2023-10-15 16:18:33 -07:00
jan
64413f69d4 create no longer exists. Make mk() give similar ordering as mkpat() 2023-10-15 16:18:33 -07:00
jan
37e4c03547 fix return value 2023-10-15 16:18:33 -07:00
jan
94691dac85 top is always a string 2023-10-15 16:18:33 -07:00
jan
4eee4d19e9 cleanup 2023-10-15 16:18:33 -07:00
jan
cbfbdf66a1 get rid of NamedPattern in favor of just returning a tuple 2023-10-15 16:18:33 -07:00
9115371b19 Drop ports when repeating 2023-10-15 16:18:33 -07:00
cf634f1c16 port translation is already handled in Pattern 2023-10-15 16:18:33 -07:00
09291e58f7 drop ability to use python-gdsii 2023-10-15 16:18:33 -07:00
ea2eaa4603 fix rounding 2023-10-15 16:18:33 -07:00
b744a11e8e str(namedpattern) should just return its name 2023-10-15 16:18:33 -07:00
f54193edf0 updates to Pattern.polygonize() 2023-10-15 16:18:33 -07:00
59c8f47f4d update to newer ezdxf 2023-10-15 16:18:33 -07:00
e5ed28a854 Need to check against self, since we may add new conflicts as we go 2023-10-15 16:18:33 -07:00
b4f36417fd Pipe-operator does not support forward references 2023-10-15 16:18:33 -07:00
45081c2d31 add polygon() and label() convenience methods 2023-10-15 16:18:33 -07:00
4482ede3a7 use Self type 2023-10-15 16:18:33 -07:00
1463535676 modernize type annotations 2023-10-15 16:18:33 -07:00
ada8c591a0 fix error message 2023-10-15 16:18:33 -07:00
9d42df831e remove per-shape polygonization state 2023-10-15 16:18:33 -07:00
7befe89af3 fixes based on mypy 2023-10-15 16:18:33 -07:00
f766a3ad64 add prune_empty and delete() 2023-10-15 16:18:33 -07:00
85a2eb6acc fixes/updates 2023-10-15 16:18:33 -07:00
069dde3648 Drop ports by default 2023-10-15 16:18:33 -07:00
e0939049dd force 'wb' mode for gzipfile 2023-10-15 16:18:33 -07:00
88adc08259 data_to_ports max_depth default to 0
Makes it more compatible with LazyLibrary -- with recursive approach, we
have to load all the subcells to run ports2data, but those subcells may
or may not exist (e.g. partial library, or maybe we've removed some
duplicates-to-be prior to merging with a different lib)
2023-10-15 16:18:33 -07:00
4ab718d578 pass along library for bounds 2023-10-15 16:18:33 -07:00
f834aaee47 fix precache 2023-10-15 16:18:33 -07:00
27d87a988d redo library merging 2023-10-15 16:18:33 -07:00
6f97f7e6db pass along tools 2023-10-15 16:18:33 -07:00
d0f76d150f Make default quiet for underscores 2023-10-15 16:18:33 -07:00
5ffcadb362 always apply postprocess 2023-10-15 16:18:33 -07:00
2ccef554db misc fixes 2023-10-15 16:18:33 -07:00
d349aa3366 Revert "allow ports2data to take a tree"
This reverts commit 44f823c736.
LazyLibrary can't take Trees anymore, so no need for it.
2023-10-15 16:18:33 -07:00
680da46f5c LazyLibrary should not contain Trees
altering itself during iteration is not a good idea
2023-10-15 16:18:33 -07:00
59a986546c missing import 2023-10-15 16:18:33 -07:00
19ac45a4f4 fix type for __contains__ 2023-10-15 16:18:33 -07:00
db7a98bb0f allow ports2data to take a tree 2023-10-15 16:18:33 -07:00
8687badac5 misc fixes 2023-10-15 16:18:33 -07:00
4a6584a60a Only allow 1-sized Libraries 2023-10-15 16:18:33 -07:00
4a94259249 Allow lshift to operate on any library. If only one name, return it, else None 2023-10-15 16:18:33 -07:00
7cc732248e add missing functions to tree 2023-10-15 16:18:33 -07:00
98728521fd add Pather.mk() 2023-10-15 16:18:33 -07:00
460222ce6e add name arg 2023-10-15 16:18:33 -07:00
f1a380b170 pather reorganization/clenaup 2023-10-15 16:18:33 -07:00
38585e5a9e add lshift operator to MutableLibrary 2023-10-15 16:18:33 -07:00
2449486a28 set default for library to None 2023-10-15 16:18:33 -07:00
4fc2e67b62 Turn Builder into a subset of Pather 2023-10-15 16:18:32 -07:00
039320d180 fix add_tree operator 2023-10-15 16:18:32 -07:00
853c20e8df Allow LazyLibrary to store Trees as well? 2023-10-15 16:18:32 -07:00
f642c226c7 Use lshift for tree combination 2023-10-15 16:18:32 -07:00
103eb4f1f8 stringy type 2023-10-15 16:18:32 -07:00
abc721cf67 ergonomics 2023-10-15 16:18:32 -07:00
d8e789f179 Add Tree as a possible way to allow construction of whole subtrees at once 2023-10-15 16:18:32 -07:00
234557dc93 Add move_references() and auto-move references during add()-with-rename
Also remove enable_cache, since we now rely on the cache.
2023-10-15 16:18:32 -07:00
439d5914e0 implement auto-renaming during merge, and change _merge() to support it 2023-10-15 16:18:32 -07:00
jan
ac9776628a remove some trailing undescores 2023-10-15 16:18:32 -07:00
ab8fd9b351 add NamedPattern 2023-10-15 16:18:32 -07:00
1a9116cdbe add .create() 2023-10-15 16:18:32 -07:00
e348267a3d notes on organization 2023-10-15 16:18:32 -07:00
7a8a3ef3c7 note in comments 2023-10-15 16:18:32 -07:00
f8b5cec340 Add recurse arg to get_bounds 2023-10-15 16:18:32 -07:00
1598582865 remove log messages 2023-10-15 16:18:32 -07:00
42ee4db989 Return WrapLibrary from read() and readfile() 2023-10-15 16:18:32 -07:00
a35bf9770a Default to adding ports at the origin 2023-10-15 16:18:32 -07:00
5c48a28661 some cleanup 2023-10-15 16:18:32 -07:00
a8da0fc429 add FlatBuilder 2023-10-15 16:18:32 -07:00
cb87543e0c import ports2data at top level 2023-10-15 16:18:32 -07:00
e5029ae21d add library .rename(...) 2023-10-15 16:18:32 -07:00
0172b7488e missing comma 2023-10-15 16:18:32 -07:00
d44374bf1f writefile should write to a temporary file first 2023-10-15 16:18:32 -07:00
5fcd31a719 add name_and_set 2023-10-15 16:18:32 -07:00
2940316c48 add missing comments 2023-10-15 16:18:32 -07:00
c0e4ee1b6b Allow library __setitem__ to take in either Pattern or Callable
No longer need it to be Generic!
2023-10-15 16:18:32 -07:00
963918d1d9 various fixes and cleanup
mainly involving ports_to_data and data_to_ports
2023-10-15 16:18:32 -07:00
16567c8a66 move builder.port_utils into utils.ports2data
and rename functions
2023-10-15 16:18:32 -07:00
5452bc5608 more fixes and improvements 2023-10-15 16:18:32 -07:00
d9fe295f4f get things working with a LazyLibrary hack while we think about cycles 2023-10-15 16:18:32 -07:00
f4537a0feb Lots of progress on tutorials 2023-10-15 16:18:32 -07:00
c31d7dfa2c Add note about reproducibility for DXF 2023-10-15 16:18:32 -07:00
61b381cfaa remove dead code 2023-10-15 16:18:32 -07:00
cca7cbaae1 formatting 2023-10-15 16:18:32 -07:00
b75c8de0c4 lots of fixes to get test_rep running 2023-10-15 16:18:32 -07:00
92f7fce6ff improve gzipped file reproducibility
Mostly avoid writing the old filename and modification time to the gzip
header
2023-10-15 16:18:32 -07:00
ea87418bf5 clarify comment 2023-10-15 16:18:32 -07:00
e812c69bfb get rid of Mapping stuff on PortsList 2023-10-15 16:18:32 -07:00
71db073a54 add todos 2023-10-15 16:18:32 -07:00
a6cb276468 add AbstractView 2023-10-15 16:18:32 -07:00
090e86644a Move Abstract into its own file 2023-10-15 16:18:32 -07:00
a1073eca6b handle library=None 2023-10-15 16:18:32 -07:00
0368cf7a00 library can generate abstracts 2023-10-15 16:18:32 -07:00
e288c3f5e0 B becomes BB for searchability 2023-10-15 16:18:32 -07:00
3b8866732b PortsRef -> Abstract 2023-10-15 16:18:32 -07:00
6b01b43559 flake8-aided fixes 2023-10-15 16:18:32 -07:00
db9b39dbc0 fix more type issues 2023-10-15 16:18:32 -07:00
557c6c98dc more wip -- most central stuff is first pass done 2023-10-15 16:18:32 -07:00
6549faddbb wip -- more fixes 2023-10-15 16:18:32 -07:00
jan
9efb6f0eeb wip 2023-10-15 16:18:32 -07:00
jan
d9ae8dd6e3 wip 2023-10-15 16:18:32 -07:00
f7902fa517 busL -> mpath 2023-10-15 16:18:32 -07:00
fbbc1d5cc7 comment out some ipython commands 2023-10-15 16:18:32 -07:00
2635c6c20c some type updates 2023-10-15 16:18:32 -07:00
c7f3e7ee52 Remove support for dose
Since there isn't GDS/OASIS level support for dose, this can be mostly
handled by using arbitrary layers/dtypes directly. Dose scaling isn't
handled as nicely that way, but it corresponds more directly to what
gets written to file.
2023-10-15 16:18:32 -07:00
f7a2edfe23 fix some type-related issues 2023-10-15 16:18:32 -07:00
a0ca53f57a get rid of "identifier" 2023-10-15 16:18:32 -07:00
jan
7ca017d993 wip again 2023-10-15 16:18:32 -07:00
jan
db9a6269a1 delete duplicate utils submodule 2023-10-15 16:18:32 -07:00
6f696bfc71 partial work on device libraries 2023-10-15 16:18:32 -07:00
f7b8f2db0c various fixes 2023-10-15 16:18:32 -07:00
e3511ed852 remove duplicatre __delitem__ 2023-10-15 16:18:32 -07:00
a4f89e6f48 improve docs 2023-10-15 16:18:32 -07:00
5f35e8c8e3 indirect type spec for Pattern 2023-10-15 16:18:32 -07:00
jan
52f0b4aa93 Add lib types 2023-10-15 16:18:32 -07:00
jan
c95b2f4c0d bifurcate Device into DeviceRef 2023-10-15 16:18:32 -07:00
7e1371c14d add notes about what is hard 2023-10-15 16:18:32 -07:00
e932687210 make error message prettier 2023-10-15 16:18:32 -07:00
jan
7aaf73cb37 WIP: make libraries and names first-class! 2023-10-15 16:18:32 -07:00
f834ec6be5 Avoid generating a container if only a single port is passed 2023-10-15 16:18:32 -07:00
885b259fb7 allow bounds to be passed as args 2023-10-15 16:18:32 -07:00
3f986957ac allow passing a single Tool to be used as the default 2023-10-15 16:18:32 -07:00
1c3c032434 Add functionality for building paths (single use wires/waveguides/etc) 2023-10-15 16:18:32 -07:00
afcbd315ae Fix extra vertex added during OASIS loading 2023-01-24 14:14:10 -08:00
56 changed files with 5424 additions and 1971 deletions

234
README.md
View File

@ -8,38 +8,221 @@ to output to multiple formats.
- [Source repository](https://mpxd.net/code/jan/masque)
- [PyPI](https://pypi.org/project/masque)
- [Github mirror](https://github.com/anewusername/masque)
## Installation
Requirements:
* python >= 3.8
* python >= 3.11
* numpy
* klamath (optional, used for `gdsii` i/o)
* matplotlib (optional, used for `visualization` functions and `text`)
* ezdxf (optional, used for `dxf` i/o)
* fatamorgana (optional, used for `oasis` i/o)
* svgwrite (optional, used for `svg` output)
* freetype (optional, used for `text`)
* klamath (used for GDSII i/o)
Optional requirements:
* `ezdxf` (DXF i/o): ezdxf
* `oasis` (OASIS i/o): fatamorgana
* `svg` (SVG output): svgwrite
* `visualization` (shape plotting): matplotlib
* `text` (`Text` shape): matplotlib, freetype
Install with pip:
```bash
pip3 install 'masque[visualization,oasis,dxf,svg,text]'
pip install 'masque[oasis,dxf,svg,visualization,text]'
```
Alternatively, install from git
```bash
pip3 install git+https://mpxd.net/code/jan/masque.git@release
## Overview
A layout consists of a hierarchy of `Pattern`s stored in a single `Library`.
Each `Pattern` can contain `Ref`s pointing at other patterns, `Shape`s, `Label`s, and `Port`s.
`masque` departs from several "classic" GDSII paradigms:
- A `Pattern` object does not store its own name. A name is only assigned when the pattern is placed
into a `Library`, which is effectively a name->`Pattern` mapping.
- Layer info for `Shape`ss and `Label`s is not stored in the individual shape and label objects.
Instead, the layer is determined by the key for the container dict (e.g. `pattern.shapes[layer]`).
* This simplifies many common tasks: filtering `Shape`s by layer, remapping layers, and checking if
a layer is empty.
* Technically, this allows reusing the same shape or label object across multiple layers. This isn't
part of the standard workflow since a mixture of single-use and multi-use shapes could be confusing.
* This is similar to the approach used in [KLayout](https://www.klayout.de)
- `Ref` target names are also determined in the key of the container dict (e.g. `pattern.refs[target_name]`).
* This similarly simplifies filtering `Ref`s by target name, updating to a new target, and checking
if a given `Pattern` is referenced.
- `Pattern` names are set by their containing `Library` and are not stored in the `Pattern` objects.
* This guarantees that there are no duplicate pattern names within any given `Library`.
* Likewise, enumerating all the names (and all the `Pattern`s) in a `Library` is straightforward.
- Each `Ref`, `Shape`, or `Label` can be repeated multiple times by attaching a `repetition` object to it.
* This is similar to how OASIS reptitions are handled, and provides extra flexibility over the GDSII
approach of only allowing arrays through AREF (`Ref` + `repetition`).
- `Label`s do not have an orientation or presentation
* This is in line with how they are used in practice, and how they are represented in OASIS.
- Non-polygonal `Shape`s are allowed. For example, elliptical arcs are a basic shape type.
* This enables compatibility with OASIS (e.g. circles) and other formats.
* `Shape`s provide a `.to_polygons()` method for GDSII compatibility.
- Most coordinate values are stored as 64-bit floats internally.
* 1 earth radii in nanometers (6e15) is still represented without approximation (53 bit mantissa -> 2^53 > 9e15)
* Operations that would otherwise clip/round on are still represented approximately.
* Memory usage is usually dominated by other Python overhead.
- `Pattern` objects also contain `Port` information, which can be used to "snap" together
multiple sub-components by matching up the requested port offsets and rotations.
* Port rotations are defined as counter-clockwise angles from the +x axis.
* Ports point into the interior of their associated device.
* Port rotations may be `None` in the case of non-oriented ports.
* Ports have a `ptype` string which is compared in order to catch mismatched connections at build time.
* Ports can be exported into/imported from `Label`s stored directly in the layout,
editable from standard tools (e.g. KLayout). A default format is provided.
In one important way, `masque` stays very orthodox:
References are accomplished by listing the target's name, not its `Pattern` object.
- The main downside of this is that any operations that traverse the hierarchy require
both the `Pattern` and the `Library` which is contains its reference targets.
- This guarantees that names within a `Library` remain unique at all times.
* Since this can be tedious in cases where you don't actually care about the name of a
pattern, patterns whose names start with `SINGLE_USE_PREFIX` (default: an underscore)
may be silently renamed in order to maintain uniqueness.
See `masque.library.SINGLE_USE_PREFIX`, `masque.library._rename_patterns()`,
and `ILibrary.add()` for more details.
- Having all patterns accessible through the `Library` avoids having to perform a
tree traversal for every operation which needs to touch all `Pattern` objects
(e.g. deleting a layer everywhere or scaling all patterns).
- Since `Pattern` doesn't know its own name, you can't create a reference by passing in
a `Pattern` object -- you need to know its name.
- You *can* reference a `Pattern` before it is created, so long as you have already decided
on its name.
- Functions like `Pattern.place()` and `Pattern.plug()` need to receive a pattern's name
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
an `Abstract` from its internally-referenced `Library`.
## Glossary
- `Library`: A collection of named cells. OASIS or GDS "library" or file.
- `Tree`: Any `{name: pattern}` mapping which has only one topcell.
- `Pattern`: A collection of geometry, text labels, and reference to other patterns.
OASIS or GDS "Cell", DXF "Block".
- `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement".
- `Shape`: Individual geometric entity. OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline".
- `repetition`: Repetition operation. OASIS "repetition".
GDS "AREF" is a `Ref` combined with a `Grid` repetition.
- `Label`: Text label. Not rendered into geometry. OASIS, GDS, DXF "Text".
- `annotation`: Additional metadata. OASIS or GDS "property".
## Syntax, shorthand, and design patterns
Most syntax and behavior should follow normal python conventions.
There are a few exceptions, either meant to catch common mistakes or to provide a shorthand for common operations:
### `Library` objects don't allow overwriting already-existing patterns
```python3
library['mycell'] = pattern0
library['mycell'] = pattern1 # Error! 'mycell' already exists and can't be overwritten
del library['mycell'] # We can explicitly delete it
library['mycell'] = pattern1 # And now it's ok to assign a new value
library.delete('mycell') # This also deletes all refs pointing to 'mycell' by default
```
## Translation
- `Pattern`: OASIS or GDS "Cell", DXF "Block"
- `Ref`: GDS "AREF/SREF", OASIS "Placement"
- `Shape`: OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline"
- `repetition`: OASIS "repetition". GDS "AREF" is a `Ref` combined with a `Grid` repetition.
- `Label`: OASIS, GDS, DXF "Text".
- `annotation`: OASIS or GDS "property"
### Insert a newly-made hierarchical pattern (with children) into a layout
```python3
# Let's say we have a function which returns a new library containing one topcell (and possibly children)
tree = make_tree(...)
# To reference this cell in our layout, we have to add all its children to our `library` first:
top_name = tree.top() # get the name of the topcell
name_mapping = library.add(tree) # add all patterns from `tree`, renaming elgible conflicting patterns
new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed)
my_pattern.ref(new_name, ...) # instantiate the cell
# This can be accomplished as follows
new_name = library << tree # Add `tree` into `library` and return the top cell's new name
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, ...)
...
my_builder.place(make_tree(...))
```
We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern:
```python3
anonymous_pattern = Pattern(...)
my_pattern.ref(lib << {'_tentative_name': anonymous_pattern}, ...)
```
### Place a hierarchical pattern into a layout, preserving its port info
```python3
# As above, we have a function that makes a new library containing one topcell (and possibly children)
tree = make_tree(...)
# We need to go get its port info to `place()` it into our existing layout,
new_name = library << tree # Add the tree to the library and return its name (see `<<` above)
abstract = library.abstract(tree) # An `Abstract` stores a pattern's name and its ports (but no geometry)
my_pattern.place(abstract, ...)
# With shorthand,
abstract = library <= tree
my_pattern.place(abstract, ...)
# or
my_pattern.place(library << make_tree(...), ...)
### Quickly add geometry, labels, or refs:
The long form for adding elements can be overly verbose:
```python3
my_pattern.shapes[layer].append(Polygon(vertices, ...))
my_pattern.labels[layer] += [Label('my text')]
my_pattern.refs[target_name].append(Ref(offset=..., ...))
```
There is shorthand for the most common elements:
```python3
my_pattern.polygon(layer=layer, vertices=vertices, ...)
my_pattern.rect(layer=layer, xctr=..., xmin=..., ymax=..., ly=...) # rectangle; pick 4 of 6 constraints
my_pattern.rect(layer=layer, ymin=..., ymax=..., xctr=..., lx=...)
my_pattern.path(...)
my_pattern.label(layer, 'my_text')
my_pattern.ref(target_name, offset=..., ...)
```
### Accessing ports
```python3
# Square brackets pull from the underlying `.ports` dict:
assert pattern['input'] is pattern.ports['input']
# And you can use them to read multiple ports at once:
assert pattern[('input', 'output')] == {
'input': pattern.ports['input'],
'output': pattern.ports['output'],
}
# But you shouldn't use them for anything except reading
pattern['input'] = Port(...) # Error!
has_input = ('input' in pattern) # Error!
```
### Building patterns
```python3
library = Library(...)
my_pattern_name, my_pattern = library.mkpat(some_name_generator())
...
def _make_my_subpattern() -> str:
# This function can draw from the outer scope (e.g. `library`) but will not pollute the outer scope
# (e.g. the variable `subpattern` will not be accessible from outside the function; you must load it
# from within `library`).
subpattern_name, subpattern = library.mkpat(...)
subpattern.rect(...)
...
return subpattern_name
my_pattern.ref(_make_my_subpattern(), offset=..., ...)
```
## TODO
@ -47,13 +230,8 @@ pip3 install git+https://mpxd.net/code/jan/masque.git@release
* Better interface for polygon operations (e.g. with `pyclipper`)
- de-embedding
- boolean ops
* Deal with shape repetitions for dxf, svg
* Maybe lib.create(bname) -> (name, pat)
* Schematic:
- Simple cell:
+ Assumes no internal hierarchy, or only other simple hierarchy
+ Return pattern, refer to it by a well-known name
- Parametrized cell:
+ Take in `lib`
+ lib.create(), and return a string
+ Can have pcell hierarchy inside
* Tests tests tests
* check renderpather
* pather and renderpather examples
* context manager for retool
* allow a specific mismatch when connecting ports

View File

@ -2,29 +2,33 @@
import numpy
import masque
import masque.file.klamath
from masque import shapes
from masque.file import gdsii
from masque import Arc, Pattern
def main():
pat = masque.Pattern(name='ellip_grating')
for rmin in numpy.arange(10, 15, 0.5):
pat.shapes.append(shapes.Arc(
pat = Pattern()
layer = (0, 0)
pat.shapes[layer].extend([
Arc(
radii=(rmin, rmin),
width=0.1,
angles=(-numpy.pi/4, numpy.pi/4),
layer=(0, 0),
))
)
for rmin in numpy.arange(10, 15, 0.5)]
)
pat.labels.append(masque.Label(string='grating centerline', offset=(1, 0), layer=(1, 2)))
pat.label(string='grating centerline', offset=(1, 0), layer=(1, 2))
pat.scale_by(1000)
pat.visualize()
pat2 = pat.copy()
pat2.name = 'grating2'
masque.file.klamath.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3)
lib = {
'ellip_grating': pat,
'grating2': pat.copy(),
}
gdsii.writefile(lib, 'out.gds.gz', meters_per_unit=1e-9, logical_units_per_unit=1e-3)
if __name__ == '__main__':

View File

@ -33,7 +33,9 @@ pyplot.show(block=False)
# Create the layout from the contours
#
pat = Pattern()
pat.shapes = [Polygon(vertices=vv) for vv in contours if len(vv) < 1_000]
pat.shapes[(0, 0)].extend([
Polygon(vertices=vv) for vv in contours if len(vv) < 1_000
])
lib = {}
lib['my_mask_name'] = pat

View File

@ -16,8 +16,10 @@ def main():
cell_name = 'ellip_grating'
pat = masque.Pattern()
layer = (0, 0)
for rmin in numpy.arange(10, 15, 0.5):
pat.shapes.append(Arc(
pat.shapes[layer].append(Arc(
radii=(rmin, rmin),
width=0.1,
angles=(0 * -pi/4, pi/4),
@ -35,27 +37,27 @@ def main():
print(f'\nAdded a copy of {cell_name} as {new_name}')
pat3 = Pattern()
pat3.refs = [
Ref(cell_name, offset=(1e5, 3e5), annotations={'4': ['Hello I am the base Ref']}),
Ref(cell_name, offset=(2e5, 3e5), rotation=pi/3),
Ref(cell_name, offset=(3e5, 3e5), rotation=pi/2),
Ref(cell_name, offset=(4e5, 3e5), rotation=pi),
Ref(cell_name, offset=(5e5, 3e5), rotation=3*pi/2),
Ref(cell_name, mirrored=(True, False), offset=(1e5, 4e5)),
Ref(cell_name, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3),
Ref(cell_name, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2),
Ref(cell_name, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi),
Ref(cell_name, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2),
Ref(cell_name, mirrored=(False, True), offset=(1e5, 5e5)),
Ref(cell_name, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3),
Ref(cell_name, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2),
Ref(cell_name, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi),
Ref(cell_name, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2),
Ref(cell_name, mirrored=(True, True), offset=(1e5, 6e5)),
Ref(cell_name, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3),
Ref(cell_name, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2),
Ref(cell_name, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi),
Ref(cell_name, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2),
pat3.refs[cell_name] = [
Ref(offset=(1e5, 3e5), annotations={'4': ['Hello I am the base Ref']}),
Ref(offset=(2e5, 3e5), rotation=pi/3),
Ref(offset=(3e5, 3e5), rotation=pi/2),
Ref(offset=(4e5, 3e5), rotation=pi),
Ref(offset=(5e5, 3e5), rotation=3*pi/2),
Ref(mirrored=True, offset=(1e5, 4e5)),
Ref(mirrored=True, offset=(2e5, 4e5), rotation=pi/3),
Ref(mirrored=True, offset=(3e5, 4e5), rotation=pi/2),
Ref(mirrored=True, offset=(4e5, 4e5), rotation=pi),
Ref(mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2),
Ref(offset=(1e5, 5e5)).mirror_target(1),
Ref(offset=(2e5, 5e5), rotation=pi/3).mirror_target(1),
Ref(offset=(3e5, 5e5), rotation=pi/2).mirror_target(1),
Ref(offset=(4e5, 5e5), rotation=pi).mirror_target(1),
Ref(offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1),
Ref(offset=(1e5, 6e5)).mirror2d_target(True, True),
Ref(offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True),
Ref(offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True),
Ref(offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True),
Ref(offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True),
]
lib['sref_test'] = pat3
@ -70,33 +72,34 @@ def main():
b_count=2,
)
pat4 = Pattern()
pat4.refs = [
Ref(cell_name, repetition=rep, offset=(1e5, 3e5)),
Ref(cell_name, repetition=rep, offset=(2e5, 3e5), rotation=pi/3),
Ref(cell_name, repetition=rep, offset=(3e5, 3e5), rotation=pi/2),
Ref(cell_name, repetition=rep, offset=(4e5, 3e5), rotation=pi),
Ref(cell_name, repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2),
Ref(cell_name, repetition=rep, mirrored=(True, False), offset=(1e5, 4e5)),
Ref(cell_name, repetition=rep, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3),
Ref(cell_name, repetition=rep, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2),
Ref(cell_name, repetition=rep, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi),
Ref(cell_name, repetition=rep, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2),
Ref(cell_name, repetition=rep, mirrored=(False, True), offset=(1e5, 5e5)),
Ref(cell_name, repetition=rep, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3),
Ref(cell_name, repetition=rep, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2),
Ref(cell_name, repetition=rep, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi),
Ref(cell_name, repetition=rep, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2),
Ref(cell_name, repetition=rep, mirrored=(True, True), offset=(1e5, 6e5)),
Ref(cell_name, repetition=rep, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3),
Ref(cell_name, repetition=rep, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2),
Ref(cell_name, repetition=rep, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi),
Ref(cell_name, repetition=rep, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2),
pat4.refs[cell_name] = [
Ref(repetition=rep, offset=(1e5, 3e5)),
Ref(repetition=rep, offset=(2e5, 3e5), rotation=pi/3),
Ref(repetition=rep, offset=(3e5, 3e5), rotation=pi/2),
Ref(repetition=rep, offset=(4e5, 3e5), rotation=pi),
Ref(repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2),
Ref(repetition=rep, mirrored=True, offset=(1e5, 4e5)),
Ref(repetition=rep, mirrored=True, offset=(2e5, 4e5), rotation=pi/3),
Ref(repetition=rep, mirrored=True, offset=(3e5, 4e5), rotation=pi/2),
Ref(repetition=rep, mirrored=True, offset=(4e5, 4e5), rotation=pi),
Ref(repetition=rep, mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2),
Ref(repetition=rep, offset=(1e5, 5e5)).mirror_target(1),
Ref(repetition=rep, offset=(2e5, 5e5), rotation=pi/3).mirror_target(1),
Ref(repetition=rep, offset=(3e5, 5e5), rotation=pi/2).mirror_target(1),
Ref(repetition=rep, offset=(4e5, 5e5), rotation=pi).mirror_target(1),
Ref(repetition=rep, offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1),
Ref(repetition=rep, offset=(1e5, 6e5)).mirror2d_target(True, True),
Ref(repetition=rep, offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True),
Ref(repetition=rep, offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True),
Ref(repetition=rep, offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True),
Ref(repetition=rep, offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True),
]
lib['aref_test'] = pat4
print('\nAdded aref_test')
folder = Path('./layouts/')
folder.mkdir(exist_ok=True)
print(f'...writing files to {folder}...')
gds1 = folder / 'rep.gds.gz'

View File

@ -1 +1,39 @@
TODO write tutorial readme
masque Tutorial
===============
Contents
--------
- [basic_shapes](basic_shapes.py):
* Draw basic geometry
* Export to GDS
- [devices](devices.py)
* Reference other patterns
* Add ports to a pattern
* Snap ports together to build a circuit
* Check for dangling references
- [library](library.py)
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
- [pather](pather.py)
* Use `Pather` to route individual wires and wire bundles
* Use `BasicTool` to generate paths
* Use `BasicTool` to automatically transition between path types
- [renderpather](rendpather.py)
* Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py),
but using `Path` shapes instead of `Polygon`s.
Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices.
Running
-------
Run from inside the examples directory:
```bash
cd examples/tutorial
python3 basic_shapes.py
klayout -e basic_shapes.gds
```

View File

@ -1,4 +1,4 @@
from typing import Sequence
from collections.abc import Sequence
import numpy
from numpy import pi
@ -32,9 +32,10 @@ def hole(
Returns:
Pattern containing a circle.
"""
pat = Pattern(shapes=[
Circle(radius=radius, offset=(0, 0), layer=layer),
])
pat = Pattern()
pat.shapes[layer].append(
Circle(radius=radius, offset=(0, 0))
)
return pat
@ -58,8 +59,9 @@ def triangle(
(numpy.cos( - pi / 6), numpy.sin( - pi / 6)),
]) * radius
pat = Pattern(shapes=[
Polygon(offset=(0, 0), layer=layer, vertices=vertices),
pat = Pattern()
pat.shapes[layer].extend([
Polygon(offset=(0, 0), vertices=vertices),
])
return pat
@ -84,16 +86,18 @@ def smile(
pat = Pattern()
# Add all the shapes we want
pat.shapes += [
Circle(radius=radius, offset=(0, 0), layer=layer), # Outer circle
Circle(radius=radius / 10, offset=(radius / 3, radius / 3), layer=secondary_layer),
Circle(radius=radius / 10, offset=(-radius / 3, radius / 3), layer=secondary_layer),
pat.shapes[layer] += [
Circle(radius=radius, offset=(0, 0)), # Outer circle
]
pat.shapes[secondary_layer] += [
Circle(radius=radius / 10, offset=(radius / 3, radius / 3)),
Circle(radius=radius / 10, offset=(-radius / 3, radius / 3)),
Arc(
radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii
angles=(7 / 6 * pi, 11 / 6 * pi), # Angles limiting the arc
width=radius / 10,
offset=(0, 0),
layer=secondary_layer,
),
]

View File

@ -1,5 +1,4 @@
# TODO update tutorials
from typing import Sequence, Mapping
from collections.abc import Sequence, Mapping
import numpy
from numpy import pi
@ -31,7 +30,7 @@ def ports_to_data(pat: Pattern) -> Pattern:
def data_to_ports(lib: Mapping[str, Pattern], name: str, pat: Pattern) -> Pattern:
"""
Scans the Pattern to determine port locations. Same port format as `ports_to_data`
Scan the Pattern to determine port locations. Same port format as `ports_to_data`
"""
return ports2data.data_to_ports(layers=[(3, 0)], library=lib, pattern=pat, name=name)
@ -82,8 +81,8 @@ def perturbed_l3(
# Build L3 cavity, using references to the provided hole pattern
pat = Pattern()
pat.refs += [
Ref(hole, scale=r, offset=(lattice_constant * x,
pat.refs[hole] += [
Ref(scale=r, offset=(lattice_constant * x,
lattice_constant * y))
for x, y, r in xyr]
@ -91,9 +90,9 @@ def perturbed_l3(
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
trench_dx = max_xy[0] - min_xy[0]
pat.shapes += [
Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width, layer=trench_layer),
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width, layer=trench_layer),
pat.shapes[trench_layer] += [
Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
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)
@ -131,8 +130,8 @@ def waveguide(
# Build the pattern
pat = Pattern()
pat.refs += [
Ref(hole, offset=(lattice_constant * x,
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
@ -170,8 +169,8 @@ def bend(
# Build the pattern
pat= Pattern()
pat.refs += [
Ref(hole, offset=(lattice_constant * x,
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
@ -209,8 +208,8 @@ def y_splitter(
# Build pattern
pat = Pattern()
pat.refs += [
Ref(hole, offset=(lattice_constant * x,
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
@ -246,32 +245,41 @@ 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 -- useful for getting abstracts
# Turn our dict of devices into a Library.
# This provides some convenience functions in the future!
lib = Library(devices)
abv = lib.abstract_view() # lets us use abv[cell] instead of lib.abstract(cell)
#
# Build a circuit
#
circ = Builder(library=lib)
# Create a `Builder`, and add the circuit to our library as "my_circuit".
circ = Builder(library=lib, name='my_circuit')
# Start by placing a waveguide. Call its ports "in" and "signal".
circ.place(abv['wg10'], offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
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".
circ.plug(abv['wg10'], {'signal': 'left'})
circ.plug('wg10', {'signal': 'left'})
# We could have done the following instead:
# circ_pat = Pattern()
# 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.
# Attach a y-splitter to the signal path.
# Since the y-splitter has 3 ports total, we can't auto-inherit the
# port name, so we have to specify what we want to name the unattached
# ports. We can call them "signal1" and "signal2".
circ.plug(abv['ysplit'], {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'})
circ.plug('ysplit', {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'})
# Add a waveguide to both signal ports, inheriting their names.
circ.plug(abv['wg05'], {'signal1': 'left'})
circ.plug(abv['wg05'], {'signal2': 'left'})
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
@ -280,22 +288,22 @@ def main(interactive: bool = True) -> None:
# 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.
circ.plug(abv['bend0'], {'signal1': 'right'})
circ.plug(abv['bend0'], {'signal2': 'left'})
circ.plug('bend0', {'signal1': 'right'})
circ.plug('bend0', {'signal2': 'left'})
# We add some waveguides and a cavity to "signal1".
circ.plug(abv['wg10'], {'signal1': 'left'})
circ.plug(abv['l3cav'], {'signal1': 'input'})
circ.plug(abv['wg10'], {'signal1': 'left'})
circ.plug('wg10', {'signal1': 'left'})
circ.plug('l3cav', {'signal1': 'input'})
circ.plug('wg10', {'signal1': 'left'})
# "signal2" just gets a single of equivalent length
circ.plug(abv['wg28'], {'signal2': 'left'})
circ.plug('wg28', {'signal2': 'left'})
# Now we bend both waveguides back towards each other
circ.plug(abv['bend0'], {'signal1': 'right'})
circ.plug(abv['bend0'], {'signal2': 'left'})
circ.plug(abv['wg05'], {'signal1': 'left'})
circ.plug(abv['wg05'], {'signal2': 'left'})
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.
@ -303,19 +311,16 @@ def main(interactive: bool = True) -> None:
# 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).
circ.plug(abv['ysplit'], {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
# Finally, add some more waveguide to "signal_out".
circ.plug(abv['wg10'], {'signal_out': 'left'})
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.
ports_to_data(circ.pattern)
# Add the pattern into our library
lib['my_circuit'] = circ.pattern
# Check if we forgot to include any patterns... ooops!
if dangling := lib.dangling_refs():
print('Warning: The following patterns are referenced, but not present in the'

View File

@ -1,4 +1,5 @@
from typing import Sequence, Callable
from typing import Any
from collections.abc import Sequence, Callable
from pprint import pformat
import numpy
@ -38,7 +39,7 @@ def main() -> None:
#
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
opts = dict(
opts: dict[str, Any] = dict(
lattice_constant = devices.LATTICE_CONSTANT,
hole = 'triangle',
)
@ -60,22 +61,23 @@ def main() -> None:
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:
# 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.
# It has already been generated, so we can use `set_const` as a shorthand for
# `lib['mixed_wg_cav'] = lambda: circ2.pattern`
lib.set_const('mixed_wg_cav', circ2.pattern)
lib['mixed_wg_cav'] = circ2.pattern
#
@ -87,7 +89,7 @@ def main() -> None:
# ... that lets us continue from where we left off.
circ3.plug('tri_bend0', {'input': 'right'})
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=(True, False)) # mirror since no tri y-symmetry
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'})
@ -96,7 +98,7 @@ def main() -> None:
circ3.plug('tri_wg28', {'input': 'right'})
circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'})
lib.set_const('loop_segment', circ3.pattern)
lib['loop_segment'] = circ3.pattern
#
# Write all devices into a GDS file
@ -128,7 +130,6 @@ if __name__ == '__main__':
# name = port_map.get(name, name)
# if name is None:
# continue
# self.pattern.labels += [
# Label(string=name, offset=self.ports[name].offset, layer=layer)]
# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer)
# return self
#

277
examples/tutorial/pather.py Normal file
View File

@ -0,0 +1,277 @@
"""
Manual wire routing tutorial: Pather and BasicTool
"""
from collections.abc import Callable
from numpy import pi
from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque.builder.tools import BasicTool, PathTool
from masque.file.gdsii import writefile
from basic_shapes import GDS_OPTS
#
# Define some basic wire widths, in nanometers
# M2 is the top metal; M1 is below it and connected with vias on V1
#
M1_WIDTH = 1000
V1_WIDTH = 500
M2_WIDTH = 4000
#
# First, we can define some functions for generating our wire geometry
#
def make_pad() -> Pattern:
"""
Create a pattern with a single rectangle of M2, with a single port on the bottom
Every pad will be an instance of the same pattern, so we will only call this function once.
"""
pat = Pattern()
pat.rect(layer='M2', xctr=0, yctr=0, lx=3 * M2_WIDTH, ly=4 * M2_WIDTH)
pat.ports['wire_port'] = Port((0, -2 * M2_WIDTH), rotation=pi / 2, ptype='m2wire')
return pat
def make_via(
layer_top: layer_t,
layer_via: layer_t,
layer_bot: layer_t,
width_top: float,
width_via: float,
width_bot: float,
ptype_top: str,
ptype_bot: str,
) -> Pattern:
"""
Generate three concentric squares, on the provided layers
(`layer_top`, `layer_via`, `layer_bot`) and with the provided widths
(`width_top`, `width_via`, `width_bot`).
Two ports are added, with the provided ptypes (`ptype_top`, `ptype_bot`).
They are placed at the left edge of the top layer and right edge of the
bottom layer, respectively.
We only have one via type, so we will only call this function once.
"""
pat = Pattern()
pat.rect(layer=layer_via, xctr=0, yctr=0, lx=width_via, ly=width_via)
pat.rect(layer=layer_bot, xctr=0, yctr=0, lx=width_bot, ly=width_bot)
pat.rect(layer=layer_top, xctr=0, yctr=0, lx=width_top, ly=width_top)
pat.ports = {
'top': Port(offset=(-width_top / 2, 0), rotation=0, ptype=ptype_top),
'bottom': Port(offset=(width_bot / 2, 0), rotation=pi, ptype=ptype_bot),
}
return pat
def make_bend(layer: layer_t, width: float, ptype: str) -> Pattern:
"""
Generate a triangular wire, with ports at the left (input) and bottom (output) edges.
This is effectively a clockwise wire bend.
Every bend will be the same, so we only need to call this twice (once each for M1 and M2).
We could call it additional times for different wire widths or bend types (e.g. squares).
"""
pat = Pattern()
pat.polygon(layer=layer, vertices=[(0, -width / 2), (0, width / 2), (width, -width / 2)])
pat.ports = {
'input': Port(offset=(0, 0), rotation=0, ptype=ptype),
'output': Port(offset=(width / 2, -width / 2), rotation=pi / 2, ptype=ptype),
}
return pat
def make_straight_wire(layer: layer_t, width: float, ptype: str, length: float) -> Pattern:
"""
Generate a straight wire with ports along either end (x=0 and x=length).
Every waveguide will be single-use, so we'll need to create lots of (mostly unique)
`Pattern`s, and this function will get called very often.
"""
pat = Pattern()
pat.rect(layer=layer, xmin=0, xmax=length, yctr=0, ly=width)
pat.ports = {
'input': Port(offset=(0, 0), rotation=0, ptype=ptype),
'output': Port(offset=(length, 0), rotation=pi, ptype=ptype),
}
return pat
def map_layer(layer: layer_t) -> layer_t:
"""
Map from a strings to GDS layer numbers
"""
layer_mapping = {
'M1': (10, 0),
'M2': (20, 0),
'V1': (30, 0),
}
return layer_mapping.get(layer, 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:
# Build some patterns (static cells) using the above functions and store them in a library
library = Library()
library['pad'] = make_pad()
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
library['v1_via'] = make_via(
layer_top='M2',
layer_via='V1',
layer_bot='M1',
width_top=M2_WIDTH,
width_via=V1_WIDTH,
width_bot=M1_WIDTH,
ptype_bot='m1wire',
ptype_top='m2wire',
)
#
# Now, define two tools.
# M1_tool will route on M1, using wires with M1_WIDTH
# M2_tool will route on M2, using wires with M2_WIDTH
# Both tools are able to automatically transition from the other wire type (with a via)
#
# Note that while we use BasicTool for this tutorial, you can define your own `Tool`
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
# transmission line geometry, or other features.
#
M1_tool = BasicTool(
straight = (
# First, we need a function which takes in a length and spits out an M1 wire
lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
'output', # and use the port named 'output' as the output
),
bend = (
library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
'input', # To orient it clockwise, use the port named 'input' as the input
'output', # and 'output' as the output
),
transitions = { # We can automate transitions for different (normally incompatible) port types
'm2wire': ( # For example, when we're attaching to a port with type 'm2wire'
library.abstract('v1_via'), # we can place a V1 via
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
'bottom', # and using the port named 'bottom' as the output
),
},
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
)
M2_tool = BasicTool(
straight = (
# Again, we use make_straight_wire, but this time we set parameters for M2
lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
'input',
'output',
),
bend = (
library.abstract('m2_bend'), # and we use an M2 bend
'input',
'output',
),
transitions = {
'm1wire': (
library.abstract('v1_via'), # We still use the same via,
'bottom', # but the input port is now 'bottom'
'top', # and the output port is now 'top'
),
},
default_out_ptype = 'm2wire', # We default to trying to stay on M2
)
#
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
# Then, place some pads and start routing wires!
#
pather = Pather(library, tools=M2_tool)
# Place two pads, and define their ports as 'VCC' and 'GND'
pather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
pather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
# Add some labels to make the pads easier to distinguish
pather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
pather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
# 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)
# Now path VCC to x=0. This time, don't include any bend (ccw=None).
# 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)
# 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)
# 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])
# 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.
# 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'])
# 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.
# Other wires in the bundle (in this case VCC) should be spaced at 5_000 pitch (so VCC ends up at x=-5_000)
#
# 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)
# 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.
pather.retool(M1_tool)
# Path the GND + VCC bundle forward and counterclockwise by 90 degrees.
# 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)
# 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
# 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)
# 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.
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
# equivalent.
pather.mpath(['GND', 'VCC'], None, 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')
# Save the pather's pattern into our library
library['Pather_and_BasicTool'] = pather.pattern
# Convert from text-based layers to numeric layers for GDS, and output the file
library.map_layers(map_layer)
writefile(library, 'pather.gds', **GDS_OPTS)
if __name__ == '__main__':
main()

View File

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

View File

@ -0,0 +1,96 @@
"""
Manual wire routing tutorial: RenderPather an PathTool
"""
from collections.abc import Callable
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
from masque.builder.tools import PathTool
from masque.file.gdsii import writefile
from basic_shapes import GDS_OPTS
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
# a single `Path` shape.
#
# We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py`
# for more detailed descriptions of the individual pathing steps.
#
# First, we make a library and generate some of the same patterns as in the pather tutorial
library = Library()
library['pad'] = make_pad()
library['v1_via'] = make_via(
layer_top='M2',
layer_via='V1',
layer_bot='M1',
width_top=M2_WIDTH,
width_via=V1_WIDTH,
width_bot=M1_WIDTH,
ptype_bot='m1wire',
ptype_top='m2wire',
)
# `PathTool` is more limited than `BasicTool`. It only generates one type of shape
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
# and what port type to present.
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
rpather = RenderPather(tools=M2_ptool, library=library)
# As in the pather tutorial, we make soem pads and labels...
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])
# `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)
# 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)
# 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)
# 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)
rpather.plug('v1_via', {'VCC': 'top'})
rpather.render()
library['RenderPather_and_PathTool'] = rpather.pattern
# Convert from text-based layers to numeric layers for GDS, and output the file
library.map_layers(map_layer)
writefile(library, 'render_pather.gds', **GDS_OPTS)
if __name__ == '__main__':
main()

View File

@ -1,7 +1,7 @@
"""
masque 2D CAD library
masque is an attempt to make a relatively small library for designing lithography
masque is an attempt to make a relatively compact library for designing lithography
masks. The general idea is to implement something resembling the GDSII and OASIS file-formats,
but with some additional vectorized element types (eg. ellipses, not just polygons), and the
ability to interface with multiple file formats.
@ -20,32 +20,73 @@
NOTES ON INTERNALS
==========================
- Many of `masque`'s classes make use of `__slots__` to make them faster / smaller.
Since `__slots__` doesn't play well with multiple inheritance, the `masque.utils.AutoSlots`
metaclass is used to auto-generate slots based on superclass type annotations.
- File I/O submodules are imported by `masque.file` to avoid creating hard dependencies on
external file-format reader/writers
Since `__slots__` doesn't play well with multiple inheritance, often they are left
empty for superclasses and it is the subclass's responsibility to set them correctly.
- File I/O submodules are not imported by `masque.file` to avoid creating hard dependencies
on external file-format reader/writers
- Try to accept the broadest-possible inputs: e.g., don't demand an `ILibraryView` if you
can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally.
"""
from .utils import layer_t, annotations_t, SupportsBool
from .error import MasqueError, PatternError, LibraryError, BuildError
from .shapes import Shape, Polygon, Path, Circle, Arc, Ellipse
from .label import Label
from .ref import Ref
from .pattern import Pattern
from .utils import (
layer_t as layer_t,
annotations_t as annotations_t,
SupportsBool as SupportsBool,
)
from .error import (
MasqueError as MasqueError,
PatternError as PatternError,
LibraryError as LibraryError,
BuildError as BuildError,
)
from .shapes import (
Shape as Shape,
Polygon as Polygon,
Path as Path,
Circle as Circle,
Arc as Arc,
Ellipse as Ellipse,
)
from .label import Label as Label
from .ref import Ref as Ref
from .pattern import (
Pattern as Pattern,
map_layers as map_layers,
map_targets as map_targets,
chain_elements as chain_elements,
)
from .library import (
ILibraryView, ILibrary,
LibraryView, Library, LazyLibrary,
AbstractView,
ILibraryView as ILibraryView,
ILibrary as ILibrary,
LibraryView as LibraryView,
Library as Library,
LazyLibrary as LazyLibrary,
AbstractView as AbstractView,
TreeView as TreeView,
Tree as Tree,
)
from .ports import (
Port as Port,
PortList as PortList,
)
from .abstract import Abstract as Abstract
from .builder import (
Builder as Builder,
Tool as Tool,
Pather as Pather,
RenderPather as RenderPather,
RenderStep as RenderStep,
BasicTool as BasicTool,
PathTool as PathTool,
)
from .utils import (
ports2data as ports2data,
oneshot as oneshot,
)
from .ports import Port, PortList
from .abstract import Abstract
from .builder import Builder, Tool, Pather, RenderPather, render_step_t
from .utils import ports2data, oneshot
__author__ = 'Jan Petykiewicz'
__version__ = '2.7'
__version__ = '3.2'
version = __version__ # legacy

View File

@ -7,7 +7,7 @@ from numpy.typing import ArrayLike
from .ref import Ref
from .ports import PortList, Port
from .utils import rotation_matrix_2d, normalize_mirror
from .utils import rotation_matrix_2d
#if TYPE_CHECKING:
# from .builder import Builder, Tool
@ -18,6 +18,12 @@ logger = logging.getLogger(__name__)
class Abstract(PortList):
"""
An `Abstract` is a container for a name and associated ports.
When snapping a sub-component to an existing pattern, only the name (not contained
in a `Pattern` object) and port info is needed, and not the geometry itself.
"""
__slots__ = ('name', '_ports')
name: str
@ -42,23 +48,6 @@ class Abstract(PortList):
self.name = name
self.ports = copy.deepcopy(ports)
# def build(
# self,
# library: 'ILibrary',
# tools: 'None | Tool | MutableMapping[str | None, Tool]' = None,
# ) -> 'Builder':
# """
# Begin building a new device around an instance of the current device
# (rather than modifying the current device).
#
# Returns:
# The new `Builder` object.
# """
# pat = Pattern(ports=self.ports)
# pat.ref(self.name)
# new = Builder(library=library, pattern=pat, tools=tools) # TODO should Abstract have tools?
# return new
# TODO do we want to store a Ref instead of just a name? then we can translate/rotate/mirror...
def __repr__(self) -> str:
@ -108,7 +97,7 @@ class Abstract(PortList):
Returns:
self
"""
pivot = numpy.array(pivot)
pivot = numpy.asarray(pivot, dtype=float)
self.translate_ports(-pivot)
self.rotate_ports(rotation)
self.rotate_port_offsets(rotation)
@ -143,7 +132,7 @@ class Abstract(PortList):
port.rotate(rotation)
return self
def mirror_port_offsets(self, across_axis: int) -> Self:
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
@ -158,7 +147,7 @@ class Abstract(PortList):
port.offset[across_axis - 1] *= -1
return self
def mirror_ports(self, across_axis: int) -> Self:
def mirror_ports(self, across_axis: int = 0) -> Self:
"""
Mirror each port's rotation across an axis, relative to its
offset
@ -174,7 +163,7 @@ class Abstract(PortList):
port.mirror(across_axis)
return self
def mirror(self, across_axis: int) -> Self:
def mirror(self, across_axis: int = 0) -> Self:
"""
Mirror the Pattern across an axis
@ -200,11 +189,10 @@ class Abstract(PortList):
Returns:
self
"""
mirrored_across_x, angle = normalize_mirror(ref.mirrored)
if mirrored_across_x:
self.mirror(across_axis=0)
self.rotate_ports(angle + ref.rotation)
self.rotate_port_offsets(angle + ref.rotation)
if ref.mirrored:
self.mirror()
self.rotate_ports(ref.rotation)
self.rotate_port_offsets(ref.rotation)
self.translate_ports(ref.offset)
return self
@ -221,10 +209,9 @@ class Abstract(PortList):
# TODO test undo_ref_transform
"""
mirrored_across_x, angle = normalize_mirror(ref.mirrored)
self.translate_ports(-ref.offset)
self.rotate_port_offsets(-angle - ref.rotation)
self.rotate_ports(-angle - ref.rotation)
if mirrored_across_x:
self.mirror(across_axis=0)
self.rotate_port_offsets(-ref.rotation)
self.rotate_ports(-ref.rotation)
if ref.mirrored:
self.mirror(0)
return self

View File

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

View File

@ -1,14 +1,17 @@
from typing import Self, Sequence, Mapping, Literal, overload, Final, cast
"""
Simplified Pattern assembly (`Builder`)
"""
from typing import Self
from collections.abc import Sequence, Mapping
import copy
import logging
from functools import wraps
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..ref import Ref
from ..library import ILibrary
from ..error import PortError, BuildError
from ..library import ILibrary, TreeView
from ..error import BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
@ -18,39 +21,44 @@ logger = logging.getLogger(__name__)
class Builder(PortList):
"""
TODO DOCUMENT Builder
A `Device` is a combination of a `Pattern` with a set of named `Port`s
which can be used to "snap" devices together to make complex layouts.
A `Builder` is a helper object used for snapping together multiple
lower-level patterns at their `Port`s.
`Device`s can be as simple as one or two ports (e.g. an electrical pad
or wire), but can also be used to build and represent a large routed
layout (e.g. a logical block with multiple I/O connections or even a
full chip).
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.
For convenience, ports can be read out using square brackets:
- `device['A'] == Port((0, 0), 0)`
- `device[['A', 'B']] == {'A': Port((0, 0), 0), 'B': Port((0, 0), pi)}`
`Builder` can also be `set_dead()`, at which point further calls to `plug()`
and `place()` are ignored (intended for debugging).
Examples: Creating a Device
Examples: Creating a Builder
===========================
- `Device(pattern, ports={'A': port_a, 'C': port_c})` uses an existing
pattern and defines some ports.
- `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'`.
- `Device(ports=None)` makes a new empty pattern with
default ports ('A' and 'B', in opposite directions, at (0, 0)).
- `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`
- `my_device.build('my_layout')` makes a new pattern and instantiates
`my_device` in it with offset (0, 0) as a base for further building.
- `Builder(library, pattern=pattern, name='mypat')` uses an existing
pattern (including its ports) and sets `library['mypat'] = pattern`.
- `my_device.as_interface('my_component', port_map=['A', 'B'])` makes a new
(empty) pattern, copies over ports 'A' and 'B' from `my_device`, 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
`my_device` (using the 'in_*' ports) but which does not itself include
`my_device` as a subcomponent.
- `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.
Examples: Adding to a Device
============================
- `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
@ -75,10 +83,9 @@ class Builder(PortList):
pattern: Pattern
""" Layout of this device """
library: ILibrary | None
library: ILibrary
"""
Library from which existing patterns should be referenced, and to which
new ones should be added
Library from which patterns should be referenced
"""
_dead: bool
@ -94,14 +101,22 @@ class Builder(PortList):
def __init__(
self,
library: ILibrary | None = None,
library: ILibrary,
*,
pattern: Pattern | None = None,
ports: str | Mapping[str, Port] | None = None,
name: str | None = None,
) -> None:
"""
# TODO documentation for Builder() constructor
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
@ -114,20 +129,16 @@ class Builder(PortList):
if self.pattern.ports:
raise BuildError('Ports supplied for pattern with pre-existing ports!')
if isinstance(ports, str):
if library is None:
raise BuildError('Ports given as a string, but `library` was `None`!')
ports = library.abstract(ports).ports
self.pattern.ports.update(copy.deepcopy(dict(ports)))
if name is not None:
if library is None:
raise BuildError('Name was supplied, but no library was given!')
library[name] = self.pattern
@classmethod
def interface(
cls,
cls: type['Builder'],
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
@ -137,31 +148,15 @@ class Builder(PortList):
name: str | None = None,
) -> 'Builder':
"""
Begin building a new device based on all or some of the ports in the
source device. Do not include the source device; instead use it
to define ports (the "interface") for the new device.
The ports specified by `port_map` (default: all ports) are copied to
new device, and additional (input) ports are created facing in the
opposite directions. The specified `in_prefix` and `out_prefix` are
prepended to the port names to differentiate them.
By default, the flipped ports are given an 'in_' prefix and unflipped
ports keep their original names, enabling intuitive construction of
a device that will "plug into" the current device; the 'in_*' ports
are used for plugging the devices together while the original port
names are used for building the new device.
Another use-case could be to build the new device using the 'in_'
ports, creating a new device which could be used in place of the
current device.
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.
library: Library from which existing patterns should be referenced, TODO
and to which new ones should be added. If not provided,
the source's library will be used (if available).
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
@ -185,112 +180,71 @@ class Builder(PortList):
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):
if library is None:
raise BuildError('Source given as a string, but `library` was `None`!')
orig_ports = library.abstract(source).ports
elif isinstance(source, PortList):
orig_ports = source.ports
elif isinstance(source, dict):
orig_ports = source
else:
raise BuildError(f'Unable to get ports from {type(source)}: {source}')
source = library.abstract(source).ports
if port_map:
if isinstance(port_map, dict):
missing_inkeys = set(port_map.keys()) - set(orig_ports.keys())
mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map}
else:
port_set = set(port_map)
missing_inkeys = port_set - set(orig_ports.keys())
mapped_ports = {k: v for k, v in orig_ports.items() if k in port_set}
if missing_inkeys:
raise PortError(f'`port_map` keys not present in source: {missing_inkeys}')
else:
mapped_ports = orig_ports
ports_in = {f'{in_prefix}{name}': port.deepcopy().rotate(pi)
for name, port in mapped_ports.items()}
ports_out = {f'{out_prefix}{name}': port.deepcopy()
for name, port in mapped_ports.items()}
duplicates = set(ports_out.keys()) & set(ports_in.keys())
if duplicates:
raise PortError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}')
new = Builder(library=library, ports={**ports_in, **ports_out}, name=name)
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
# @overload
# def plug(
# self,
# other: Abstract | str,
# map_in: dict[str, str],
# map_out: dict[str, str | None] | None,
# *,
# mirrored: tuple[bool, bool],
# inherit_name: bool,
# set_rotation: bool | None,
# append: bool,
# ) -> Self:
# pass
#
# @overload
# def plug(
# self,
# other: Pattern,
# map_in: dict[str, str],
# map_out: dict[str, str | None] | None = None,
# *,
# mirrored: tuple[bool, bool] = (False, False),
# inherit_name: bool = True,
# set_rotation: bool | None = None,
# append: bool = False,
# ) -> Self:
# pass
@wraps(Pattern.label)
def label(self, *args, **kwargs) -> Self:
self.pattern.label(*args, **kwargs)
return self
@wraps(Pattern.ref)
def ref(self, *args, **kwargs) -> Self:
self.pattern.ref(*args, **kwargs)
return self
@wraps(Pattern.polygon)
def polygon(self, *args, **kwargs) -> Self:
self.pattern.polygon(*args, **kwargs)
return self
@wraps(Pattern.rect)
def rect(self, *args, **kwargs) -> Self:
self.pattern.rect(*args, **kwargs)
return self
# Note: We're a superclass of `Pather`, where path() means something different...
#@wraps(Pattern.path)
#def path(self, *args, **kwargs) -> Self:
# self.pattern.path(*args, **kwargs)
# return self
def plug(
self,
other: Abstract | str | Pattern,
other: Abstract | str | Pattern | TreeView,
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: tuple[bool, bool] = (False, False),
mirrored: bool = False,
inherit_name: bool = True,
set_rotation: bool | None = None,
append: bool = False,
) -> Self:
"""
Instantiate or append a pattern into the current device, connecting
the ports specified by `map_in` and renaming the unconnected
ports specified by `map_out`.
Wrapper around `Pattern.plug` which allows a string for `other`.
Examples:
=========
- `my_device.plug(lib, 'subdevice', {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
instantiates `lib['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(lib, 'wire', {'myport': 'A'})` places port 'A' of `lib['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 `inherit_name` 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.
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` describing the device to be instatiated.
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
device to be instatiated. If it is a `TreeView`, it is first
added into `self.library`, after which the topcell is plugged;
an equivalent statement is `self.plug(self.library << other, ...)`.
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
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 or y axes prior
to connecting any ports.
mirrored: Enables mirroring `other` across the x axis prior to
connecting any ports.
inherit_name: If `True`, and `map_in` specifies only a single port,
and `map_out` is `None`, and `other` has only two ports total,
then automatically renames the output port of `other` to the
@ -303,6 +257,9 @@ class Builder(PortList):
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`).
Returns:
self
@ -319,114 +276,64 @@ class Builder(PortList):
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):
if self.library is None:
raise BuildError('No library available, but `other` was a string!')
other = self.library.abstract(other)
if append and isinstance(other, Abstract):
other = self.library[other.name]
# If asked to inherit a name, check that all conditions are met
if (inherit_name
and not map_out
and len(map_in) == 1
and len(other.ports) == 2):
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
map_out = {out_port_name: next(iter(map_in.keys()))}
if map_out is None:
map_out = {}
map_out = copy.deepcopy(map_out)
self.check_ports(other.ports.keys(), map_in, map_out)
translation, rotation, pivot = self.find_transform(
other,
map_in,
self.pattern.plug(
other=other,
map_in=map_in,
map_out=map_out,
mirrored=mirrored,
inherit_name=inherit_name,
set_rotation=set_rotation,
append=append,
)
# get rid of plugged ports
for ki, vi in map_in.items():
del self.ports[ki]
map_out[vi] = None
if isinstance(other, Pattern):
assert append
self.place(other, offset=translation, rotation=rotation, pivot=pivot,
mirrored=mirrored, port_map=map_out, skip_port_check=True, append=append)
else:
self.place(other, offset=translation, rotation=rotation, pivot=pivot,
mirrored=mirrored, port_map=map_out, skip_port_check=True, append=append)
return self
@overload
def place(
self,
other: Abstract | str,
*,
offset: ArrayLike,
rotation: float,
pivot: ArrayLike,
mirrored: tuple[bool, bool],
port_map: dict[str, str | None] | None,
skip_port_check: bool,
append: bool,
) -> Self:
pass
@overload
def place(
self,
other: Pattern,
*,
offset: ArrayLike,
rotation: float,
pivot: ArrayLike,
mirrored: tuple[bool, bool],
port_map: dict[str, str | None] | None,
skip_port_check: bool,
append: Literal[True],
) -> Self:
pass
def place(
self,
other: Abstract | str | Pattern,
other: Abstract | str | Pattern | TreeView,
*,
offset: ArrayLike = (0, 0),
rotation: float = 0,
pivot: ArrayLike = (0, 0),
mirrored: tuple[bool, bool] = (False, False),
mirrored: bool = False,
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
) -> Self:
"""
Instantiate or append the device `other` into the current device, adding its
ports to those of the current device (but not connecting any ports).
Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.
Mirroring is applied before rotation; translation (`offset`) is applied last.
Examples:
=========
- `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.
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` describing the device to be instatiated.
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
device to be instatiated. If it is a `TreeView`, it is first
added into `self.library`, after which the topcell is plugged;
an equivalent statement is `self.plug(self.library << other, ...)`.
offset: Offset at which to place the instance. Default (0, 0).
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 and y axes.
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
@ -441,51 +348,25 @@ class Builder(PortList):
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):
if self.library is None:
raise BuildError('No library available, but `other` was a string!')
other = self.library.abstract(other)
if append and isinstance(other, Abstract):
other = self.library[other.name]
if port_map is None:
port_map = {}
if not skip_port_check:
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
ports = {}
for name, port in other.ports.items():
new_name = port_map.get(name, name)
if new_name is None:
continue
ports[new_name] = port
for name, port in ports.items():
p = port.deepcopy()
p.mirror2d(mirrored)
p.rotate_around(pivot, rotation)
p.translate(offset)
self.ports[name] = p
if append:
if isinstance(other, Pattern):
other_pat = other
elif isinstance(other, Abstract):
assert self.library is not None
other_pat = self.library[other.name]
else:
other_pat = self.library[name]
other_copy = other_pat.deepcopy()
other_copy.ports.clear()
other_copy.mirror2d(mirrored)
other_copy.rotate_around(pivot, rotation)
other_copy.translate_elements(offset)
self.pattern.append(other_copy)
else:
assert not isinstance(other, Pattern)
ref = Ref(other.name, mirrored=mirrored)
ref.rotate_around(pivot, rotation)
ref.translate(offset)
self.pattern.refs.append(ref)
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:
@ -517,7 +398,7 @@ class Builder(PortList):
port.rotate_around(pivot, angle)
return self
def mirror(self, axis: int) -> Self:
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the pattern and all ports across the specified axis.
@ -528,8 +409,6 @@ class Builder(PortList):
self
"""
self.pattern.mirror(axis)
for p in self.ports.values():
p.mirror(axis)
return self
def set_dead(self) -> Self:
@ -551,7 +430,7 @@ class Builder(PortList):
return self
def __repr__(self) -> str:
s = f'<Builder {self.pattern} >' # TODO maybe show lib and tools? in builder repr?
s = f'<Builder {self.pattern} L({len(self.library)})>'
return s

View File

@ -1,17 +1,22 @@
from typing import Self, Sequence, MutableMapping, Mapping
"""
Manual wire/waveguide routing (`Pather`)
"""
from typing import Self
from collections.abc import Sequence, MutableMapping, Mapping
import copy
import logging
from pprint import pformat
import numpy
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..library import ILibrary
from ..library import ILibrary, SINGLE_USE_PREFIX
from ..error import PortError, BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
from ..utils import SupportsBool
from ..utils import SupportsBool, rotation_matrix_2d
from .tools import Tool
from .utils import ell
from .builder import Builder
@ -22,57 +27,87 @@ logger = logging.getLogger(__name__)
class Pather(Builder):
"""
TODO DOCUMENT Builder
A `Device` is a combination of a `Pattern` with a set of named `Port`s
which can be used to "snap" devices together to make complex layouts.
An extension of `Builder` which provides functionality for routing and attaching
single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns.
`Device`s can be as simple as one or two ports (e.g. an electrical pad
or wire), but can also be used to build and represent a large routed
layout (e.g. a logical block with multiple I/O connections or even a
full chip).
`Pather` is mostly concerned with calculating how long each wire should be. It calls
out to `Tool.path` functions provided by subclasses of `Tool` to build the actual patterns.
`Tool`s are assigned on a per-port basis and stored in `.tools`; a key of `None` represents
a "default" `Tool` used for all ports which do not have a port-specific `Tool` assigned.
For convenience, ports can be read out using square brackets:
- `device['A'] == Port((0, 0), 0)`
- `device[['A', 'B']] == {'A': Port((0, 0), 0), 'B': Port((0, 0), pi)}`
Examples: Creating a Device
Examples: Creating a Pather
===========================
- `Device(pattern, ports={'A': port_a, 'C': port_c})` uses an existing
pattern and defines some ports.
- `Pather(library, tools=my_tool)` makes an empty pattern with no ports. The pattern
is not added into `library` and must later be added with e.g.
`library['mypat'] = pather.pattern`.
The default wire/waveguide generating tool for all ports is set to `my_tool`.
- `Device(ports=None)` makes a new empty pattern with
default ports ('A' and 'B', in opposite directions, at (0, 0)).
- `Pather(library, ports={'in': Port(...), 'out': ...}, name='mypat', tools=my_tool)`
makes an empty pattern, adds the given ports, and places it into `library`
under the name `'mypat'`. The default wire/waveguide generating tool
for all ports is set to `my_tool`
- `my_device.build('my_layout')` makes a new pattern and instantiates
`my_device` in it with offset (0, 0) as a base for further building.
- `Pather(..., tools={'in': top_metal_40um, 'out': bottom_metal_1um, None: my_tool})`
assigns specific tools to individual ports, and `my_tool` as a default for ports
which are not specified.
- `my_device.as_interface('my_component', port_map=['A', 'B'])` makes a new
(empty) pattern, copies over ports 'A' and 'B' from `my_device`, 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
`my_device` (using the 'in_*' ports) but which does not itself include
`my_device` as a subcomponent.
- `Pather.interface(other_pat, port_map=['A', 'B'], library=library, tools=my_tool)`
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.
Examples: Adding to a Device
============================
- `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'.
- `Pather.interface(other_pather, ...)` does the same thing as
`Builder.interface(other_builder.pattern, ...)` but also uses
`other_builder.library` as its library by default.
- `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`,
Examples: Adding to a pattern
=============================
- `pather.path('my_port', ccw=True, distance)` creates a "wire" for which the output
port is `distance` units away along the axis of `'my_port'` and rotated 90 degrees
counterclockwise (since `ccw=True`) relative to `'my_port'`. The wire is `plug`ged
into the existing `'my_port'`, causing the port to move to the wire's output.
There is no formal guarantee about how far off-axis the output will be located;
there may be a significant width to the bend that is used to accomplish the 90 degree
turn. However, an error is raised if `distance` is too small to fit the bend.
- `pather.path('my_port', ccw=None, distance)` creates a straight wire with a length
of `distance` and `plug`s it into `'my_port'`.
- `pather.path_to('my_port', ccw=False, position)` creates a wire which starts at
`'my_port'` and has its output at the specified `position`, pointing 90 degrees
clockwise relative to the input. Again, the off-axis position or distance to the
output is not specified, so `position` takes the form of a single coordinate. To
ease debugging, position may be specified as `x=position` or `y=position` and an
error will be raised if the wrong coordinate is given.
- `pather.mpath(['A', 'B', 'C'], ..., spacing=spacing)` is a superset of `path`
and `path_to` which can act on multiple ports simultaneously. Each port's wire is
generated using its own `Tool` (or the default tool if left unspecified).
The output ports are spaced out by `spacing` along the input ports' axis, unless
`ccw=None` is specified (i.e. no bends) in which case they all end at the same
destination coordinate.
- `pather.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
of `pather.pattern`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
argument is provided, and the `inherit_name` argument is not explicitly
set to `False`, 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'})`
- `pather.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
rotation, adding its ports to those of `pather.pattern`. 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.
- `pather.retool(tool)` or `pather.retool(tool, ['in', 'out', None])` can change
which tool is used for the given ports (or as the default tool). Useful
when placing vias or using multiple waveguide types along a route.
"""
__slots__ = ('tools',)
@ -84,8 +119,9 @@ class Pather(Builder):
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.
Tool objects are used to dynamically generate new single-use `Pattern`s
(e.g wires or waveguides) to be plugged into this device. A key of `None`
indicates the default `Tool`.
"""
def __init__(
@ -98,13 +134,19 @@ class Pather(Builder):
name: str | None = None,
) -> None:
"""
# TODO documentation for Builder() constructor
# TODO MOVE THE BELOW DOCS to PortList
# If `ports` is `None`, two default ports ('A' and 'B') are created.
# Both are placed at (0, 0) and have default `ptype`, but 'A' has rotation 0
# (attached devices will be placed to the left) and 'B' has rotation
# pi (attached devices will be placed to the right).
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.path` implementations.
name: If specified, `library[name]` is set to `self.pattern`.
"""
self._dead = False
self.library = library
@ -121,6 +163,9 @@ class Pather(Builder):
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):
@ -128,40 +173,29 @@ class Pather(Builder):
else:
self.tools = dict(tools)
if name is not None:
library[name] = self.pattern
@classmethod
def mk(
cls,
library: ILibrary,
name: str,
*,
ports: str | Mapping[str, Port] | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
) -> tuple[str, 'Pather']:
""" Name-and-make combination """ # TODO document
pather = Pather(library, name=name, ports=ports, tools=tools)
return name, pather
@classmethod
def from_builder(
cls,
cls: type['Pather'],
builder: Builder,
*,
library: ILibrary | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
) -> 'Pather':
"""TODO from_builder docs"""
library = library if library is not None else builder.library
if library is None:
raise BuildError('No library available for Pather!')
new = Pather(library=library, tools=tools, pattern=builder.pattern)
"""
Construct a `Pather` by adding tools to a `Builder`.
Args:
builder: Builder to turn into a Pather
tools: Tools for the `Pather`
Returns:
A new Pather object, using `builder.library` and `builder.pattern`.
"""
new = Pather(library=builder.library, tools=tools, pattern=builder.pattern)
return new
@classmethod
def interface(
cls,
cls: type['Pather'],
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
@ -172,7 +206,36 @@ class Pather(Builder):
name: str | None = None,
) -> 'Pather':
"""
TODO doc pather.interface
Wrapper for `Pattern.interface()`, which returns a Pather 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 pather, 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):
@ -183,21 +246,15 @@ class Pather(Builder):
if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
tools = source.tools
new = Pather.from_builder(
Builder.interface(
source=source,
library=library,
in_prefix=in_prefix,
out_prefix=out_prefix,
port_map=port_map,
name=name,
),
tools=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 = Pather(library=library, pattern=pat, name=name, tools=tools)
return new
def __repr__(self) -> str:
s = f'<Pather {self.pattern} >' # TODO maybe show lib and tools? in builder repr?
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
return s
def retool(
@ -205,6 +262,18 @@ class Pather(Builder):
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:
@ -219,36 +288,112 @@ class Pather(Builder):
length: float,
*,
tool_port_names: tuple[str, str] = ('A', 'B'),
base_name: str = '_path',
plug_into: str | None = None,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" and `plug` it into 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.
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.)
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
plug_into: If not None, attempts to plug the wire's output port into the provided
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
tool = self.tools.get(portspec, self.tools[None])
in_ptype = self.pattern[portspec].ptype
pat = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
name = self.library.get_name(base_name)
self.library[name] = pat
return self.plug(Abstract(name, pat.ports), {portspec: tool_port_names[0]})
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
abstract = self.library << tree
if plug_into is not None:
output = {plug_into: tool_port_names[1]}
else:
output = {}
return self.plug(abstract, {portspec: tool_port_names[0], **output})
def path_to(
self,
portspec: str,
ccw: SupportsBool | None,
position: float,
position: float | None = None,
*,
x: float | None = None,
y: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'),
base_name: str = '_pathto',
plug_into: str | None = None,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" and `plug` it into 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.
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.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
plug_into: If not None, attempts to plug the wire's output port into the provided
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]
x, y = port.offset
if port.rotation is None:
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
@ -257,15 +402,156 @@ class Pather(Builder):
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x):
raise BuildError(f'path_to routing to behind source port: x={x:g} to {position:g}')
length = numpy.abs(position - x)
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 numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y):
raise BuildError(f'path_to routing to behind source port: y={y:g} to {position:g}')
length = numpy.abs(position - y)
if x is not None:
raise BuildError('Asked to path to x-coordinate, but port is vertical')
if position is None:
position = y
return self.path(portspec, ccw, length, tool_port_names=tool_port_names, base_name=base_name, **kwargs)
x0, y0 = port.offset
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
length = numpy.abs(position - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0)
return self.path(
portspec,
ccw,
length,
tool_port_names=tool_port_names,
plug_into=plug_into,
**kwargs,
)
def path_into(
self,
portspec_src: str,
portspec_dst: str,
*,
tool_port_names: tuple[str, str] = ('A', 'B'),
out_ptype: str | None = None,
plug_destination: bool = True,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" and traveling between the ports `portspec_src` and
`portspec_dst`, and `plug` it into both (or just the source port).
Only unambiguous scenarios are allowed:
- Straight connector between facing ports
- Single 90 degree bend
- Jog between facing ports
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
By default, the destination's `pytpe` will be used as the `out_ptype` for the
wire, and the `portspec_dst` will be plugged (i.e. removed).
Args:
portspec_src: The name of the starting port into which the wire will be plugged.
portspec_dst: The name of the destination port.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
out_ptype: Passed to the pathing tool in order to specify the desired port type
to be generated at the destination end. If `None` (default), the destination
port's `ptype` will be used.
Returns:
self
Raises:
PortError if either port does not have a specified rotation.
BuildError if and invalid port config is encountered:
- Non-manhattan ports
- U-bend
- Destination too close to (or behind) source
"""
if self._dead:
logger.error('Skipping path_into() since device is dead')
return self
port_src = self.pattern[portspec_src]
port_dst = self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None:
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
if port_dst.rotation is None:
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route from non-manhattan port')
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route to non-manhattan port')
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
xs, ys = port_src.offset
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east
def get_jog(ccw: SupportsBool, length: float) -> float:
tool = self.tools.get(portspec_src, self.tools[None])
in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already...
tree2 = tool.path(ccw, length, in_ptype=in_ptype, port_names=('A', 'B'), out_ptype=out_ptype, **kwargs)
top2 = tree2.top_pattern()
jog = rotation_matrix_2d(top2['A'].rotation) @ (top2['B'].offset - top2['A'].offset)
return jog[1]
dst_extra_args = {'out_ptype': out_ptype}
if plug_destination:
dst_extra_args['plug_into'] = portspec_dst
src_args = {**kwargs, 'tool_port_names': tool_port_names}
dst_args = {**src_args, **dst_extra_args}
if src_is_horizontal and not dst_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
self.path_to(portspec_src, None, y=yd, **dst_args)
elif dst_is_horizontal and not src_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
self.path_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
if src_is_horizontal and ys == yd:
# straight connector
self.path_to(portspec_src, None, x=xd, **dst_args)
elif not src_is_horizontal and xs == xd:
# straight connector
self.path_to(portspec_src, None, y=yd, **dst_args)
elif src_is_horizontal:
# figure out how much x our y-segment (2nd) takes up, then path based on that
y_len = numpy.abs(yd - ys)
ccw2 = src_ne != (yd > ys)
jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs)
self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args)
self.path_to(portspec_src, ccw2, y=yd, **dst_args)
else:
# figure out how much y our x-segment (2nd) takes up, then path based on that
x_len = numpy.abs(xd - xs)
ccw2 = src_ne != (xd < xs)
jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys)
self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args)
self.path_to(portspec_src, ccw2, x=xd, **dst_args)
elif numpy.isclose(angle, 0):
raise BuildError('Don\'t know how to route a U-bend at this time!')
else:
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
return self
def mpath(
self,
@ -276,9 +562,84 @@ class Pather(Builder):
set_rotation: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'),
force_container: bool = False,
base_name: str = '_mpath',
base_name: str = SINGLE_USE_PREFIX + 'mpath',
**kwargs,
) -> Self:
"""
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
of "wires or "waveguides".
The wires will travel so that the output ports will be placed at well-defined
locations along the axis of their input ports, but may have arbitrary (tool-
dependent) offsets in the perpendicular direction.
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
which is required when `ccw` is not `None`. The final position of bundle as a
whole can be set in a number of ways:
=A>---------------------------V turn direction: `ccw=False`
=B>-------------V |
=C>-----------------------V |
=D=>----------------V |
|
x---x---x---x `spacing` (can be scalar or array)
<--------------> `emin=`
<------> `bound_type='min_past_furthest', bound=`
<--------------------------------> `emax=`
x `pmin=`
x `pmax=`
- `emin=`, equivalent to `bound_type='min_extension', bound=`
The total extension value for the furthest-out port (B in the diagram).
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
The total extension value for the closest-in port (C in the diagram).
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
The coordinate of the innermost bend (D's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
The coordinate of the outermost bend (A's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `bound_type='min_past_furthest', bound=`:
The distance between furthest out-port (B) and the innermost bend (D's bend).
If `ccw=None`, final output positions (along the input axis) of all wires will be
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
required. In this case, `emin=` and `emax=` are equivalent to each other, and
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
Args:
portspec: The names of the ports which are to be routed.
ccw: If `None`, the outputs should be along the same axis as the inputs.
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Must be provided if (and only if) `ccw` is not `None`.
set_rotation: If the provided ports have `rotation=None`, this can be used
to set a rotation for them.
tool_port_names: The names of the ports on the generated pattern. It is unlikely
that you will need to change these. The first port is the input (to be
connected to `portspec`).
force_container: If `False` (default), and only a single port is provided, the
generated wire for that port will be referenced directly, rather than being
wrapped in an additonal `Pattern`.
base_name: Name to use for the generated `Pattern`. This will be passed through
`self.library.get_name()` to get a unique name for each new `Pattern`.
Returns:
self
Raises:
BuildError if the implied length for any wire is too close to fit the bend
(if a bend is requested).
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
match the axis of `portspec`.
BuildError if an incorrect bound type or spacing is specified.
"""
if self._dead:
logger.error('Skipping mpath() since device is dead')
return self
@ -287,14 +648,17 @@ class Pather(Builder):
if 'bound_type' in kwargs:
bound_types.add(kwargs['bound_type'])
bound = kwargs['bound']
for bt in ('emin', 'emax', 'pmin', 'pmax', 'min_past_furthest'):
del kwargs['bound_type']
del kwargs['bound']
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in kwargs:
bound_types.add(bt)
bound = kwargs[bt]
del kwargs[bt]
if not bound_types:
raise BuildError('No bound type specified for mpath')
elif len(bound_types) > 1:
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
@ -307,16 +671,16 @@ class Pather(Builder):
if len(ports) == 1 and not force_container:
# Not a bus, so having a container just adds noise to the layout
port_name = tuple(portspec)[0]
return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names)
else:
return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names, **kwargs)
bld = Pather.interface(source=ports, library=self.library, tools=self.tools)
for port_name, length in extensions.items():
bld.path(port_name, ccw, length, tool_port_names=tool_port_names)
bld.path(port_name, ccw, length, tool_port_names=tool_port_names, **kwargs)
name = self.library.get_name(base_name)
self.library[name] = bld.pattern
return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports.keys()}) # TODO safe to use 'in_'?
return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
# TODO def path_join() and def bus_join()?
# TODO def bus_join()?
def flatten(self) -> Self:
"""

View File

@ -1,42 +1,62 @@
from typing import Self, Sequence, Mapping, Final
"""
Pather with batched (multi-step) rendering
"""
from typing import Self
from collections.abc import Sequence, Mapping, MutableMapping
import copy
import logging
from collections import defaultdict
from pprint import pformat
import numpy
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..ref import Ref
from ..library import ILibrary
from ..error import PortError, BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
from ..utils import rotation_matrix_2d
from ..utils import SupportsBool
from .tools import Tool, render_step_t
from .tools import Tool, RenderStep
from .utils import ell
from .builder import Builder
logger = logging.getLogger(__name__)
class RenderPather(PortList):
"""
`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 | None
library: ILibrary
""" Library from which patterns should be referenced """
_dead: bool
""" If True, plug()/place() are skipped (for debugging) """
paths: defaultdict[str, list[render_step_t]]
# op, start_port, dx, dy, o_ptype tool
paths: defaultdict[str, list[RenderStep]]
""" Per-port list of operations, to be used by `render` """
tools: dict[str | None, Tool]
"""
@ -54,17 +74,30 @@ class RenderPather(PortList):
def __init__(
self,
library: ILibrary | None = None,
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:
"""
# TODO documentation for Builder() constructor
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
@ -86,46 +119,37 @@ class RenderPather(PortList):
raise BuildError('Name was supplied, but no library was given!')
library[name] = self.pattern
self.paths = defaultdict(list)
if tools is None:
self.tools = {}
elif isinstance(tools, Tool):
self.tools = {None: tools}
else:
self.tools = dict(tools)
@classmethod
def interface(
cls,
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':
"""
Begin building a new device based on all or some of the ports in the
source device. Do not include the source device; instead use it
to define ports (the "interface") for the new device.
The ports specified by `port_map` (default: all ports) are copied to
new device, and additional (input) ports are created facing in the
opposite directions. The specified `in_prefix` and `out_prefix` are
prepended to the port names to differentiate them.
By default, the flipped ports are given an 'in_' prefix and unflipped
ports keep their original names, enabling intuitive construction of
a device that will "plug into" the current device; the 'in_*' ports
are used for plugging the devices together while the original port
names are used for building the new device.
Another use-case could be to build the new device using the 'in_'
ports, creating a new device which could be used in place of the
current device.
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.
library: Used for buildin functions; if not passed and the source
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 new ones should be added. If not provided,
the source's library will be used (if available).
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
@ -137,7 +161,7 @@ class RenderPather(PortList):
renamed (to the values).
Returns:
The new builder, with an empty pattern and 2x as many ports as
The new `RenderPather`, with an empty pattern and 2x as many ports as
listed in port_map.
Raises:
@ -149,42 +173,17 @@ class RenderPather(PortList):
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):
if library is None:
raise BuildError('Source given as a string, but `library` was `None`!')
orig_ports = library.abstract(source).ports
elif isinstance(source, PortList):
orig_ports = source.ports
elif isinstance(source, dict):
orig_ports = source
else:
raise BuildError(f'Unable to get ports from {type(source)}: {source}')
source = library.abstract(source).ports
if port_map:
if isinstance(port_map, dict):
missing_inkeys = set(port_map.keys()) - set(orig_ports.keys())
mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map}
else:
port_set = set(port_map)
missing_inkeys = port_set - set(orig_ports.keys())
mapped_ports = {k: v for k, v in orig_ports.items() if k in port_set}
if missing_inkeys:
raise PortError(f'`port_map` keys not present in source: {missing_inkeys}')
else:
mapped_ports = orig_ports
ports_in = {f'{in_prefix}{pname}': port.deepcopy().rotate(pi)
for pname, port in mapped_ports.items()}
ports_out = {f'{out_prefix}{pname}': port.deepcopy()
for pname, port in mapped_ports.items()}
duplicates = set(ports_out.keys()) & set(ports_in.keys())
if duplicates:
raise PortError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}')
new = RenderPather(library=library, ports={**ports_in, **ports_out}, name=name)
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 plug(
@ -193,48 +192,84 @@ class RenderPather(PortList):
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: tuple[bool, bool] = (False, False),
mirrored: bool = False,
inherit_name: bool = True,
set_rotation: bool | None = None,
append: bool = False,
) -> 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.
inherit_name: If `True`, and `map_in` specifies only a single port,
and `map_out` is `None`, and `other` has only two ports total,
then automatically renames the output port of `other` to the
name of the port from `self` that appears in `map_in`. This
makes it easy to extend a device 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`).
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):
if self.library is None:
raise BuildError('No library available, but `other` was a string!')
other = self.library.abstract(other)
# If asked to inherit a name, check that all conditions are met
if (inherit_name
and not map_out
and len(map_in) == 1
and len(other.ports) == 2):
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
map_out = {out_port_name: next(iter(map_in.keys()))}
if map_out is None:
map_out = {}
map_out = copy.deepcopy(map_out)
self.check_ports(other.ports.keys(), map_in, map_out)
translation, rotation, pivot = self.find_transform(
other,
map_in,
mirrored=mirrored,
set_rotation=set_rotation,
)
other_tgt = self.library.abstract(other)
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
# get rid of plugged ports
for ki, vi in map_in.items():
del self.ports[ki]
map_out[vi] = None
if ki in self.paths:
self.paths[ki].append(('P', None, 0.0, 0.0, 'unk', None))
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,
inherit_name=inherit_name,
set_rotation=set_rotation,
append=append,
)
self.place(other, offset=translation, rotation=rotation, pivot=pivot,
mirrored=mirrored, port_map=map_out, skip_port_check=True)
return self
def place(
@ -244,45 +279,94 @@ class RenderPather(PortList):
offset: ArrayLike = (0, 0),
rotation: float = 0,
pivot: ArrayLike = (0, 0),
mirrored: tuple[bool, bool] = (False, False),
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):
if self.library is None:
raise BuildError('No library available, but `other` was a string!')
other = self.library.abstract(other)
other_tgt = self.library.abstract(other)
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
if port_map is None:
port_map = {}
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))
if not skip_port_check:
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
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,
)
ports = {}
for name, port in other.ports.items():
new_name = port_map.get(name, name)
if new_name is None:
continue
ports[new_name] = port
if new_name in self.paths:
self.paths[new_name].append(('P', None, 0.0, 0.0, 'unk', None))
return self
for name, port in ports.items():
p = port.deepcopy()
p.mirror2d(mirrored)
p.rotate_around(pivot, rotation)
p.translate(offset)
self.ports[name] = p
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`.
sp = Ref(other.name, mirrored=mirrored)
sp.rotate_around(pivot, rotation)
sp.translate(offset)
self.pattern.refs.append(sp)
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
def path(
@ -292,6 +376,31 @@ class RenderPather(PortList):
length: float,
**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.)
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
@ -299,53 +408,79 @@ class RenderPather(PortList):
port = self.pattern[portspec]
in_ptype = port.ptype
port_rot = port.rotation
assert port_rot is not None # TODO allow manually setting 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
bend_radius, out_ptype = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
if ccw is None:
bend_run = 0.0
elif bool(ccw):
bend_run = bend_radius
else:
bend_run = -bend_radius
dx, dy = rotation_matrix_2d(port_rot + pi) @ [length, bend_run]
step: Final = ('L', port.deepcopy(), dx, dy, out_ptype, tool)
self.paths[portspec].append(step)
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
# Update port
port.offset += (dx, dy)
if ccw is not None:
port.rotate((-1 if ccw else 1) * pi / 2)
port.ptype = out_ptype
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()
return self
'''
- record ('path', port, dx, dy, out_ptype, tool)
- to render, ccw = {0: None, 1: True, -1: False}[numpy.sign(dx) * numpy.sign(dy) * (-1 if x_start else 1)
- length is just dx or dy
- in_ptype and out_ptype are taken directly
- for sbend: dx and dy are maybe reordered (length and jog)
'''
def path_to(
self,
portspec: str,
ccw: SupportsBool | None,
position: float,
position: float | None = None,
*,
x: float | None = None,
y: float | None = None,
**kwargs,
) -> Self:
"""
Plan 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.
`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.
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]
x, y = port.offset
if port.rotation is None:
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
@ -354,13 +489,25 @@ class RenderPather(PortList):
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x):
raise BuildError(f'path_to routing to behind source port: x={x:g} to {position:g}')
length = numpy.abs(position - x)
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 numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y):
raise BuildError(f'path_to routing to behind source port: y={y:g} to {position:g}')
length = numpy.abs(position - y)
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, **kwargs)
@ -373,6 +520,32 @@ class RenderPather(PortList):
set_rotation: float | None = None,
**kwargs,
) -> Self:
"""
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
of "wires or "waveguides".
See `Pather.mpath` for details.
Args:
portspec: The names of the ports which are to be routed.
ccw: If `None`, the outputs should be along the same axis as the inputs.
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Must be provided if (and only if) `ccw` is not `None`.
set_rotation: If the provided ports have `rotation=None`, this can be used
to set a rotation for them.
Returns:
self
Raises:
BuildError if the implied length for any wire is too close to fit the bend
(if a bend is requested).
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
match the axis of `portspec`.
BuildError if an incorrect bound type or spacing is specified.
"""
if self._dead:
logger.error('Skipping mpath() since device is dead')
return self
@ -381,14 +554,14 @@ class RenderPather(PortList):
if 'bound_type' in kwargs:
bound_types.add(kwargs['bound_type'])
bound = kwargs['bound']
for bt in ('emin', 'emax', 'pmin', 'pmax', 'min_past_furthest'):
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in kwargs:
bound_types.add(bt)
bound = kwargs[bt]
if not bound_types:
raise BuildError('No bound type specified for mpath')
elif len(bound_types) > 1:
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
@ -407,48 +580,61 @@ class RenderPather(PortList):
self.path(port_name, ccw, length)
return self
def render(self, lib: ILibrary | None = None) -> Self:
lib = lib if lib is not None else self.library
assert lib is not None
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')
bb = Builder(lib)
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[render_step_t] = []
batch: list[RenderStep] = []
for step in steps:
opcode, _start_port, _dx, _dy, _out_ptype, tool = step
appendable_op = step.opcode in ('L', 'S', 'U')
same_tool = batch and step.tool == batch[0].tool
appendable_op = opcode in ('L', 'S', 'U')
same_tool = batch and tool == batch[-1]
if batch and (not appendable_op or not same_tool):
# If we can't continue a batch, render it
assert tool is not None
assert batch[0][1] is not None
name = lib << tool.render(batch, portnames=tool_port_names)
bb.ports[portspec] = batch[0][1]
bb.plug(name, {portspec: tool_port_names[0]})
if batch and (not appendable_op or not same_tool):
render_batch(portspec, batch, append)
batch = []
# batch is emptied already if we couldn't
# 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:
del bb.ports[portspec]
if not appendable_op and portspec in pat.ports:
del pat.ports[portspec]
#If the last batch didn't end yet
if batch:
# A batch didn't end yet
assert tool is not None
assert batch[0][1] is not None
name = lib << tool.render(batch, portnames=tool_port_names)
bb.ports[portspec] = batch[0][1]
bb.plug(name, {portspec: tool_port_names[0]})
render_batch(portspec, batch, append)
bb.ports.clear()
self.pattern.append(bb.pattern)
self.paths.clear()
pat.ports.clear()
self.pattern.append(pat)
return self
@ -511,7 +697,7 @@ class RenderPather(PortList):
return self
def __repr__(self) -> str:
s = f'<RenderPather {self.pattern} >' # TODO maybe show lib and tools? in builder repr?
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
return s

View File

@ -1,26 +1,61 @@
"""
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
# TODO document all tools
"""
from typing import TYPE_CHECKING, Sequence, Literal, Callable
from abc import ABCMeta, abstractmethod
from typing import Literal, Any
from collections.abc import Sequence, Callable
from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method?
from dataclasses import dataclass
import numpy
from numpy.typing import NDArray
from numpy import pi
from ..utils import SupportsBool, rotation_matrix_2d
from ..utils import SupportsBool, rotation_matrix_2d, layer_t
from ..ports import Port
from ..pattern import Pattern
from ..abstract import Abstract
from ..library import ILibrary, Library
from ..library import ILibrary, Library, SINGLE_USE_PREFIX
from ..error import BuildError
from .builder import Builder
render_step_t = (
tuple[Literal['L', 'S', 'U'], Port, float, float, str, 'Tool']
| tuple[Literal['P'], None, float, float, str, None]
)
@dataclass(frozen=True, slots=True)
class RenderStep:
"""
Representation of a single saved operation, used by `RenderPather` and passed
to `Tool.render()` when `RenderPather.render()` is called.
"""
opcode: Literal['L', 'S', 'U', 'P']
""" What operation is being performed.
L: planL (straight, optionally with a single bend)
S: planS (s-bend)
U: planU (u-bend)
P: plug
"""
tool: 'Tool | None'
""" The current tool. May be `None` if `opcode='P'` """
start_port: Port
end_port: Port
data: Any
""" Arbitrary tool-specific data"""
def __post_init__(self) -> None:
if self.opcode != 'P' and self.tool is None:
raise BuildError('Got tool=None but the opcode is not "P"')
class Tool:
"""
Interface for path (e.g. wire or waveguide) generation.
Note that subclasses may implement only a subset of the methods and leave others
unimplemented (e.g. in cases where they don't make sense or the required components
are impractical or unavailable).
"""
def path(
self,
ccw: SupportsBool | None,
@ -30,7 +65,40 @@ class Tool:
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Pattern:
) -> Library:
"""
Create a wire or waveguide that travels exactly `length` distance along the axis
of its input port.
Used by `Pather`.
The output port must be exactly `length` away along the input port's axis, but
may be placed an additional (unspecified) distance away along the perpendicular
direction. The output port should be rotated (or not) based on the value of
`ccw`.
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively. They should also be named `port_names[0]` and
`port_names[1]`, respectively.
Args:
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.)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
port_names: The output pattern will have its input port named `port_names[0]` and
its output named `port_names[1]`.
kwargs: Custom tool-specific parameters.
Returns:
A pattern tree containing the requested L-shaped (or straight) wire or waveguide
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'path() not implemented for {type(self)}')
def planL(
@ -41,38 +109,167 @@ class Tool:
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs,
) -> tuple[float, str]:
) -> tuple[Port, Any]:
"""
Plan a wire or waveguide that travels exactly `length` distance along the axis
of its input port.
Used by `RenderPather`.
The output port must be exactly `length` away along the input port's axis, but
may be placed an additional (unspecified) distance away along the perpendicular
direction. The output port should be rotated (or not) based on the value of
`ccw`.
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively.
Args:
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.)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
kwargs: Custom tool-specific parameters.
Returns:
The calculated output `Port` for the wire.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'planL() not implemented for {type(self)}')
def planS(
self,
ccw: SupportsBool | None,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs,
) -> str: # out_ptype only?
) -> tuple[Port, Any]:
"""
Plan a wire or waveguide that travels exactly `length` distance along the axis
of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend).
Used by `RenderPather`.
The output port must have an orientation rotated by pi from the input port.
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively.
Args:
length: The total distance from input to output, along the input's axis only.
jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a rightwards shift (i.e. clockwise bend followed
by a counterclockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
kwargs: Custom tool-specific parameters.
Returns:
The calculated output `Port` for the wire.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'planS() not implemented for {type(self)}')
def render(
def planU(
self,
batch: Sequence[render_step_t],
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: Sequence[str] = ('A', 'B'),
**kwargs,
) -> tuple[Port, Any]:
"""
# NOTE: TODO: U-bend is WIP; this interface may change in the future.
Plan a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
Used by `RenderPather`.
The output port must have an orientation identical to the input port.
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively.
Args:
jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a rightwards shift (i.e. clockwise bend followed
by a counterclockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
kwargs: Custom tool-specific parameters.
Returns:
The calculated output `Port` for the wire.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'planU() not implemented for {type(self)}')
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused)
**kwargs, # noqa: ARG002 (unused)
) -> ILibrary:
assert batch[0][-1] == self
"""
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
(a Library with a single topcell).
Args:
batch: A sequence of `RenderStep` objects containing the ports and data
provided by this tool's `planL`/`planS`/`planU` functions.
port_names: The topcell's input and output ports should be named
`port_names[0]` and `port_names[1]` respectively.
kwargs: Custom tool-specific parameters.
"""
assert not batch or batch[0].tool == self
raise NotImplementedError(f'render() not implemented for {type(self)}')
abstract_tuple_t = tuple[Abstract, str, str]
@dataclass
class BasicTool(Tool, metaclass=ABCMeta):
"""
A simple tool which relies on a single pre-rendered `bend` pattern, a function
for generating straight paths, and a table of pre-rendered `transitions` for converting
from non-native ptypes.
"""
straight: tuple[Callable[[float], Pattern], str, str]
bend: tuple[Abstract, str, str] # Assumed to be clockwise
transitions: dict[str, tuple[Abstract, str, str]]
""" `create_straight(length: float), in_port_name, out_port_name` """
bend: abstract_tuple_t # Assumed to be clockwise
""" `clockwise_bend_abstract, in_port_name, out_port_name` """
transitions: dict[str, abstract_tuple_t]
""" `{ptype: (transition_abstract`, ptype_port_name, other_port_name), ...}` """
default_out_ptype: str
""" Default value for out_ptype """
@dataclass(frozen=True, slots=True)
class LData:
""" Data for planL """
straight_length: float
ccw: SupportsBool | None
in_transition: abstract_tuple_t | None
out_transition: abstract_tuple_t | None
def path(
self,
@ -83,26 +280,63 @@ class BasicTool(Tool, metaclass=ABCMeta):
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Pattern:
) -> Library:
_out_port, data = self.planL(
ccw,
length,
in_ptype=in_ptype,
out_ptype=out_ptype,
)
gen_straight, sport_in, sport_out = self.straight
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=port_names)
if data.in_transition:
ipat, iport_theirs, _iport_ours = data.in_transition
pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(data.straight_length, 0):
straight = tree <= {SINGLE_USE_PREFIX + 'straight': gen_straight(data.straight_length, **kwargs)}
pat.plug(straight, {port_names[1]: sport_in})
if data.ccw is not None:
bend, bport_in, bport_out = self.bend
pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw))
if data.out_transition:
opat, oport_theirs, oport_ours = data.out_transition
pat.plug(opat, {port_names[1]: oport_ours})
return tree
def planL(
self,
ccw: SupportsBool | None,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs, # noqa: ARG002 (unused)
) -> tuple[Port, LData]:
# TODO check all the math for L-shaped bends
straight_length = length
bend_run = 0
if ccw is not None:
bend, bport_in, bport_out = self.bend
brot = bend.ports[bport_in].rotation
assert brot is not None
bend_dxy = numpy.abs(
rotation_matrix_2d(-brot) @ (
angle_in = bend.ports[bport_in].rotation
angle_out = bend.ports[bport_out].rotation
assert angle_in is not None
assert angle_out is not None
bend_dxy = rotation_matrix_2d(-angle_in) @ (
bend.ports[bport_out].offset
- bend.ports[bport_in].offset
)
)
straight_length -= bend_dxy[0]
bend_run += bend_dxy[1]
bend_angle = angle_out - angle_in
if bool(ccw):
bend_dxy[1] *= -1
bend_angle *= -1
else:
bend_dxy = numpy.zeros(2)
bend_angle = 0
in_transition = self.transitions.get('unk' if in_ptype is None else in_ptype, None)
if in_transition is not None:
@ -113,9 +347,6 @@ class BasicTool(Tool, metaclass=ABCMeta):
ipat.ports[iport_ours].offset
- ipat.ports[iport_theirs].offset
)
straight_length -= itrans_dxy[0]
bend_run += itrans_dxy[1]
else:
itrans_dxy = numpy.zeros(2)
@ -124,35 +355,199 @@ class BasicTool(Tool, metaclass=ABCMeta):
opat, oport_theirs, oport_ours = out_transition
orot = opat.ports[oport_ours].rotation
assert orot is not None
otrans_dxy = rotation_matrix_2d(-orot) @ (
otrans_dxy = rotation_matrix_2d(-orot + bend_angle) @ (
opat.ports[oport_theirs].offset
- opat.ports[oport_ours].offset
)
if ccw:
otrans_dxy[0] *= -1
straight_length -= otrans_dxy[1]
bend_run += otrans_dxy[0]
else:
otrans_dxy = numpy.zeros(2)
if out_transition is not None:
out_ptype_actual = opat.ports[oport_theirs].ptype
elif ccw is not None:
out_ptype_actual = bend.ports[bport_out].ptype
else:
out_ptype_actual = self.default_out_ptype
straight_length = length - bend_dxy[0] - itrans_dxy[0] - otrans_dxy[0]
bend_run = bend_dxy[1] + itrans_dxy[1] + otrans_dxy[1]
if straight_length < 0:
raise BuildError(f'Asked to draw path with total length {length:g}, shorter than required bends and tapers:\n'
f'bend: {bend_dxy[0]:g} in_taper: {abs(itrans_dxy[0])} out_taper: {otrans_dxy[1]}')
raise BuildError(
f'Asked to draw path with total length {length:,g}, shorter than required bends and transitions:\n'
f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g} out_trans: {otrans_dxy[0]:,g}'
)
gen_straight, sport_in, sport_out = self.straight
tree = Library()
bb = Builder(library=tree, name='_path').add_port_pair(names=port_names)
data = self.LData(straight_length, ccw, in_transition, out_transition)
out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual)
return out_port, data
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: Sequence[str] = ('A', 'B'),
append: bool = True,
**kwargs,
) -> ILibrary:
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.add_port_pair(names=(port_names[0], port_names[1]))
gen_straight, sport_in, _sport_out = self.straight
for step in batch:
straight_length, ccw, in_transition, out_transition = step.data
assert step.tool == self
if step.opcode == 'L':
if in_transition:
bb.plug(ipat, {port_names[1]: iport_theirs})
ipat, iport_theirs, _iport_ours = in_transition
pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(straight_length, 0):
straight = tree << {'_straight': gen_straight(straight_length)}
bb.plug(straight, {port_names[1]: sport_in})
straight_pat = gen_straight(straight_length, **kwargs)
if append:
pat.plug(straight_pat, {port_names[1]: sport_in}, append=True)
else:
straight = tree <= {SINGLE_USE_PREFIX + 'straight': straight_pat}
pat.plug(straight, {port_names[1]: sport_in}, append=True)
if ccw is not None:
bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw)))
bend, bport_in, bport_out = self.bend
pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw))
if out_transition:
bb.plug(opat, {port_names[1]: oport_ours})
opat, oport_theirs, oport_ours = out_transition
pat.plug(opat, {port_names[1]: oport_ours})
return tree
return bb.pattern
@dataclass
class PathTool(Tool, metaclass=ABCMeta):
"""
A tool which draws `Path` geometry elements.
If `planL` / `render` are used, the `Path` elements can cover >2 vertices;
with `path` only individual rectangles will be drawn.
"""
layer: layer_t
""" Layer to draw on """
width: float
""" `Path` width """
ptype: str = 'unk'
""" ptype for any ports in patterns generated by this tool """
#@dataclass(frozen=True, slots=True)
#class LData:
# dxy: NDArray[numpy.float64]
#def __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None:
# Tool.__init__(self)
# self.layer = layer
# self.width = width
# self.ptype: str
def path(
self,
ccw: SupportsBool | None,
length: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs, # noqa: ARG002 (unused)
) -> Library:
out_port, dxy = self.planL(
ccw,
length,
in_ptype=in_ptype,
out_ptype=out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)])
if ccw is None:
out_rot = pi
elif bool(ccw):
out_rot = -pi / 2
else:
out_rot = pi / 2
pat.ports = {
port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype),
port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype),
}
return tree
def planL(
self,
ccw: SupportsBool | None,
length: float,
*,
in_ptype: str | None = None, # noqa: ARG002 (unused)
out_ptype: str | None = None,
**kwargs, # noqa: ARG002 (unused)
) -> tuple[Port, NDArray[numpy.float64]]:
# TODO check all the math for L-shaped bends
if out_ptype and out_ptype != self.ptype:
raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}')
if ccw is not None:
bend_dxy = numpy.array([1, -1]) * self.width / 2
bend_angle = pi / 2
if bool(ccw):
bend_dxy[1] *= -1
bend_angle *= -1
else:
bend_dxy = numpy.zeros(2)
bend_angle = pi
straight_length = length - bend_dxy[0]
bend_run = bend_dxy[1]
if straight_length < 0:
raise BuildError(
f'Asked to draw path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}'
)
data = numpy.array((length, bend_run))
out_port = Port(data, rotation=bend_angle, ptype=self.ptype)
return out_port, data
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: Sequence[str] = ('A', 'B'),
**kwargs, # noqa: ARG002 (unused)
) -> ILibrary:
path_vertices = [batch[0].start_port.offset]
for step in batch:
assert step.tool == self
port_rot = step.start_port.rotation
assert port_rot is not None
if step.opcode == 'L':
length, bend_run = step.data
dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0)
#path_vertices.append(step.start_port.offset)
path_vertices.append(step.start_port.offset + dxy)
else:
raise BuildError(f'Unrecognized opcode "{step.opcode}"')
if (path_vertices[-1] != batch[-1].end_port.offset).any():
# If the path ends in a bend, we need to add the final vertex
path_vertices.append(batch[-1].end_port.offset)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
pat.ports = {
port_names[0]: batch[0].start_port.copy().rotate(pi),
port_names[1]: batch[-1].end_port.copy().rotate(pi),
}
return tree

View File

@ -1,4 +1,5 @@
from typing import Mapping, Sequence, SupportsFloat, cast, TYPE_CHECKING
from typing import SupportsFloat, cast, TYPE_CHECKING
from collections.abc import Mapping, Sequence
from pprint import pformat
import numpy
@ -53,9 +54,9 @@ def ell(
The distance between furthest out-port (B) and the innermost bend (D's bend).
- 'max_extension' or 'emax':
The total extension value for the closest-in port (C in the diagram).
- 'min_position' or 'pmin':
- 'min_position', 'pmin', 'xmin', 'ymin':
The coordinate of the innermost bend (D's bend).
- 'max_position' or 'pmax':
- 'max_position', 'pmax', 'xmax', 'ymax':
The coordinate of the outermost bend (A's bend).
`bound` can also be a vector. If specifying an extension (e.g. 'min_extension',
@ -109,6 +110,12 @@ def ell(
raise BuildError('set_rotation must be specified if no ports have rotations!')
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
is_horizontal = numpy.isclose(rotations[0] % pi, 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:
raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!')
direction = rotations[0] + pi # direction we want to travel in (+pi relative to port)
rot_matrix = rotation_matrix_2d(-direction)
@ -116,6 +123,8 @@ def ell(
orig_offsets = numpy.array([p.offset for p in ports.values()])
rot_offsets = (rot_matrix @ orig_offsets.T).T
# ordering_base = rot_offsets.T * [[1], [-1 if ccw else 1]] # could work, but this is actually a more complex routing problem
# y_order = numpy.lexsort(ordering_base) # (need to make sure we don't collide with the next input port @ same y)
y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
y_ind = numpy.empty_like(y_order, dtype=int)
y_ind[y_order] = numpy.arange(y_ind.shape[0])
@ -184,16 +193,16 @@ def ell(
rot_bound = -bound if neg else bound
min_possible = x_start + offsets
if bound_type in ('pmax', 'max_position'):
if bound_type in ('pmax', 'max_position', 'xmax', 'ymax'):
extension = rot_bound - min_possible.max()
elif bound_type in ('pmin', 'min_position'):
elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'):
extension = rot_bound - min_possible.min()
offsets += extension
if extension < 0:
ext_floor = -numpy.floor(extension)
raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t'
+ '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets)))
+ '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets, strict=True)))
result = dict(zip(ports.keys(), offsets))
result = dict(zip(ports.keys(), offsets, strict=True))
return result

View File

@ -6,7 +6,8 @@ Notes:
* ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
to unique values, so byte-for-byte reproducibility is not achievable for now
"""
from typing import Any, Callable, Mapping, cast, TextIO, IO
from typing import Any, cast, TextIO, IO
from collections.abc import Mapping, Callable
import io
import logging
import pathlib
@ -15,13 +16,14 @@ import gzip
import numpy
import ezdxf
from ezdxf.enums import TextEntityAlignment
from ezdxf.entities import LWPolyline, Polyline, Text, Insert
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label
from ..library import ILibraryView, LibraryView, Library
from ..shapes import Shape, Polygon, Path
from ..repetition import Grid
from ..utils import rotation_matrix_2d, layer_t
from ..utils import rotation_matrix_2d, layer_t, normalize_mirror
logger = logging.getLogger(__name__)
@ -38,7 +40,7 @@ def write(
top_name: str,
stream: TextIO,
*,
dxf_version='AC1024',
dxf_version: str = 'AC1024',
) -> None:
"""
Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes
@ -204,26 +206,25 @@ def read(
return mlib, library_info
def _read_block(block) -> tuple[str, Pattern]:
def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]:
name = block.name
pat = Pattern()
for element in block:
eltype = element.dxftype()
if eltype in ('POLYLINE', 'LWPOLYLINE'):
if eltype == 'LWPOLYLINE':
points = numpy.array(tuple(element.lwpoints))
else:
points = numpy.array(tuple(element.points()))
if isinstance(element, LWPolyline | Polyline):
if isinstance(element, LWPolyline):
points = numpy.asarray(element.get_points())
elif isinstance(element, Polyline):
points = numpy.asarray(element.points())[:, :2]
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
if points.shape[1] == 2:
raise PatternError('Invalid or unimplemented polygon?')
#shape = Polygon(layer=layer)
elif points.shape[1] > 2:
if points.shape[1] > 2:
if (points[0, 2] != points[:, 2]).any():
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
elif points.shape[1] == 4 and (points[:, 3] != 0).any():
if points.shape[1] == 4 and (points[:, 3] != 0).any():
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
width = points[0, 2]
@ -232,15 +233,15 @@ def _read_block(block) -> tuple[str, Pattern]:
shape: Path | Polygon
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
shape = Polygon(layer=layer, vertices=points[:-1, :2])
shape = Polygon(vertices=points[:-1, :2])
else:
shape = Path(layer=layer, width=width, vertices=points[:, :2])
shape = Path(width=width, vertices=points[:, :2])
pat.shapes.append(shape)
pat.shapes[layer].append(shape)
elif eltype in ('TEXT',):
elif isinstance(element, Text):
args = dict(
offset=numpy.array(element.get_pos()[1])[:2],
offset=numpy.asarray(element.get_placement()[1])[:2],
layer=element.dxfattribs().get('layer', DEFAULT_LAYER),
)
string = element.dxfattribs().get('text', '')
@ -248,20 +249,20 @@ def _read_block(block) -> tuple[str, Pattern]:
# if height != 0:
# logger.warning('Interpreting DXF TEXT as a label despite nonzero height. '
# 'This could be changed in the future by setting a font path in the masque DXF code.')
pat.labels.append(Label(string=string, **args))
pat.label(string=string, **args)
# else:
# pat.shapes.append(Text(string=string, height=height, font_path=????))
elif eltype in ('INSERT',):
# pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????))
elif isinstance(element, Insert):
attr = element.dxfattribs()
xscale = attr.get('xscale', 1)
yscale = attr.get('yscale', 1)
if abs(xscale) != abs(yscale):
logger.warning('Masque does not support per-axis scaling; using x-scaling only!')
scale = abs(xscale)
mirrored = (yscale < 0, xscale < 0)
rotation = numpy.deg2rad(attr.get('rotation', 0))
mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0))
rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle
offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2]
offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]
args = dict(
target=attr.get('name', None),
@ -286,17 +287,13 @@ def _read_block(block) -> tuple[str, Pattern]:
def _mrefs_to_drefs(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
refs: list[Ref],
refs: dict[str | None, list[Ref]],
) -> None:
for ref in refs:
if ref.target is None:
continue
encoded_name = ref.target
def mk_blockref(encoded_name: str, ref: Ref) -> None:
rotation = numpy.rad2deg(ref.rotation) % 360
attribs = dict(
xscale=ref.scale * (-1 if ref.mirrored[1] else 1),
yscale=ref.scale * (-1 if ref.mirrored[0] else 1),
xscale=ref.scale,
yscale=ref.scale * (-1 if ref.mirrored else 1),
rotation=rotation,
)
@ -330,22 +327,29 @@ def _mrefs_to_drefs(
for dd in rep.displacements:
block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)
for target, rseq in refs.items():
if target is None:
continue
for ref in rseq:
mk_blockref(target, ref)
def _shapes_to_elements(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
shapes: list[Shape],
polygonize_paths: bool = False,
shapes: dict[layer_t, list[Shape]],
) -> None:
# Add `LWPolyline`s for each shape.
# Could set do paths with width setting, but need to consider endcaps.
for shape in shapes:
# TODO: can DXF do paths?
for layer, sseq in shapes.items():
attribs = dict(layer=_mlayer2dxf(layer))
for shape in sseq:
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.'
)
attribs = dict(layer=_mlayer2dxf(shape.layer))
for polygon in shape.to_polygons():
xy_open = polygon.vertices + polygon.offset
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
@ -354,12 +358,16 @@ def _shapes_to_elements(
def _labels_to_texts(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
labels: list[Label],
labels: dict[layer_t, list[Label]],
) -> None:
for label in labels:
attribs = dict(layer=_mlayer2dxf(label.layer))
for layer, lseq in labels.items():
attribs = dict(layer=_mlayer2dxf(layer))
for label in lseq:
xy = label.offset
block.add_text(label.string, dxfattribs=attribs).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
block.add_text(
label.string,
dxfattribs=attribs
).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
def _mlayer2dxf(layer: layer_t) -> str:

View File

@ -19,7 +19,8 @@ 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 Callable, Iterable, Mapping, IO, cast, Any
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
import io
import mmap
import logging
@ -37,7 +38,7 @@ 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, normalize_mirror, annotations_t
from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
@ -144,7 +145,7 @@ def writefile(
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'))
stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
@ -253,21 +254,21 @@ def read_elements(
elements = klamath.library.read_elements(stream)
for element in elements:
if isinstance(element, klamath.elements.Boundary):
poly = _boundary_to_polygon(element, raw_mode)
pat.shapes.append(poly)
layer, poly = _boundary_to_polygon(element, raw_mode)
pat.shapes[layer].append(poly)
elif isinstance(element, klamath.elements.Path):
path = _gpath_to_mpath(element, raw_mode)
pat.shapes.append(path)
layer, path = _gpath_to_mpath(element, raw_mode)
pat.shapes[layer].append(path)
elif isinstance(element, klamath.elements.Text):
label = Label(
offset=element.xy.astype(float),
pat.label(
layer=element.layer,
offset=element.xy.astype(float),
string=element.string.decode('ASCII'),
annotations=_properties_to_annotations(element.properties),
)
pat.labels.append(label)
elif isinstance(element, klamath.elements.Reference):
pat.refs.append(_gref_to_mref(element))
target, ref = _gref_to_mref(element)
pat.refs[target].append(ref)
return pat
@ -287,7 +288,7 @@ def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]:
return layer, data_type
def _gref_to_mref(ref: klamath.library.Reference) -> Ref:
def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
"""
Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name.
"""
@ -301,19 +302,19 @@ def _gref_to_mref(ref: klamath.library.Reference) -> Ref:
repetition = Grid(a_vector=a_vector, b_vector=b_vector,
a_count=a_count, b_count=b_count)
target = ref.struct_name.decode('ASCII')
mref = Ref(
target=ref.struct_name.decode('ASCII'),
offset=offset,
rotation=numpy.deg2rad(ref.angle_deg),
scale=ref.mag,
mirrored=(ref.invert_y, False),
mirrored=ref.invert_y,
annotations=_properties_to_annotations(ref.properties),
repetition=repetition,
)
return mref
return target, mref
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path:
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]
else:
@ -321,7 +322,6 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path:
mpath = Path(
vertices=gpath.xy.astype(float),
layer=gpath.layer,
width=gpath.width,
cap=cap,
offset=numpy.zeros(2),
@ -330,36 +330,34 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path:
)
if cap == Path.Cap.SquareCustom:
mpath.cap_extensions = gpath.extension
return mpath
return gpath.layer, mpath
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> Polygon:
return Polygon(
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),
layer=boundary.layer,
offset=numpy.zeros(2),
annotations=_properties_to_annotations(boundary.properties),
raw=raw_mode,
)
def _mrefs_to_grefs(refs: list[Ref]) -> list[klamath.library.Reference]:
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
grefs = []
for ref in refs:
if ref.target is None:
for target, rseq in refs.items():
if target is None:
continue
encoded_name = ref.target.encode('ASCII')
# Note: GDS mirrors first and rotates second
mirror_across_x, extra_angle = normalize_mirror(ref.mirrored)
encoded_name = target.encode('ASCII')
for ref in rseq:
# Note: GDS also mirrors first and rotates second
rep = ref.repetition
angle_deg = numpy.rad2deg(ref.rotation + extra_angle) % 360
angle_deg = numpy.rad2deg(ref.rotation) % 360
properties = _annotations_to_properties(ref.annotations, 512)
if isinstance(rep, Grid):
b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
b_count = rep.b_count if rep.b_count is not None else 1
xy = numpy.array(ref.offset) + numpy.array([
xy = numpy.asarray(ref.offset) + numpy.array([
[0.0, 0.0],
rep.a_vector * rep.a_count,
b_vector * b_count,
@ -369,7 +367,7 @@ def _mrefs_to_grefs(refs: list[Ref]) -> list[klamath.library.Reference]:
xy=rint_cast(xy),
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
angle_deg=angle_deg,
invert_y=mirror_across_x,
invert_y=ref.mirrored,
mag=ref.scale,
properties=properties,
)
@ -380,7 +378,7 @@ def _mrefs_to_grefs(refs: list[Ref]) -> list[klamath.library.Reference]:
xy=rint_cast([ref.offset]),
colrow=None,
angle_deg=angle_deg,
invert_y=mirror_across_x,
invert_y=ref.mirrored,
mag=ref.scale,
properties=properties,
)
@ -392,7 +390,7 @@ def _mrefs_to_grefs(refs: list[Ref]) -> list[klamath.library.Reference]:
xy=rint_cast([ref.offset + dd]),
colrow=None,
angle_deg=angle_deg,
invert_y=mirror_across_x,
invert_y=ref.mirrored,
mag=ref.scale,
properties=properties,
)
@ -411,8 +409,8 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
for key, vals in annotations.items():
try:
i = int(key)
except ValueError:
raise PatternError(f'Annotation key {key} is not convertable to an integer')
except ValueError as err:
raise PatternError(f'Annotation key {key} is not convertable to an integer') from err
if not (0 < i < 126):
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])')
@ -428,17 +426,18 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
def _shapes_to_elements(
shapes: list[Shape],
shapes: dict[layer_t, list[Shape]],
polygonize_paths: bool = False,
) -> list[klamath.elements.Element]:
elements: list[klamath.elements.Element] = []
# Add a Boundary element for each shape, and Path elements if necessary
for shape in shapes:
for mlayer, sseq in shapes.items():
layer, data_type = _mlayer2gds(mlayer)
for shape in sseq:
if shape.repetition is not None:
raise PatternError('Shape repetitions are not supported by GDS.'
' Please call library.wrap_repeated_shapes() before writing to file.')
layer, data_type = _mlayer2gds(shape.layer)
properties = _annotations_to_properties(shape.annotations, 128)
if isinstance(shape, Path) and not polygonize_paths:
xy = rint_cast(shape.vertices + shape.offset)
@ -485,23 +484,24 @@ def _shapes_to_elements(
return elements
def _labels_to_texts(labels: list[Label]) -> list[klamath.elements.Text]:
def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]:
texts = []
for label in labels:
for mlayer, lseq in labels.items():
layer, text_type = _mlayer2gds(mlayer)
for label in lseq:
properties = _annotations_to_properties(label.annotations, 128)
layer, text_type = _mlayer2gds(label.layer)
xy = rint_cast([label.offset])
text = klamath.elements.Text(
layer=(layer, text_type),
xy=xy,
string=label.string.encode('ASCII'),
properties=properties,
presentation=0, # TODO maybe set some of these?
angle_deg=0,
invert_y=False,
width=0,
path_type=0,
mag=1,
presentation=0, # font number & alignment -- unused by us
angle_deg=0, # rotation -- unused by us
invert_y=False, # inversion -- unused by us
width=0, # stroke width -- unused by us
path_type=0, # text path endcaps, unused
mag=1, # size -- unused by us
)
texts.append(text)
return texts
@ -597,19 +597,19 @@ def load_libraryfile(
path = pathlib.Path(filename)
stream: IO[bytes]
if is_gzipped(path):
if mmap:
if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
gz_stream = gzip.open(path, mode='rb')
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BytesIO(gz_stream.read()) # type: ignore
else:
gz_stream = gzip.open(path, mode='rb')
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BufferedReader(gz_stream) # type: ignore
else:
if mmap:
base_stream = open(path, mode='rb', buffering=0)
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 = open(path, mode='rb')
stream = path.open(mode='rb') # noqa: SIM115
return load_library(stream, full_load=full_load, postprocess=postprocess)

View File

@ -14,7 +14,8 @@ Note that OASIS references follow the same convention as `masque`,
Notes:
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
"""
from typing import Any, Callable, Iterable, IO, Mapping, cast, Sequence
from typing import Any, IO, cast
from collections.abc import Sequence, Iterable, Mapping, Callable
import logging
import pathlib
import gzip
@ -30,9 +31,9 @@ from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringR
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..library import Library, ILibrary
from ..shapes import Polygon, Path, Circle
from ..shapes import Path, Circle
from ..repetition import Grid, Arbitrary, Repetition
from ..utils import layer_t, normalize_mirror, annotations_t
from ..utils import layer_t, annotations_t
logger = logging.getLogger(__name__)
@ -284,23 +285,20 @@ def read(
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list()[:-1])), axis=0)
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
poly = Polygon(
pat.polygon(
vertices=vertices,
layer=element.get_layer_tuple(),
offset=element.get_xy(),
annotations=annotations,
repetition=repetition,
)
pat.shapes.append(poly)
elif isinstance(element, fatrec.Path):
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
cap_start = path_cap_map[element.get_extension_start()[0]]
cap_end = path_cap_map[element.get_extension_end()[0]]
if cap_start != cap_end:
raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
raise PatternError('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
cap = cap_start
path_args: dict[str, Any] = {}
@ -311,7 +309,7 @@ def read(
))
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
path = Path(
pat.path(
vertices=vertices,
layer=element.get_layer_tuple(),
offset=element.get_xy(),
@ -322,20 +320,17 @@ def read(
**path_args,
)
pat.shapes.append(path)
elif isinstance(element, fatrec.Rectangle):
width = element.get_width()
height = element.get_height()
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
rect = Polygon(
pat.polygon(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
annotations=annotations,
)
pat.shapes.append(rect)
elif isinstance(element, fatrec.Trapezoid):
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (element.get_width(), element.get_height())
@ -363,14 +358,13 @@ def read(
vertices[2, 0] -= b
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
trapz = Polygon(
pat.polygon(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
vertices=vertices,
annotations=annotations,
)
pat.shapes.append(trapz)
elif isinstance(element, fatrec.CTrapezoid):
cttype = element.get_ctrapezoid_type()
@ -419,25 +413,24 @@ def read(
vertices[0, 1] += width
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
ctrapz = Polygon(
pat.polygon(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
vertices=vertices,
annotations=annotations,
)
pat.shapes.append(ctrapz)
elif isinstance(element, fatrec.Circle):
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
layer = element.get_layer_tuple()
circle = Circle(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
annotations=annotations,
radius=float(element.get_radius()),
)
pat.shapes.append(circle)
pat.shapes[layer].append(circle)
elif isinstance(element, fatrec.Text):
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
@ -446,21 +439,23 @@ def read(
string = lib.textstrings[str_or_ref].string
else:
string = str_or_ref.string
label = Label(
pat.label(
layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
annotations=annotations,
string=string,
)
pat.labels.append(label)
else:
logger.warning(f'Skipping record {element} (unimplemented)')
continue
for placement in cell.placements:
pat.refs.append(_placement_to_ref(placement, lib))
target, ref = _placement_to_ref(placement, lib)
if isinstance(target, int):
target = lib.cellnames[target].nstring.string
pat.refs[target].append(ref)
mlib[cell_name] = pat
@ -484,9 +479,9 @@ def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]:
return layer, data_type
def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> Ref:
def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> tuple[int | str, Ref]:
"""
Helper function to create a Ref from a placment. Sets ref.target to the placement name.
Helper function to create a Ref from a placment. Also returns the placement name (or id).
"""
assert not isinstance(placement.repetition, fatamorgana.ReuseRepetition)
xy = numpy.array((placement.x, placement.y))
@ -501,34 +496,32 @@ def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout)
else:
rotation = numpy.deg2rad(float(placement.angle))
ref = Ref(
target=name,
offset=xy,
mirrored=(placement.flip, False),
mirrored=placement.flip,
rotation=rotation,
scale=float(mag),
repetition=repetition_fata2masq(placement.repetition),
annotations=annotations,
)
return ref
return name, ref
def _refs_to_placements(
refs: list[Ref],
refs: dict[str | None, list[Ref]],
) -> list[fatrec.Placement]:
placements = []
for ref in refs:
if ref.target is None:
for target, rseq in refs.items():
if target is None:
continue
# Note: OASIS mirrors first and rotates second
mirror_across_x, extra_angle = normalize_mirror(ref.mirrored)
for ref in rseq:
# Note: OASIS also mirrors first and rotates second
frep, rep_offset = repetition_masq2fata(ref.repetition)
offset = rint_cast(ref.offset + rep_offset)
angle = numpy.rad2deg(ref.rotation + extra_angle) % 360
angle = numpy.rad2deg(ref.rotation) % 360
placement = fatrec.Placement(
name=ref.target,
flip=mirror_across_x,
name=target,
flip=ref.mirrored,
angle=angle,
magnification=ref.scale,
properties=annotations_to_properties(ref.annotations),
@ -542,13 +535,14 @@ def _refs_to_placements(
def _shapes_to_elements(
shapes: list[Shape],
shapes: dict[layer_t, list[Shape]],
layer2oas: Callable[[layer_t], tuple[int, int]],
) -> list[fatrec.Polygon | fatrec.Path | fatrec.Circle]:
# Add a Polygon record for each shape, and Path elements if necessary
elements: list[fatrec.Polygon | fatrec.Path | fatrec.Circle] = []
for shape in shapes:
layer, datatype = layer2oas(shape.layer)
for mlayer, sseq in shapes.items():
layer, datatype = layer2oas(mlayer)
for shape in sseq:
repetition, rep_offset = repetition_masq2fata(shape.repetition)
properties = annotations_to_properties(shape.annotations)
if isinstance(shape, Circle):
@ -601,12 +595,13 @@ def _shapes_to_elements(
def _labels_to_texts(
labels: list[Label],
labels: dict[layer_t, list[Label]],
layer2oas: Callable[[layer_t], tuple[int, int]],
) -> list[fatrec.Text]:
texts = []
for label in labels:
layer, datatype = layer2oas(label.layer)
for mlayer, lseq in labels.items():
layer, datatype = layer2oas(mlayer)
for label in lseq:
repetition, rep_offset = repetition_masq2fata(label.repetition)
xy = rint_cast(label.offset + rep_offset)
properties = annotations_to_properties(label.annotations)
@ -700,9 +695,9 @@ def properties_to_annotations(
assert proprec.values is not None
for value in proprec.values:
if isinstance(value, (float, int)):
if isinstance(value, float | int):
values.append(value)
elif isinstance(value, (NString, AString)):
elif isinstance(value, NString | AString):
values.append(value.string)
elif isinstance(value, PropStringReference):
values.append(propstrings[value.ref].string) # dereference

View File

@ -1,7 +1,7 @@
"""
SVG file format readers and writers
"""
from typing import Mapping
from collections.abc import Mapping
import warnings
import numpy
@ -50,7 +50,7 @@ def writefile(
bounds = pattern.get_bounds(library=library)
if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox')
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
else:
bounds_min, bounds_max = bounds
@ -65,21 +65,23 @@ def writefile(
for name, pat in library.items():
svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red')
for shape in pat.shapes:
for layer, shapes in pat.shapes.items():
for shape in shapes:
for polygon in shape.to_polygons():
path_spec = poly2path(polygon.vertices + polygon.offset)
path = svg.path(d=path_spec)
if custom_attributes:
path['pattern_layer'] = polygon.layer
path['pattern_layer'] = layer
svg_group.add(path)
for ref in pat.refs:
if ref.target is None:
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(ref.target), transform=transform)
use = svg.use(href='#' + mangle_name(target), transform=transform)
svg_group.add(use)
svg.defs.add(svg_group)
@ -115,7 +117,7 @@ def writefile_inverted(
bounds = pattern.get_bounds(library=library)
if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox')
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
else:
bounds_min, bounds_max = bounds
@ -133,7 +135,8 @@ def writefile_inverted(
path_spec = poly2path(slab_edge)
# Draw polygons with reversed vertex order
for shape in pattern.shapes:
for _layer, shapes in pattern.shapes.items():
for shape in shapes:
for polygon in shape.to_polygons():
path_spec += poly2path(polygon.vertices[::-1] + polygon.offset)
@ -151,9 +154,9 @@ def poly2path(vertices: ArrayLike) -> str:
Returns:
SVG path-string.
"""
verts = numpy.array(vertices, copy=False)
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1])
verts = numpy.asarray(vertices)
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032
for vertex in verts[1:]:
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1])
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032
commands += ' Z '
return commands

View File

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

View File

@ -1,21 +1,26 @@
from typing import Self
from typing import Self, Any
import copy
import functools
import numpy
from numpy.typing import ArrayLike, NDArray
from .repetition import Repetition
from .utils import rotation_matrix_2d, layer_t, AutoSlots, annotations_t
from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, RepeatableImpl
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
from .traits import AnnotatableImpl
class Label(PositionableImpl, LayerableImpl, RepeatableImpl, AnnotatableImpl,
Pivotable, Copyable, metaclass=AutoSlots):
@functools.total_ordering
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
"""
A text annotation with a position and layer (but no size; it is not drawn)
A text annotation with a position (but no size; it is not drawn)
"""
__slots__ = ( '_string', )
__slots__ = (
'_string',
# Inherited
'_offset', '_repetition', '_annotations',
)
_string: str
""" Label string """
@ -40,13 +45,11 @@ class Label(PositionableImpl, LayerableImpl, RepeatableImpl, AnnotatableImpl,
string: str,
*,
offset: ArrayLike = (0.0, 0.0),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
) -> None:
self.string = string
self.offset = numpy.array(offset, dtype=float, copy=True)
self.layer = layer
self.offset = numpy.array(offset, dtype=float)
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
@ -54,7 +57,6 @@ class Label(PositionableImpl, LayerableImpl, RepeatableImpl, AnnotatableImpl,
return type(self)(
string=self.string,
offset=self.offset.copy(),
layer=self.layer,
repetition=self.repetition,
)
@ -64,6 +66,23 @@ class Label(PositionableImpl, LayerableImpl, RepeatableImpl, AnnotatableImpl,
new._offset = self._offset.copy()
return new
def __lt__(self, other: 'Label') -> bool:
if self.string != other.string:
return self.string < other.string
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def __eq__(self, other: Any) -> bool:
return (
self.string == other.string
and numpy.array_equal(self.offset, other.offset)
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
"""
Rotate the label around a point.
@ -75,13 +94,13 @@ class Label(PositionableImpl, LayerableImpl, RepeatableImpl, AnnotatableImpl,
Returns:
self
"""
pivot = numpy.array(pivot, dtype=float)
pivot = numpy.asarray(pivot, dtype=float)
self.translate(-pivot)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
self.translate(+pivot)
return self
def get_bounds(self) -> NDArray[numpy.float64]:
def get_bounds_single(self) -> NDArray[numpy.float64]:
"""
Return the bounds of the label.

View File

@ -1,29 +1,38 @@
"""
Library classes for managing unique name->pattern mappings and
deferred loading or creation.
Library classes for managing unique name->pattern mappings and deferred loading or execution.
# TODO documentn all library classes
# TODO toplevel documentation of library, classes, and abstracts
Classes include:
- `ILibraryView`: Defines a general interface for read-only name->pattern mappings.
- `LibraryView`: An implementation of `ILibraryView` backed by an arbitrary `Mapping`.
Can be used to wrap any arbitrary `Mapping` to give it all the functionality in `ILibraryView`
- `ILibrary`: Defines a general interface for mutable name->pattern mappings.
- `Library`: An implementation of `ILibrary` backed by an arbitrary `MutableMapping`.
Can be used to wrap any arbitrary `MutableMapping` to give it all the functionality in `ILibrary`.
By default, uses a `dict` as the underylingmapping.
- `LazyLibrary`: An implementation of `ILibrary` which enables on-demand loading or generation
of patterns.
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
library. Generated with `ILibraryView.abstract_view()`.
"""
from typing import Callable, Self, Type, TYPE_CHECKING, cast
from typing import Iterator, Mapping, MutableMapping, Sequence
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
import logging
import base64
import struct
import re
import copy
from pprint import pformat
from collections import defaultdict
from abc import ABCMeta, abstractmethod
from graphlib import TopologicalSorter
import numpy
from numpy.typing import ArrayLike
from numpy.typing import ArrayLike, NDArray
from .error import LibraryError, PatternError
from .utils import rotation_matrix_2d, normalize_mirror
from .utils import layer_t, apply_transforms
from .shapes import Shape, Polygon
from .label import Label
from .abstract import Abstract
from .pattern import map_layers
if TYPE_CHECKING:
from .pattern import Pattern
@ -32,19 +41,64 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
visitor_function_t = Callable[..., 'Pattern']
class visitor_function_t(Protocol):
""" Signature for `Library.dfs()` visitor functions. """
def __call__(
self,
pattern: 'Pattern',
hierarchy: tuple[str | None, ...],
memo: dict,
transform: NDArray[numpy.float64] | Literal[False],
) -> 'Pattern':
...
TreeView: TypeAlias = Mapping[str, 'Pattern']
""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """
Tree: TypeAlias = MutableMapping[str, 'Pattern']
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
SINGLE_USE_PREFIX = '_'
"""
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?
def _rename_patterns(lib: 'ILibraryView', name: str) -> str:
# TODO document rename function
if not name.startswith('_'): # TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
"""
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.split('$')[0]
return lib.get_name(stem)
stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
return lib.get_name(SINGLE_USE_PREFIX + stem)
class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
"""
Interface for a read-only library.
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
"""
# inherited abstract functions
#def __getitem__(self, key: str) -> 'Pattern':
#def __iter__(self) -> Iterator[str]:
@ -52,7 +106,14 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
#__contains__, keys, items, values, get, __eq__, __ne__ supplied by Mapping
def __repr__(self) -> str:
return '<ILibraryView with keys\n' + pformat(list(self.keys())) + '>'
def abstract_view(self) -> 'AbstractView':
"""
Returns:
An AbstractView into this library
"""
return AbstractView(self)
def abstract(self, name: str) -> Abstract:
@ -67,9 +128,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
"""
return Abstract(name=name, ports=self[name].ports)
def __repr__(self) -> str:
return '<ILibraryView with keys\n' + pformat(list(self.keys())) + '>'
def dangling_refs(
self,
tops: str | Sequence[str] | None = None,
@ -116,7 +174,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
tops = tuple(self.keys())
if skip is None:
skip = set([None])
skip = {None}
if isinstance(tops, str):
tops = (tops,)
@ -153,7 +211,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str):
tops = (tops,)
keep = cast(set[str], self.referenced_patterns(tops) - set((None,)))
keep = cast(set[str], self.referenced_patterns(tops) - {None})
keep |= set(tops)
filtered = {kk: vv for kk, vv in self.items() if kk in keep}
@ -205,14 +263,19 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def flatten(
self,
tops: str | Sequence[str],
flatten_ports: bool = False, # TODO document
flatten_ports: bool = False,
) -> dict[str, 'Pattern']:
"""
Removes all refs and adds equivalent shapes.
Also flattens all referenced patterns.
Returns copies of all `tops` patterns with all refs
removed and replaced with equivalent shapes.
Also returns flattened copies of all referenced patterns.
The originals in the calling `Library` are not modified.
For an in-place variant, see `Pattern.flatten`.
Args:
tops: The pattern(s) to flattern.
flatten_ports: If `True`, keep ports from any referenced
patterns; otherwise discard them.
Returns:
{name: flat_pattern} mapping for all flattened patterns.
@ -220,17 +283,15 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str):
tops = (tops,)
flattened: dict[str, 'Pattern | None'] = {}
flattened: dict[str, Pattern | None] = {}
def flatten_single(name) -> None:
def flatten_single(name: str) -> None:
flattened[name] = None
pat = self[name].deepcopy()
for ref in pat.refs:
target = ref.target
for target in pat.refs:
if target is None:
continue
if target not in flattened:
flatten_single(target)
@ -240,7 +301,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if target_pat.is_empty(): # avoid some extra allocations
continue
p = ref.as_pattern(pattern=flattened[target])
for ref in pat.refs[target]:
p = ref.as_pattern(pattern=target_pat)
if not flatten_ports:
p.ports.clear()
pat.append(p)
@ -256,7 +318,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def get_name(
self,
name: str = '__',
name: str = SINGLE_USE_PREFIX * 2,
sanitize: bool = True,
max_length: int = 32,
quiet: bool | None = None,
@ -267,17 +329,17 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
This function may be overridden in a subclass or monkey-patched to fit the caller's requirements.
Args:
name: Preferred name for the pattern. Default '__'.
name: Preferred name for the pattern. Default is `SINGLE_USE_PREFIX * 2`.
sanitize: Allows only alphanumeric charaters and _?$. Replaces invalid characters with underscores.
max_length: Names longer than this will be truncated.
quiet: If `True`, suppress log messages. Default `None` suppresses messages only if
the name starts with an underscore.
the name starts with `SINGLE_USE_PREFIX`.
Returns:
Name, unique within this library.
"""
if quiet is None:
quiet = name.startswith('_')
quiet = name.startswith(SINGLE_USE_PREFIX)
if sanitize:
# Remove invalid characters
@ -285,12 +347,13 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
else:
sanitized_name = name
ii = 0
suffixed_name = sanitized_name
if sanitized_name in self:
ii = sum(1 for nn in self.keys() if nn.startswith(sanitized_name))
else:
ii = 0
while suffixed_name in self or suffixed_name == '':
suffix = base64.b64encode(struct.pack('>Q', ii), b'$?').decode('ASCII')
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
suffixed_name = sanitized_name + b64suffix(ii)
ii += 1
if len(suffixed_name) > max_length:
@ -316,7 +379,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
names = set(self.keys())
not_toplevel: set[str | None] = set()
for name in names:
not_toplevel |= set(sp.target for sp in self[name].refs)
not_toplevel |= set(self[name].refs.keys())
toplevel = list(names - not_toplevel)
return toplevel
@ -324,6 +387,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def top(self) -> str:
"""
Return the name of the topcell, or raise an exception if there isn't a single topcell
Raises:
LibraryError if there is not exactly one topcell.
"""
tops = self.tops()
if len(tops) != 1:
@ -333,6 +399,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def top_pattern(self) -> 'Pattern':
"""
Shorthand for self[self.top()]
Raises:
LibraryError if there is not exactly one topcell.
"""
return self[self.top()]
@ -352,8 +421,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
At each pattern in the tree, the following sequence is called:
```
current_pattern = visit_before(current_pattern, **vist_args)
for sp in current_pattern.refs]
self.dfs(sp.target, visit_before, visit_after,
for target in current_pattern.refs:
for ref in pattern.refs[target]:
self.dfs(target, visit_before, visit_after,
hierarchy + (sp.target,), updated_transform, memo)
current_pattern = visit_after(current_pattern, **visit_args)
```
@ -391,36 +461,32 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if transform is None or transform is True:
transform = numpy.zeros(4)
elif transform is not False:
transform = numpy.array(transform)
transform = numpy.asarray(transform, dtype=float)
original_pattern = pattern
if visit_before is not None:
pattern = visit_before(pattern, hierarchy=hierarchy, memo=memo, transform=transform)
for ref in pattern.refs:
if transform is not False:
sign = numpy.ones(2)
if transform[3]:
sign[1] = -1
xy = numpy.dot(rotation_matrix_2d(transform[2]), ref.offset * sign)
mirror_x, angle = normalize_mirror(ref.mirrored)
angle += ref.rotation
ref_transform = transform + (xy[0], xy[1], angle, mirror_x)
ref_transform[3] %= 2
else:
ref_transform = False
if ref.target is None:
for target in pattern.refs:
if target is None:
continue
if ref.target in hierarchy:
raise LibraryError(f'.dfs() called on pattern with circular reference to "{ref.target}"')
if target in hierarchy:
raise LibraryError(f'.dfs() called on pattern with circular reference to "{target}"')
for ref in pattern.refs[target]:
ref_transforms: list[bool] | NDArray[numpy.float64]
if transform is not False:
ref_transforms = apply_transforms(transform, ref.as_transforms())
else:
ref_transforms = [False]
for ref_transform in ref_transforms:
self.dfs(
pattern=self[ref.target],
pattern=self[target],
visit_before=visit_before,
visit_after=visit_after,
hierarchy=hierarchy + (ref.target,),
hierarchy=hierarchy + (target,),
transform=ref_transform,
memo=memo,
)
@ -429,11 +495,12 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
pattern = visit_after(pattern, hierarchy=hierarchy, memo=memo, transform=transform)
if pattern is not original_pattern:
name = hierarchy[-1] # TODO what is name=None?
name = hierarchy[-1]
if not isinstance(self, ILibrary):
raise LibraryError('visit_* functions returned a new `Pattern` object'
' but the library is immutable')
if name is None:
# The top pattern is not the original pattern, but we don't know what to call it!
raise LibraryError('visit_* functions returned a new `Pattern` object'
' but no top-level name was provided in `hierarchy`')
@ -441,8 +508,150 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
return self
def child_graph(self) -> dict[str, set[str | None]]:
"""
Return a mapping from pattern name to a set of all child patterns
(patterns it references).
Returns:
Mapping from pattern name to a set of all pattern names it references.
"""
graph = {name: set(pat.refs.keys()) for name, pat in self.items()}
return graph
def parent_graph(self) -> dict[str, set[str]]:
"""
Return a mapping from pattern name to a set of all parent patterns
(patterns which reference it).
Returns:
Mapping from pattern name to a set of all patterns which reference it.
"""
igraph: dict[str, set[str]] = {name: set() for name in self}
for name, pat in self.items():
for child, reflist in pat.refs.items():
if reflist and child is not None:
igraph[child].add(name)
return igraph
def child_order(self) -> list[str]:
"""
Return a topologically sorted list of all contained pattern names.
Child (referenced) patterns will appear before their parents.
Return:
Topologically sorted list of pattern names.
"""
return list(TopologicalSorter(self.child_graph()).static_order())
def find_refs_local(
self,
name: str,
parent_graph: dict[str, set[str]] | None = None,
) -> dict[str, list[NDArray[numpy.float64]]]:
"""
Find the location and orientation of all refs pointing to `name`.
Refs with a `repetition` are resolved into multiple instances (locations).
Args:
name: Name of the referenced pattern.
parent_graph: Mapping from pattern name to the set of patterns which
reference it. Default (`None`) calls `self.parent_graph()`.
The provided graph may be for a superset of `self` (i.e. it may
contain additional patterns which are not present in self; they
will be ignored).
Returns:
Mapping of {parent_name: transform_list}, where transform_list
is an Nx4 ndarray with rows
`(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
"""
instances = defaultdict(list)
if parent_graph is None:
parent_graph = self.parent_graph()
for parent in parent_graph[name]:
if parent not in self: # parent_graph may be a for a superset of self
continue
for ref in self[parent].refs[name]:
instances[parent].append(ref.as_transforms())
return instances
def find_refs_global(
self,
name: str,
order: list[str] | None = None,
parent_graph: dict[str, set[str]] | None = None,
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
"""
Find the absolute (top-level) location and orientation of all refs (including
repetitions) pointing to `name`.
Args:
name: Name of the referenced pattern.
order: List of pattern names in which children are guaranteed
to appear before their parents (i.e. topologically sorted).
Default (`None`) calls `self.child_order()`.
parent_graph: Passed to `find_refs_local`.
Mapping from pattern name to the set of patterns which
reference it. Default (`None`) calls `self.parent_graph()`.
The provided graph may be for a superset of `self` (i.e. it may
contain additional patterns which are not present in self; they
will be ignored).
Returns:
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
"""
if name not in self:
return {}
if order is None:
order = self.child_order()
if parent_graph is None:
parent_graph = self.parent_graph()
self_keys = set(self.keys())
transforms: dict[str, list[tuple[
tuple[str, ...],
NDArray[numpy.float64]
]]]
transforms = defaultdict(list)
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items():
transforms[parent] = [((name,), numpy.concatenate(vals))]
for next_name in order:
if next_name not in transforms:
continue
if not parent_graph[next_name] & self_keys:
continue
outers = self.find_refs_local(next_name, parent_graph=parent_graph)
inners = transforms.pop(next_name)
for parent, outer in outers.items():
for path, inner in inners:
combined = apply_transforms(numpy.concatenate(outer), inner)
transforms[parent].append((
(next_name,) + path,
combined,
))
result = {}
for parent, targets in transforms.items():
for path, instances in targets:
full_path = (parent,) + path
assert full_path not in result
result[full_path] = instances
return result
class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
"""
Interface for a writeable library.
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
"""
# inherited abstract functions
#def __getitem__(self, key: str) -> 'Pattern':
#def __iter__(self) -> Iterator[str]:
@ -478,7 +687,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Args:
old_name: Current name for the pattern
new_name: New name for the pattern
#TODO move_Reference
move_references: If `True`, any refs in this library pointing to `old_name`
will be updated to point to `new_name`.
Returns:
self
@ -508,9 +718,31 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
self
"""
for pattern in self.values():
for ref in pattern.refs:
if ref.target == old_target:
ref.target = new_target
if old_target in pattern.refs:
pattern.refs[new_target].extend(pattern.refs[old_target])
del pattern.refs[old_target]
return self
def map_layers(
self,
map_layer: Callable[[layer_t], layer_t],
) -> Self:
"""
Move all the elements in all patterns from one layer onto a different layer.
Can also handle multiple such mappings simultaneously.
Args:
map_layer: Callable which may be called with each layer present in `elements`,
and should return the new layer to which it will be mapped.
A simple example which maps `old_layer` to `new_layer` and leaves all others
as-is would look like `lambda layer: {old_layer: new_layer}.get(layer, layer)`
Returns:
self
"""
for pattern in self.values():
pattern.shapes = map_layers(pattern.shapes, map_layer)
pattern.labels = map_layers(pattern.labels, map_layer)
return self
def mkpat(self, name: str) -> tuple[str, 'Pattern']:
@ -533,30 +765,52 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
self,
other: Mapping[str, 'Pattern'],
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
mutate_other: bool = False,
) -> dict[str, str]:
"""
Add keys from another library into this one.
Add items from another library into this one.
# TODO explain reference renaming and return
If any name in `other` is already present in `self`, `rename_theirs(self, name)` is called
to pick a new name for the newly-added pattern. If the new name still conflicts with a name
in `self` a `LibraryError` is raised. All references to the original name (within `other)`
are updated to the new name.
If `mutate_other=False` (default), all changes are made to a deepcopy of `other`.
By default, `rename_theirs` makes no changes to the name (causing a `LibraryError`) unless the
name starts with `SINGLE_USE_PREFIX`. Prefixed names are truncated to before their first
non-prefix '$' and then passed to `self.get_name()` to create a new unique name.
Args:
other: The library to insert keys from
other: The library to insert keys from.
rename_theirs: Called as rename_theirs(self, name) for each duplicate name
encountered in `other`. Should return the new name for the pattern in
`other`.
Default is effectively
`name.split('$')[0] if name.startswith('_') else name`
`other`. See above for default behavior.
mutate_other: If `True`, modify the original library and its contained patterns
(e.g. when renaming patterns and updating refs). Otherwise, operate on a deepcopy
(default).
Returns:
self
A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged
names map to themselves.
Raises:
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
"""
from .pattern import map_targets
duplicates = set(self.keys()) & set(other.keys())
if not duplicates:
for key in other.keys():
for key in other:
self._merge(key, other, key)
return {}
temp = Library(copy.deepcopy(dict(other))) # TODO maybe add a `mutate` arg? Might want to keep the same patterns
if mutate_other:
if isinstance(other, Library):
temp = other
else:
temp = Library(dict(other))
else:
temp = Library(copy.deepcopy(dict(other)))
rename_map = {}
for old_name in temp:
if old_name in self:
@ -572,12 +826,20 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# Update references in the newly-added cells
for old_name in temp:
new_name = rename_map.get(old_name, old_name)
for ref in self[new_name].refs:
ref.target = rename_map.get(cast(str, ref.target), ref.target)
pat = self[new_name]
pat.refs = map_targets(pat.refs, lambda tt: cast(dict[str | None, str | None], rename_map).get(tt, tt))
return rename_map
def __lshift__(self, other: Mapping[str, 'Pattern']) -> str:
def __lshift__(self, other: TreeView) -> str:
"""
`add()` items from a tree (single-topcell name: pattern mapping) into this one,
and return the name of the tree's topcell (in this library; it may have changed
based on `add()`'s default `rename_theirs` argument).
Raises:
LibraryError if there is more than one topcell in `other`.
"""
if len(other) == 1:
name = next(iter(other))
else:
@ -591,12 +853,24 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
name = tops[0]
rename_map = self.add(other)
return rename_map.get(name, name)
new_name = rename_map.get(name, name)
return new_name
def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract:
"""
Perform the same operation as `__lshift__` / `<<`, but return an `Abstract` instead
of just the pattern's name.
Raises:
LibraryError if there is more than one topcell in `other`.
"""
new_name = self << other
return self.abstract(new_name)
def dedup(
self,
norm_value: int = int(1e6),
exclude_types: tuple[Type] = (Polygon,),
exclude_types: tuple[type] = (Polygon,),
label2name: Callable[[tuple], str] | None = None,
threshold: int = 2,
) -> Self:
@ -617,7 +891,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
exclude_types: Shape types passed in this argument are always left untouched, for
speed or convenience. Default: `(shapes.Polygon,)`
label2name: Given a label tuple as returned by `shape.normalized_form(...)`, pick
a name for the generated pattern. Default `self.get_name('_shape')`.
a name for the generated pattern.
Default `self.get_name(SINGLE_USE_PREIX + 'shape')`.
threshold: Only replace shapes with refs if there will be at least this many
instances.
@ -633,9 +908,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
exclude_types = ()
if label2name is None:
def label2name(label):
return self.get_name('_shape')
#label2name = lambda label: self.get_name('_shape')
def label2name(label: tuple) -> str: # noqa: ARG001
return self.get_name(SINGLE_USE_PREFIX + 'shape')
shape_counts: MutableMapping[tuple, int] = defaultdict(int)
shape_funcs = {}
@ -644,9 +918,11 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# Using the label tuple from `.normalized_form()` as a key, check how many of each shape
# are present and store the shape function for each one
for pat in tuple(self.values()):
for i, shape in enumerate(pat.shapes):
for layer, sseq in pat.shapes.items():
for shape in sseq:
if not any(isinstance(shape, t) for t in exclude_types):
label, _values, func = shape.normalized_form(norm_value)
base_label, _values, func = shape.normalized_form(norm_value)
label = (*base_label, layer)
shape_funcs[label] = func
shape_counts[label] += 1
@ -656,7 +932,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
continue
shape_func = shape_funcs[label]
shape_pat = Pattern(shapes=[shape_func()])
shape_pat = Pattern()
shape_pat.shapes[label[-1]] += [shape_func()]
shape_pats[label] = shape_pat
# ## Second pass ##
@ -665,12 +942,14 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# are to be replaced.
# The `values` are `(offset, scale, rotation)`.
shape_table: MutableMapping[tuple, list] = defaultdict(list)
for i, shape in enumerate(pat.shapes):
shape_table: dict[tuple, list] = defaultdict(list)
for layer, sseq in pat.shapes.items():
for i, shape in enumerate(sseq):
if any(isinstance(shape, t) for t in exclude_types):
continue
label, values, _func = shape.normalized_form(norm_value)
base_label, values, _func = shape.normalized_form(norm_value)
label = (*base_label, layer)
if label not in shape_pats:
continue
@ -682,16 +961,17 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# we should delete the `pat.shapes` entries for which we made `Ref`s.
shapes_to_remove = []
for label in shape_table:
layer = label[-1]
target = label2name(label)
for i, values in shape_table[label]:
for ii, values in shape_table[label]:
offset, scale, rotation, mirror_x = values
pat.ref(target=target, offset=offset, scale=scale,
rotation=rotation, mirrored=(mirror_x, False))
shapes_to_remove.append(i)
shapes_to_remove.append(ii)
# Remove any shapes for which we have created refs.
for i in sorted(shapes_to_remove, reverse=True):
del pat.shapes[i]
for ii in sorted(shapes_to_remove, reverse=True):
del pat.shapes[layer][ii]
for ll, pp in shape_pats.items():
self[label2name(ll)] = pp
@ -709,7 +989,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Args:
name_func: Function f(this_pattern, shape) which generates a name for the
wrapping pattern. Default is `self.get_name('_rep')`.
wrapping pattern.
Default is `self.get_name(SINGLE_USE_PREFIX + 'rep')`.
Returns:
self
@ -717,33 +998,34 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
from .pattern import Pattern
if name_func is None:
def name_func(_pat, _shape):
return self.get_name('_rep')
#name_func = lambda _pat, _shape: self.get_name('_rep')
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
return self.get_name(SINGLE_USE_PREFIX + 'rep')
for pat in tuple(self.values()):
for layer in pat.shapes:
new_shapes = []
for shape in pat.shapes:
for shape in pat.shapes[layer]:
if shape.repetition is None:
new_shapes.append(shape)
continue
name = name_func(pat, shape)
self[name] = Pattern(shapes=[shape])
self[name] = Pattern(shapes={layer: [shape]})
pat.ref(name, repetition=shape.repetition)
shape.repetition = None
pat.shapes = new_shapes
pat.shapes[layer] = new_shapes
for layer in pat.labels:
new_labels = []
for label in pat.labels:
for label in pat.labels[layer]:
if label.repetition is None:
new_labels.append(label)
continue
name = name_func(pat, label)
self[name] = Pattern(labels=[label])
self[name] = Pattern(labels={layer: [label]})
pat.ref(name, repetition=label.repetition)
label.repetition = None
pat.labels = new_labels
pat.labels[layer] = new_labels
return self
@ -765,7 +1047,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str):
tops = (tops,)
keep = cast(set[str], self.referenced_patterns(tops) - set((None,)))
keep = cast(set[str], self.referenced_patterns(tops) - {None})
keep |= set(tops)
new = type(self)()
@ -777,13 +1059,25 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
self,
repeat: bool = True,
) -> set[str]:
# TODO doc prune_empty
"""
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
Args:
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
Returns:
A set containing the names of all deleted patterns
"""
trimmed = set()
while empty := set(name for name, pat in self.items() if pat.is_empty()):
while empty := {name for name, pat in self.items() if pat.is_empty()}:
for name in empty:
del self[name]
for pat in self.values():
pat.refs = [ref for ref in pat.refs if ref.target not in empty]
for name in empty:
# Second pass to skip looking at refs in empty patterns
if name in pat.refs:
del pat.refs[name]
trimmed |= empty
if not repeat:
@ -795,15 +1089,28 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
key: str,
delete_refs: bool = True,
) -> Self:
# TODO doc delete()
"""
Delete a pattern and (optionally) all refs pointing to that pattern.
Args:
key: Name of the pattern to be deleted.
delete_refs: If `True` (default), also delete all refs pointing to the pattern.
"""
del self[key]
if delete_refs:
for pat in self.values():
pat.refs = [ref for ref in pat.refs if ref.target != key]
if key in pat.refs:
del pat.refs[key]
return self
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__(
@ -829,6 +1136,12 @@ class LibraryView(ILibraryView):
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__(
@ -860,10 +1173,7 @@ class Library(ILibrary):
if key in self.mapping:
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
if callable(value):
value = value()
else:
value = value
value = value() if callable(value) else value
self.mapping[key] = value
def __delitem__(self, key: str) -> None:
@ -876,9 +1186,15 @@ class Library(ILibrary):
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
@classmethod
def mktree(cls, name: str) -> tuple[Self, 'Pattern']:
def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']:
"""
Create a new Library and immediately add a pattern
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
tree = cls()
@ -935,7 +1251,7 @@ class LazyLibrary(ILibrary):
raise LibraryError(
f'Detected multiple simultaneous lookups of "{key}".\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 refernce cycles.' # TODO give advice on finding cycles
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' # TODO give advice on finding cycles
)
self._lookups_in_progress.add(key)
@ -1007,9 +1323,9 @@ class LazyLibrary(ILibrary):
"""
self.precache()
for pattern in self.cache.values():
for ref in pattern.refs:
if ref.target == old_target:
ref.target = new_target
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:
@ -1028,6 +1344,11 @@ class LazyLibrary(ILibrary):
class AbstractView(Mapping[str, Abstract]):
"""
A read-only mapping from names to `Abstract` objects.
This is usually just used as a shorthand for repeated calls to `library.abstract()`.
"""
library: ILibraryView
def __init__(self, library: ILibraryView) -> None:
@ -1041,3 +1362,20 @@ class AbstractView(Mapping[str, Abstract]):
def __len__(self) -> int:
return self.library.__len__()
def b64suffix(ii: int) -> str:
"""
Turn an integer into a base64-equivalent suffix.
This could be done with base64.b64encode, but this way is faster for many small `ii`.
"""
def i2a(nn: int) -> str:
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]
parts = ['$', i2a(ii % 64)]
ii >>= 6
while ii:
parts.append(i2a(ii % 64))
ii >>= 6
return ''.join(parts)

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,12 @@
from typing import Iterable, KeysView, ValuesView, overload, Self, Mapping
from typing import overload, Self, NoReturn, Any
from collections.abc import Iterable, KeysView, ValuesView, Mapping
import warnings
import traceback
import logging
import functools
from collections import Counter
from abc import ABCMeta, abstractmethod
from itertools import chain
import numpy
from numpy import pi
@ -17,6 +20,7 @@ from .error import PortError
logger = logging.getLogger(__name__)
@functools.total_ordering
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
"""
A point at which a `Device` can be snapped to another `Device`.
@ -68,7 +72,28 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
raise PortError('Rotation must be a scalar')
self._rotation = val % (2 * pi)
def get_bounds(self):
@property
def x(self) -> float:
""" Alias for offset[0] """
return self.offset[0]
@x.setter
def x(self, val: float) -> None:
self.offset[0] = val
@property
def y(self) -> float:
""" Alias for offset[1] """
return self.offset[1]
@y.setter
def y(self, val: float) -> None:
self.offset[1] = val
def copy(self) -> Self:
return self.deepcopy()
def get_bounds(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset, self.offset))
def set_ptype(self, ptype: str) -> Self:
@ -76,7 +101,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
self.ptype = ptype
return self
def mirror(self, axis: int) -> Self:
def mirror(self, axis: int = 0) -> Self:
self.offset[1 - axis] *= -1
if self.rotation is not None:
self.rotation *= -1
@ -99,6 +124,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
rot = str(numpy.rad2deg(self.rotation))
return f'<{self.offset}, {rot}, [{self.ptype}]>'
def __lt__(self, other: 'Port') -> bool:
if self.ptype != other.ptype:
return self.ptype < other.ptype
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
if self.rotation is None:
return True
if other.rotation is None:
return False
return self.rotation < other.rotation
return False
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and self.ptype == other.ptype
and numpy.array_equal(self.offset, other.offset)
and self.rotation == other.rotation
)
class PortList(metaclass=ABCMeta):
__slots__ = () # Allow subclasses to use __slots__
@ -135,13 +181,40 @@ class PortList(metaclass=ABCMeta):
"""
if isinstance(key, str):
return self.ports[key]
else:
else: # noqa: RET505
return {k: self.ports[k] for k in key}
def __contains__(self, key: str) -> NoReturn:
raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')
# NOTE: Didn't add keys(), items(), values(), __contains__(), etc.
# because it's weird on stuff like Pattern that contains other lists
# and because you can just grab .ports and use that instead
def mkport(
self,
name: str,
value: Port,
) -> Self:
"""
Create a port, raising a `PortError` if a port with the same name already exists.
Args:
name: Name for the port. A port with this name should not already exist.
value: The `Port` object to which `name` will refer.
Returns:
self
Raises:
`PortError` if the name already exists.
"""
if name in self.ports:
raise PortError(f'Port {name} already exists.')
assert name not in self.ports
self.ports[name] = value
return self
def rename_ports(
self,
mapping: dict[str, str | None],
@ -166,7 +239,7 @@ class PortList(metaclass=ABCMeta):
if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()}
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
if None in renamed:
del renamed[None]
@ -201,6 +274,75 @@ class PortList(metaclass=ABCMeta):
self.ports.update(new_ports)
return self
def plugged(
self,
connections: dict[str, str],
) -> Self:
"""
Verify that the ports specified by `connections` are coincident and have opposing
rotations, then remove the ports.
This is used when ports have been "manually" aligned as part of some other routing,
but for whatever reason were not eliminated via `plug()`.
Args:
connections: Pairs of ports which "plug" each other (same offset, opposing directions)
Returns:
self
Raises:
`PortError` if the ports are not properly aligned.
"""
a_names, b_names = list(zip(*connections.items(), strict=True))
a_ports = [self.ports[pp] for pp in a_names]
b_ports = [self.ports[pp] for pp in b_names]
a_types = [pp.ptype for pp in a_ports]
b_types = [pp.ptype for pp in b_ports]
type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
for at, bt in zip(a_types, b_types, strict=True)])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (k, v) in enumerate(connections.items()):
if type_conflicts[nn]:
msg += f'{k} | {a_types[nn]}:{b_types[nn]} | {v}\n'
msg = ''.join(traceback.format_stack()) + '\n' + msg
warnings.warn(msg, stacklevel=2)
a_offsets = numpy.array([pp.offset for pp in a_ports])
b_offsets = numpy.array([pp.offset for pp in b_ports])
a_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in a_ports])
b_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in b_ports])
a_has_rot = numpy.array([pp.rotation is not None for pp in a_ports], dtype=bool)
b_has_rot = numpy.array([pp.rotation is not None for pp in b_ports], dtype=bool)
has_rot = a_has_rot & b_has_rot
if has_rot.any():
rotations = numpy.mod(a_rotations - b_rotations - pi, 2 * pi)
rotations[~has_rot] = rotations[has_rot][0]
if not numpy.allclose(rotations, 0):
rot_deg = numpy.rad2deg(rotations)
msg = 'Port orientations do not match:\n'
for nn, (k, v) in enumerate(connections.items()):
if not numpy.isclose(rot_deg[nn], 0):
msg += f'{k} | {rot_deg[nn]:g} | {v}\n'
raise PortError(msg)
translations = a_offsets - b_offsets
if not numpy.allclose(translations, 0):
msg = 'Port translations do not match:\n'
for nn, (k, v) in enumerate(connections.items()):
if not numpy.allclose(translations[nn], 0):
msg += f'{k} | {translations[nn]} | {v}\n'
raise PortError(msg)
for pp in chain(a_names, b_names):
del self.ports[pp]
return self
def check_ports(
self,
other_names: Iterable[str],
@ -275,7 +417,7 @@ class PortList(metaclass=ABCMeta):
other: 'PortList',
map_in: dict[str, str],
*,
mirrored: tuple[bool, bool] = (False, False),
mirrored: bool = False,
set_rotation: bool | None = None,
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
"""
@ -286,7 +428,7 @@ class PortList(metaclass=ABCMeta):
other: a device
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
port connections between the two devices.
mirrored: Mirrors `other` across the x or y axes prior to
mirrored: Mirrors `other` across the x axis prior to
connecting any ports.
set_rotation: If the necessary rotation cannot be determined from
the ports being connected (i.e. all pairs have at least one
@ -303,7 +445,7 @@ class PortList(metaclass=ABCMeta):
"""
s_ports = self[map_in.keys()]
o_ports = other[map_in.values()]
return self.find_ptransform(
return self.find_port_transform(
s_ports=s_ports,
o_ports=o_ports,
map_in=map_in,
@ -312,12 +454,12 @@ class PortList(metaclass=ABCMeta):
)
@staticmethod
def find_ptransform( # TODO needs better name
def find_port_transform(
s_ports: Mapping[str, Port],
o_ports: Mapping[str, Port],
map_in: dict[str, str],
*,
mirrored: tuple[bool, bool] = (False, False),
mirrored: bool = False,
set_rotation: bool | None = None,
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
"""
@ -330,7 +472,7 @@ class PortList(metaclass=ABCMeta):
o_ports: A list of ports which are to be moved/mirrored.
map_in: dict of `{'s_port': 'o_port'}` mappings, specifying
port connections.
mirrored: Mirrors `o_ports` across the x or y axes prior to
mirrored: Mirrors `o_ports` across the x axis prior to
connecting any ports.
set_rotation: If the necessary rotation cannot be determined from
the ports being connected (i.e. all pairs have at least one
@ -356,16 +498,12 @@ class PortList(metaclass=ABCMeta):
o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool)
has_rot = s_has_rot & o_has_rot
if mirrored[0]:
if mirrored:
o_offsets[:, 1] *= -1
o_rotations *= -1
if mirrored[1]:
o_offsets[:, 0] *= -1
o_rotations *= -1
o_rotations += pi
type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk'
for st, ot in zip(s_types, o_types)])
type_conflicts = numpy.array([st != ot and 'unk' not in (st, ot)
for st, ot in zip(s_types, o_types, strict=True)])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (k, v) in enumerate(map_in.items()):

View File

@ -1,18 +1,17 @@
"""
Ref provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and other such properties to the reference.
Ref provides basic support for nesting Pattern objects within each other.
It carries offset, rotation, mirroring, and scaling data for each individual instance.
"""
#TODO more top-level documentation
from typing import Sequence, Mapping, TYPE_CHECKING, Any, Self
from typing import TYPE_CHECKING, Self, Any
from collections.abc import Mapping
import copy
import functools
import numpy
from numpy import pi
from numpy.typing import NDArray, ArrayLike
from .error import PatternError
from .utils import is_scalar, annotations_t
from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key
from .repetition import Repetition
from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl,
@ -24,63 +23,73 @@ if TYPE_CHECKING:
from . import Pattern
@functools.total_ordering
class Ref(
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
):
"""
`Ref` provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and associated methods.
`Ref` provides basic support for nesting Pattern objects within each other.
It containts the transformation (mirror, rotation, scale, offset, repetition)
and annotations for a single instantiation of a `Pattern`.
Note that the target (i.e. which pattern a `Ref` instantiates) is not stored within the
`Ref` itself, but is specified by the containing `Pattern`.
Order of operations is (mirror, rotate, scale, translate, repeat).
"""
__slots__ = (
'_target', '_mirrored',
'_mirrored',
# inherited
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
)
_target: str | None
""" The name of the `Pattern` being instanced """
_mirrored: bool
""" Whether to mirror the instance across the x axis (new_y = -old_y)ubefore rotating. """
_mirrored: NDArray[numpy.bool_]
""" Whether to mirror the instance across the x and/or y axes. """
# Mirrored property
@property
def mirrored(self) -> bool: # mypy#3004, setter should be SupportsBool
return self._mirrored
@mirrored.setter
def mirrored(self, val: bool) -> None:
self._mirrored = bool(val)
def __init__(
self,
target: str | None,
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: Sequence[bool] | None = None,
mirrored: bool = False,
scale: float = 1.0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
) -> None:
"""
Note: Order is (mirror, rotate, scale, translate, repeat)
Args:
target: Name of the Pattern to reference.
offset: (x, y) offset applied to the referenced pattern. Not affected by rotation etc.
rotation: Rotation (radians, counterclockwise) relative to the referenced pattern's (0, 0).
mirrored: Whether to mirror the referenced pattern across its x and y axes.
mirrored: Whether to mirror the referenced pattern across its x axis before rotating.
scale: Scaling factor applied to the pattern's geometry.
repetition: `Repetition` object, default `None`
"""
self.target = target
self.offset = offset
self.rotation = rotation
self.scale = scale
if mirrored is None:
mirrored = (False, False)
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
def __copy__(self) -> 'Ref':
new = Ref(
target=self.target,
offset=self.offset.copy(),
rotation=self.rotation,
scale=self.scale,
mirrored=self.mirrored.copy(),
mirrored=self.mirrored,
repetition=copy.deepcopy(self.repetition),
annotations=copy.deepcopy(self.annotations),
)
@ -89,61 +98,51 @@ class Ref(
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.repetition = copy.deepcopy(self.repetition, memo)
#new.annotations = copy.deepcopy(self.annotations, memo)
return new
# target property
@property
def target(self) -> str | None:
return self._target
def __lt__(self, other: 'Ref') -> bool:
if (self.offset != other.offset).any():
return tuple(self.offset) < tuple(other.offset)
if self.mirrored != other.mirrored:
return self.mirrored < other.mirrored
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.scale != other.scale:
return self.scale < other.scale
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
@target.setter
def target(self, val: str | None) -> None:
if val is not None and not isinstance(val, str):
raise PatternError(f'Provided target {val} is not a str or None!')
self._target = val
# Mirrored property
@property
def mirrored(self) -> Any: # TODO mypy#3004 NDArray[numpy.bool_]:
return self._mirrored
@mirrored.setter
def mirrored(self, val: ArrayLike) -> None:
if is_scalar(val):
raise PatternError('Mirrored must be a 2-element list of booleans')
self._mirrored = numpy.array(val, dtype=bool, copy=True)
def __eq__(self, other: Any) -> bool:
return (
numpy.array_equal(self.offset, other.offset)
and self.mirrored == other.mirrored
and self.rotation == other.rotation
and self.scale == other.scale
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def as_pattern(
self,
*,
pattern: 'Pattern | None' = None,
library: Mapping[str, 'Pattern'] | None = None,
pattern: 'Pattern',
) -> 'Pattern':
"""
Args:
pattern: Pattern object to transform
library: A str->Pattern mapping, used instead of `pattern`. Must contain
`self.target`.
Returns:
A copy of the referenced Pattern which has been scaled, rotated, etc.
according to this `Ref`'s properties.
"""
if pattern is None:
if library is None:
raise PatternError('as_pattern() must be given a pattern or library.')
assert self.target is not None
pattern = library[self.target]
pattern = pattern.deepcopy()
if self.scale != 1:
pattern.scale_by(self.scale)
if numpy.any(self.mirrored):
pattern.mirror2d(self.mirrored)
if self.mirrored:
pattern.mirror()
if self.rotation % (2 * pi) != 0:
pattern.rotate_around((0.0, 0.0), self.rotation)
if numpy.any(self.offset):
@ -166,17 +165,38 @@ class Ref(
self.repetition.rotate(rotation)
return self
def mirror(self, axis: int) -> Self:
self.mirrored[axis] = not self.mirrored[axis]
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 get_bounds(
def mirror_target(self, axis: int = 0) -> Self:
self.mirrored = not self.mirrored
self.rotation += axis * pi
return self
def mirror2d_target(self, across_x: bool = False, across_y: bool = False) -> Self:
self.mirrored = bool((self.mirrored + across_x + across_y) % 2)
if across_y:
self.rotation += pi
return self
def as_transforms(self) -> NDArray[numpy.float64]:
xys = self.offset[None, :]
if self.repetition is not None:
xys = xys + self.repetition.displacements
transforms = numpy.empty((xys.shape[0], 4))
transforms[:, :2] = xys
transforms[:, 2] = self.rotation
transforms[:, 3] = self.mirrored
return transforms
def get_bounds_single(
self,
pattern: 'Pattern',
*,
pattern: 'Pattern | None' = None,
library: Mapping[str, 'Pattern'] | None = None,
) -> NDArray[numpy.float64] | None:
"""
@ -190,20 +210,27 @@ class Ref(
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
"""
if pattern is None and library is None:
raise PatternError('as_pattern() must be given a pattern or library.')
if pattern is None and self.target is None:
return None
if library is not None and self.target not in library:
raise PatternError(f'get_bounds() called on dangling reference to "{self.target}"')
if pattern is not None and pattern.is_empty():
if pattern.is_empty():
# no need to run as_pattern()
return None
return self.as_pattern(pattern=pattern, library=library).get_bounds(library)
# if rotation is manhattan, can take pattern's bounds and transform them
if numpy.isclose(self.rotation % (pi / 2), 0):
unrot_bounds = pattern.get_bounds(library)
if unrot_bounds is None:
return None
if self.mirrored:
unrot_bounds[:, 1] *= -1
corners = (rotation_matrix_2d(self.rotation) @ unrot_bounds.T).T
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)
def __repr__(self) -> str:
name = f'"{self.target}"' if self.target is not None else None
rotation = f' r{self.rotation*180/pi:g}' if self.rotation != 0 else ''
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
scale = f' d{self.scale:g}' if self.scale != 1 else ''
mirrored = ' m{:d}{:d}'.format(*self.mirrored) if self.mirrored.any() else ''
return f'<Ref {name} at {self.offset}{rotation}{scale}{mirrored}>'
mirrored = ' m' if self.mirrored else ''
return f'<Ref {self.offset}{rotation}{scale}{mirrored}>'

View File

@ -2,20 +2,24 @@
Repetitions provide support for efficiently representing multiple identical
instances of an object .
"""
from typing import Any, Type
from typing import Any, Self, TypeVar, cast
import copy
import functools
from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import ArrayLike, NDArray
from .traits import Copyable, Scalable, Rotatable, Mirrorable
from .traits import Copyable, Scalable, Rotatable, Mirrorable, Bounded
from .error import PatternError
from .utils import rotation_matrix_2d
class Repetition(Copyable, Rotatable, Mirrorable, Scalable, metaclass=ABCMeta):
GG = TypeVar('GG', bound='Grid')
@functools.total_ordering
class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=ABCMeta):
"""
Interface common to all objects which specify repetitions
"""
@ -29,6 +33,14 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, metaclass=ABCMeta):
"""
pass
@abstractmethod
def __le__(self, other: 'Repetition') -> bool:
pass
@abstractmethod
def __eq__(self, other: Any) -> bool:
pass
class Grid(Repetition):
"""
@ -89,7 +101,6 @@ class Grid(Repetition):
if b_vector is None:
if b_count > 1:
raise PatternError('Repetition has b_count > 1 but no b_vector')
else:
b_vector = numpy.array([0.0, 0.0])
if a_count < 1:
@ -104,12 +115,12 @@ class Grid(Repetition):
@classmethod
def aligned(
cls: Type,
cls: type[GG],
x: float,
y: float,
x_count: int,
y_count: int,
) -> 'Grid':
) -> GG:
"""
Simple constructor for an axis-aligned 2D grid
@ -133,7 +144,7 @@ class Grid(Repetition):
)
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Grid':
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
return new
@ -145,12 +156,11 @@ class Grid(Repetition):
@a_vector.setter
def a_vector(self, val: ArrayLike) -> None:
if not isinstance(val, numpy.ndarray):
val = numpy.array(val, dtype=float)
if val.size != 2:
raise PatternError('a_vector must be convertible to size-2 ndarray')
self._a_vector = val.flatten().astype(float)
self._a_vector = val.flatten()
# b_vector property
@property
@ -159,8 +169,7 @@ class Grid(Repetition):
@b_vector.setter
def b_vector(self, val: ArrayLike) -> None:
if not isinstance(val, numpy.ndarray):
val = numpy.array(val, dtype=float, copy=True)
val = numpy.array(val, dtype=float)
if val.size != 2:
raise PatternError('b_vector must be convertible to size-2 ndarray')
@ -197,7 +206,7 @@ class Grid(Repetition):
return (aa.flatten()[:, None] * self.a_vector[None, :]
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
def rotate(self, rotation: float) -> 'Grid':
def rotate(self, rotation: float) -> Self:
"""
Rotate lattice vectors (around (0, 0))
@ -212,7 +221,7 @@ class Grid(Repetition):
self.b_vector = numpy.dot(rotation_matrix_2d(rotation), self.b_vector)
return self
def mirror(self, axis: int) -> 'Grid':
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the Grid across an axis.
@ -236,15 +245,19 @@ class Grid(Repetition):
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
"""
a_extent = self.a_vector * self.a_count
b_extent = self.b_vector * self.b_count if (self.b_vector is not None) else 0 # type: NDArray[numpy.float64] | float
a_extent = self.a_vector * (self.a_count - 1)
if self.b_count is None:
b_extent = numpy.zeros(2)
else:
assert self.b_vector is not None
b_extent = self.b_vector * (self.b_count - 1)
corners = numpy.stack(((0, 0), a_extent, b_extent, a_extent + b_extent))
xy_min = numpy.min(corners, axis=0)
xy_max = numpy.max(corners, axis=0)
return numpy.array((xy_min, xy_max))
def scale_by(self, c: float) -> 'Grid':
def scale_by(self, c: float) -> Self:
"""
Scale the Grid by a factor
@ -264,7 +277,7 @@ class Grid(Repetition):
return (f'<Grid {self.a_count}x{self.b_count} ({self.a_vector}{bv})>')
def __eq__(self, other: Any) -> bool:
if not isinstance(other, type(self)):
if type(other) is not type(self):
return False
if self.a_count != other.a_count or self.b_count != other.b_count:
return False
@ -274,10 +287,28 @@ class Grid(Repetition):
return True
if self.b_vector is None or other.b_vector is None:
return False
if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)):
if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)): # noqa: SIM103
return False
return True
def __le__(self, other: Repetition) -> bool:
if type(self) is not type(other):
return repr(type(self)) < repr(type(other))
other = cast(Grid, other)
if self.a_count != other.a_count:
return self.a_count < other.a_count
if self.b_count != other.b_count:
return self.b_count < other.b_count
if not numpy.array_equal(self.a_vector, other.a_vector):
return tuple(self.a_vector) < tuple(other.a_vector)
if self.b_vector is None:
return other.b_vector is not None
if other.b_vector is None:
return False
if not numpy.array_equal(self.b_vector, other.b_vector):
return tuple(self.a_vector) < tuple(other.a_vector)
return False
class Arbitrary(Repetition):
"""
@ -296,14 +327,14 @@ class Arbitrary(Repetition):
"""
@property
def displacements(self) -> Any: # TODO: mypy#3004 NDArray[numpy.float64]:
def displacements(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
return self._displacements
@displacements.setter
def displacements(self, val: ArrayLike) -> None:
vala: NDArray[numpy.float64] = numpy.array(val, dtype=float)
vala = numpy.sort(vala.view([('', vala.dtype)] * vala.shape[1]), 0).view(vala.dtype) # sort rows
self._displacements = vala
vala = numpy.array(val, dtype=float)
order = numpy.lexsort(vala.T[::-1]) # sortrows
self._displacements = vala[order]
def __init__(
self,
@ -319,11 +350,24 @@ class Arbitrary(Repetition):
return (f'<Arbitrary {len(self.displacements)}pts >')
def __eq__(self, other: Any) -> bool:
if not isinstance(other, type(self)):
if not type(other) is not type(self):
return False
return numpy.array_equal(self.displacements, other.displacements)
def rotate(self, rotation: float) -> 'Arbitrary':
def __le__(self, other: Repetition) -> bool:
if type(self) is not type(other):
return repr(type(self)) < repr(type(other))
other = cast(Arbitrary, other)
if self.displacements.size != other.displacements.size:
return self.displacements.size < other.displacements.size
neq = (self.displacements != other.displacements)
if neq.any():
return self.displacements[neq][0] < other.displacements[neq][0]
return False
def rotate(self, rotation: float) -> Self:
"""
Rotate dispacements (around (0, 0))
@ -336,7 +380,7 @@ class Arbitrary(Repetition):
self.displacements = numpy.dot(rotation_matrix_2d(rotation), self.displacements.T).T
return self
def mirror(self, axis: int) -> 'Arbitrary':
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the displacements across an axis.
@ -362,7 +406,7 @@ class Arbitrary(Repetition):
xy_max = numpy.max(self.displacements, axis=0)
return numpy.array((xy_min, xy_max))
def scale_by(self, c: float) -> 'Arbitrary':
def scale_by(self, c: float) -> Self:
"""
Scale the displacements by a factor

View File

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

View File

@ -1,6 +1,6 @@
from typing import Sequence, Any
from typing import Any, cast
import copy
import math
import functools
import numpy
from numpy import pi
@ -9,9 +9,10 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
from ..error import PatternError
from ..repetition import Repetition
from ..utils import is_scalar, layer_t, annotations_t
from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
@functools.total_ordering
class Arc(Shape):
"""
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
@ -24,7 +25,7 @@ class Arc(Shape):
__slots__ = (
'_radii', '_angles', '_width', '_rotation',
# Inherited
'_offset', '_layer', '_repetition', '_annotations',
'_offset', '_repetition', '_annotations',
)
_radii: NDArray[numpy.float64]
@ -41,7 +42,7 @@ class Arc(Shape):
# radius properties
@property
def radii(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]:
def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
"""
Return the radii `[rx, ry]`
"""
@ -78,7 +79,7 @@ class Arc(Shape):
# arc start/stop angle properties
@property
def angles(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]:
def angles(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
"""
Return the start and stop angles `[a_start, a_stop]`.
Angles are measured from x-axis after rotation
@ -155,8 +156,6 @@ class Arc(Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
raw: bool = False,
@ -172,7 +171,6 @@ class Arc(Shape):
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
self._layer = layer
else:
self.radii = radii
self.angles = angles
@ -181,8 +179,6 @@ class Arc(Shape):
self.rotation = rotation
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.layer = layer
[self.mirror(a) for a, do in enumerate(mirrored) if do]
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
memo = {} if memo is None else memo
@ -193,6 +189,38 @@ class Arc(Shape):
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.radii, other.radii)
and numpy.array_equal(self.angles, other.angles)
and self.width == other.width
and self.rotation == other.rotation
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast(Arc, other)
if self.width != other.width:
return self.width < other.width
if not numpy.array_equal(self.radii, other.radii):
return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.angles, other.angles):
return tuple(self.angles) < tuple(other.angles)
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons(
self,
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
@ -207,27 +235,62 @@ class Arc(Shape):
# Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
a_ranges = self._angles_to_parameters()
# Approximate perimeter
# Ramanujan, S., "Modular Equations and Approximations to ,"
# Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
a0, a1 = a_ranges[1] # use outer arc
h = ((r1 - r0) / (r1 + r0)) ** 2
ellipse_perimeter = pi * (r1 + r0) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
perimeter = abs(a0 - a1) / (2 * pi) * ellipse_perimeter # TODO: make this more accurate
# Approximate perimeter via numerical integration
n = []
if num_vertices is not None:
n += [num_vertices]
if max_arclen is not None:
n += [perimeter / max_arclen]
num_vertices = int(round(max(n)))
#perimeter1 = numpy.trapz(numpy.sqrt(r0sin * r0sin + r1cos * r1cos), dx=dt)
#from scipy.special import ellipeinc
#m = 1 - (r1 / r0) ** 2
#t1 = ellipeinc(a1 - pi / 2, m)
#t0 = ellipeinc(a0 - pi / 2, m)
#perimeter2 = r0 * (t1 - t0)
def get_arclens(n_pts: int, a0: float, a1: float, dr: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
""" Get `n_pts` arclengths """
t, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points
r0sin = (r0 + dr) * numpy.sin(t)
r1cos = (r1 + dr) * numpy.cos(t)
arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos)
#arc_lengths = numpy.diff(t) * (arc_dl[1:] + arc_dl[:-1]) / 2
arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2
return arc_lengths, t
wh = self.width / 2.0
if wh == r0 or wh == r1:
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
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)
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
while start < arc_lengths.size:
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
keep.append(next_to_keep)
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
start = next_to_keep + 1
if keep[-1] != thetas.size - 1:
keep.append(thetas.size - 1)
thetas = thetas[keep]
if inner:
thetas = thetas[::-1]
return thetas
if wh in (r0, r1):
thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin
else:
thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], num_vertices, endpoint=True)
thetas_outer = numpy.linspace(a_ranges[1][0], a_ranges[1][1], num_vertices, endpoint=True)
thetas_inner = get_thetas(inner=True)
thetas_outer = get_thetas(inner=False)
sin_th_i, cos_th_i = (numpy.sin(thetas_inner), numpy.cos(thetas_inner))
sin_th_o, cos_th_o = (numpy.sin(thetas_outer), numpy.cos(thetas_outer))
@ -241,11 +304,11 @@ class Arc(Shape):
ys = numpy.hstack((ys1, ys2))
xys = numpy.vstack((xs, ys)).T
poly = Polygon(xys, layer=self.layer, offset=self.offset, rotation=self.rotation)
poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
return [poly]
def get_bounds(self) -> NDArray[numpy.float64]:
'''
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)`
@ -256,12 +319,12 @@ class Arc(Shape):
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 = self._angles_to_parameters()
mins = []
maxs = []
for a, sgn in zip(a_ranges, (-1, +1)):
for a, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2
rx = self.radius_x + wh
ry = self.radius_y + wh
@ -318,7 +381,7 @@ class Arc(Shape):
self.rotation += theta
return self
def mirror(self, axis: int) -> 'Arc':
def mirror(self, axis: int = 0) -> 'Arc':
self.offset[axis - 1] *= -1
self.rotation *= -1
self.rotation += axis * pi
@ -352,28 +415,27 @@ class Arc(Shape):
rotation %= 2 * pi
width = self.width
return ((type(self), radii, angles, width / norm_value, self.layer),
return ((type(self), radii, angles, width / norm_value),
(self.offset, scale / norm_value, rotation, False),
lambda: Arc(
radii=radii * norm_value,
angles=angles,
width=width * norm_value,
layer=self.layer,
))
def get_cap_edges(self) -> NDArray[numpy.float64]:
'''
"""
Returns:
```
[[[x0, y0], [x1, y1]], array of 4 points, specifying the two cuts which
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
```
'''
"""
a_ranges = self._angles_to_parameters()
mins = []
maxs = []
for a, sgn in zip(a_ranges, (-1, +1)):
for a, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2
rx = self.radius_x + wh
ry = self.radius_y + wh
@ -392,27 +454,28 @@ class Arc(Shape):
return numpy.array([mins, maxs]) + self.offset
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
'''
"""
Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
Returns:
"Eccentric anomaly" parameter ranges for the inner and outer edges, in the form
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
'''
"""
a = []
for sgn in (-1, +1):
wh = sgn * self.width / 2
wh = sgn * self.width / 2.0
rx = self.radius_x + wh
ry = self.radius_y + wh
# create paremeter 'a' for parametrized ellipse
a0, a1 = (numpy.arctan2(rx * numpy.sin(a), ry * numpy.cos(a)) for a in self.angles)
sign = numpy.sign(self.angles[1] - self.angles[0])
if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi
a.append((a0, a1))
return numpy.array(a)
return numpy.array(a, dtype=float)
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 l{self.layer} o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'

View File

@ -1,4 +1,6 @@
from typing import Any, cast
import copy
import functools
import numpy
from numpy import pi
@ -7,9 +9,10 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
from ..error import PatternError
from ..repetition import Repetition
from ..utils import is_scalar, layer_t, annotations_t
from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
@functools.total_ordering
class Circle(Shape):
"""
A circle, which has a position and radius.
@ -17,7 +20,7 @@ class Circle(Shape):
__slots__ = (
'_radius',
# Inherited
'_offset', '_layer', '_repetition', '_annotations',
'_offset', '_repetition', '_annotations',
)
_radius: float
@ -44,7 +47,6 @@ class Circle(Shape):
radius: float,
*,
offset: ArrayLike = (0.0, 0.0),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
raw: bool = False,
@ -55,13 +57,11 @@ class Circle(Shape):
self._offset = offset
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
self._layer = layer
else:
self.radius = radius
self.offset = offset
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.layer = layer
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
memo = {} if memo is None else memo
@ -70,6 +70,29 @@ class Circle(Shape):
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and self.radius == other.radius
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast(Circle, other)
if not self.radius == other.radius:
return self.radius < other.radius
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons(
self,
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
@ -90,16 +113,16 @@ class Circle(Shape):
ys = numpy.sin(thetas) * self.radius
xys = numpy.vstack((xs, ys)).T
return [Polygon(xys, offset=self.offset, layer=self.layer)]
return [Polygon(xys, offset=self.offset)]
def get_bounds(self) -> NDArray[numpy.float64]:
def get_bounds_single(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset - self.radius,
self.offset + self.radius))
def rotate(self, theta: float) -> 'Circle':
def rotate(self, theta: float) -> 'Circle': # noqa: ARG002 (theta unused)
return self
def mirror(self, axis: int) -> 'Circle':
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
self.offset *= -1
return self
@ -107,12 +130,12 @@ class Circle(Shape):
self.radius *= c
return self
def normalized_form(self, norm_value) -> normalized_shape_tuple:
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
rotation = 0.0
magnitude = self.radius / norm_value
return ((type(self), self.layer),
return ((type(self),),
(self.offset, magnitude, rotation, False),
lambda: Circle(radius=norm_value, layer=self.layer))
lambda: Circle(radius=norm_value))
def __repr__(self) -> str:
return f'<Circle l{self.layer} o{self.offset} r{self.radius:g}>'
return f'<Circle o{self.offset} r{self.radius:g}>'

View File

@ -1,6 +1,7 @@
from typing import Sequence, Any
from typing import Any, Self, cast
import copy
import math
import functools
import numpy
from numpy import pi
@ -9,9 +10,10 @@ from numpy.typing import ArrayLike, NDArray
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
from ..error import PatternError
from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d, layer_t, annotations_t
from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key
@functools.total_ordering
class Ellipse(Shape):
"""
An ellipse, which has a position, two radii, and a rotation.
@ -20,7 +22,7 @@ class Ellipse(Shape):
__slots__ = (
'_radii', '_rotation',
# Inherited
'_offset', '_layer', '_repetition', '_annotations',
'_offset', '_repetition', '_annotations',
)
_radii: NDArray[numpy.float64]
@ -31,7 +33,7 @@ class Ellipse(Shape):
# radius properties
@property
def radii(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]:
def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
"""
Return the radii `[rx, ry]`
"""
@ -90,8 +92,6 @@ class Ellipse(Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
raw: bool = False,
@ -104,17 +104,14 @@ class Ellipse(Shape):
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
self._layer = layer
else:
self.radii = radii
self.offset = offset
self.rotation = rotation
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.layer = layer
[self.mirror(a) for a, do in enumerate(mirrored) if do]
def __deepcopy__(self, memo: dict | None = None) -> 'Ellipse':
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
new._offset = self._offset.copy()
@ -122,6 +119,32 @@ class Ellipse(Shape):
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.radii, other.radii)
and self.rotation == other.rotation
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast(Ellipse, other)
if not numpy.array_equal(self.radii, other.radii):
return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons(
self,
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
@ -152,25 +175,25 @@ class Ellipse(Shape):
ys = r1 * sin_th
xys = numpy.vstack((xs, ys)).T
poly = Polygon(xys, layer=self.layer, offset=self.offset, rotation=self.rotation)
poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
return [poly]
def get_bounds(self) -> NDArray[numpy.float64]:
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]))
def rotate(self, theta: float) -> 'Ellipse':
def rotate(self, theta: float) -> Self:
self.rotation += theta
return self
def mirror(self, axis: int) -> 'Ellipse':
def mirror(self, axis: int = 0) -> Self:
self.offset[axis - 1] *= -1
self.rotation *= -1
self.rotation += axis * pi
return self
def scale_by(self, c: float) -> 'Ellipse':
def scale_by(self, c: float) -> Self:
self.radii *= c
return self
@ -183,10 +206,10 @@ class Ellipse(Shape):
radii = self.radii[::-1] / self.radius_y
scale = self.radius_y
angle = (self.rotation + pi / 2) % pi
return ((type(self), radii, self.layer),
return ((type(self), radii),
(self.offset, scale / norm_value, angle, False),
lambda: Ellipse(radii=radii * norm_value, layer=self.layer))
lambda: Ellipse(radii=radii * norm_value))
def __repr__(self) -> str:
rotation = f' r{self.rotation*180/pi:g}' if self.rotation != 0 else ''
return f'<Ellipse l{self.layer} o{self.offset} r{self.radii}{rotation}>'
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
return f'<Ellipse o{self.offset} r{self.radii}{rotation}>'

View File

@ -1,5 +1,7 @@
from typing import Sequence, Any, cast
from typing import Any, cast
from collections.abc import Sequence
import copy
import functools
from enum import Enum
import numpy
@ -9,10 +11,11 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, normalized_shape_tuple, Polygon, Circle
from ..error import PatternError
from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d, layer_t
from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
@functools.total_ordering
class PathCap(Enum):
Flush = 0 # Path ends at final vertices
Circle = 1 # Path extends past final vertices with a semicircle of radius width/2
@ -20,18 +23,24 @@ class PathCap(Enum):
SquareCustom = 4 # Path extends past final vertices with a rectangle of length
# # defined by path.cap_extensions
def __lt__(self, other: Any) -> bool:
return self.value == other.value
@functools.total_ordering
class Path(Shape):
"""
A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape,
and an offset.
Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates.
A normalized_form(...) is available, but can be quite slow with lots of vertices.
"""
__slots__ = (
'_vertices', '_width', '_cap', '_cap_extensions',
# Inherited
'_offset', '_layer', '_repetition', '_annotations',
'_offset', '_repetition', '_annotations',
)
_vertices: NDArray[numpy.float64]
_width: float
@ -61,12 +70,14 @@ class Path(Shape):
def cap(self) -> PathCap:
"""
Path end-cap
Note that `cap_extensions` will be reset to default values if
`cap` is changed away from `PathCap.SquareCustom`.
"""
return self._cap
@cap.setter
def cap(self, val: PathCap) -> None:
# TODO: Document that setting cap can change cap_extensions
self._cap = PathCap(val)
if self.cap != PathCap.SquareCustom:
self.cap_extensions = None
@ -76,10 +87,13 @@ class Path(Shape):
# cap_extensions property
@property
def cap_extensions(self) -> Any | None: # TODO mypy#3004 NDArray[numpy.float64]]:
def cap_extensions(self) -> Any | None: # mypy#3004 NDArray[numpy.float64]]:
"""
Path end-cap extension
Note that `cap_extensions` will be reset to default values if
`cap` is changed away from `PathCap.SquareCustom`.
Returns:
2-element ndarray or `None`
"""
@ -90,24 +104,26 @@ class Path(Shape):
custom_caps = (PathCap.SquareCustom,)
if self.cap in custom_caps:
if vals is None:
raise Exception('Tried to set cap extensions to None on path with custom cap type')
raise PatternError('Tried to set cap extensions to None on path with custom cap type')
self._cap_extensions = numpy.array(vals, dtype=float)
else:
if vals is not None:
raise Exception('Tried to set custom cap extensions on path with non-custom cap type')
raise PatternError('Tried to set custom cap extensions on path with non-custom cap type')
self._cap_extensions = vals
# vertices property
@property
def vertices(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]]:
def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]]:
"""
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`
When setting, note that a copy of the provided vertices will be made.
"""
return self._vertices
@vertices.setter
def vertices(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
val = numpy.array(val, dtype=float)
if len(val.shape) < 2 or val.shape[1] != 2:
raise PatternError('Vertices must be an Nx2 array')
if val.shape[0] < 2:
@ -153,8 +169,6 @@ class Path(Shape):
cap_extensions: ArrayLike | None = None,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
raw: bool = False,
@ -169,7 +183,6 @@ class Path(Shape):
self._offset = offset
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
self._layer = layer
self._width = width
self._cap = cap
self._cap_extensions = cap_extensions
@ -178,12 +191,10 @@ class Path(Shape):
self.offset = offset
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.layer = layer
self.width = width
self.cap = cap
self.cap_extensions = cap_extensions
self.rotate(rotation)
[self.mirror(a) for a, do in enumerate(mirrored) if do]
def __deepcopy__(self, memo: dict | None = None) -> 'Path':
memo = {} if memo is None else memo
@ -195,6 +206,40 @@ class Path(Shape):
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.vertices, other.vertices)
and self.width == other.width
and self.cap == other.cap
and numpy.array_equal(self.cap_extensions, other.cap_extensions) # type: ignore
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast(Path, other)
if self.width != other.width:
return self.width < other.width
if self.cap != other.cap:
return self.cap < other.cap
if not numpy.array_equal(self.cap_extensions, other.cap_extensions): # type: ignore
if other.cap_extensions is None:
return False
if self.cap_extensions is None:
return True
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
@staticmethod
def travel(
travel_pairs: Sequence[tuple[float, float]],
@ -203,8 +248,6 @@ class Path(Shape):
cap_extensions: tuple[float, float] | None = None,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
) -> 'Path':
"""
Build a path by specifying the turn angles and travel distances
@ -221,15 +264,11 @@ class Path(Shape):
Default `(0, 0)` or `None`, depending on cap type
offset: Offset, default `(0, 0)`
rotation: Rotation counterclockwise, in radians. Default `0`
mirrored: Whether to mirror across the x or y axes. For example,
`mirrored=(True, False)` results in a reflection across the x-axis,
multiplying the path's y-coordinates by -1. Default `(False, False)`
layer: Layer, default `0`
Returns:
The resulting Path object
"""
# TODO: needs testing
# TODO: Path.travel() needs testing
direction = numpy.array([1, 0])
verts = [numpy.zeros(2)]
@ -238,8 +277,7 @@ class Path(Shape):
verts.append(verts[-1] + direction * distance)
return Path(vertices=verts, width=width, cap=cap, cap_extensions=cap_extensions,
offset=offset, rotation=rotation, mirrored=mirrored,
layer=layer)
offset=offset, rotation=rotation)
def to_polygons(
self,
@ -254,7 +292,7 @@ class Path(Shape):
if self.width == 0:
verts = numpy.vstack((v, v[::-1]))
return [Polygon(offset=self.offset, vertices=verts, layer=self.layer)]
return [Polygon(offset=self.offset, vertices=verts)]
perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2
@ -305,31 +343,33 @@ class Path(Shape):
o1.append(v[-1] - perp[-1])
verts = numpy.vstack((o0, o1[::-1]))
polys = [Polygon(offset=self.offset, vertices=verts, layer=self.layer)]
polys = [Polygon(offset=self.offset, vertices=verts)]
if self.cap == PathCap.Circle:
#for vert in v: # not sure if every vertex, or just ends?
for vert in [v[0], v[-1]]:
circ = Circle(offset=vert, radius=self.width / 2, layer=self.layer)
circ = Circle(offset=vert, radius=self.width / 2)
polys += circ.to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
return polys
def get_bounds(self) -> NDArray[numpy.float64]:
def get_bounds_single(self) -> NDArray[numpy.float64]:
if self.cap == PathCap.Circle:
bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2,
numpy.max(self.vertices, axis=0) + self.width / 2))
elif self.cap in (PathCap.Flush,
elif self.cap in (
PathCap.Flush,
PathCap.Square,
PathCap.SquareCustom):
PathCap.SquareCustom,
):
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons()
for poly in polys:
poly_bounds = poly.get_bounds_nonempty()
poly_bounds = poly.get_bounds_single_nonempty()
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
else:
raise PatternError(f'get_bounds() not implemented for endcaps: {self.cap}')
raise PatternError(f'get_bounds_single() not implemented for endcaps: {self.cap}')
return bounds
@ -338,7 +378,7 @@ class Path(Shape):
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
return self
def mirror(self, axis: int) -> 'Path':
def mirror(self, axis: int = 0) -> 'Path':
self.vertices[:, axis - 1] *= -1
return self
@ -370,13 +410,12 @@ class Path(Shape):
width0 = self.width / norm_value
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer),
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
(offset, scale / norm_value, rotation, False),
lambda: Path(
reordered_vertices * norm_value,
width=self.width * norm_value,
cap=self.cap,
layer=self.layer,
))
def clean_vertices(self) -> 'Path':
@ -390,22 +429,22 @@ class Path(Shape):
return self
def remove_duplicate_vertices(self) -> 'Path':
'''
"""
Removes all consecutive duplicate (repeated) vertices.
Returns:
self
'''
"""
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False)
return self
def remove_colinear_vertices(self) -> 'Path':
'''
"""
Removes consecutive co-linear vertices.
Returns:
self
'''
"""
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
return self
@ -422,4 +461,4 @@ class Path(Shape):
def __repr__(self) -> str:
centroid = self.offset + self.vertices.mean(axis=0)
return f'<Path l{self.layer} centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'
return f'<Path centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'

View File

@ -1,5 +1,7 @@
from typing import Sequence, Any, cast
from typing import Any, cast
from collections.abc import Sequence
import copy
import functools
import numpy
from numpy import pi
@ -8,21 +10,25 @@ from numpy.typing import NDArray, ArrayLike
from . import Shape, normalized_shape_tuple
from ..error import PatternError
from ..repetition import Repetition
from ..utils import is_scalar, rotation_matrix_2d, layer_t
from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
@functools.total_ordering
class Polygon(Shape):
"""
A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an
implicitly-closed boundary, and an offset.
Note that the setter for `Polygon.vertices` creates a copy of the
passed vertex coordinates.
A `normalized_form(...)` is available, but can be quite slow with lots of vertices.
"""
__slots__ = (
'_vertices',
# Inherited
'_offset', '_layer', '_repetition', '_annotations',
'_offset', '_repetition', '_annotations',
)
_vertices: NDArray[numpy.float64]
@ -30,15 +36,17 @@ class Polygon(Shape):
# vertices property
@property
def vertices(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]:
def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
"""
Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
When setting, note that a copy of the provided vertices will be made,
"""
return self._vertices
@vertices.setter
def vertices(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
val = numpy.array(val, dtype=float)
if len(val.shape) < 2 or val.shape[1] != 2:
raise PatternError('Vertices must be an Nx2 array')
if val.shape[0] < 3:
@ -81,8 +89,6 @@ class Polygon(Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: Sequence[bool] = (False, False),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
raw: bool = False,
@ -94,15 +100,12 @@ class Polygon(Shape):
self._offset = offset
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
self._layer = layer
else:
self.vertices = vertices
self.offset = offset
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.layer = layer
self.rotate(rotation)
[self.mirror(a) for a, do in enumerate(mirrored) if do]
def __deepcopy__(self, memo: dict | None = None) -> 'Polygon':
memo = {} if memo is None else memo
@ -112,13 +115,41 @@ class Polygon(Shape):
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and numpy.array_equal(self.vertices, other.vertices)
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast(Polygon, other)
if not numpy.array_equal(self.vertices, other.vertices):
min_len = min(self.vertices.shape[0], other.vertices.shape[0])
eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
eq_lt_masked = eq_lt[eq_mask]
if eq_lt_masked.size > 0:
return eq_lt_masked.flat[0]
return self.vertices.shape[0] < other.vertices.shape[0]
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
@staticmethod
def square(
side_length: float,
*,
rotation: float = 0.0,
offset: ArrayLike = (0.0, 0.0),
layer: layer_t = 0,
repetition: Repetition | None = None,
) -> 'Polygon':
"""
@ -128,7 +159,6 @@ class Polygon(Shape):
side_length: Length of one side
rotation: Rotation counterclockwise, in radians
offset: Offset, default `(0, 0)`
layer: Layer, default `0`
repetition: `Repetition` object, default `None`
Returns:
@ -139,7 +169,7 @@ class Polygon(Shape):
[+1, +1],
[+1, -1]], dtype=float)
vertices = 0.5 * side_length * norm_square
poly = Polygon(vertices, offset=offset, layer=layer, repetition=repetition)
poly = Polygon(vertices, offset=offset, repetition=repetition)
poly.rotate(rotation)
return poly
@ -150,7 +180,6 @@ class Polygon(Shape):
*,
rotation: float = 0,
offset: ArrayLike = (0.0, 0.0),
layer: layer_t = 0,
repetition: Repetition | None = None,
) -> 'Polygon':
"""
@ -161,7 +190,6 @@ class Polygon(Shape):
ly: Length along y (before rotation)
rotation: Rotation counterclockwise, in radians
offset: Offset, default `(0, 0)`
layer: Layer, default `0`
repetition: `Repetition` object, default `None`
Returns:
@ -171,7 +199,7 @@ class Polygon(Shape):
[-lx, +ly],
[+lx, +ly],
[+lx, -ly]], dtype=float)
poly = Polygon(vertices, offset=offset, layer=layer, repetition=repetition)
poly = Polygon(vertices, offset=offset, repetition=repetition)
poly.rotate(rotation)
return poly
@ -186,7 +214,6 @@ class Polygon(Shape):
yctr: float | None = None,
ymax: float | None = None,
ly: float | None = None,
layer: layer_t = 0,
repetition: Repetition | None = None,
) -> 'Polygon':
"""
@ -204,7 +231,6 @@ class Polygon(Shape):
yctr: Center y coordinate
ymax: Maximum y coordinate
ly: Length along y direction
layer: Layer, default `0`
repetition: `Repetition` object, default `None`
Returns:
@ -226,7 +252,7 @@ class Polygon(Shape):
lx = 2 * (xmax - xctr)
else:
raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
else:
else: # noqa: PLR5501
if xctr is not None:
pass
elif xmax is None:
@ -256,7 +282,7 @@ class Polygon(Shape):
ly = 2 * (ymax - yctr)
else:
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
else:
else: # noqa: PLR5501
if yctr is not None:
pass
elif ymax is None:
@ -270,7 +296,7 @@ class Polygon(Shape):
else:
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), layer=layer, repetition=repetition)
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
return poly
@staticmethod
@ -281,7 +307,6 @@ class Polygon(Shape):
regular: bool = True,
center: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
layer: layer_t = 0,
repetition: Repetition | None = None,
) -> 'Polygon':
"""
@ -300,16 +325,12 @@ class Polygon(Shape):
rotation: Rotation counterclockwise, in radians.
`0` results in four axis-aligned sides (the long sides of the
irregular octagon).
layer: Layer, default `0`
repetition: `Repetition` object, default `None`
Returns:
A Polygon object containing the requested octagon
"""
if regular:
s = 1 + numpy.sqrt(2)
else:
s = 2
s = (1 + numpy.sqrt(2)) if regular else 2
norm_oct = numpy.array([
[-1, -s],
@ -327,18 +348,18 @@ class Polygon(Shape):
side_length = 2 * inner_radius / s
vertices = 0.5 * side_length * norm_oct
poly = Polygon(vertices, offset=center, layer=layer, repetition=repetition)
poly = Polygon(vertices, offset=center, repetition=repetition)
poly.rotate(rotation)
return poly
def to_polygons(
self,
num_vertices: int | None = None, # unused
max_arclen: float | None = None, # unused
num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused # noqa: ARG002
) -> list['Polygon']:
return [copy.deepcopy(self)]
def get_bounds(self) -> NDArray[numpy.float64]:
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0),
self.offset + numpy.max(self.vertices, axis=0)))
@ -347,7 +368,7 @@ class Polygon(Shape):
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
return self
def mirror(self, axis: int) -> 'Polygon':
def mirror(self, axis: int = 0) -> 'Polygon':
self.vertices[:, axis - 1] *= -1
return self
@ -358,8 +379,9 @@ class Polygon(Shape):
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
# Note: this function is going to be pretty slow for many-vertexed polygons, relative to
# other shapes
offset = self.vertices.mean(axis=0) + self.offset
zeroed_vertices = self.vertices - offset
meanv = self.vertices.mean(axis=0)
zeroed_vertices = self.vertices - meanv
offset = meanv + self.offset
scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale
@ -378,9 +400,9 @@ class Polygon(Shape):
# TODO: normalize mirroring?
return ((type(self), reordered_vertices.data.tobytes(), self.layer),
return ((type(self), reordered_vertices.data.tobytes()),
(offset, scale / norm_value, rotation, False),
lambda: Polygon(reordered_vertices * norm_value, layer=self.layer))
lambda: Polygon(reordered_vertices * norm_value))
def clean_vertices(self) -> 'Polygon':
"""
@ -393,25 +415,25 @@ class Polygon(Shape):
return self
def remove_duplicate_vertices(self) -> 'Polygon':
'''
"""
Removes all consecutive duplicate (repeated) vertices.
Returns:
self
'''
"""
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=True)
return self
def remove_colinear_vertices(self) -> 'Polygon':
'''
"""
Removes consecutive co-linear vertices.
Returns:
self
'''
"""
self.vertices = remove_colinear_vertices(self.vertices, closed_path=True)
return self
def __repr__(self) -> str:
centroid = self.offset + self.vertices.mean(axis=0)
return f'<Polygon l{self.layer} centroid {centroid} v{len(self.vertices)}>'
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'

View File

@ -1,4 +1,5 @@
from typing import Callable, Self, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from abc import ABCMeta, abstractmethod
import numpy
@ -6,8 +7,7 @@ from numpy.typing import NDArray, ArrayLike
from ..traits import (
Rotatable, Mirrorable, Copyable, Scalable,
PositionableImpl, LayerableImpl,
PivotableImpl, RepeatableImpl, AnnotatableImpl,
PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
)
if TYPE_CHECKING:
@ -26,23 +26,31 @@ normalized_shape_tuple = tuple[
DEFAULT_POLY_NUM_VERTICES = 24
class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Scalable,
class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
"""
Class specifying functions common to all shapes.
"""
__slots__ = () # Children should use AutoSlots
__slots__ = () # Children should use AutoSlots or set slots themselves
def __copy__(self) -> Self:
cls = self.__class__
new = cls.__new__(cls)
for name in self.__slots__: # type: str
object.__setattr__(new, name, getattr(self, name))
return new
#def __copy__(self) -> Self:
# cls = self.__class__
# new = cls.__new__(cls)
# for name in self.__slots__: # type: str
# object.__setattr__(new, name, getattr(self, name))
# return new
#
# Methods (abstract)
#
@abstractmethod
def __eq__(self, other: Any) -> bool:
pass
@abstractmethod
def __lt__(self, other: 'Shape') -> bool:
pass
@abstractmethod
def to_polygons(
self,
@ -119,7 +127,7 @@ class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Sc
polygon_contours = []
for polygon in self.to_polygons():
bounds = polygon.get_bounds()
bounds = polygon.get_bounds_single()
if bounds is None:
continue
@ -127,7 +135,7 @@ class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Sc
vertex_lists = []
p_verts = polygon.vertices + polygon.offset
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0)):
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
dv = v_next - v
# Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
@ -157,7 +165,7 @@ class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Sc
m = dv[1] / dv[0]
def get_grid_inds(xes: ArrayLike) -> NDArray[numpy.float64]:
def get_grid_inds(xes: ArrayLike, m: float = m, v: NDArray = v) -> NDArray[numpy.float64]:
ys = m * (xes - v[0]) + v[1]
# (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid
@ -194,10 +202,7 @@ class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Sc
vertex_lists.append(vlist)
polygon_contours.append(numpy.vstack(vertex_lists))
manhattan_polygons = [
Polygon(vertices=contour, layer=self.layer)
for contour in polygon_contours
]
manhattan_polygons = [Polygon(vertices=contour) for contour in polygon_contours]
return manhattan_polygons
@ -254,18 +259,19 @@ class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Sc
polygon_contours = []
for polygon in self.to_polygons():
# Get rid of unused gridlines (anything not within 2 lines of the polygon bounds)
bounds = polygon.get_bounds()
bounds = polygon.get_bounds_single()
if bounds is None:
continue
mins, maxs = bounds
keep_x = numpy.logical_and(grx > mins[0], grx < maxs[0])
keep_y = numpy.logical_and(gry > mins[1], gry < maxs[1])
for k in (keep_x, keep_y):
for s in (1, 2):
k[s:] += k[:-s]
k[:-s] += k[s:]
k = k > 0
# Flood left & rightwards by 2 cells
for kk in (keep_x, keep_y):
for ss in (1, 2):
kk[ss:] += kk[:-ss]
kk[:-ss] += kk[ss:]
kk[:] = kk > 0
gx = grx[keep_x]
gy = gry[keep_y]
@ -292,9 +298,6 @@ class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Sc
vertices = numpy.hstack((grx[snapped_contour[:, None, 0] + offset_i[0]],
gry[snapped_contour[:, None, 1] + offset_i[1]]))
manhattan_polygons.append(Polygon(
vertices=vertices,
layer=self.layer,
))
manhattan_polygons.append(Polygon(vertices=vertices))
return manhattan_polygons

View File

@ -1,22 +1,23 @@
from typing import Sequence, Any
from typing import Self, Any, cast
import copy
import functools
import numpy
from numpy import pi, inf
from numpy import pi, nan
from numpy.typing import NDArray, ArrayLike
from . import Shape, Polygon, normalized_shape_tuple
from ..error import PatternError
from ..repetition import Repetition
from ..traits import RotatableImpl
from ..utils import is_scalar, get_bit, normalize_mirror, layer_t
from ..utils import annotations_t
from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key
# Loaded on use:
# from freetype import Face
# from matplotlib.path import Path
@functools.total_ordering
class Text(RotatableImpl, Shape):
"""
Text (to be printed e.g. as a set of polygons).
@ -25,12 +26,12 @@ class Text(RotatableImpl, Shape):
__slots__ = (
'_string', '_height', '_mirrored', 'font_path',
# Inherited
'_offset', '_layer', '_repetition', '_annotations', '_rotation',
'_offset', '_repetition', '_annotations', '_rotation',
)
_string: str
_height: float
_mirrored: NDArray[numpy.bool_]
_mirrored: bool
font_path: str
# vertices property
@ -53,16 +54,13 @@ class Text(RotatableImpl, Shape):
raise PatternError('Height must be a scalar')
self._height = val
# Mirrored property
@property
def mirrored(self) -> Any: # TODO mypy#3004 NDArray[numpy.bool_]:
def mirrored(self) -> bool: # mypy#3004, should be bool
return self._mirrored
@mirrored.setter
def mirrored(self, val: Sequence[bool]) -> None:
if is_scalar(val):
raise PatternError('Mirrored must be a 2-element list of booleans')
self._mirrored = numpy.array(val, dtype=bool, copy=True)
def mirrored(self, val: bool) -> None:
self._mirrored = bool(val)
def __init__(
self,
@ -72,46 +70,70 @@ class Text(RotatableImpl, Shape):
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: ArrayLike = (False, False),
layer: layer_t = 0,
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
raw: bool = False,
) -> None:
if raw:
assert isinstance(offset, numpy.ndarray)
assert isinstance(mirrored, numpy.ndarray)
self._offset = offset
self._layer = layer
self._string = string
self._height = height
self._rotation = rotation
self._mirrored = mirrored
self._repetition = repetition
self._annotations = annotations if annotations is not None else {}
else:
self.offset = offset
self.layer = layer
self.string = string
self.height = height
self.rotation = rotation
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
self.font_path = font_path
def __deepcopy__(self, memo: dict | None = None) -> 'Text':
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._mirrored = copy.deepcopy(self._mirrored, memo)
new._annotations = copy.deepcopy(self._annotations)
return new
def __eq__(self, other: Any) -> bool:
return (
type(self) is type(other)
and numpy.array_equal(self.offset, other.offset)
and self.string == other.string
and self.height == other.height
and self.font_path == other.font_path
and self.rotation == other.rotation
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
def __lt__(self, other: Shape) -> bool:
if type(self) is not type(other):
if repr(type(self)) != repr(type(other)):
return repr(type(self)) < repr(type(other))
return id(type(self)) < id(type(other))
other = cast(Text, other)
if not self.height == other.height:
return self.height < other.height
if not self.string == other.string:
return self.string < other.string
if not self.font_path == other.font_path:
return self.font_path < other.font_path
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
return self.rotation < other.rotation
if self.repetition != other.repetition:
return rep2key(self.repetition) < rep2key(other.repetition)
return annotations_lt(self.annotations, other.annotations)
def to_polygons(
self,
num_vertices: int | None = None, # unused
max_arclen: float | None = None, # unused
num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused # noqa: ARG002
) -> list[Polygon]:
all_polygons = []
total_advance = 0.0
@ -120,8 +142,9 @@ class Text(RotatableImpl, Shape):
# Move these polygons to the right of the previous letter
for xys in raw_polys:
poly = Polygon(xys, layer=self.layer)
poly.mirror2d(self.mirrored)
poly = Polygon(xys)
if self.mirrored:
poly.mirror()
poly.scale_by(self.height)
poly.offset = self.offset + [total_advance, 0]
poly.rotate_around(self.offset, self.rotation)
@ -132,41 +155,47 @@ class Text(RotatableImpl, Shape):
return all_polygons
def mirror(self, axis: int) -> 'Text':
self.mirrored[axis] = not self.mirrored[axis]
def mirror(self, axis: int = 0) -> Self:
self.mirrored = not self.mirrored
if axis == 1:
self.rotation += pi
return self
def scale_by(self, c: float) -> 'Text':
def scale_by(self, c: float) -> Self:
self.height *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
mirror_x, rotation = normalize_mirror(self.mirrored)
rotation += self.rotation
rotation %= 2 * pi
return ((type(self), self.string, self.font_path, self.layer),
(self.offset, self.height / norm_value, rotation, mirror_x),
rotation = self.rotation % (2 * pi)
return ((type(self), self.string, self.font_path),
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
lambda: Text(
string=self.string,
height=self.height * norm_value,
font_path=self.font_path,
rotation=rotation,
mirrored=(mirror_x, False),
layer=self.layer,
))
).mirror2d(across_x=self.mirrored),
)
def get_bounds(self) -> NDArray[numpy.float64]:
def get_bounds_single(self) -> NDArray[numpy.float64]:
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
# just convert to polygons instead
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons()
for poly in polys:
poly_bounds = poly.get_bounds()
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
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),
))
return bounds
def __repr__(self) -> str:
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
mirrored = ' m{:d}' if self.mirrored else ''
return f'<TextShape "{self.string}" o{self.offset} h{self.height:g}{rotation}{mirrored}>'
def get_char_as_polygons(
font_path: str,
@ -192,7 +221,7 @@ def get_char_as_polygons(
'advance' distance (distance from the start of this glyph to the start of the next one)
"""
if len(char) != 1:
raise Exception('get_char_as_polygons called with non-char')
raise PatternError('get_char_as_polygons called with non-char')
face = Face(font_path)
face.set_char_size(resolution)
@ -201,7 +230,8 @@ def get_char_as_polygons(
outline = slot.outline
start = 0
all_verts_list, all_codes = [], []
all_verts_list = []
all_codes = []
for end in outline.contours:
points = outline.points[start:end + 1]
points.append(points[0])
@ -254,8 +284,3 @@ def get_char_as_polygons(
polygons = path.to_polygons()
return polygons, advance
def __repr__(self) -> str:
rotation = f'{self.rotation*180/pi:g}' if self.rotation != 0 else ''
mirrored = ' m{:d}{:d}'.format(*self.mirrored) if self.mirrored.any() else ''
return f'<TextShape "{self.string}" l{self.layer} o{self.offset} h{self.height:g}{rotation}{mirrored}>'

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ class Mirrorable(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def mirror(self, axis: int) -> Self:
def mirror(self, axis: int = 0) -> Self:
"""
Mirror the entity across an axis.
@ -21,7 +21,7 @@ class Mirrorable(metaclass=ABCMeta):
"""
pass
def mirror2d(self, axes: tuple[bool, bool]) -> Self:
def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self:
"""
Optionally mirror the entity across both axes
@ -31,9 +31,9 @@ class Mirrorable(metaclass=ABCMeta):
Returns:
self
"""
if axes[0]:
if across_x:
self.mirror(0)
if axes[1]:
if across_y:
self.mirror(1)
return self
@ -44,7 +44,7 @@ class Mirrorable(metaclass=ABCMeta):
# """
# __slots__ = ()
#
# _mirrored: numpy.ndarray # ndarray[bool]
# _mirrored: NDArray[numpy.bool]
# """ Whether to mirror the instance across the x and/or y axes. """
#
# #
@ -52,15 +52,15 @@ class Mirrorable(metaclass=ABCMeta):
# #
# # Mirrored property
# @property
# def mirrored(self) -> numpy.ndarray: # ndarray[bool]
# def mirrored(self) -> NDArray[numpy.bool]:
# """ Whether to mirror across the [x, y] axes, respectively """
# return self._mirrored
#
# @mirrored.setter
# def mirrored(self, val: Sequence[bool]):
# def mirrored(self, val: Sequence[bool]) -> None:
# if is_scalar(val):
# raise MasqueError('Mirrored must be a 2-element list of booleans')
# self._mirrored = numpy.array(val, dtype=bool, copy=True)
# self._mirrored = numpy.array(val, dtype=bool)
#
# #
# # Methods

View File

@ -1,5 +1,3 @@
# TODO top-level comment about how traits should set __slots__ = (), and how to use AutoSlots
from typing import Self, Any
from abc import ABCMeta, abstractmethod
@ -60,25 +58,6 @@ class Positionable(metaclass=ABCMeta):
"""
pass
@abstractmethod
def get_bounds(self) -> NDArray[numpy.float64] | None:
"""
Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
Returns `None` for an empty entity.
"""
pass
def get_bounds_nonempty(self) -> NDArray[numpy.float64]:
"""
Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None).
This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()`
"""
bounds = self.get_bounds()
assert bounds is not None
return bounds
class PositionableImpl(Positionable, metaclass=ABCMeta):
"""
@ -94,7 +73,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
#
# offset property
@property
def offset(self) -> Any: # TODO mypy#3003 NDArray[numpy.float64]:
def offset(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
"""
[x, y] offset
"""
@ -102,12 +81,11 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
@offset.setter
def offset(self, val: ArrayLike) -> None:
if not isinstance(val, numpy.ndarray) or val.dtype != numpy.float64:
val = numpy.array(val, dtype=float)
if val.size != 2:
raise MasqueError('Offset must be convertible to size-2 ndarray')
self._offset = val.flatten() # type: ignore
self._offset = val.flatten()
#
# Methods
@ -119,3 +97,26 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
def translate(self, offset: ArrayLike) -> Self:
self._offset += offset # type: ignore # NDArray += ArrayLike should be fine??
return self
class Bounded(metaclass=ABCMeta):
@abstractmethod
def get_bounds(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
"""
Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
Returns `None` for an empty entity.
"""
pass
def get_bounds_nonempty(self, *args, **kwargs) -> NDArray[numpy.float64]:
"""
Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None).
This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()`
"""
bounds = self.get_bounds(*args, **kwargs)
assert bounds is not None
return bounds

View File

@ -1,7 +1,11 @@
from typing import Self, TYPE_CHECKING
from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import NDArray
from ..error import MasqueError
from .positionable import Bounded
_empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass
@ -30,7 +34,7 @@ class Repeatable(metaclass=ABCMeta):
# @repetition.setter
# @abstractmethod
# def repetition(self, repetition: 'Repetition | None'):
# def repetition(self, repetition: 'Repetition | None') -> None:
# pass
#
@ -50,15 +54,19 @@ class Repeatable(metaclass=ABCMeta):
pass
class RepeatableImpl(Repeatable, metaclass=ABCMeta):
class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta):
"""
Simple implementation of `Repeatable`
Simple implementation of `Repeatable` and extension of `Bounded` to include repetition bounds.
"""
__slots__ = _empty_slots
_repetition: 'Repetition | None'
""" Repetition object, or None (single instance only) """
@abstractmethod
def get_bounds_single(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
pass
#
# Non-abstract properties
#
@ -67,7 +75,7 @@ class RepeatableImpl(Repeatable, metaclass=ABCMeta):
return self._repetition
@repetition.setter
def repetition(self, repetition: 'Repetition | None'):
def repetition(self, repetition: 'Repetition | None') -> None:
from ..repetition import Repetition
if repetition is not None and not isinstance(repetition, Repetition):
raise MasqueError(f'{repetition} is not a valid Repetition object!')
@ -79,3 +87,24 @@ class RepeatableImpl(Repeatable, metaclass=ABCMeta):
def set_repetition(self, repetition: 'Repetition | None') -> Self:
self.repetition = repetition
return self
def get_bounds_single_nonempty(self, *args, **kwargs) -> NDArray[numpy.float64]:
"""
Returns `[[x_min, y_min], [x_max, y_max]]` which specify a minimal bounding box for the entity.
Asserts that the entity is non-empty (i.e., `get_bounds()` does not return None).
This is handy for destructuring like `xy_min, xy_max = entity.get_bounds_nonempty()`
"""
bounds = self.get_bounds_single(*args, **kwargs)
assert bounds is not None
return bounds
def get_bounds(self, *args, **kwargs) -> NDArray[numpy.float64] | None:
bounds = self.get_bounds_single(*args, **kwargs)
if bounds is not None and self.repetition is not None:
rep_bounds = self.repetition.get_bounds()
if rep_bounds is None:
return None
bounds += rep_bounds
return bounds

View File

@ -54,7 +54,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta):
return self._rotation
@rotation.setter
def rotation(self, val: float):
def rotation(self, val: float) -> None:
if not numpy.size(val) == 1:
raise MasqueError('Rotation must be a scalar')
self._rotation = val % (2 * pi)
@ -112,10 +112,10 @@ class PivotableImpl(Pivotable, metaclass=ABCMeta):
""" `[x_offset, y_offset]` """
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
pivot = numpy.array(pivot, dtype=float)
pivot = numpy.asarray(pivot, dtype=float)
cast(Positionable, self).translate(-pivot)
cast(Rotatable, self).rotate(rotation)
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # TODO: mypy#3004
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # mypy#3004
cast(Positionable, self).translate(+pivot)
return self

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -1,14 +1,13 @@
"""
2D bin-packing
"""
from typing import Sequence, Callable, Mapping
from collections.abc import Sequence, Mapping, Callable
import numpy
from numpy.typing import NDArray, ArrayLike
from ..error import MasqueError
from ..pattern import Pattern
from ..ref import Ref
def maxrects_bssf(
@ -18,16 +17,34 @@ def maxrects_bssf(
allow_rejects: bool = True,
) -> tuple[NDArray[numpy.float64], set[int]]:
"""
sizes should be Nx2
regions should be Mx4 (xmin, ymin, xmax, ymax)
Pack rectangles `rects` into regions `containers` using the "maximal rectangles best short side fit"
algorithm (maxrects_bssf) from "A thousand ways to pack the bin", Jukka Jylanki, 2010.
This algorithm gives the best results, but is asymptotically slower than `guillotine_bssf_sas`.
Args:
rects: Nx2 array of rectangle sizes `[[x_size0, y_size0], ...]`.
containers: Mx4 array of regions into which `rects` will be placed, specified using their
corner coordinates ` [[x_min0, y_min0, x_max0, y_max0], ...]`.
presort: If `True` (default), largest-shortest-side rectangles will be placed
first. Otherwise, they will be placed in the order provided.
allow_rejects: If `False`, `MasqueError` will be raised if any rectangle cannot be placed.
Returns:
`[[x_min0, y_min0], ...]` placement locations for `rects`, with the same ordering.
The second argument is a set of indicies of `rects` entries which were rejected; their
corresponding placement locations should be ignored.
Raises:
MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
"""
regions = numpy.array(containers, copy=False, dtype=float)
rect_sizes = numpy.array(rects, copy=False, dtype=float)
regions = numpy.asarray(containers, dtype=float)
rect_sizes = numpy.asarray(rects, dtype=float)
rect_locs = numpy.zeros_like(rect_sizes)
rejected_inds = set()
if presort:
rotated_sizes = numpy.sort(rect_sizes, axis=0) # shortest side first
rotated_sizes = numpy.sort(rect_sizes, axis=1) # shortest side first
rect_order = numpy.lexsort(rotated_sizes.T)[::-1] # Descending shortest side
rect_sizes = rect_sizes[rect_order]
@ -45,14 +62,14 @@ def maxrects_bssf(
''' Place the rect '''
# Best short-side fit (bssf) to pick a region
bssf_scores = ((regions[:, 2:] - regions[:, :2]) - rect_size).min(axis=1).astype(float)
region_sizes = regions[:, 2:] - regions[:, :2]
bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit!
rr = bssf_scores.argmin()
if numpy.isinf(bssf_scores[rr]):
if allow_rejects:
rejected_inds.add(rect_ind)
continue
else:
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
# Read out location
@ -81,87 +98,146 @@ def maxrects_bssf(
r_top[:, 1] = loc[1] + rect_size[1]
regions = numpy.vstack((regions[~intersects], r_lft, r_bot, r_rgt, r_top))
if presort:
unsort_order = rect_order.argsort()
rect_locs = rect_locs[unsort_order]
rejected_inds = set(unsort_order[list(rejected_inds)])
return rect_locs, rejected_inds
def guillotine_bssf_sas(rect_sizes: numpy.ndarray,
regions: numpy.ndarray,
def guillotine_bssf_sas(
rects: ArrayLike,
containers: ArrayLike,
presort: bool = True,
allow_rejects: bool = True,
) -> tuple[numpy.ndarray, set[int]]:
) -> tuple[NDArray[numpy.float64], set[int]]:
"""
sizes should be Nx2
regions should be Mx4 (xmin, ymin, xmax, ymax)
#TODO: test me!
# TODO add rectangle-merge?
Pack rectangles `rects` into regions `containers` using the "guillotine best short side fit with
shorter axis split rule" algorithm (guillotine-BSSF-SAS) from "A thousand ways to pack the bin",
Jukka Jylanki, 2010.
This algorithm gives the worse results than `maxrects_bssf`, but is asymptotically faster.
# TODO consider adding rectangle-merge?
# TODO guillotine could use some additional testing
Args:
rects: Nx2 array of rectangle sizes `[[x_size0, y_size0], ...]`.
containers: Mx4 array of regions into which `rects` will be placed, specified using their
corner coordinates ` [[x_min0, y_min0, x_max0, y_max0], ...]`.
presort: If `True` (default), largest-shortest-side rectangles will be placed
first. Otherwise, they will be placed in the order provided.
allow_rejects: If `False`, `MasqueError` will be raised if any rectangle cannot be placed.
Returns:
`[[x_min0, y_min0], ...]` placement locations for `rects`, with the same ordering.
The second argument is a set of indicies of `rects` entries which were rejected; their
corresponding placement locations should be ignored.
Raises:
MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
"""
rect_sizes = numpy.array(rect_sizes)
regions = numpy.asarray(containers, dtype=float)
rect_sizes = numpy.asarray(rects, dtype=float)
rect_locs = numpy.zeros_like(rect_sizes)
rejected_inds = set()
if presort:
rotated_sizes = numpy.sort(rect_sizes, axis=0) # shortest side first
rotated_sizes = numpy.sort(rect_sizes, axis=1) # shortest side first
rect_order = numpy.lexsort(rotated_sizes.T)[::-1] # Descending shortest side
rect_sizes = rect_sizes[rect_order]
for rect_ind, rect_size in enumerate(rect_sizes):
''' Place the rect '''
# Best short-side fit (bssf) to pick a region
bssf_scores = ((regions[:, 2:] - regions[:, :2]) - rect_size).min(axis=1).astype(float)
region_sizes = regions[:, 2:] - regions[:, :2]
bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
bssf_scores[bssf_scores < 0] = numpy.inf # doesn't fit!
rr = bssf_scores.argmin()
if numpy.isinf(bssf_scores[rr]):
if allow_rejects:
rejected_inds.add(rect_ind)
continue
else:
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
# Read out location
loc = regions[rr, :2]
rect_locs[rect_ind] = loc
region_size = regions[rr, 2:] - loc
region_size = region_sizes[rr]
split_horiz = region_size[0] < region_size[1]
new_region0 = regions[rr].copy()
new_region1 = new_region0.copy()
split_vert = loc + rect_size
split_vertex = loc + rect_size
if split_horiz:
new_region0[2] = split_vert[0]
new_region0[1] = split_vert[1]
new_region1[0] = split_vert[0]
new_region0[2] = split_vertex[0]
new_region0[1] = split_vertex[1]
new_region1[0] = split_vertex[0]
else:
new_region0[3] = split_vert[1]
new_region0[0] = split_vert[0]
new_region1[1] = split_vert[1]
new_region0[3] = split_vertex[1]
new_region0[0] = split_vertex[0]
new_region1[1] = split_vertex[1]
regions = numpy.vstack((regions[:rr], regions[rr + 1:],
new_region0, new_region1))
if presort:
unsort_order = rect_order.argsort()
rect_locs = rect_locs[unsort_order]
rejected_inds = set(unsort_order[list(rejected_inds)])
return rect_locs, rejected_inds
def pack_patterns(
library: Mapping[str, Pattern],
patterns: Sequence[str],
regions: numpy.ndarray,
containers: ArrayLike,
spacing: tuple[float, float],
presort: bool = True,
allow_rejects: bool = True,
packer: Callable = maxrects_bssf,
) -> tuple[Pattern, list[str]]:
half_spacing = numpy.array(spacing) / 2
"""
Pick placement locations for `patterns` inside the regions specified by `containers`.
No rotations are performed.
Args:
library: Library from which `Pattern` objects will be drawn.
patterns: Sequence of pattern names which are to be placed.
containers: Mx4 array of regions into which `patterns` will be placed, specified using their
corner coordinates ` [[x_min0, y_min0, x_max0, y_max0], ...]`.
spacing: (x, y) spacing between adjacent patterns. Patterns are effectively expanded outwards
by `spacing / 2` prior to placement, so this also affects pattern position relative to
container edges.
presort: If `True` (default), largest-shortest-side rectangles will be placed
first. Otherwise, they will be placed in the order provided.
allow_rejects: If `False`, `MasqueError` will be raised if any rectangle cannot be placed.
packer: Bin-packing method; see the other functions in this module (namely `maxrects_bssf`
and `guillotine_bssf_sas`).
Returns:
A `Pattern` containing one `Ref` for each entry in `patterns`.
A list of "rejected" pattern names, for which a valid placement location could not be found.
Raises:
MasqueError if `allow_rejects` is `True` but some `rects` could not be placed.
"""
half_spacing = numpy.asarray(spacing, dtype=float) / 2
bounds = [library[pp].get_bounds() for pp in patterns]
sizes = [bb[1] - bb[0] + spacing if bb is not None else spacing for bb in bounds]
offsets = [half_spacing - bb[0] if bb is not None else (0, 0) for bb in bounds]
locations, reject_inds = packer(sizes, regions, presort=presort, allow_rejects=allow_rejects)
locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects)
pat = Pattern()
pat.refs = [Ref(pp, offset=oo + loc)
for pp, oo, loc in zip(patterns, offsets, locations)]
for pp, oo, loc in zip(patterns, offsets, locations, strict=True):
pat.ref(pp, offset=oo + loc)
rejects = [patterns[ii] for ii in reject_inds]
return pat, rejects

View File

@ -6,13 +6,13 @@ and retrieving it (`data_to_ports`).
the port locations. This particular approach is just a sensible default; feel free to
to write equivalent functions for your own format or alternate storage methods.
"""
from typing import Sequence, Mapping
from collections.abc import Sequence, Mapping
import logging
from itertools import chain
import numpy
from ..pattern import Pattern
from ..label import Label
from ..utils import layer_t
from ..ports import Port
from ..error import PatternError
@ -44,9 +44,7 @@ def ports_to_data(pattern: Pattern, layer: layer_t) -> Pattern:
angle_deg = numpy.inf
else:
angle_deg = numpy.rad2deg(port.rotation)
pattern.labels += [
Label(string=f'{name}:{port.ptype} {angle_deg:g}', layer=layer, offset=port.offset)
]
pattern.label(layer=layer, string=f'{name}:{port.ptype} {angle_deg:g}', offset=port.offset)
return pattern
@ -62,8 +60,8 @@ def data_to_ports(
# TODO missing ok?
) -> Pattern:
"""
# TODO fixup documentation in port_utils
# TODO move port_utils to utils.file?
# TODO fixup documentation in ports2data
# TODO move to utils.file?
Examine `pattern` for labels specifying port info, and use that info
to fill out its `ports` attribute.
@ -97,7 +95,7 @@ def data_to_ports(
# Load ports for all subpatterns, and use any we find
found_ports = False
for target in set(rr.target for rr in pattern.refs):
for target in pattern.refs:
if target is None:
continue
pp = data_to_ports(
@ -113,15 +111,18 @@ def data_to_ports(
if not found_ports:
return pattern
for ref in pattern.refs:
if ref.target is None:
for target, refs in pattern.refs.items():
if target is None:
continue
aa = library.abstract(ref.target)
if not refs:
continue
for ref in refs:
aa = library.abstract(target)
if not aa.ports:
continue
break
aa.apply_ref_transform(ref)
pattern.check_ports(other_names=aa.ports.keys())
pattern.ports.update(aa.ports)
return pattern
@ -149,13 +150,13 @@ def data_to_ports_flat(
Returns:
The updated `pattern`. Port labels are not removed.
"""
labels = [ll for ll in pattern.labels if ll.layer in layers]
labels = list(chain.from_iterable(pattern.labels[layer] for layer in layers))
if not labels:
return pattern
pstr = cell_name if cell_name is not None else repr(pattern)
if pattern.ports:
raise PatternError('Pattern "{pstr}" has pre-existing ports!')
raise PatternError(f'Pattern "{pstr}" has pre-existing ports!')
local_ports = {}
for label in labels:

View File

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

View File

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

View File

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