Compare commits

..

142 Commits

Author SHA1 Message Date
jan
dc4f24a028 delete FlatBuilder (Builder subsumes it) 2023-04-11 20:05:50 -07:00
jan
e5de33e1f1 pather fixes / type updates 2023-04-11 19:57:09 -07:00
jan
f22e737e60 add RenderPather 2023-04-11 11:47:57 -07:00
jan
6ec4823244 comment 2023-04-11 11:44:53 -07:00
jan
fa7b94a4c0 split out find_ptransform (static version, only need ports) 2023-04-11 11:44:47 -07:00
jan
9b88be0e92 add todo about underscore 2023-04-08 00:40:52 -07:00
jan
4aad8ab786 shorten labels 2023-04-07 23:50:31 -07:00
jan
de04d06b7a cleanup 2023-04-07 23:49:20 -07:00
jan
8b3f76c2e3 split pather into its own file 2023-04-07 23:20:09 -07:00
jan
66f3ad04b7 comment updates 2023-04-07 23:19:55 -07:00
jan
ed77e389af only mutable variant should have rename_top 2023-04-07 22:29:47 -07:00
jan
372deaca09 fixes 2023-04-07 22:00:23 -07:00
jan
8b92d1ee96 add functions for dealing with the topcell and its name 2023-04-07 21:53:48 -07:00
jan
e7a1d1824a add mktree 2023-04-07 18:13:21 -07:00
jan
9c9d3c3928 redo library class naming 2023-04-07 18:08:42 -07:00
jan
c7505a12b0 should be union; we want to exclude dangling refs 2023-04-07 16:55:50 -07:00
jan
abef8771db fixes to subtree and lshift, as well as some cast() improvements 2023-04-07 16:48:40 -07:00
jan
f1baf8b577 oneshot available at toplevel 2023-04-07 16:33:59 -07:00
jan
355af43fe4 add @oneshot decorator 2023-04-07 16:33:23 -07:00
jan
e8b5c7dec8 lshift operator shouldn't special-case trees
Instead, just call .tops() if there are multiple cells, and fail if
there are multiple tops
2023-04-07 15:29:14 -07:00
jan
438b81e62e find_toplevel -> tops 2023-04-07 15:20:51 -07:00
jan
41409cf4f7 create no longer exists. Make mk() give similar ordering as mkpat() 2023-04-06 17:09:46 -07:00
jan
0a14325af8 fix return value 2023-04-06 17:06:41 -07:00
jan
5b1abf5f72 top is always a string 2023-04-06 17:06:13 -07:00
jan
463c41b62a cleanup 2023-04-06 17:03:31 -07:00
jan
62b82eb230 get rid of NamedPattern in favor of just returning a tuple 2023-04-06 16:52:01 -07:00
3e48cc7190 Drop ports when repeating 2023-03-31 13:35:18 -07:00
2364288ba7 port translation is already handled in Pattern 2023-03-31 13:34:49 -07:00
ddcd38674f drop ability to use python-gdsii 2023-03-19 10:18:01 -07:00
742058885f fix rounding 2023-03-19 10:17:37 -07:00
0917b02a31 str(namedpattern) should just return its name 2023-03-19 10:17:30 -07:00
c515ada2f8 updates to Pattern.polygonize() 2023-03-19 10:17:09 -07:00
68ac593270 update to newer ezdxf 2023-03-19 10:16:54 -07:00
e87b13c4eb Need to check against self, since we may add new conflicts as we go 2023-02-24 09:34:26 -08:00
f5d1fd2c29 Pipe-operator does not support forward references 2023-02-23 16:23:06 -08:00
28424be3db add polygon() and label() convenience methods 2023-02-23 13:42:26 -08:00
a710494dd8 use Self type 2023-02-23 13:37:34 -08:00
c9402500e2 modernize type annotations 2023-02-23 13:15:32 -08:00
dfd745a76b fix error message 2023-02-23 11:26:07 -08:00
23c64b4f63 remove per-shape polygonization state 2023-02-23 11:25:40 -08:00
7a4a96ff5f fixes based on mypy 2023-02-09 16:43:06 -08:00
3191866ce0 add prune_empty and delete() 2023-02-09 16:38:42 -08:00
8c42145e44 fixes/updates 2023-02-09 16:38:33 -08:00
1d720b6577 Drop ports by default 2023-02-08 09:26:44 -08:00
38a7ba6434 force 'wb' mode for gzipfile 2023-02-08 09:26:24 -08:00
2e8d06ad6e 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-02-08 08:51:30 -08:00
ea1a882c4e pass along library for bounds 2023-02-08 08:46:38 -08:00
ed8f2c1864 fix precache 2023-02-08 08:44:42 -08:00
492565c1a6 redo library merging 2023-02-08 08:44:36 -08:00
c6b8027b4d pass along tools 2023-02-08 08:44:17 -08:00
e8348dfa75 Make default quiet for underscores 2023-02-07 14:34:47 -08:00
81b381e031 always apply postprocess 2023-02-07 14:25:56 -08:00
cca6b90830 misc fixes 2023-02-07 14:24:34 -08:00
d079b44883 Revert "allow ports2data to take a tree"
This reverts commit 44f823c736.
LazyLibrary can't take Trees anymore, so no need for it.
2023-02-07 09:19:01 -08:00
1d649389a0 LazyLibrary should not contain Trees
altering itself during iteration is not a good idea
2023-02-06 19:01:42 -08:00
dc2c12c26f missing import 2023-02-06 19:00:56 -08:00
a1c4cdee1e fix type for __contains__ 2023-02-06 18:58:53 -08:00
44f823c736 allow ports2data to take a tree 2023-02-06 12:39:43 -08:00
9d466882a0 misc fixes 2023-02-06 12:11:53 -08:00
369bad9ae4 Only allow 1-sized Libraries 2023-02-04 09:28:53 -08:00
ee6d857cad Allow lshift to operate on any library. If only one name, return it, else None 2023-02-04 09:08:05 -08:00
5e2018a1a1 add missing functions to tree 2023-02-04 09:06:51 -08:00
5446a8c40b add Pather.mk() 2023-02-04 09:06:31 -08:00
a9188c5655 add name arg 2023-02-04 09:06:22 -08:00
05f327387e pather reorganization/clenaup 2023-02-04 09:05:34 -08:00
482ca058bb add lshift operator to MutableLibrary 2023-01-31 22:50:10 -08:00
c69081331c set default for library to None 2023-01-31 22:33:45 -08:00
4a2c4c5220 Turn Builder into a subset of Pather 2023-01-31 22:28:02 -08:00
83c710a85f fix add_tree operator 2023-01-31 15:33:42 -08:00
81171e9b02 Allow LazyLibrary to store Trees as well? 2023-01-31 15:31:54 -08:00
02da37a890 Use lshift for tree combination 2023-01-31 15:31:22 -08:00
7da6f5126b stringy type 2023-01-31 12:07:50 -08:00
b9848d149c ergonomics 2023-01-31 12:05:44 -08:00
454f167340 Add Tree as a possible way to allow construction of whole subtrees at once 2023-01-30 18:38:56 -08:00
7191e5f62c Add move_references() and auto-move references during add()-with-rename
Also remove enable_cache, since we now rely on the cache.
2023-01-30 14:38:23 -08:00
3be4da3e7c implement auto-renaming during merge, and change _merge() to support it 2023-01-30 13:30:59 -08:00
jan
6f6143da1a remove some trailing undescores 2023-01-29 16:05:22 -08:00
3105a669b4 add NamedPattern 2023-01-27 10:07:39 -08:00
171d61ccab add .create() 2023-01-27 09:24:17 -08:00
95485ab4cd notes on organization 2023-01-26 23:51:13 -08:00
0be7f9d42a note in comments 2023-01-26 23:47:27 -08:00
7fc0902fe7 Add recurse arg to get_bounds 2023-01-26 23:47:16 -08:00
3758df6938 remove log messages 2023-01-26 19:51:15 -08:00
3205091286 Return WrapLibrary from read() and readfile() 2023-01-26 19:28:10 -08:00
9351f5b5f8 Default to adding ports at the origin 2023-01-26 19:16:34 -08:00
658eca5eea some cleanup 2023-01-26 16:49:42 -08:00
783953bb73 add FlatBuilder 2023-01-26 16:49:35 -08:00
afab6fd940 import ports2data at top level 2023-01-26 15:37:36 -08:00
7adaea32ec add library .rename(...) 2023-01-26 14:26:00 -08:00
d6b897131b missing comma 2023-01-26 13:54:04 -08:00
6172abf77c writefile should write to a temporary file first 2023-01-26 13:54:00 -08:00
6cbdd7930d add name_and_set 2023-01-26 11:43:55 -08:00
a061c5a2d9 add missing comments 2023-01-26 11:43:49 -08:00
f80c21ed4d Allow library __setitem__ to take in either Pattern or Callable
No longer need it to be Generic!
2023-01-26 11:36:27 -08:00
089e5192e3 various fixes and cleanup
mainly involving ports_to_data and data_to_ports
2023-01-25 23:57:02 -08:00
6599dad48f move builder.port_utils into utils.ports2data
and rename functions
2023-01-25 23:26:06 -08:00
c2ce9ed547 more fixes and improvements 2023-01-25 23:19:25 -08:00
6eb4af3203 get things working with a LazyLibrary hack while we think about cycles 2023-01-24 23:52:32 -08:00
22735125d5 Lots of progress on tutorials 2023-01-24 23:25:10 -08:00
34a5369a55 Add note about reproducibility for DXF 2023-01-24 14:13:46 -08:00
7cec1e84c9 remove dead code 2023-01-24 13:58:49 -08:00
592b91044b formatting 2023-01-24 13:43:49 -08:00
060f6978cd Fix extra vertex added during OASIS loading 2023-01-24 13:43:22 -08:00
1b04fb7ed0 lots of fixes to get test_rep running 2023-01-24 12:45:44 -08:00
88b64bf525 improve gzipped file reproducibility
Mostly avoid writing the old filename and modification time to the gzip
header
2023-01-24 12:45:21 -08:00
bd14ef37c7 clarify comment 2023-01-23 23:01:14 -08:00
95c41d4519 get rid of Mapping stuff on PortsList 2023-01-23 22:58:55 -08:00
c0b9b7fe81 add todos 2023-01-23 22:49:21 -08:00
189f517dcf add AbstractView 2023-01-23 22:48:31 -08:00
9f041e51f4 Move Abstract into its own file 2023-01-23 22:42:37 -08:00
09d76203e8 handle library=None 2023-01-23 22:35:15 -08:00
2302d29433 library can generate abstracts 2023-01-23 22:34:58 -08:00
09b7ecd80e B becomes BB for searchability 2023-01-23 22:34:44 -08:00
aff0df33cc PortsRef -> Abstract 2023-01-23 22:34:31 -08:00
326c9b9727 flake8-aided fixes 2023-01-23 22:27:26 -08:00
8484628f2f fix more type issues 2023-01-22 22:16:09 -08:00
6565b8baa3 more wip -- most central stuff is first pass done 2023-01-22 16:59:32 -08:00
df1acd7c87 wip -- more fixes 2023-01-21 23:38:53 -08:00
jan
743428d8d7 wip 2023-01-21 21:22:11 -08:00
jan
e482107366 wip 2023-01-19 22:20:16 -08:00
a15131f22a busL -> mpath 2023-01-18 22:32:57 -08:00
81eadffa56 comment out some ipython commands 2023-01-18 18:15:51 -08:00
cb8897b8fe some type updates 2023-01-18 18:14:53 -08:00
83b9af0cc3 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-01-18 18:14:33 -08:00
dfdceefdcd fix some type-related issues 2023-01-18 17:15:14 -08:00
ecb61c9174 get rid of "identifier" 2023-01-18 17:14:35 -08:00
jan
1741cfb755 wip again 2023-01-13 20:33:14 -08:00
jan
58894fa596 delete duplicate utils submodule 2023-01-13 16:08:17 -08:00
5bb67c817f partial work on device libraries 2023-01-12 23:06:39 -08:00
fd045e16d4 various fixes 2023-01-12 23:06:12 -08:00
073ccacee9 remove duplicatre __delitem__ 2023-01-12 23:06:02 -08:00
804b662780 improve docs 2023-01-12 23:05:45 -08:00
14e9a7ccbe indirect type spec for Pattern 2023-01-12 23:04:59 -08:00
jan
9bb7ddbb79 Add lib types 2023-01-12 02:23:36 -08:00
jan
273d828d87 bifurcate Device into DeviceRef 2023-01-11 20:19:31 -08:00
b7df6a3f73 add notes about what is hard 2023-01-11 19:00:06 -08:00
b4eee6b84e make error message prettier 2023-01-11 19:00:06 -08:00
jan
42c3a2b1e1 WIP: make libraries and names first-class! 2023-01-11 18:59:57 -08:00
fff20b3da9 Avoid generating a container if only a single port is passed 2023-01-11 18:32:08 -08:00
4bae737630 allow bounds to be passed as args 2023-01-11 18:32:08 -08:00
9891ba9e47 allow passing a single Tool to be used as the default 2023-01-11 18:32:08 -08:00
df320e80cc Add functionality for building paths (single use wires/waveguides/etc) 2023-01-11 18:32:01 -08:00
56 changed files with 1958 additions and 5411 deletions

234
README.md
View File

@ -8,221 +8,38 @@ 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.11
* python >= 3.8
* numpy
* 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
* 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`)
Install with pip:
```bash
pip install 'masque[oasis,dxf,svg,visualization,text]'
pip3 install 'masque[visualization,oasis,dxf,svg,text]'
```
## 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
Alternatively, install from git
```bash
pip3 install git+https://mpxd.net/code/jan/masque.git@release
```
### 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=..., ...)
```
## 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"
## TODO
@ -230,8 +47,13 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
* Better interface for polygon operations (e.g. with `pyclipper`)
- de-embedding
- boolean ops
* Tests tests tests
* check renderpather
* pather and renderpather examples
* context manager for retool
* allow a specific mismatch when connecting ports
* 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

View File

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

View File

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

View File

@ -16,10 +16,8 @@ def main():
cell_name = 'ellip_grating'
pat = masque.Pattern()
layer = (0, 0)
for rmin in numpy.arange(10, 15, 0.5):
pat.shapes[layer].append(Arc(
pat.shapes.append(Arc(
radii=(rmin, rmin),
width=0.1,
angles=(0 * -pi/4, pi/4),
@ -37,27 +35,27 @@ def main():
print(f'\nAdded a copy of {cell_name} as {new_name}')
pat3 = Pattern()
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),
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),
]
lib['sref_test'] = pat3
@ -72,34 +70,33 @@ def main():
b_count=2,
)
pat4 = Pattern()
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),
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),
]
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,39 +1 @@
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
```
TODO write tutorial readme

View File

@ -1,4 +1,4 @@
from collections.abc import Sequence
from typing import Sequence
import numpy
from numpy import pi
@ -32,10 +32,9 @@ def hole(
Returns:
Pattern containing a circle.
"""
pat = Pattern()
pat.shapes[layer].append(
Circle(radius=radius, offset=(0, 0))
)
pat = Pattern(shapes=[
Circle(radius=radius, offset=(0, 0), layer=layer),
])
return pat
@ -59,9 +58,8 @@ def triangle(
(numpy.cos( - pi / 6), numpy.sin( - pi / 6)),
]) * radius
pat = Pattern()
pat.shapes[layer].extend([
Polygon(offset=(0, 0), vertices=vertices),
pat = Pattern(shapes=[
Polygon(offset=(0, 0), layer=layer, vertices=vertices),
])
return pat
@ -86,18 +84,16 @@ def smile(
pat = Pattern()
# Add all the shapes we want
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)),
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),
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,4 +1,5 @@
from collections.abc import Sequence, Mapping
# TODO update tutorials
from typing import Sequence, Mapping
import numpy
from numpy import pi
@ -30,7 +31,7 @@ def ports_to_data(pat: Pattern) -> Pattern:
def data_to_ports(lib: Mapping[str, Pattern], name: str, pat: Pattern) -> Pattern:
"""
Scan the Pattern to determine port locations. Same port format as `ports_to_data`
Scans 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)
@ -81,18 +82,18 @@ def perturbed_l3(
# Build L3 cavity, using references to the provided hole pattern
pat = Pattern()
pat.refs[hole] += [
Ref(scale=r, offset=(lattice_constant * x,
lattice_constant * y))
pat.refs += [
Ref(hole, scale=r, offset=(lattice_constant * x,
lattice_constant * y))
for x, y, r in xyr]
# Add rectangular undercut aids
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
trench_dx = max_xy[0] - min_xy[0]
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),
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),
]
# Ports are at outer extents of the device (with y=0)
@ -130,9 +131,9 @@ def waveguide(
# Build the pattern
pat = Pattern()
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
pat.refs += [
Ref(hole, offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
# Ports are at outer edges, with y=0
@ -169,9 +170,9 @@ def bend(
# Build the pattern
pat= Pattern()
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
pat.refs += [
Ref(hole, offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
# Figure out port locations.
@ -208,9 +209,9 @@ def y_splitter(
# Build pattern
pat = Pattern()
pat.refs[hole] += [
Ref(offset=(lattice_constant * x,
lattice_constant * y))
pat.refs += [
Ref(hole, offset=(lattice_constant * x,
lattice_constant * y))
for x, y in xy]
# Determine port locations
@ -245,41 +246,32 @@ def main(interactive: bool = True) -> None:
devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5)
devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :)
# Turn our dict of devices into a Library.
# This provides some convenience functions in the future!
# Turn our dict of devices into a Library -- useful for getting abstracts
lib = Library(devices)
abv = lib.abstract_view() # lets us use abv[cell] instead of lib.abstract(cell)
#
# Build a circuit
#
# Create a `Builder`, and add the circuit to our library as "my_circuit".
circ = Builder(library=lib, name='my_circuit')
circ = Builder(library=lib)
# Start by placing a waveguide. Call its ports "in" and "signal".
circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
circ.place(abv['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('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.
circ.plug(abv['wg10'], {'signal': 'left'})
# 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('ysplit', {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'})
circ.plug(abv['ysplit'], {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'})
# Add a waveguide to both signal ports, inheriting their names.
circ.plug('wg05', {'signal1': 'left'})
circ.plug('wg05', {'signal2': 'left'})
circ.plug(abv['wg05'], {'signal1': 'left'})
circ.plug(abv['wg05'], {'signal2': 'left'})
# Add a bend to both ports.
# Our bend's ports "left" and "right" refer to the original counterclockwise
@ -288,22 +280,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('bend0', {'signal1': 'right'})
circ.plug('bend0', {'signal2': 'left'})
circ.plug(abv['bend0'], {'signal1': 'right'})
circ.plug(abv['bend0'], {'signal2': 'left'})
# We add some waveguides and a cavity to "signal1".
circ.plug('wg10', {'signal1': 'left'})
circ.plug('l3cav', {'signal1': 'input'})
circ.plug('wg10', {'signal1': 'left'})
circ.plug(abv['wg10'], {'signal1': 'left'})
circ.plug(abv['l3cav'], {'signal1': 'input'})
circ.plug(abv['wg10'], {'signal1': 'left'})
# "signal2" just gets a single of equivalent length
circ.plug('wg28', {'signal2': 'left'})
circ.plug(abv['wg28'], {'signal2': 'left'})
# Now we bend both waveguides back towards each other
circ.plug('bend0', {'signal1': 'right'})
circ.plug('bend0', {'signal2': 'left'})
circ.plug('wg05', {'signal1': 'left'})
circ.plug('wg05', {'signal2': 'left'})
circ.plug(abv['bend0'], {'signal1': 'right'})
circ.plug(abv['bend0'], {'signal2': 'left'})
circ.plug(abv['wg05'], {'signal1': 'left'})
circ.plug(abv['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.
@ -311,16 +303,19 @@ 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('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
circ.plug(abv['ysplit'], {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
# Finally, add some more waveguide to "signal_out".
circ.plug('wg10', {'signal_out': 'left'})
circ.plug(abv['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,5 +1,4 @@
from typing import Any
from collections.abc import Sequence, Callable
from typing import Sequence, Callable
from pprint import pformat
import numpy
@ -39,7 +38,7 @@ def main() -> None:
#
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
opts: dict[str, Any] = dict(
opts = dict(
lattice_constant = devices.LATTICE_CONSTANT,
hole = 'triangle',
)
@ -61,23 +60,22 @@ 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.
# This wouldn't work if we only had a `Pattern` (not a `Builder`).
# it from the library already within the Builder object:
# Just pass the pattern name!
circ2.plug('tri_wg10', {'input': 'right'})
circ2.plug('tri_wg10', {'output': 'left'})
# Add the circuit to the device library.
lib['mixed_wg_cav'] = circ2.pattern
# 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)
#
@ -89,7 +87,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) # mirror since no tri y-symmetry
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=(True, False)) # mirror since no tri y-symmetry
circ3.plug('tri_bend0', {'input': 'right'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('bend0', {'output': 'left'})
@ -98,7 +96,7 @@ def main() -> None:
circ3.plug('tri_wg28', {'input': 'right'})
circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'})
lib['loop_segment'] = circ3.pattern
lib.set_const('loop_segment', circ3.pattern)
#
# Write all devices into a GDS file
@ -130,6 +128,7 @@ if __name__ == '__main__':
# name = port_map.get(name, name)
# if name is None:
# continue
# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer)
# self.pattern.labels += [
# Label(string=name, offset=self.ports[name].offset, layer=layer)]
# return self
#

View File

@ -1,277 +0,0 @@
"""
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 collection.abc import Sequence
from typing 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.asarray(shifts_a)
tmp_r = numpy.asarray(shifts_r)
tmp_a = numpy.array(shifts_a)
tmp_r = numpy.array(shifts_r)
n_shifted = max(tmp_a.size, tmp_r.size)
shifts_a = numpy.ones(n_shifted)

View File

@ -1,96 +0,0 @@
"""
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 compact library for designing lithography
masque is an attempt to make a relatively small 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,73 +20,32 @@
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, 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.
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
"""
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 .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 .library import (
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,
ILibraryView, ILibrary,
LibraryView, Library, LazyLibrary,
AbstractView,
)
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__ = '3.2'
__version__ = '2.7'
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
from .utils import rotation_matrix_2d, normalize_mirror
#if TYPE_CHECKING:
# from .builder import Builder, Tool
@ -18,12 +18,6 @@ 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
@ -48,6 +42,23 @@ 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:
@ -97,7 +108,7 @@ class Abstract(PortList):
Returns:
self
"""
pivot = numpy.asarray(pivot, dtype=float)
pivot = numpy.array(pivot)
self.translate_ports(-pivot)
self.rotate_ports(rotation)
self.rotate_port_offsets(rotation)
@ -132,7 +143,7 @@ class Abstract(PortList):
port.rotate(rotation)
return self
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
def mirror_port_offsets(self, across_axis: int) -> Self:
"""
Mirror the offsets of all shapes, labels, and refs across an axis
@ -147,7 +158,7 @@ class Abstract(PortList):
port.offset[across_axis - 1] *= -1
return self
def mirror_ports(self, across_axis: int = 0) -> Self:
def mirror_ports(self, across_axis: int) -> Self:
"""
Mirror each port's rotation across an axis, relative to its
offset
@ -163,7 +174,7 @@ class Abstract(PortList):
port.mirror(across_axis)
return self
def mirror(self, across_axis: int = 0) -> Self:
def mirror(self, across_axis: int) -> Self:
"""
Mirror the Pattern across an axis
@ -189,10 +200,11 @@ class Abstract(PortList):
Returns:
self
"""
if ref.mirrored:
self.mirror()
self.rotate_ports(ref.rotation)
self.rotate_port_offsets(ref.rotation)
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)
self.translate_ports(ref.offset)
return self
@ -209,9 +221,10 @@ 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(-ref.rotation)
self.rotate_ports(-ref.rotation)
if ref.mirrored:
self.mirror(0)
self.rotate_port_offsets(-angle - ref.rotation)
self.rotate_ports(-angle - ref.rotation)
if mirrored_across_x:
self.mirror(across_axis=0)
return self

View File

@ -1,10 +1,5 @@
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,
)
from .builder import Builder
from .pather import Pather
from .renderpather import RenderPather
from .utils import ell
from .tools import Tool, render_step_t

View File

@ -1,17 +1,14 @@
"""
Simplified Pattern assembly (`Builder`)
"""
from typing import Self
from collections.abc import Sequence, Mapping
from typing import Self, Sequence, Mapping, Literal, overload, Final, cast
import copy
import logging
from functools import wraps
from numpy import pi
from numpy.typing import ArrayLike
from ..pattern import Pattern
from ..library import ILibrary, TreeView
from ..error import BuildError
from ..ref import Ref
from ..library import ILibrary
from ..error import PortError, BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
@ -21,44 +18,39 @@ logger = logging.getLogger(__name__)
class Builder(PortList):
"""
A `Builder` is a helper object used for snapping together multiple
lower-level patterns at their `Port`s.
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.
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.
`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).
`Builder` can also be `set_dead()`, at which point further calls to `plug()`
and `place()` are ignored (intended for debugging).
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 Builder
Examples: Creating a Device
===========================
- `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(pattern, ports={'A': port_a, 'C': port_c})` uses an existing
pattern and defines some ports.
- `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`
- `Device(ports=None)` makes a new empty pattern with
default ports ('A' and 'B', in opposite directions, at (0, 0)).
- `Builder(library, pattern=pattern, name='mypat')` uses an existing
pattern (including its ports) and sets `library['mypat'] = 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.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.
- `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_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
=============================
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
@ -83,9 +75,10 @@ class Builder(PortList):
pattern: Pattern
""" Layout of this device """
library: ILibrary
library: ILibrary | None
"""
Library from which patterns should be referenced
Library from which existing patterns should be referenced, and to which
new ones should be added
"""
_dead: bool
@ -101,22 +94,14 @@ class Builder(PortList):
def __init__(
self,
library: ILibrary,
library: ILibrary | None = None,
*,
pattern: Pattern | None = None,
ports: str | Mapping[str, Port] | None = None,
name: str | None = None,
) -> None:
"""
Args:
library: The library from which referenced patterns will be taken
pattern: The pattern which will be modified by subsequent operations.
If `None` (default), a new pattern is created.
ports: Allows specifying the initial set of ports, if `pattern` does
not already have any ports (or is not provided). May be a string,
in which case it is interpreted as a name in `library`.
Default `None` (no ports).
name: If specified, `library[name]` is set to `self.pattern`.
# TODO documentation for Builder() constructor
"""
self._dead = False
self.library = library
@ -129,16 +114,20 @@ 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: type['Builder'],
cls,
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
@ -148,15 +137,31 @@ class Builder(PortList):
name: str | None = None,
) -> 'Builder':
"""
Wrapper for `Pattern.interface()`, which returns a Builder instead.
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.
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.
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).
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
@ -180,71 +185,112 @@ 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):
source = library.abstract(source).ports
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}')
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
new = Builder(library=library, pattern=pat, name=name)
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)
return new
@wraps(Pattern.label)
def label(self, *args, **kwargs) -> Self:
self.pattern.label(*args, **kwargs)
return self
@wraps(Pattern.ref)
def ref(self, *args, **kwargs) -> Self:
self.pattern.ref(*args, **kwargs)
return self
@wraps(Pattern.polygon)
def polygon(self, *args, **kwargs) -> Self:
self.pattern.polygon(*args, **kwargs)
return self
@wraps(Pattern.rect)
def rect(self, *args, **kwargs) -> Self:
self.pattern.rect(*args, **kwargs)
return self
# Note: We're a superclass of `Pather`, where path() means something different...
#@wraps(Pattern.path)
#def path(self, *args, **kwargs) -> Self:
# self.pattern.path(*args, **kwargs)
# return self
# @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
def plug(
self,
other: Abstract | str | Pattern | TreeView,
other: Abstract | str | Pattern,
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: bool = False,
mirrored: tuple[bool, bool] = (False, False),
inherit_name: bool = True,
set_rotation: bool | None = None,
append: bool = False,
) -> Self:
"""
Wrapper around `Pattern.plug` which allows a string for `other`.
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`.
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`.
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.
Args:
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
device to be instatiated. If it is a `TreeView`, it is first
added into `self.library`, after which the topcell is plugged;
an equivalent statement is `self.plug(self.library << other, ...)`.
other: An `Abstract` 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.
mirrored: Enables mirroring `other` across the x or y axes 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
@ -257,9 +303,6 @@ 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
@ -276,64 +319,114 @@ 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]
self.pattern.plug(
other=other,
map_in=map_in,
map_out=map_out,
# 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,
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 | TreeView,
other: Abstract | str | Pattern,
*,
offset: ArrayLike = (0, 0),
rotation: float = 0,
pivot: ArrayLike = (0, 0),
mirrored: bool = False,
mirrored: tuple[bool, bool] = (False, False),
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
) -> Self:
"""
Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.
Instantiate or append the device `other` into the current device, adding its
ports to those of the current device (but not connecting any ports).
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`.
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.
Args:
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
device to be instatiated. If it is a `TreeView`, it is first
added into `self.library`, after which the topcell is plugged;
an equivalent statement is `self.plug(self.library << other, ...)`.
other: An `Abstract` 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.
mirrored: Whether theinstance should be mirrored across the x and y axes.
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
@ -348,25 +441,51 @@ 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]
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,
)
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)
return self
def translate(self, offset: ArrayLike) -> Self:
@ -398,7 +517,7 @@ class Builder(PortList):
port.rotate_around(pivot, angle)
return self
def mirror(self, axis: int = 0) -> Self:
def mirror(self, axis: int) -> Self:
"""
Mirror the pattern and all ports across the specified axis.
@ -409,6 +528,8 @@ class Builder(PortList):
self
"""
self.pattern.mirror(axis)
for p in self.ports.values():
p.mirror(axis)
return self
def set_dead(self) -> Self:
@ -430,7 +551,7 @@ class Builder(PortList):
return self
def __repr__(self) -> str:
s = f'<Builder {self.pattern} L({len(self.library)})>'
s = f'<Builder {self.pattern} >' # TODO maybe show lib and tools? in builder repr?
return s

View File

@ -1,22 +1,17 @@
"""
Manual wire/waveguide routing (`Pather`)
"""
from typing import Self
from collections.abc import Sequence, MutableMapping, Mapping
from typing import Self, 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, SINGLE_USE_PREFIX
from ..library import ILibrary
from ..error import PortError, BuildError
from ..ports import PortList, Port
from ..abstract import Abstract
from ..utils import SupportsBool, rotation_matrix_2d
from ..utils import SupportsBool
from .tools import Tool
from .utils import ell
from .builder import Builder
@ -27,87 +22,57 @@ logger = logging.getLogger(__name__)
class Pather(Builder):
"""
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.
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.
`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.
`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).
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 Pather
Examples: Creating a Device
===========================
- `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(pattern, ports={'A': port_a, 'C': port_c})` uses an existing
pattern and defines some ports.
- `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`
- `Device(ports=None)` makes a new empty pattern with
default ports ('A' and 'B', in opposite directions, at (0, 0)).
- `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.build('my_layout')` makes a new pattern and instantiates
`my_device` in it with offset (0, 0) as a base for further building.
- `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.
- `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_pather, ...)` 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 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'.
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`,
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
argument is provided, and the `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.
- `pather.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
- `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 `pather.pattern`. Port 'A' of `pad` is
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.
- `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',)
@ -119,9 +84,8 @@ class Pather(Builder):
tools: dict[str | None, Tool]
"""
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`.
Tool objects are used to dynamically generate new single-use Devices
(e.g wires or waveguides) to be plugged into this device.
"""
def __init__(
@ -134,19 +98,13 @@ class Pather(Builder):
name: str | None = None,
) -> None:
"""
Args:
library: The library from which referenced patterns will be taken,
and where new patterns (e.g. generated by the `tools`) will be placed.
pattern: The pattern which will be modified by subsequent operations.
If `None` (default), a new pattern is created.
ports: Allows specifying the initial set of ports, if `pattern` does
not already have any ports (or is not provided). May be a string,
in which case it is interpreted as a name in `library`.
Default `None` (no ports).
tools: A mapping of {port: tool} which specifies what `Tool` should be used
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
are called. Relies on `Tool.path` implementations.
name: If specified, `library[name]` is set to `self.pattern`.
# 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).
"""
self._dead = False
self.library = library
@ -163,9 +121,6 @@ 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):
@ -173,29 +128,40 @@ 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: type['Pather'],
cls,
builder: Builder,
*,
library: ILibrary | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
) -> 'Pather':
"""
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)
"""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)
return new
@classmethod
def interface(
cls: type['Pather'],
cls,
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
@ -206,36 +172,7 @@ class Pather(Builder):
name: str | None = None,
) -> 'Pather':
"""
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.
TODO doc pather.interface
"""
if library is None:
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
@ -246,15 +183,21 @@ class Pather(Builder):
if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
tools = source.tools
if isinstance(source, str):
source = library.abstract(source).ports
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
new = Pather(library=library, pattern=pat, name=name, tools=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,
)
return new
def __repr__(self) -> str:
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
s = f'<Pather {self.pattern} >' # TODO maybe show lib and tools? in builder repr?
return s
def retool(
@ -262,18 +205,6 @@ 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:
@ -288,112 +219,36 @@ class Pather(Builder):
length: float,
*,
tool_port_names: tuple[str, str] = ('A', 'B'),
plug_into: str | None = None,
base_name: str = '_path',
**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
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})
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]})
def path_to(
self,
portspec: str,
ccw: SupportsBool | None,
position: float | None = None,
position: float,
*,
x: float | None = None,
y: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'),
plug_into: str | None = None,
base_name: str = '_pathto',
**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()')
@ -402,156 +257,15 @@ class Pather(Builder):
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if y is not None:
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
if position is None:
position = x
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)
else:
if x is not None:
raise BuildError('Asked to path to x-coordinate, but port is vertical')
if position is None:
position = y
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)
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
return self.path(portspec, ccw, length, tool_port_names=tool_port_names, base_name=base_name, **kwargs)
def mpath(
self,
@ -562,84 +276,9 @@ class Pather(Builder):
set_rotation: float | None = None,
tool_port_names: tuple[str, str] = ('A', 'B'),
force_container: bool = False,
base_name: str = SINGLE_USE_PREFIX + 'mpath',
base_name: str = '_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
@ -648,17 +287,14 @@ class Pather(Builder):
if 'bound_type' in kwargs:
bound_types.add(kwargs['bound_type'])
bound = kwargs['bound']
del kwargs['bound_type']
del kwargs['bound']
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
for bt in ('emin', 'emax', 'pmin', 'pmax', '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')
if len(bound_types) > 1:
elif len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
@ -671,16 +307,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, **kwargs)
return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names)
else:
bld = Pather.interface(source=ports, library=self.library, tools=self.tools)
for port_name, length in extensions.items():
bld.path(port_name, ccw, length, tool_port_names=tool_port_names)
name = self.library.get_name(base_name)
self.library[name] = bld.pattern
return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports.keys()}) # TODO safe to use 'in_'?
bld = Pather.interface(source=ports, library=self.library, tools=self.tools)
for port_name, length in extensions.items():
bld.path(port_name, ccw, length, tool_port_names=tool_port_names, **kwargs)
name = self.library.get_name(base_name)
self.library[name] = bld.pattern
return self.plug(Abstract(name, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
# TODO def bus_join()?
# TODO def path_join() and def bus_join()?
def flatten(self) -> Self:
"""

View File

@ -1,62 +1,42 @@
"""
Pather with batched (multi-step) rendering
"""
from typing import Self
from collections.abc import Sequence, Mapping, MutableMapping
from typing import Self, Sequence, Mapping, Final
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, RenderStep
from .tools import Tool, render_step_t
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
library: ILibrary | None
""" Library from which patterns should be referenced """
_dead: bool
""" If True, plug()/place() are skipped (for debugging) """
paths: defaultdict[str, list[RenderStep]]
""" Per-port list of operations, to be used by `render` """
paths: defaultdict[str, list[render_step_t]]
# op, start_port, dx, dy, o_ptype tool
tools: dict[str | None, Tool]
"""
@ -74,30 +54,17 @@ class RenderPather(PortList):
def __init__(
self,
library: ILibrary,
library: ILibrary | None = None,
*,
pattern: Pattern | None = None,
ports: str | Mapping[str, Port] | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
name: str | None = None,
) -> None:
"""
Args:
library: The library from which referenced patterns will be taken,
and where new patterns (e.g. generated by the `tools`) will be placed.
pattern: The pattern which will be modified by subsequent operations.
If `None` (default), a new pattern is created.
ports: Allows specifying the initial set of ports, if `pattern` does
not already have any ports (or is not provided). May be a string,
in which case it is interpreted as a name in `library`.
Default `None` (no ports).
tools: A mapping of {port: tool} which specifies what `Tool` should be used
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
are called. Relies on `Tool.planL` and `Tool.render` implementations.
name: If specified, `library[name]` is set to `self.pattern`.
# TODO documentation for Builder() constructor
"""
self._dead = False
self.paths = defaultdict(list)
self.library = library
if pattern is not None:
self.pattern = pattern
@ -119,37 +86,46 @@ class RenderPather(PortList):
raise BuildError('Name was supplied, but no library was given!')
library[name] = self.pattern
if tools is None:
self.tools = {}
elif isinstance(tools, Tool):
self.tools = {None: tools}
else:
self.tools = dict(tools)
self.paths = defaultdict(list)
@classmethod
def interface(
cls: type['RenderPather'],
cls,
source: PortList | Mapping[str, Port] | str,
*,
library: ILibrary | None = None,
tools: Tool | MutableMapping[str | None, Tool] | None = None,
in_prefix: str = 'in_',
out_prefix: str = '',
port_map: dict[str, str] | Sequence[str] | None = None,
name: str | None = None,
) -> 'RenderPather':
"""
Wrapper for `Pattern.interface()`, which returns a RenderPather instead.
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.
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.
from which to create the interface.
library: Used for buildin functions; if not passed and the source
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`).
and to which new ones should be added. If not provided,
the source's library will be used (if available).
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
@ -161,7 +137,7 @@ class RenderPather(PortList):
renamed (to the values).
Returns:
The new `RenderPather`, with an empty pattern and 2x as many ports as
The new builder, with an empty pattern and 2x as many ports as
listed in port_map.
Raises:
@ -173,17 +149,42 @@ 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):
source = library.abstract(source).ports
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}')
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)
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)
return new
def plug(
@ -192,84 +193,48 @@ class RenderPather(PortList):
map_in: dict[str, str],
map_out: dict[str, str | None] | None = None,
*,
mirrored: bool = False,
mirrored: tuple[bool, bool] = (False, 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):
other_tgt = self.library.abstract(other)
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
if self.library is None:
raise BuildError('No library available, but `other` was a string!')
other = self.library.abstract(other)
# get rid of plugged ports
for kk in map_in:
if kk in self.paths:
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
# 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()))}
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))
if map_out is None:
map_out = {}
map_out = copy.deepcopy(map_out)
self.pattern.plug(
other=other_tgt,
map_in=map_in,
map_out=map_out,
self.check_ports(other.ports.keys(), map_in, map_out)
translation, rotation, pivot = self.find_transform(
other,
map_in,
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 ki in self.paths:
self.paths[ki].append(('P', None, 0.0, 0.0, 'unk', None))
self.place(other, offset=translation, rotation=rotation, pivot=pivot,
mirrored=mirrored, port_map=map_out, skip_port_check=True)
return self
def place(
@ -279,94 +244,45 @@ class RenderPather(PortList):
offset: ArrayLike = (0, 0),
rotation: float = 0,
pivot: ArrayLike = (0, 0),
mirrored: bool = False,
mirrored: tuple[bool, bool] = (False, False),
port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False,
append: bool = False,
) -> Self:
"""
Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P'
for any affected ports. This separates any future `RenderStep`s on the
same port into a new batch, since the placed device interferes with drawing.
Note that mirroring is applied before rotation; translation (`offset`) is applied last.
Args:
other: An `Abstract` or `Pattern` describing the device to be instatiated.
offset: Offset at which to place the instance. Default (0, 0).
rotation: Rotation applied to the instance before placement. Default 0.
pivot: Rotation is applied around this pivot point (default (0, 0)).
Rotation is applied prior to translation (`offset`).
mirrored: Whether theinstance should be mirrored across the x axis.
Mirroring is applied before translation and rotation.
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
new names for ports in the instantiated pattern. New names can be
`None`, which will delete those ports.
skip_port_check: Can be used to skip the internal call to `check_ports`,
in case it has already been performed elsewhere.
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
Returns:
self
Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other.ports`.
`PortError` if there are any duplicate names after `map_in` and `map_out`
are applied.
"""
if self._dead:
logger.error('Skipping place() since device is dead')
return self
other_tgt: Pattern | Abstract
if isinstance(other, str):
other_tgt = self.library.abstract(other)
if append and isinstance(other, Abstract):
other_tgt = self.library[other.name]
if self.library is None:
raise BuildError('No library available, but `other` was a string!')
other = self.library.abstract(other)
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 port_map is None:
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,
)
if not skip_port_check:
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
return self
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))
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`.
for name, port in ports.items():
p = port.deepcopy()
p.mirror2d(mirrored)
p.rotate_around(pivot, rotation)
p.translate(offset)
self.ports[name] = p
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
sp = Ref(other.name, mirrored=mirrored)
sp.rotate_around(pivot, rotation)
sp.translate(offset)
self.pattern.refs.append(sp)
return self
def path(
@ -376,31 +292,6 @@ 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
@ -408,79 +299,53 @@ 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 for RenderPather.path()?
assert port_rot is not None # TODO allow manually setting rotation?
tool = self.tools.get(portspec, self.tools[None])
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
bend_radius, out_ptype = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
# Update port
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
if ccw is None:
bend_run = 0.0
elif bool(ccw):
bend_run = bend_radius
else:
bend_run = -bend_radius
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
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)
self.pattern.ports[portspec] = out_port.copy()
# Update port
port.offset += (dx, dy)
if ccw is not None:
port.rotate((-1 if ccw else 1) * pi / 2)
port.ptype = out_ptype
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 | None = None,
*,
x: float | None = None,
y: float | None = None,
position: float,
**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()')
@ -489,25 +354,13 @@ class RenderPather(PortList):
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if y is not None:
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
if position is None:
position = x
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)
else:
if x is not None:
raise BuildError('Asked to path to x-coordinate, but port is vertical')
if position is None:
position = y
x0, y0 = port.offset
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
length = numpy.abs(position - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0)
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)
return self.path(portspec, ccw, length, **kwargs)
@ -520,32 +373,6 @@ 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
@ -554,14 +381,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', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
for bt in ('emin', 'emax', 'pmin', 'pmax', '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')
if len(bound_types) > 1:
elif len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
@ -580,61 +407,48 @@ class RenderPather(PortList):
self.path(port_name, ccw, length)
return self
def render(
self,
append: bool = True,
) -> Self:
"""
Generate the geometry which has been planned out with `path`/`path_to`/etc.
def render(self, lib: ILibrary | None = None) -> Self:
lib = lib if lib is not None else self.library
assert lib is not None
Args:
append: If `True`, the rendered geometry will be directly appended to
`self.pattern`. Note that it will not be flattened, so if only one
layer of hierarchy is eliminated.
Returns:
self
"""
lib = self.library
tool_port_names = ('A', 'B')
pat = Pattern()
def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
assert batch[0].tool is not None
name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
pat.ports[portspec] = batch[0].start_port.copy()
if append:
pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append)
del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened
else:
pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append)
bb = Builder(lib)
for portspec, steps in self.paths.items():
batch: list[RenderStep] = []
batch: list[render_step_t] = []
for step in steps:
appendable_op = step.opcode in ('L', 'S', 'U')
same_tool = batch and step.tool == batch[0].tool
opcode, _start_port, _dx, _dy, _out_ptype, tool = step
appendable_op = opcode in ('L', 'S', 'U')
same_tool = batch and tool == batch[-1]
# If we can't continue a batch, render it
if batch and (not appendable_op or not same_tool):
render_batch(portspec, batch, append)
# 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]})
batch = []
# batch is emptied already if we couldn't continue it
# batch is emptied already if we couldn't
if appendable_op:
batch.append(step)
# Opcodes which break the batch go below this line
if not appendable_op and portspec in pat.ports:
del pat.ports[portspec]
if not appendable_op:
del bb.ports[portspec]
#If the last batch didn't end yet
if batch:
render_batch(portspec, batch, append)
# 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]})
self.paths.clear()
pat.ports.clear()
self.pattern.append(pat)
bb.ports.clear()
self.pattern.append(bb.pattern)
return self
@ -697,7 +511,7 @@ class RenderPather(PortList):
return self
def __repr__(self) -> str:
s = f'<Pather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
s = f'<RenderPather {self.pattern} >' # TODO maybe show lib and tools? in builder repr?
return s

View File

@ -1,61 +1,26 @@
"""
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
# TODO document all tools
"""
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
from typing import TYPE_CHECKING, Sequence, Literal, Callable
from abc import ABCMeta, abstractmethod
import numpy
from numpy.typing import NDArray
from numpy import pi
from ..utils import SupportsBool, rotation_matrix_2d, layer_t
from ..utils import SupportsBool, rotation_matrix_2d
from ..ports import Port
from ..pattern import Pattern
from ..abstract import Abstract
from ..library import ILibrary, Library, SINGLE_USE_PREFIX
from ..library import ILibrary, Library
from ..error import BuildError
from .builder import Builder
@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"')
render_step_t = (
tuple[Literal['L', 'S', 'U'], Port, float, float, str, 'Tool']
| tuple[Literal['P'], None, float, float, str, None]
)
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,
@ -65,40 +30,7 @@ class Tool:
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> 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.
"""
) -> Pattern:
raise NotImplementedError(f'path() not implemented for {type(self)}')
def planL(
@ -109,167 +41,38 @@ class Tool:
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs,
) -> 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.
"""
) -> tuple[float, str]:
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,
) -> 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.
"""
) -> str: # out_ptype only?
raise NotImplementedError(f'planS() not implemented for {type(self)}')
def planU(
self,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**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],
batch: Sequence[render_step_t],
*,
port_names: Sequence[str] = ('A', 'B'), # noqa: ARG002 (unused)
**kwargs, # noqa: ARG002 (unused)
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: Sequence[str] = ('A', 'B'),
**kwargs,
) -> ILibrary:
"""
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
assert batch[0][-1] == 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]
""" `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
bend: tuple[Abstract, str, str] # Assumed to be clockwise
transitions: dict[str, tuple[Abstract, str, str]]
def path(
self,
@ -280,63 +83,26 @@ class BasicTool(Tool, metaclass=ABCMeta):
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
_out_port, data = self.planL(
ccw,
length,
in_ptype=in_ptype,
out_ptype=out_ptype,
)
) -> Pattern:
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
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
brot = bend.ports[bport_in].rotation
assert brot is not None
bend_dxy = numpy.abs(
rotation_matrix_2d(-brot) @ (
bend.ports[bport_out].offset
- bend.ports[bport_in].offset
)
)
bend_angle = angle_out - angle_in
if bool(ccw):
bend_dxy[1] *= -1
bend_angle *= -1
straight_length -= bend_dxy[0]
bend_run += bend_dxy[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:
@ -347,6 +113,9 @@ 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)
@ -355,199 +124,35 @@ 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 + bend_angle) @ (
otrans_dxy = rotation_matrix_2d(-orot) @ (
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 transitions:\n'
f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g} out_trans: {otrans_dxy[0]:,g}'
)
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:
ipat, iport_theirs, _iport_ours = in_transition
pat.plug(ipat, {port_names[1]: iport_theirs})
if not numpy.isclose(straight_length, 0):
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:
bend, bport_in, bport_out = self.bend
pat.plug(bend, {port_names[1]: bport_in}, mirrored=bool(ccw))
if out_transition:
opat, oport_theirs, oport_ours = out_transition
pat.plug(opat, {port_names[1]: oport_ours})
return tree
@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}')
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]}')
gen_straight, sport_in, sport_out = self.straight
tree = Library()
bb = Builder(library=tree, name='_path').add_port_pair(names=port_names)
if in_transition:
bb.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})
if ccw is not None:
bend_dxy = numpy.array([1, -1]) * self.width / 2
bend_angle = pi / 2
bb.plug(bend, {port_names[1]: bport_in}, mirrored=(False, bool(ccw)))
if out_transition:
bb.plug(opat, {port_names[1]: oport_ours})
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]
return bb.pattern
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,5 +1,4 @@
from typing import SupportsFloat, cast, TYPE_CHECKING
from collections.abc import Mapping, Sequence
from typing import Mapping, Sequence, SupportsFloat, cast, TYPE_CHECKING
from pprint import pformat
import numpy
@ -54,9 +53,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', 'pmin', 'xmin', 'ymin':
- 'min_position' or 'pmin':
The coordinate of the innermost bend (D's bend).
- 'max_position', 'pmax', 'xmax', 'ymax':
- 'max_position' or 'pmax':
The coordinate of the outermost bend (A's bend).
`bound` can also be a vector. If specifying an extension (e.g. 'min_extension',
@ -110,12 +109,6 @@ 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)
@ -123,8 +116,6 @@ 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])
@ -193,16 +184,16 @@ def ell(
rot_bound = -bound if neg else bound
min_possible = x_start + offsets
if bound_type in ('pmax', 'max_position', 'xmax', 'ymax'):
if bound_type in ('pmax', 'max_position'):
extension = rot_bound - min_possible.max()
elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'):
elif bound_type in ('pmin', 'min_position'):
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, strict=True)))
+ '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets)))
result = dict(zip(ports.keys(), offsets, strict=True))
result = dict(zip(ports.keys(), offsets))
return result

View File

@ -6,8 +6,7 @@ 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, cast, TextIO, IO
from collections.abc import Mapping, Callable
from typing import Any, Callable, Mapping, cast, TextIO, IO
import io
import logging
import pathlib
@ -16,14 +15,13 @@ 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, normalize_mirror
from ..utils import rotation_matrix_2d, layer_t
logger = logging.getLogger(__name__)
@ -40,7 +38,7 @@ def write(
top_name: str,
stream: TextIO,
*,
dxf_version: str = 'AC1024',
dxf_version='AC1024',
) -> None:
"""
Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes
@ -206,25 +204,26 @@ def read(
return mlib, library_info
def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]:
def _read_block(block) -> tuple[str, Pattern]:
name = block.name
pat = Pattern()
for element in block:
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]
eltype = element.dxftype()
if eltype in ('POLYLINE', 'LWPOLYLINE'):
if eltype == 'LWPOLYLINE':
points = numpy.array(tuple(element.lwpoints))
else:
points = numpy.array(tuple(element.points()))
attr = element.dxfattribs()
layer = attr.get('layer', DEFAULT_LAYER)
if points.shape[1] == 2:
raise PatternError('Invalid or unimplemented polygon?')
if points.shape[1] > 2:
#shape = Polygon(layer=layer)
elif points.shape[1] > 2:
if (points[0, 2] != points[:, 2]).any():
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
if points.shape[1] == 4 and (points[:, 3] != 0).any():
elif points.shape[1] == 4 and (points[:, 3] != 0).any():
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
width = points[0, 2]
@ -233,15 +232,15 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
shape: Path | Polygon
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
shape = Polygon(vertices=points[:-1, :2])
shape = Polygon(layer=layer, vertices=points[:-1, :2])
else:
shape = Path(width=width, vertices=points[:, :2])
shape = Path(layer=layer, width=width, vertices=points[:, :2])
pat.shapes[layer].append(shape)
pat.shapes.append(shape)
elif isinstance(element, Text):
elif eltype in ('TEXT',):
args = dict(
offset=numpy.asarray(element.get_placement()[1])[:2],
offset=numpy.array(element.get_pos()[1])[:2],
layer=element.dxfattribs().get('layer', DEFAULT_LAYER),
)
string = element.dxfattribs().get('text', '')
@ -249,20 +248,20 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
# 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.label(string=string, **args)
pat.labels.append(Label(string=string, **args))
# else:
# pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????))
elif isinstance(element, Insert):
# pat.shapes.append(Text(string=string, height=height, font_path=????))
elif eltype in ('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, extra_angle = normalize_mirror((yscale < 0, xscale < 0))
rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle
mirrored = (yscale < 0, xscale < 0)
rotation = numpy.deg2rad(attr.get('rotation', 0))
offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]
offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2]
args = dict(
target=attr.get('name', None),
@ -287,13 +286,17 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
def _mrefs_to_drefs(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
refs: dict[str | None, list[Ref]],
refs: list[Ref],
) -> None:
def mk_blockref(encoded_name: str, ref: Ref) -> None:
for ref in refs:
if ref.target is None:
continue
encoded_name = ref.target
rotation = numpy.rad2deg(ref.rotation) % 360
attribs = dict(
xscale=ref.scale,
yscale=ref.scale * (-1 if ref.mirrored else 1),
xscale=ref.scale * (-1 if ref.mirrored[1] else 1),
yscale=ref.scale * (-1 if ref.mirrored[0] else 1),
rotation=rotation,
)
@ -327,47 +330,36 @@ 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: dict[layer_t, list[Shape]],
shapes: list[Shape],
polygonize_paths: bool = False,
) -> None:
# Add `LWPolyline`s for each shape.
# Could set do paths with width setting, but need to consider endcaps.
# 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.'
)
for shape in shapes:
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.'
)
for polygon in shape.to_polygons():
xy_open = polygon.vertices + polygon.offset
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
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, :]))
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
def _labels_to_texts(
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
labels: dict[layer_t, list[Label]],
labels: list[Label],
) -> None:
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)
for label in labels:
attribs = dict(layer=_mlayer2dxf(label.layer))
xy = label.offset
block.add_text(label.string, dxfattribs=attribs).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
def _mlayer2dxf(layer: layer_t) -> str:

View File

@ -19,8 +19,7 @@ Notes:
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
"""
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
from typing import Callable, Iterable, Mapping, IO, cast, Any
import io
import mmap
import logging
@ -38,7 +37,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, annotations_t
from ..utils import layer_t, normalize_mirror, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
@ -145,7 +144,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', compresslevel=6))
stream = cast(IO[bytes], gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb'))
streams = (stream,) + streams
else:
stream = base_stream
@ -254,21 +253,21 @@ def read_elements(
elements = klamath.library.read_elements(stream)
for element in elements:
if isinstance(element, klamath.elements.Boundary):
layer, poly = _boundary_to_polygon(element, raw_mode)
pat.shapes[layer].append(poly)
poly = _boundary_to_polygon(element, raw_mode)
pat.shapes.append(poly)
elif isinstance(element, klamath.elements.Path):
layer, path = _gpath_to_mpath(element, raw_mode)
pat.shapes[layer].append(path)
path = _gpath_to_mpath(element, raw_mode)
pat.shapes.append(path)
elif isinstance(element, klamath.elements.Text):
pat.label(
layer=element.layer,
label = Label(
offset=element.xy.astype(float),
layer=element.layer,
string=element.string.decode('ASCII'),
annotations=_properties_to_annotations(element.properties),
)
pat.labels.append(label)
elif isinstance(element, klamath.elements.Reference):
target, ref = _gref_to_mref(element)
pat.refs[target].append(ref)
pat.refs.append(_gref_to_mref(element))
return pat
@ -288,7 +287,7 @@ def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]:
return layer, data_type
def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
def _gref_to_mref(ref: klamath.library.Reference) -> Ref:
"""
Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name.
"""
@ -302,19 +301,19 @@ def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, 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,
mirrored=(ref.invert_y, False),
annotations=_properties_to_annotations(ref.properties),
repetition=repetition,
)
return target, mref
return mref
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]:
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path:
if gpath.path_type in path_cap_map:
cap = path_cap_map[gpath.path_type]
else:
@ -322,6 +321,7 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_
mpath = Path(
vertices=gpath.xy.astype(float),
layer=gpath.layer,
width=gpath.width,
cap=cap,
offset=numpy.zeros(2),
@ -330,72 +330,74 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_
)
if cap == Path.Cap.SquareCustom:
mpath.cap_extensions = gpath.extension
return gpath.layer, mpath
return mpath
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
return boundary.layer, Polygon(
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> Polygon:
return 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: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
def _mrefs_to_grefs(refs: list[Ref]) -> list[klamath.library.Reference]:
grefs = []
for target, rseq in refs.items():
if target is None:
for ref in refs:
if ref.target is None:
continue
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) % 360
properties = _annotations_to_properties(ref.annotations, 512)
encoded_name = ref.target.encode('ASCII')
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.asarray(ref.offset) + numpy.array([
[0.0, 0.0],
rep.a_vector * rep.a_count,
b_vector * b_count,
])
aref = klamath.library.Reference(
# Note: GDS mirrors first and rotates second
mirror_across_x, extra_angle = normalize_mirror(ref.mirrored)
rep = ref.repetition
angle_deg = numpy.rad2deg(ref.rotation + extra_angle) % 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([
[0.0, 0.0],
rep.a_vector * rep.a_count,
b_vector * b_count,
])
aref = klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast(xy),
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
angle_deg=angle_deg,
invert_y=mirror_across_x,
mag=ref.scale,
properties=properties,
)
grefs.append(aref)
elif rep is None:
sref = klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset]),
colrow=None,
angle_deg=angle_deg,
invert_y=mirror_across_x,
mag=ref.scale,
properties=properties,
)
grefs.append(sref)
else:
new_srefs = [
klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast(xy),
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
angle_deg=angle_deg,
invert_y=ref.mirrored,
mag=ref.scale,
properties=properties,
)
grefs.append(aref)
elif rep is None:
sref = klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset]),
xy=rint_cast([ref.offset + dd]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
invert_y=mirror_across_x,
mag=ref.scale,
properties=properties,
)
grefs.append(sref)
else:
new_srefs = [
klamath.library.Reference(
struct_name=encoded_name,
xy=rint_cast([ref.offset + dd]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
mag=ref.scale,
properties=properties,
)
for dd in rep.displacements]
grefs += new_srefs
for dd in rep.displacements]
grefs += new_srefs
return grefs
@ -409,8 +411,8 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
for key, vals in annotations.items():
try:
i = int(key)
except ValueError as err:
raise PatternError(f'Annotation key {key} is not convertable to an integer') from err
except ValueError:
raise PatternError(f'Annotation key {key} is not convertable to an integer')
if not (0 < i < 126):
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])')
@ -426,41 +428,51 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -
def _shapes_to_elements(
shapes: dict[layer_t, list[Shape]],
shapes: 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 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.')
for shape in shapes:
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.')
properties = _annotations_to_properties(shape.annotations, 128)
if isinstance(shape, Path) and not polygonize_paths:
xy = rint_cast(shape.vertices + shape.offset)
width = rint_cast(shape.width)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
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)
width = rint_cast(shape.width)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
extension: tuple[int, int]
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
extension = tuple(shape.cap_extensions) # type: ignore
else:
extension = (0, 0)
extension: tuple[int, int]
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
extension = tuple(shape.cap_extensions) # type: ignore
else:
extension = (0, 0)
path = klamath.elements.Path(
layer=(layer, data_type),
xy=xy,
path_type=path_type,
width=int(width),
extension=extension,
properties=properties,
)
elements.append(path)
elif isinstance(shape, Polygon):
polygon = shape
path = klamath.elements.Path(
layer=(layer, data_type),
xy=xy,
path_type=path_type,
width=int(width),
extension=extension,
properties=properties,
)
elements.append(path)
elif isinstance(shape, Polygon):
polygon = shape
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
xy_closed[-1] = xy_closed[0]
boundary = klamath.elements.Boundary(
layer=(layer, data_type),
xy=xy_closed,
properties=properties,
)
elements.append(boundary)
else:
for polygon in shape.to_polygons():
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
xy_closed[-1] = xy_closed[0]
@ -470,40 +482,28 @@ def _shapes_to_elements(
properties=properties,
)
elements.append(boundary)
else:
for polygon in shape.to_polygons():
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
xy_closed[-1] = xy_closed[0]
boundary = klamath.elements.Boundary(
layer=(layer, data_type),
xy=xy_closed,
properties=properties,
)
elements.append(boundary)
return elements
def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]:
def _labels_to_texts(labels: list[Label]) -> list[klamath.elements.Text]:
texts = []
for mlayer, lseq in labels.items():
layer, text_type = _mlayer2gds(mlayer)
for label in lseq:
properties = _annotations_to_properties(label.annotations, 128)
xy = rint_cast([label.offset])
text = klamath.elements.Text(
layer=(layer, text_type),
xy=xy,
string=label.string.encode('ASCII'),
properties=properties,
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)
for label in labels:
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,
)
texts.append(text)
return texts
@ -597,19 +597,19 @@ def load_libraryfile(
path = pathlib.Path(filename)
stream: IO[bytes]
if is_gzipped(path):
if use_mmap:
if mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
gz_stream = gzip.open(path, mode='rb')
stream = io.BytesIO(gz_stream.read()) # type: ignore
else:
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
gz_stream = gzip.open(path, mode='rb')
stream = io.BufferedReader(gz_stream) # type: ignore
else: # noqa: PLR5501
if use_mmap:
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
else:
if mmap:
base_stream = open(path, mode='rb', buffering=0)
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
else:
stream = path.open(mode='rb') # noqa: SIM115
stream = open(path, mode='rb')
return load_library(stream, full_load=full_load, postprocess=postprocess)

View File

@ -14,8 +14,7 @@ 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, IO, cast
from collections.abc import Sequence, Iterable, Mapping, Callable
from typing import Any, Callable, Iterable, IO, Mapping, cast, Sequence
import logging
import pathlib
import gzip
@ -31,9 +30,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 Path, Circle
from ..shapes import Polygon, Path, Circle
from ..repetition import Grid, Arbitrary, Repetition
from ..utils import layer_t, annotations_t
from ..utils import layer_t, normalize_mirror, annotations_t
logger = logging.getLogger(__name__)
@ -285,20 +284,23 @@ 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)
pat.polygon(
poly = 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 PatternError('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
cap = cap_start
path_args: dict[str, Any] = {}
@ -309,7 +311,7 @@ def read(
))
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.path(
path = Path(
vertices=vertices,
layer=element.get_layer_tuple(),
offset=element.get_xy(),
@ -320,17 +322,20 @@ 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)
pat.polygon(
rect = 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())
@ -358,13 +363,14 @@ def read(
vertices[2, 0] -= b
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.polygon(
trapz = 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()
@ -413,24 +419,25 @@ def read(
vertices[0, 1] += width
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
pat.polygon(
ctrapz = 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[layer].append(circle)
pat.shapes.append(circle)
elif isinstance(element, fatrec.Text):
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
@ -439,23 +446,21 @@ def read(
string = lib.textstrings[str_or_ref].string
else:
string = str_or_ref.string
pat.label(
label = 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:
target, ref = _placement_to_ref(placement, lib)
if isinstance(target, int):
target = lib.cellnames[target].nstring.string
pat.refs[target].append(ref)
pat.refs.append(_placement_to_ref(placement, lib))
mlib[cell_name] = pat
@ -479,9 +484,9 @@ def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]:
return layer, data_type
def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> tuple[int | str, Ref]:
def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> Ref:
"""
Helper function to create a Ref from a placment. Also returns the placement name (or id).
Helper function to create a Ref from a placment. Sets ref.target to the placement name.
"""
assert not isinstance(placement.repetition, fatamorgana.ReuseRepetition)
xy = numpy.array((placement.x, placement.y))
@ -496,124 +501,124 @@ 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,
mirrored=(placement.flip, False),
rotation=rotation,
scale=float(mag),
repetition=repetition_fata2masq(placement.repetition),
annotations=annotations,
)
return name, ref
return ref
def _refs_to_placements(
refs: dict[str | None, list[Ref]],
refs: list[Ref],
) -> list[fatrec.Placement]:
placements = []
for target, rseq in refs.items():
if target is None:
for ref in refs:
if ref.target is None:
continue
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) % 360
placement = fatrec.Placement(
name=target,
flip=ref.mirrored,
angle=angle,
magnification=ref.scale,
properties=annotations_to_properties(ref.annotations),
x=offset[0],
y=offset[1],
repetition=frep,
)
# Note: OASIS mirrors first and rotates second
mirror_across_x, extra_angle = normalize_mirror(ref.mirrored)
frep, rep_offset = repetition_masq2fata(ref.repetition)
placements.append(placement)
offset = rint_cast(ref.offset + rep_offset)
angle = numpy.rad2deg(ref.rotation + extra_angle) % 360
placement = fatrec.Placement(
name=ref.target,
flip=mirror_across_x,
angle=angle,
magnification=ref.scale,
properties=annotations_to_properties(ref.annotations),
x=offset[0],
y=offset[1],
repetition=frep,
)
placements.append(placement)
return placements
def _shapes_to_elements(
shapes: dict[layer_t, list[Shape]],
shapes: 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 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):
offset = rint_cast(shape.offset + rep_offset)
radius = rint_cast(shape.radius)
circle = fatrec.Circle(
for shape in shapes:
layer, datatype = layer2oas(shape.layer)
repetition, rep_offset = repetition_masq2fata(shape.repetition)
properties = annotations_to_properties(shape.annotations)
if isinstance(shape, Circle):
offset = rint_cast(shape.offset + rep_offset)
radius = rint_cast(shape.radius)
circle = fatrec.Circle(
layer=layer,
datatype=datatype,
radius=cast(int, radius),
x=offset[0],
y=offset[1],
properties=properties,
repetition=repetition,
)
elements.append(circle)
elif isinstance(shape, Path):
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
half_width = rint_cast(shape.width / 2)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
path = fatrec.Path(
layer=layer,
datatype=datatype,
point_list=cast(Sequence[Sequence[int]], deltas),
half_width=cast(int, half_width),
x=xy[0],
y=xy[1],
extension_start=extension_start, # TODO implement multiple cap types?
extension_end=extension_end,
properties=properties,
repetition=repetition,
)
elements.append(path)
else:
for polygon in shape.to_polygons():
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
elements.append(fatrec.Polygon(
layer=layer,
datatype=datatype,
radius=cast(int, radius),
x=offset[0],
y=offset[1],
properties=properties,
repetition=repetition,
)
elements.append(circle)
elif isinstance(shape, Path):
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
half_width = rint_cast(shape.width / 2)
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
path = fatrec.Path(
layer=layer,
datatype=datatype,
point_list=cast(Sequence[Sequence[int]], deltas),
half_width=cast(int, half_width),
x=xy[0],
y=xy[1],
extension_start=extension_start, # TODO implement multiple cap types?
extension_end=extension_end,
point_list=cast(list[list[int]], points),
properties=properties,
repetition=repetition,
)
elements.append(path)
else:
for polygon in shape.to_polygons():
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
elements.append(fatrec.Polygon(
layer=layer,
datatype=datatype,
x=xy[0],
y=xy[1],
point_list=cast(list[list[int]], points),
properties=properties,
repetition=repetition,
))
))
return elements
def _labels_to_texts(
labels: dict[layer_t, list[Label]],
labels: list[Label],
layer2oas: Callable[[layer_t], tuple[int, int]],
) -> list[fatrec.Text]:
texts = []
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)
texts.append(fatrec.Text(
layer=layer,
datatype=datatype,
x=xy[0],
y=xy[1],
string=label.string,
properties=properties,
repetition=repetition,
))
for label in labels:
layer, datatype = layer2oas(label.layer)
repetition, rep_offset = repetition_masq2fata(label.repetition)
xy = rint_cast(label.offset + rep_offset)
properties = annotations_to_properties(label.annotations)
texts.append(fatrec.Text(
layer=layer,
datatype=datatype,
x=xy[0],
y=xy[1],
string=label.string,
properties=properties,
repetition=repetition,
))
return texts
@ -695,9 +700,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 collections.abc import Mapping
from typing 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', stacklevel=1)
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox')
else:
bounds_min, bounds_max = bounds
@ -65,24 +65,22 @@ def writefile(
for name, pat in library.items():
svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red')
for layer, shapes in pat.shapes.items():
for shape in shapes:
for polygon in shape.to_polygons():
path_spec = poly2path(polygon.vertices + polygon.offset)
for shape in pat.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'] = layer
path = svg.path(d=path_spec)
if custom_attributes:
path['pattern_layer'] = polygon.layer
svg_group.add(path)
svg_group.add(path)
for target, refs in pat.refs.items():
if target is None:
for ref in pat.refs:
if ref.target is None:
continue
for ref in refs:
transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})'
use = svg.use(href='#' + mangle_name(target), transform=transform)
svg_group.add(use)
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)
svg_group.add(use)
svg.defs.add(svg_group)
svg.add(svg.use(href='#' + mangle_name(top)))
@ -117,7 +115,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', stacklevel=1)
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox')
else:
bounds_min, bounds_max = bounds
@ -135,10 +133,9 @@ def writefile_inverted(
path_spec = poly2path(slab_edge)
# Draw polygons with reversed vertex order
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)
for shape in pattern.shapes:
for polygon in shape.to_polygons():
path_spec += poly2path(polygon.vertices[::-1] + polygon.offset)
svg.add(svg.path(d=path_spec, fill='blue', stroke='red'))
svg.save()
@ -154,9 +151,9 @@ def poly2path(vertices: ArrayLike) -> str:
Returns:
SVG path-string.
"""
verts = numpy.asarray(vertices)
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032
verts = numpy.array(vertices, copy=False)
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1])
for vertex in verts[1:]:
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1])
commands += ' Z '
return commands

View File

@ -1,93 +1,21 @@
"""
Helper functions for file reading and writing
"""
from typing import IO
from collections.abc import Iterator, Mapping
from typing import IO, Iterator
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, Library, LibraryError
from .. import Pattern, PatternError
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.
@ -114,22 +42,21 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern:
Returns:
pat
"""
for shapes in pat.shapes.values():
remove_inds = []
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 shapes[ii]
remove_inds = []
for ii, shape in enumerate(pat.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]
return pat
def is_gzipped(path: pathlib.Path) -> bool:
with path.open('rb') as stream:
with open(path, 'rb') as stream:
magic_bytes = stream.read(2)
return magic_bytes == b'\x1f\x8b'

View File

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

View File

@ -1,38 +1,29 @@
"""
Library classes for managing unique name->pattern mappings and deferred loading or execution.
Library classes for managing unique name->pattern mappings and
deferred loading or creation.
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()`.
# TODO documentn all library classes
# TODO toplevel documentation of library, classes, and abstracts
"""
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
from typing import Callable, Self, Type, TYPE_CHECKING, cast
from typing import Iterator, Mapping, MutableMapping, Sequence
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, NDArray
from numpy.typing import ArrayLike
from .error import LibraryError, PatternError
from .utils import layer_t, apply_transforms
from .utils import rotation_matrix_2d, normalize_mirror
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
@ -41,64 +32,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class visitor_function_t(Protocol):
""" Signature for `Library.dfs()` visitor functions. """
def __call__(
self,
pattern: 'Pattern',
hierarchy: tuple[str | None, ...],
memo: dict,
transform: NDArray[numpy.float64] | Literal[False],
) -> 'Pattern':
...
TreeView: TypeAlias = Mapping[str, 'Pattern']
""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """
Tree: TypeAlias = MutableMapping[str, 'Pattern']
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
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?
visitor_function_t = Callable[..., 'Pattern']
def _rename_patterns(lib: 'ILibraryView', name: str) -> str:
"""
The default `rename_theirs` function for `ILibrary.add`.
Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as
"one-offs" for which name conflicts should be automatically resolved.
Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)`
where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`.
Names lacking the prefix are directly returned (not renamed).
Args:
lib: The library into which `name` is to be added (but is presumed to conflict)
name: The original name, to be modified
Returns:
The new name, not guaranteed to be conflict-free!
"""
if not name.startswith(SINGLE_USE_PREFIX):
# TODO document rename function
if not name.startswith('_'): # TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
return name
stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
return lib.get_name(SINGLE_USE_PREFIX + stem)
stem = name.split('$')[0]
return lib.get_name(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]:
@ -106,14 +52,7 @@ 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:
@ -128,6 +67,9 @@ 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,
@ -174,7 +116,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
tops = tuple(self.keys())
if skip is None:
skip = {None}
skip = set([None])
if isinstance(tops, str):
tops = (tops,)
@ -211,7 +153,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str):
tops = (tops,)
keep = cast(set[str], self.referenced_patterns(tops) - {None})
keep = cast(set[str], self.referenced_patterns(tops) - set((None,)))
keep |= set(tops)
filtered = {kk: vv for kk, vv in self.items() if kk in keep}
@ -263,19 +205,14 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def flatten(
self,
tops: str | Sequence[str],
flatten_ports: bool = False,
flatten_ports: bool = False, # TODO document
) -> dict[str, 'Pattern']:
"""
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`.
Removes all refs and adds equivalent shapes.
Also flattens all referenced patterns.
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.
@ -283,15 +220,17 @@ 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: str) -> None:
def flatten_single(name) -> None:
flattened[name] = None
pat = self[name].deepcopy()
for target in pat.refs:
for ref in pat.refs:
target = ref.target
if target is None:
continue
if target not in flattened:
flatten_single(target)
@ -301,11 +240,10 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
if target_pat.is_empty(): # avoid some extra allocations
continue
for ref in pat.refs[target]:
p = ref.as_pattern(pattern=target_pat)
if not flatten_ports:
p.ports.clear()
pat.append(p)
p = ref.as_pattern(pattern=flattened[target])
if not flatten_ports:
p.ports.clear()
pat.append(p)
pat.refs.clear()
flattened[name] = pat
@ -318,7 +256,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
def get_name(
self,
name: str = SINGLE_USE_PREFIX * 2,
name: str = '__',
sanitize: bool = True,
max_length: int = 32,
quiet: bool | None = None,
@ -329,17 +267,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 is `SINGLE_USE_PREFIX * 2`.
name: Preferred name for the pattern. Default '__'.
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 `SINGLE_USE_PREFIX`.
the name starts with an underscore.
Returns:
Name, unique within this library.
"""
if quiet is None:
quiet = name.startswith(SINGLE_USE_PREFIX)
quiet = name.startswith('_')
if sanitize:
# Remove invalid characters
@ -347,13 +285,12 @@ 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 == '':
suffixed_name = sanitized_name + b64suffix(ii)
suffix = base64.b64encode(struct.pack('>Q', ii), b'$?').decode('ASCII')
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
ii += 1
if len(suffixed_name) > max_length:
@ -379,7 +316,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(self[name].refs.keys())
not_toplevel |= set(sp.target for sp in self[name].refs)
toplevel = list(names - not_toplevel)
return toplevel
@ -387,9 +324,6 @@ 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:
@ -399,9 +333,6 @@ 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()]
@ -421,10 +352,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 target in current_pattern.refs:
for ref in pattern.refs[target]:
self.dfs(target, visit_before, visit_after,
hierarchy + (sp.target,), updated_transform, memo)
for sp in current_pattern.refs]
self.dfs(sp.target, visit_before, visit_after,
hierarchy + (sp.target,), updated_transform, memo)
current_pattern = visit_after(current_pattern, **visit_args)
```
where `visit_args` are
@ -461,46 +391,49 @@ 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.asarray(transform, dtype=float)
transform = numpy.array(transform)
original_pattern = pattern
if visit_before is not None:
pattern = visit_before(pattern, hierarchy=hierarchy, memo=memo, transform=transform)
for target in pattern.refs:
if target is None:
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:
continue
if target in hierarchy:
raise LibraryError(f'.dfs() called on pattern with circular reference to "{target}"')
if ref.target in hierarchy:
raise LibraryError(f'.dfs() called on pattern with circular reference to "{ref.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[target],
visit_before=visit_before,
visit_after=visit_after,
hierarchy=hierarchy + (target,),
transform=ref_transform,
memo=memo,
)
self.dfs(
pattern=self[ref.target],
visit_before=visit_before,
visit_after=visit_after,
hierarchy=hierarchy + (ref.target,),
transform=ref_transform,
memo=memo,
)
if visit_after is not None:
pattern = visit_after(pattern, hierarchy=hierarchy, memo=memo, transform=transform)
if pattern is not original_pattern:
name = hierarchy[-1]
name = hierarchy[-1] # TODO what is name=None?
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`')
@ -508,150 +441,8 @@ 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]:
@ -687,8 +478,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
Args:
old_name: Current name for the pattern
new_name: New name for the pattern
move_references: If `True`, any refs in this library pointing to `old_name`
will be updated to point to `new_name`.
#TODO move_Reference
Returns:
self
@ -718,31 +508,9 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
self
"""
for pattern in self.values():
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)
for ref in pattern.refs:
if ref.target == old_target:
ref.target = new_target
return self
def mkpat(self, name: str) -> tuple[str, 'Pattern']:
@ -765,52 +533,30 @@ 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 items from another library into this one.
Add keys from another library into this one.
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.
# TODO explain reference renaming and return
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`. 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).
`other`.
Default is effectively
`name.split('$')[0] if name.startswith('_') else name`
Returns:
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()`.
self
"""
from .pattern import map_targets
duplicates = set(self.keys()) & set(other.keys())
if not duplicates:
for key in other:
for key in other.keys():
self._merge(key, other, key)
return {}
if mutate_other:
if isinstance(other, Library):
temp = other
else:
temp = Library(dict(other))
else:
temp = Library(copy.deepcopy(dict(other)))
temp = Library(copy.deepcopy(dict(other))) # TODO maybe add a `mutate` arg? Might want to keep the same patterns
rename_map = {}
for old_name in temp:
if old_name in self:
@ -826,20 +572,12 @@ 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)
pat = self[new_name]
pat.refs = map_targets(pat.refs, lambda tt: cast(dict[str | None, str | None], rename_map).get(tt, tt))
for ref in self[new_name].refs:
ref.target = rename_map.get(cast(str, ref.target), ref.target)
return rename_map
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`.
"""
def __lshift__(self, other: Mapping[str, 'Pattern']) -> str:
if len(other) == 1:
name = next(iter(other))
else:
@ -853,24 +591,12 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
name = tops[0]
rename_map = self.add(other)
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)
return rename_map.get(name, 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:
@ -891,8 +617,7 @@ 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(SINGLE_USE_PREIX + 'shape')`.
a name for the generated pattern. Default `self.get_name('_shape')`.
threshold: Only replace shapes with refs if there will be at least this many
instances.
@ -908,8 +633,9 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
exclude_types = ()
if label2name is None:
def label2name(label: tuple) -> str: # noqa: ARG001
return self.get_name(SINGLE_USE_PREFIX + 'shape')
def label2name(label):
return self.get_name('_shape')
#label2name = lambda label: self.get_name('_shape')
shape_counts: MutableMapping[tuple, int] = defaultdict(int)
shape_funcs = {}
@ -918,13 +644,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 layer, sseq in pat.shapes.items():
for shape in sseq:
if not any(isinstance(shape, t) for t in exclude_types):
base_label, _values, func = shape.normalized_form(norm_value)
label = (*base_label, layer)
shape_funcs[label] = func
shape_counts[label] += 1
for i, shape in enumerate(pat.shapes):
if not any(isinstance(shape, t) for t in exclude_types):
label, _values, func = shape.normalized_form(norm_value)
shape_funcs[label] = func
shape_counts[label] += 1
shape_pats = {}
for label, count in shape_counts.items():
@ -932,8 +656,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
continue
shape_func = shape_funcs[label]
shape_pat = Pattern()
shape_pat.shapes[label[-1]] += [shape_func()]
shape_pat = Pattern(shapes=[shape_func()])
shape_pats[label] = shape_pat
# ## Second pass ##
@ -942,36 +665,33 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
# are to be replaced.
# The `values` are `(offset, scale, rotation)`.
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
shape_table: MutableMapping[tuple, list] = defaultdict(list)
for i, shape in enumerate(pat.shapes):
if any(isinstance(shape, t) for t in exclude_types):
continue
base_label, values, _func = shape.normalized_form(norm_value)
label = (*base_label, layer)
label, values, _func = shape.normalized_form(norm_value)
if label not in shape_pats:
continue
if label not in shape_pats:
continue
shape_table[label].append((i, values))
shape_table[label].append((i, values))
# For repeated shapes, create a `Pattern` holding a normalized shape object,
# and add `pat.refs` entries for each occurrence in pat. Also, note down that
# we should delete the `pat.shapes` entries for which we made `Ref`s.
shapes_to_remove = []
for label in shape_table:
layer = label[-1]
target = label2name(label)
for ii, values in shape_table[label]:
for i, 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(ii)
shapes_to_remove.append(i)
# Remove any shapes for which we have created refs.
for ii in sorted(shapes_to_remove, reverse=True):
del pat.shapes[layer][ii]
# Remove any shapes for which we have created refs.
for i in sorted(shapes_to_remove, reverse=True):
del pat.shapes[i]
for ll, pp in shape_pats.items():
self[label2name(ll)] = pp
@ -989,8 +709,7 @@ 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(SINGLE_USE_PREFIX + 'rep')`.
wrapping pattern. Default is `self.get_name('_rep')`.
Returns:
self
@ -998,34 +717,33 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
from .pattern import Pattern
if name_func is None:
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
return self.get_name(SINGLE_USE_PREFIX + 'rep')
def name_func(_pat, _shape):
return self.get_name('_rep')
#name_func = lambda _pat, _shape: self.get_name('_rep')
for pat in tuple(self.values()):
for layer in pat.shapes:
new_shapes = []
for shape in pat.shapes[layer]:
if shape.repetition is None:
new_shapes.append(shape)
continue
new_shapes = []
for shape in pat.shapes:
if shape.repetition is None:
new_shapes.append(shape)
continue
name = name_func(pat, shape)
self[name] = Pattern(shapes={layer: [shape]})
pat.ref(name, repetition=shape.repetition)
shape.repetition = None
pat.shapes[layer] = new_shapes
name = name_func(pat, shape)
self[name] = Pattern(shapes=[shape])
pat.ref(name, repetition=shape.repetition)
shape.repetition = None
pat.shapes = new_shapes
for layer in pat.labels:
new_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={layer: [label]})
pat.ref(name, repetition=label.repetition)
label.repetition = None
pat.labels[layer] = new_labels
new_labels = []
for label in pat.labels:
if label.repetition is None:
new_labels.append(label)
continue
name = name_func(pat, label)
self[name] = Pattern(labels=[label])
pat.ref(name, repetition=label.repetition)
label.repetition = None
pat.labels = new_labels
return self
@ -1047,7 +765,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
if isinstance(tops, str):
tops = (tops,)
keep = cast(set[str], self.referenced_patterns(tops) - {None})
keep = cast(set[str], self.referenced_patterns(tops) - set((None,)))
keep |= set(tops)
new = type(self)()
@ -1059,25 +777,13 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
self,
repeat: bool = True,
) -> set[str]:
"""
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
Args:
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
Returns:
A set containing the names of all deleted patterns
"""
# TODO doc prune_empty
trimmed = set()
while empty := {name for name, pat in self.items() if pat.is_empty()}:
while empty := set(name for name, pat in self.items() if pat.is_empty()):
for name in empty:
del self[name]
for pat in self.values():
for name in empty:
# Second pass to skip looking at refs in empty patterns
if name in pat.refs:
del pat.refs[name]
pat.refs = [ref for ref in pat.refs if ref.target not in empty]
trimmed |= empty
if not repeat:
@ -1089,28 +795,15 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
key: str,
delete_refs: bool = True,
) -> Self:
"""
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.
"""
# TODO doc delete()
del self[key]
if delete_refs:
for pat in self.values():
if key in pat.refs:
del pat.refs[key]
pat.refs = [ref for ref in pat.refs if ref.target != 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__(
@ -1136,12 +829,6 @@ 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__(
@ -1173,7 +860,10 @@ class Library(ILibrary):
if key in self.mapping:
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
value = value() if callable(value) else value
if callable(value):
value = value()
else:
value = value
self.mapping[key] = value
def __delitem__(self, key: str) -> None:
@ -1186,15 +876,9 @@ class Library(ILibrary):
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
@classmethod
def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']:
def mktree(cls, 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()
@ -1251,7 +935,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 reference cycles.' # TODO give advice on finding cycles
'If you are lazy-loading a file, try a non-lazy load and check for refernce cycles.' # TODO give advice on finding cycles
)
self._lookups_in_progress.add(key)
@ -1323,9 +1007,9 @@ class LazyLibrary(ILibrary):
"""
self.precache()
for pattern in self.cache.values():
if old_target in pattern.refs:
pattern.refs[new_target].extend(pattern.refs[old_target])
del pattern.refs[old_target]
for ref in pattern.refs:
if ref.target == old_target:
ref.target = new_target
return self
def precache(self) -> Self:
@ -1344,11 +1028,6 @@ 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:
@ -1362,20 +1041,3 @@ 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,12 +1,9 @@
from typing import overload, Self, NoReturn, Any
from collections.abc import Iterable, KeysView, ValuesView, Mapping
from typing import Iterable, KeysView, ValuesView, overload, Self, 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
@ -20,7 +17,6 @@ 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`.
@ -72,28 +68,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
raise PortError('Rotation must be a scalar')
self._rotation = val % (2 * pi)
@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]:
def get_bounds(self):
return numpy.vstack((self.offset, self.offset))
def set_ptype(self, ptype: str) -> Self:
@ -101,7 +76,7 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
self.ptype = ptype
return self
def mirror(self, axis: int = 0) -> Self:
def mirror(self, axis: int) -> Self:
self.offset[1 - axis] *= -1
if self.rotation is not None:
self.rotation *= -1
@ -124,27 +99,6 @@ 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__
@ -181,40 +135,13 @@ class PortList(metaclass=ABCMeta):
"""
if isinstance(key, str):
return self.ports[key]
else: # noqa: RET505
else:
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],
@ -239,7 +166,7 @@ class PortList(metaclass=ABCMeta):
if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()}
if None in renamed:
del renamed[None]
@ -274,75 +201,6 @@ 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],
@ -417,7 +275,7 @@ class PortList(metaclass=ABCMeta):
other: 'PortList',
map_in: dict[str, str],
*,
mirrored: bool = False,
mirrored: tuple[bool, bool] = (False, False),
set_rotation: bool | None = None,
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
"""
@ -428,7 +286,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 axis prior to
mirrored: Mirrors `other` across the x or y axes 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
@ -445,7 +303,7 @@ class PortList(metaclass=ABCMeta):
"""
s_ports = self[map_in.keys()]
o_ports = other[map_in.values()]
return self.find_port_transform(
return self.find_ptransform(
s_ports=s_ports,
o_ports=o_ports,
map_in=map_in,
@ -454,12 +312,12 @@ class PortList(metaclass=ABCMeta):
)
@staticmethod
def find_port_transform(
def find_ptransform( # TODO needs better name
s_ports: Mapping[str, Port],
o_ports: Mapping[str, Port],
map_in: dict[str, str],
*,
mirrored: bool = False,
mirrored: tuple[bool, bool] = (False, False),
set_rotation: bool | None = None,
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
"""
@ -472,7 +330,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 axis prior to
mirrored: Mirrors `o_ports` across the x or y axes 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
@ -498,12 +356,16 @@ 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:
if mirrored[0]:
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 'unk' not in (st, ot)
for st, ot in zip(s_types, o_types, strict=True)])
type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk'
for st, ot in zip(s_types, o_types)])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (k, v) in enumerate(map_in.items()):

View File

@ -1,17 +1,18 @@
"""
Ref provides basic support for nesting Pattern objects within each other.
It carries offset, rotation, mirroring, and scaling data for each individual instance.
Ref provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and other such properties to the reference.
"""
from typing import TYPE_CHECKING, Self, Any
from collections.abc import Mapping
#TODO more top-level documentation
from typing import Sequence, Mapping, TYPE_CHECKING, Any, Self
import copy
import functools
import numpy
from numpy import pi
from numpy.typing import NDArray, ArrayLike
from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key
from .error import PatternError
from .utils import is_scalar, annotations_t
from .repetition import Repetition
from .traits import (
PositionableImpl, RotatableImpl, ScalableImpl,
@ -23,73 +24,63 @@ 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.
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).
`Ref` provides basic support for nesting Pattern objects within each other, by adding
offset, rotation, scaling, and associated methods.
"""
__slots__ = (
'_mirrored',
'_target', '_mirrored',
# inherited
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
)
_mirrored: bool
""" Whether to mirror the instance across the x axis (new_y = -old_y)ubefore rotating. """
_target: str | None
""" The name of the `Pattern` being instanced """
# 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)
_mirrored: NDArray[numpy.bool_]
""" Whether to mirror the instance across the x and/or y axes. """
def __init__(
self,
target: str | None,
*,
offset: ArrayLike = (0.0, 0.0),
rotation: float = 0.0,
mirrored: bool = False,
mirrored: Sequence[bool] | None = None,
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 axis before rotating.
mirrored: Whether to mirror the referenced pattern across its x and y axes.
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,
mirrored=self.mirrored.copy(),
repetition=copy.deepcopy(self.repetition),
annotations=copy.deepcopy(self.annotations),
)
@ -98,51 +89,61 @@ 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
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 property
@property
def target(self) -> str | None:
return self._target
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)
)
@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 as_pattern(
self,
pattern: 'Pattern',
*,
pattern: 'Pattern | None' = None,
library: Mapping[str, 'Pattern'] | None = None,
) -> '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 self.mirrored:
pattern.mirror()
if numpy.any(self.mirrored):
pattern.mirror2d(self.mirrored)
if self.rotation % (2 * pi) != 0:
pattern.rotate_around((0.0, 0.0), self.rotation)
if numpy.any(self.offset):
@ -165,38 +166,17 @@ class Ref(
self.repetition.rotate(rotation)
return self
def mirror(self, axis: int = 0) -> Self:
self.mirror_target(axis)
def mirror(self, axis: int) -> Self:
self.mirrored[axis] = not self.mirrored[axis]
self.rotation *= -1
if self.repetition is not None:
self.repetition.mirror(axis)
return self
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(
def get_bounds(
self,
pattern: 'Pattern',
*,
pattern: 'Pattern | None' = None,
library: Mapping[str, 'Pattern'] | None = None,
) -> NDArray[numpy.float64] | None:
"""
@ -210,27 +190,20 @@ class Ref(
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
"""
if pattern.is_empty():
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():
# no need to run as_pattern()
return None
# 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)
return self.as_pattern(pattern=pattern, library=library).get_bounds(library)
def __repr__(self) -> str:
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
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 ''
scale = f' d{self.scale:g}' if self.scale != 1 else ''
mirrored = ' m' if self.mirrored else ''
return f'<Ref {self.offset}{rotation}{scale}{mirrored}>'
mirrored = ' m{:d}{:d}'.format(*self.mirrored) if self.mirrored.any() else ''
return f'<Ref {name} at {self.offset}{rotation}{scale}{mirrored}>'

View File

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

View File

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

View File

@ -1,6 +1,6 @@
from typing import Any, cast
from typing import Sequence, Any
import copy
import functools
import math
import numpy
from numpy import pi
@ -9,10 +9,9 @@ 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, annotations_t, annotations_lt, annotations_eq, rep2key
from ..utils import is_scalar, layer_t, annotations_t
@functools.total_ordering
class Arc(Shape):
"""
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
@ -25,7 +24,7 @@ class Arc(Shape):
__slots__ = (
'_radii', '_angles', '_width', '_rotation',
# Inherited
'_offset', '_repetition', '_annotations',
'_offset', '_layer', '_repetition', '_annotations',
)
_radii: NDArray[numpy.float64]
@ -42,7 +41,7 @@ class Arc(Shape):
# radius properties
@property
def radii(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
def radii(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]:
"""
Return the radii `[rx, ry]`
"""
@ -79,7 +78,7 @@ class Arc(Shape):
# arc start/stop angle properties
@property
def angles(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
def angles(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]:
"""
Return the start and stop angles `[a_start, a_stop]`.
Angles are measured from x-axis after rotation
@ -156,6 +155,8 @@ 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,
@ -171,6 +172,7 @@ 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
@ -179,6 +181,8 @@ 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
@ -189,38 +193,6 @@ 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,
@ -235,62 +207,27 @@ 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 via numerical integration
# 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
#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
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)))
wh = self.width / 2.0
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):
if wh == r0 or wh == r1:
thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin
else:
thetas_inner = get_thetas(inner=True)
thetas_outer = get_thetas(inner=False)
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)
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))
@ -304,11 +241,11 @@ class Arc(Shape):
ys = numpy.hstack((ys1, ys2))
xys = numpy.vstack((xs, ys)).T
poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
poly = Polygon(xys, layer=self.layer, offset=self.offset, rotation=self.rotation)
return [poly]
def get_bounds_single(self) -> NDArray[numpy.float64]:
"""
def get_bounds(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)`
@ -319,12 +256,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), strict=True):
for a, sgn in zip(a_ranges, (-1, +1)):
wh = sgn * self.width / 2
rx = self.radius_x + wh
ry = self.radius_y + wh
@ -381,7 +318,7 @@ class Arc(Shape):
self.rotation += theta
return self
def mirror(self, axis: int = 0) -> 'Arc':
def mirror(self, axis: int) -> 'Arc':
self.offset[axis - 1] *= -1
self.rotation *= -1
self.rotation += axis * pi
@ -415,27 +352,28 @@ class Arc(Shape):
rotation %= 2 * pi
width = self.width
return ((type(self), radii, angles, width / norm_value),
return ((type(self), radii, angles, width / norm_value, self.layer),
(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), strict=True):
for a, sgn in zip(a_ranges, (-1, +1)):
wh = sgn * self.width / 2
rx = self.radius_x + wh
ry = self.radius_y + wh
@ -454,28 +392,27 @@ 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.0
wh = sgn * self.width / 2
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, dtype=float)
return numpy.array(a)
def __repr__(self) -> str:
angles = f'{numpy.rad2deg(self.angles)}'
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
return f'<Arc l{self.layer} o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'

View File

@ -1,6 +1,4 @@
from typing import Any, cast
import copy
import functools
import numpy
from numpy import pi
@ -9,10 +7,9 @@ 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, annotations_t, annotations_lt, annotations_eq, rep2key
from ..utils import is_scalar, layer_t, annotations_t
@functools.total_ordering
class Circle(Shape):
"""
A circle, which has a position and radius.
@ -20,7 +17,7 @@ class Circle(Shape):
__slots__ = (
'_radius',
# Inherited
'_offset', '_repetition', '_annotations',
'_offset', '_layer', '_repetition', '_annotations',
)
_radius: float
@ -47,6 +44,7 @@ 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,
@ -57,11 +55,13 @@ 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,29 +70,6 @@ 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,
@ -113,16 +90,16 @@ class Circle(Shape):
ys = numpy.sin(thetas) * self.radius
xys = numpy.vstack((xs, ys)).T
return [Polygon(xys, offset=self.offset)]
return [Polygon(xys, offset=self.offset, layer=self.layer)]
def get_bounds_single(self) -> NDArray[numpy.float64]:
def get_bounds(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset - self.radius,
self.offset + self.radius))
def rotate(self, theta: float) -> 'Circle': # noqa: ARG002 (theta unused)
def rotate(self, theta: float) -> 'Circle':
return self
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
def mirror(self, axis: int) -> 'Circle':
self.offset *= -1
return self
@ -130,12 +107,12 @@ class Circle(Shape):
self.radius *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
def normalized_form(self, norm_value) -> normalized_shape_tuple:
rotation = 0.0
magnitude = self.radius / norm_value
return ((type(self),),
return ((type(self), self.layer),
(self.offset, magnitude, rotation, False),
lambda: Circle(radius=norm_value))
lambda: Circle(radius=norm_value, layer=self.layer))
def __repr__(self) -> str:
return f'<Circle o{self.offset} r{self.radius:g}>'
return f'<Circle l{self.layer} o{self.offset} r{self.radius:g}>'

View File

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

View File

@ -1,7 +1,5 @@
from typing import Any, cast
from collections.abc import Sequence
from typing import Sequence, Any, cast
import copy
import functools
from enum import Enum
import numpy
@ -11,11 +9,10 @@ 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, annotations_lt, annotations_eq, rep2key
from ..utils import is_scalar, rotation_matrix_2d, layer_t
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
@ -23,24 +20,18 @@ 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', '_repetition', '_annotations',
'_offset', '_layer', '_repetition', '_annotations',
)
_vertices: NDArray[numpy.float64]
_width: float
@ -70,14 +61,12 @@ 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
@ -87,13 +76,10 @@ class Path(Shape):
# cap_extensions property
@property
def cap_extensions(self) -> Any | None: # mypy#3004 NDArray[numpy.float64]]:
def cap_extensions(self) -> Any | None: # TODO 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`
"""
@ -104,26 +90,24 @@ class Path(Shape):
custom_caps = (PathCap.SquareCustom,)
if self.cap in custom_caps:
if vals is None:
raise PatternError('Tried to set cap extensions to None on path with custom cap type')
raise Exception('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 PatternError('Tried to set custom cap extensions on path with non-custom cap type')
raise Exception('Tried to set custom cap extensions on path with non-custom cap type')
self._cap_extensions = vals
# vertices property
@property
def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]]:
def vertices(self) -> Any: # TODO mypy#3004 NDArray[numpy.float64]]:
"""
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`
When setting, note that a copy of the provided vertices will be made.
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
"""
return self._vertices
@vertices.setter
def vertices(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float)
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
if len(val.shape) < 2 or val.shape[1] != 2:
raise PatternError('Vertices must be an Nx2 array')
if val.shape[0] < 2:
@ -169,6 +153,8 @@ 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,
@ -183,6 +169,7 @@ 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
@ -191,10 +178,12 @@ 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
@ -206,40 +195,6 @@ 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]],
@ -248,6 +203,8 @@ 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
@ -264,11 +221,15 @@ 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: Path.travel() needs testing
# TODO: needs testing
direction = numpy.array([1, 0])
verts = [numpy.zeros(2)]
@ -277,7 +238,8 @@ 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)
offset=offset, rotation=rotation, mirrored=mirrored,
layer=layer)
def to_polygons(
self,
@ -292,7 +254,7 @@ class Path(Shape):
if self.width == 0:
verts = numpy.vstack((v, v[::-1]))
return [Polygon(offset=self.offset, vertices=verts)]
return [Polygon(offset=self.offset, vertices=verts, layer=self.layer)]
perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2
@ -343,33 +305,31 @@ class Path(Shape):
o1.append(v[-1] - perp[-1])
verts = numpy.vstack((o0, o1[::-1]))
polys = [Polygon(offset=self.offset, vertices=verts)]
polys = [Polygon(offset=self.offset, vertices=verts, layer=self.layer)]
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)
circ = Circle(offset=vert, radius=self.width / 2, layer=self.layer)
polys += circ.to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
return polys
def get_bounds_single(self) -> NDArray[numpy.float64]:
def get_bounds(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,
PathCap.Square,
PathCap.SquareCustom,
):
elif self.cap in (PathCap.Flush,
PathCap.Square,
PathCap.SquareCustom):
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
polys = self.to_polygons()
for poly in polys:
poly_bounds = poly.get_bounds_single_nonempty()
poly_bounds = poly.get_bounds_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_single() not implemented for endcaps: {self.cap}')
raise PatternError(f'get_bounds() not implemented for endcaps: {self.cap}')
return bounds
@ -378,7 +338,7 @@ class Path(Shape):
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
return self
def mirror(self, axis: int = 0) -> 'Path':
def mirror(self, axis: int) -> 'Path':
self.vertices[:, axis - 1] *= -1
return self
@ -410,12 +370,13 @@ class Path(Shape):
width0 = self.width / norm_value
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer),
(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':
@ -429,22 +390,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
@ -461,4 +422,4 @@ class Path(Shape):
def __repr__(self) -> str:
centroid = self.offset + self.vertices.mean(axis=0)
return f'<Path centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'
return f'<Path l{self.layer} centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'

View File

@ -1,7 +1,5 @@
from typing import Any, cast
from collections.abc import Sequence
from typing import Sequence, Any, cast
import copy
import functools
import numpy
from numpy import pi
@ -10,25 +8,21 @@ 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, annotations_lt, annotations_eq, rep2key
from ..utils import is_scalar, rotation_matrix_2d, layer_t
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', '_repetition', '_annotations',
'_offset', '_layer', '_repetition', '_annotations',
)
_vertices: NDArray[numpy.float64]
@ -36,17 +30,15 @@ class Polygon(Shape):
# vertices property
@property
def vertices(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
def vertices(self) -> Any: # TODO 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)
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
if len(val.shape) < 2 or val.shape[1] != 2:
raise PatternError('Vertices must be an Nx2 array')
if val.shape[0] < 3:
@ -89,6 +81,8 @@ 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,
@ -100,12 +94,15 @@ 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
@ -115,41 +112,13 @@ 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':
"""
@ -159,6 +128,7 @@ 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:
@ -169,7 +139,7 @@ class Polygon(Shape):
[+1, +1],
[+1, -1]], dtype=float)
vertices = 0.5 * side_length * norm_square
poly = Polygon(vertices, offset=offset, repetition=repetition)
poly = Polygon(vertices, offset=offset, layer=layer, repetition=repetition)
poly.rotate(rotation)
return poly
@ -180,6 +150,7 @@ class Polygon(Shape):
*,
rotation: float = 0,
offset: ArrayLike = (0.0, 0.0),
layer: layer_t = 0,
repetition: Repetition | None = None,
) -> 'Polygon':
"""
@ -190,6 +161,7 @@ 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:
@ -199,7 +171,7 @@ class Polygon(Shape):
[-lx, +ly],
[+lx, +ly],
[+lx, -ly]], dtype=float)
poly = Polygon(vertices, offset=offset, repetition=repetition)
poly = Polygon(vertices, offset=offset, layer=layer, repetition=repetition)
poly.rotate(rotation)
return poly
@ -214,6 +186,7 @@ class Polygon(Shape):
yctr: float | None = None,
ymax: float | None = None,
ly: float | None = None,
layer: layer_t = 0,
repetition: Repetition | None = None,
) -> 'Polygon':
"""
@ -231,6 +204,7 @@ 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:
@ -252,7 +226,7 @@ class Polygon(Shape):
lx = 2 * (xmax - xctr)
else:
raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
else: # noqa: PLR5501
else:
if xctr is not None:
pass
elif xmax is None:
@ -282,7 +256,7 @@ class Polygon(Shape):
ly = 2 * (ymax - yctr)
else:
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
else: # noqa: PLR5501
else:
if yctr is not None:
pass
elif ymax is None:
@ -296,7 +270,7 @@ class Polygon(Shape):
else:
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), layer=layer, repetition=repetition)
return poly
@staticmethod
@ -307,6 +281,7 @@ 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':
"""
@ -325,12 +300,16 @@ 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
"""
s = (1 + numpy.sqrt(2)) if regular else 2
if regular:
s = 1 + numpy.sqrt(2)
else:
s = 2
norm_oct = numpy.array([
[-1, -s],
@ -348,18 +327,18 @@ class Polygon(Shape):
side_length = 2 * inner_radius / s
vertices = 0.5 * side_length * norm_oct
poly = Polygon(vertices, offset=center, repetition=repetition)
poly = Polygon(vertices, offset=center, layer=layer, repetition=repetition)
poly.rotate(rotation)
return poly
def to_polygons(
self,
num_vertices: int | None = None, # unused # noqa: ARG002
max_arclen: float | None = None, # unused # noqa: ARG002
num_vertices: int | None = None, # unused
max_arclen: float | None = None, # unused
) -> list['Polygon']:
return [copy.deepcopy(self)]
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
def get_bounds(self) -> NDArray[numpy.float64]:
return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0),
self.offset + numpy.max(self.vertices, axis=0)))
@ -368,7 +347,7 @@ class Polygon(Shape):
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
return self
def mirror(self, axis: int = 0) -> 'Polygon':
def mirror(self, axis: int) -> 'Polygon':
self.vertices[:, axis - 1] *= -1
return self
@ -379,9 +358,8 @@ 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
meanv = self.vertices.mean(axis=0)
zeroed_vertices = self.vertices - meanv
offset = meanv + self.offset
offset = self.vertices.mean(axis=0) + self.offset
zeroed_vertices = self.vertices - offset
scale = zeroed_vertices.std()
normed_vertices = zeroed_vertices / scale
@ -400,9 +378,9 @@ class Polygon(Shape):
# TODO: normalize mirroring?
return ((type(self), reordered_vertices.data.tobytes()),
return ((type(self), reordered_vertices.data.tobytes(), self.layer),
(offset, scale / norm_value, rotation, False),
lambda: Polygon(reordered_vertices * norm_value))
lambda: Polygon(reordered_vertices * norm_value, layer=self.layer))
def clean_vertices(self) -> 'Polygon':
"""
@ -415,25 +393,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 centroid {centroid} v{len(self.vertices)}>'
return f'<Polygon l{self.layer} centroid {centroid} v{len(self.vertices)}>'

View File

@ -1,5 +1,4 @@
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from typing import Callable, Self, TYPE_CHECKING
from abc import ABCMeta, abstractmethod
import numpy
@ -7,7 +6,8 @@ from numpy.typing import NDArray, ArrayLike
from ..traits import (
Rotatable, Mirrorable, Copyable, Scalable,
PositionableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
PositionableImpl, LayerableImpl,
PivotableImpl, RepeatableImpl, AnnotatableImpl,
)
if TYPE_CHECKING:
@ -26,31 +26,23 @@ normalized_shape_tuple = tuple[
DEFAULT_POLY_NUM_VERTICES = 24
class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
class Shape(PositionableImpl, LayerableImpl, Rotatable, Mirrorable, Copyable, Scalable,
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
"""
Class specifying functions common to all shapes.
"""
__slots__ = () # Children should use AutoSlots or set slots themselves
__slots__ = () # Children should use AutoSlots
#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,
@ -127,7 +119,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
polygon_contours = []
for polygon in self.to_polygons():
bounds = polygon.get_bounds_single()
bounds = polygon.get_bounds()
if bounds is None:
continue
@ -135,7 +127,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
vertex_lists = []
p_verts = polygon.vertices + polygon.offset
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0)):
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
@ -165,7 +157,7 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
m = dv[1] / dv[0]
def get_grid_inds(xes: ArrayLike, m: float = m, v: NDArray = v) -> NDArray[numpy.float64]:
def get_grid_inds(xes: ArrayLike) -> 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
@ -202,7 +194,10 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
vertex_lists.append(vlist)
polygon_contours.append(numpy.vstack(vertex_lists))
manhattan_polygons = [Polygon(vertices=contour) for contour in polygon_contours]
manhattan_polygons = [
Polygon(vertices=contour, layer=self.layer)
for contour in polygon_contours
]
return manhattan_polygons
@ -259,19 +254,18 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
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_single()
bounds = polygon.get_bounds()
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])
# 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
for k in (keep_x, keep_y):
for s in (1, 2):
k[s:] += k[:-s]
k[:-s] += k[s:]
k = k > 0
gx = grx[keep_x]
gy = gry[keep_y]
@ -298,6 +292,9 @@ class Shape(PositionableImpl, Rotatable, Mirrorable, Copyable, Scalable,
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))
manhattan_polygons.append(Polygon(
vertices=vertices,
layer=self.layer,
))
return manhattan_polygons

View File

@ -1,23 +1,22 @@
from typing import Self, Any, cast
from typing import Sequence, Any
import copy
import functools
import numpy
from numpy import pi, nan
from numpy import pi, inf
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, annotations_t, annotations_lt, annotations_eq, rep2key
from ..utils import is_scalar, get_bit, normalize_mirror, layer_t
from ..utils import annotations_t
# 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).
@ -26,12 +25,12 @@ class Text(RotatableImpl, Shape):
__slots__ = (
'_string', '_height', '_mirrored', 'font_path',
# Inherited
'_offset', '_repetition', '_annotations', '_rotation',
'_offset', '_layer', '_repetition', '_annotations', '_rotation',
)
_string: str
_height: float
_mirrored: bool
_mirrored: NDArray[numpy.bool_]
font_path: str
# vertices property
@ -54,13 +53,16 @@ class Text(RotatableImpl, Shape):
raise PatternError('Height must be a scalar')
self._height = val
# Mirrored property
@property
def mirrored(self) -> bool: # mypy#3004, should be bool
def mirrored(self) -> Any: # TODO mypy#3004 NDArray[numpy.bool_]:
return self._mirrored
@mirrored.setter
def mirrored(self, val: bool) -> None:
self._mirrored = bool(val)
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 __init__(
self,
@ -70,70 +72,46 @@ 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) -> Self:
def __deepcopy__(self, memo: dict | None = None) -> 'Text':
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 # noqa: ARG002
max_arclen: float | None = None, # unused # noqa: ARG002
num_vertices: int | None = None, # unused
max_arclen: float | None = None, # unused
) -> list[Polygon]:
all_polygons = []
total_advance = 0.0
@ -142,9 +120,8 @@ class Text(RotatableImpl, Shape):
# Move these polygons to the right of the previous letter
for xys in raw_polys:
poly = Polygon(xys)
if self.mirrored:
poly.mirror()
poly = Polygon(xys, layer=self.layer)
poly.mirror2d(self.mirrored)
poly.scale_by(self.height)
poly.offset = self.offset + [total_advance, 0]
poly.rotate_around(self.offset, self.rotation)
@ -155,47 +132,41 @@ class Text(RotatableImpl, Shape):
return all_polygons
def mirror(self, axis: int = 0) -> Self:
self.mirrored = not self.mirrored
if axis == 1:
self.rotation += pi
def mirror(self, axis: int) -> 'Text':
self.mirrored[axis] = not self.mirrored[axis]
return self
def scale_by(self, c: float) -> Self:
def scale_by(self, c: float) -> 'Text':
self.height *= c
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
rotation = self.rotation % (2 * pi)
return ((type(self), self.string, self.font_path),
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
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),
lambda: Text(
string=self.string,
height=self.height * norm_value,
font_path=self.font_path,
rotation=rotation,
).mirror2d(across_x=self.mirrored),
)
mirrored=(mirror_x, False),
layer=self.layer,
))
def get_bounds_single(self) -> NDArray[numpy.float64]:
def get_bounds(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()
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),
))
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, :])
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,
@ -221,7 +192,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 PatternError('get_char_as_polygons called with non-char')
raise Exception('get_char_as_polygons called with non-char')
face = Face(font_path)
face.set_char_size(resolution)
@ -230,8 +201,7 @@ 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])
@ -284,3 +254,8 @@ 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,34 +1,11 @@
"""
Traits (mixins) and default implementations
Traits and mixins should set `__slots__ = ()` to enable use of `__slots__` in subclasses.
"""
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,
)
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

View File

@ -1,8 +1,9 @@
from typing import Self
from abc import ABCMeta
import copy
class Copyable:
class Copyable(metaclass=ABCMeta):
"""
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) -> None:
def layer(self, val: layer_t):
self._layer = val
#

View File

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

View File

@ -1,3 +1,5 @@
# 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
@ -58,6 +60,25 @@ 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):
"""
@ -73,7 +94,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
#
# offset property
@property
def offset(self) -> Any: # mypy#3004 NDArray[numpy.float64]:
def offset(self) -> Any: # TODO mypy#3003 NDArray[numpy.float64]:
"""
[x, y] offset
"""
@ -81,11 +102,12 @@ class PositionableImpl(Positionable, metaclass=ABCMeta):
@offset.setter
def offset(self, val: ArrayLike) -> None:
val = numpy.array(val, dtype=float)
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()
self._offset = val.flatten() # type: ignore
#
# Methods
@ -97,26 +119,3 @@ 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,11 +1,7 @@
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
@ -34,7 +30,7 @@ class Repeatable(metaclass=ABCMeta):
# @repetition.setter
# @abstractmethod
# def repetition(self, repetition: 'Repetition | None') -> None:
# def repetition(self, repetition: 'Repetition | None'):
# pass
#
@ -54,19 +50,15 @@ class Repeatable(metaclass=ABCMeta):
pass
class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta):
class RepeatableImpl(Repeatable, metaclass=ABCMeta):
"""
Simple implementation of `Repeatable` and extension of `Bounded` to include repetition bounds.
Simple implementation of `Repeatable`
"""
__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
#
@ -75,7 +67,7 @@ class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta):
return self._repetition
@repetition.setter
def repetition(self, repetition: 'Repetition | None') -> None:
def repetition(self, repetition: 'Repetition | 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!')
@ -87,24 +79,3 @@ class RepeatableImpl(Repeatable, Bounded, 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) -> None:
def rotation(self, val: float):
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.asarray(pivot, dtype=float)
pivot = numpy.array(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 # mypy#3004
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) # type: ignore # TODO: 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) -> None:
def scale(self, val: float):
if not is_scalar(val):
raise MasqueError('Scale must be a scalar')
if not val > 0:

View File

@ -1,41 +1,18 @@
"""
Various helper functions, type definitions, etc.
"""
from .types import (
layer_t as layer_t,
annotations_t as annotations_t,
SupportsBool as SupportsBool,
)
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 .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 as get_bit,
set_bit as set_bit,
)
from .bitwise import get_bit, 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,
remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points
)
from .transform import rotation_matrix_2d, normalize_mirror, rotate_offsets_around
from . import ports2data as ports2data
from . import ports2data
from . import pack2d as pack2d
#from . import 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): # noqa: ANN001,ANN204
def __new__(cls, name, bases, dctn):
parents = set()
for base in bases:
parents |= set(base.mro())
slots = tuple(dctn.get('__slots__', ()))
slots = tuple(dctn.get('__slots__', tuple()))
for parent in parents:
if not hasattr(parent, '__annotations__'):
continue
slots += tuple(parent.__annotations__.keys())
slots += tuple(getattr(parent, '__annotations__').keys())
dctn['__slots__'] = slots
return super().__new__(cls, name, bases, dctn)

View File

@ -1,106 +0,0 @@
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 collections.abc import Callable
from typing 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): # noqa: ANN202
def wrapper(*args, **kwargs):
nonlocal expired
if expired:
raise OneShotError(func.__name__)

View File

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

View File

@ -1,13 +1,14 @@
"""
2D bin-packing
"""
from collections.abc import Sequence, Mapping, Callable
from typing import Sequence, Callable, Mapping
import numpy
from numpy.typing import NDArray, ArrayLike
from ..error import MasqueError
from ..pattern import Pattern
from ..ref import Ref
def maxrects_bssf(
@ -17,34 +18,16 @@ def maxrects_bssf(
allow_rejects: bool = True,
) -> tuple[NDArray[numpy.float64], set[int]]:
"""
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.
sizes should be Nx2
regions should be Mx4 (xmin, ymin, xmax, ymax)
"""
regions = numpy.asarray(containers, dtype=float)
rect_sizes = numpy.asarray(rects, dtype=float)
regions = numpy.array(containers, copy=False, dtype=float)
rect_sizes = numpy.array(rects, copy=False, dtype=float)
rect_locs = numpy.zeros_like(rect_sizes)
rejected_inds = set()
if presort:
rotated_sizes = numpy.sort(rect_sizes, axis=1) # shortest side first
rotated_sizes = numpy.sort(rect_sizes, axis=0) # shortest side first
rect_order = numpy.lexsort(rotated_sizes.T)[::-1] # Descending shortest side
rect_sizes = rect_sizes[rect_order]
@ -62,15 +45,15 @@ def maxrects_bssf(
''' Place the rect '''
# Best short-side fit (bssf) to pick a region
region_sizes = regions[:, 2:] - regions[:, :2]
bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
bssf_scores = ((regions[:, 2:] - regions[:, :2]) - 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
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
else:
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
# Read out location
loc = regions[rr, :2]
@ -98,146 +81,87 @@ 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(
rects: ArrayLike,
containers: ArrayLike,
presort: bool = True,
allow_rejects: bool = True,
) -> tuple[NDArray[numpy.float64], set[int]]:
def guillotine_bssf_sas(rect_sizes: numpy.ndarray,
regions: numpy.ndarray,
presort: bool = True,
allow_rejects: bool = True,
) -> tuple[numpy.ndarray, set[int]]:
"""
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.
sizes should be Nx2
regions should be Mx4 (xmin, ymin, xmax, ymax)
#TODO: test me!
# TODO add rectangle-merge?
"""
regions = numpy.asarray(containers, dtype=float)
rect_sizes = numpy.asarray(rects, dtype=float)
rect_sizes = numpy.array(rect_sizes)
rect_locs = numpy.zeros_like(rect_sizes)
rejected_inds = set()
if presort:
rotated_sizes = numpy.sort(rect_sizes, axis=1) # shortest side first
rotated_sizes = numpy.sort(rect_sizes, axis=0) # 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
region_sizes = regions[:, 2:] - regions[:, :2]
bssf_scores = (region_sizes - rect_size).min(axis=1).astype(float)
bssf_scores = ((regions[:, 2:] - regions[:, :2]) - 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
raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}')
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 = region_sizes[rr]
region_size = regions[rr, 2:] - loc
split_horiz = region_size[0] < region_size[1]
new_region0 = regions[rr].copy()
new_region1 = new_region0.copy()
split_vertex = loc + rect_size
split_vert = loc + rect_size
if split_horiz:
new_region0[2] = split_vertex[0]
new_region0[1] = split_vertex[1]
new_region1[0] = split_vertex[0]
new_region0[2] = split_vert[0]
new_region0[1] = split_vert[1]
new_region1[0] = split_vert[0]
else:
new_region0[3] = split_vertex[1]
new_region0[0] = split_vertex[0]
new_region1[1] = split_vertex[1]
new_region0[3] = split_vert[1]
new_region0[0] = split_vert[0]
new_region1[1] = split_vert[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],
containers: ArrayLike,
regions: numpy.ndarray,
spacing: tuple[float, float],
presort: bool = True,
allow_rejects: bool = True,
packer: Callable = maxrects_bssf,
) -> tuple[Pattern, list[str]]:
"""
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
half_spacing = numpy.array(spacing) / 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, containers, presort=presort, allow_rejects=allow_rejects)
locations, reject_inds = packer(sizes, regions, presort=presort, allow_rejects=allow_rejects)
pat = Pattern()
for pp, oo, loc in zip(patterns, offsets, locations, strict=True):
pat.ref(pp, offset=oo + loc)
pat.refs = [Ref(pp, offset=oo + loc)
for pp, oo, loc in zip(patterns, offsets, locations)]
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 collections.abc import Sequence, Mapping
from typing 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,7 +44,9 @@ def ports_to_data(pattern: Pattern, layer: layer_t) -> Pattern:
angle_deg = numpy.inf
else:
angle_deg = numpy.rad2deg(port.rotation)
pattern.label(layer=layer, string=f'{name}:{port.ptype} {angle_deg:g}', offset=port.offset)
pattern.labels += [
Label(string=f'{name}:{port.ptype} {angle_deg:g}', layer=layer, offset=port.offset)
]
return pattern
@ -60,8 +62,8 @@ def data_to_ports(
# TODO missing ok?
) -> Pattern:
"""
# TODO fixup documentation in ports2data
# TODO move to utils.file?
# TODO fixup documentation in port_utils
# TODO move port_utils to utils.file?
Examine `pattern` for labels specifying port info, and use that info
to fill out its `ports` attribute.
@ -95,7 +97,7 @@ def data_to_ports(
# Load ports for all subpatterns, and use any we find
found_ports = False
for target in pattern.refs:
for target in set(rr.target for rr in pattern.refs):
if target is None:
continue
pp = data_to_ports(
@ -111,20 +113,17 @@ def data_to_ports(
if not found_ports:
return pattern
for target, refs in pattern.refs.items():
if target is None:
for ref in pattern.refs:
if ref.target is None:
continue
if not refs:
aa = library.abstract(ref.target)
if not aa.ports:
continue
for ref in refs:
aa = library.abstract(target)
if not aa.ports:
break
aa.apply_ref_transform(ref)
aa.apply_ref_transform(ref)
pattern.check_ports(other_names=aa.ports.keys())
pattern.ports.update(aa.ports)
pattern.check_ports(other_names=aa.ports.keys())
pattern.ports.update(aa.ports)
return pattern
@ -150,13 +149,13 @@ def data_to_ports_flat(
Returns:
The updated `pattern`. Port labels are not removed.
"""
labels = list(chain.from_iterable(pattern.labels[layer] for layer in layers))
labels = [ll for ll in pattern.labels if ll.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(f'Pattern "{pstr}" has pre-existing ports!')
raise PatternError('Pattern "{pstr}" has pre-existing ports!')
local_ports = {}
for label in labels:

View File

@ -1,15 +1,12 @@
"""
Geometric transforms
"""
from collections.abc import Sequence
from functools import lru_cache
from typing import Sequence
import numpy
from numpy.typing import NDArray, ArrayLike
from numpy import pi
from numpy.typing import NDArray
@lru_cache
def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
"""
2D rotation matrix for rotating counterclockwise around the origin.
@ -20,15 +17,8 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
Returns:
rotation matrix
"""
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
return numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
[numpy.sin(theta), +numpy.cos(theta)]])
def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]:
@ -57,62 +47,8 @@ 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. This may be a view into the original array.
`vertices` with no consecutive duplicates.
"""
vertices = numpy.asarray(vertices)
vertices = numpy.array(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. May be a view into the original array.
`vertices` with colinear (superflous) vertices removed.
"""
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.asarray(points, dtype=float)
vertices = numpy.asarray(vertices, dtype=float)
points = numpy.array(points, copy=False)
vertices = numpy.array(vertices, copy=False)
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.11"
requires-python = ">=3.8"
dynamic = ["version"]
dependencies = [
"numpy>=1.26",
"klamath~=1.4",
"numpy~=1.21",
"klamath~=1.2",
]
@ -57,36 +57,3 @@ 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
]