Compare commits
No commits in common. "tool_rework" and "master" have entirely different histories.
tool_rewor
...
master
132 changed files with 4481 additions and 23003 deletions
426
MIGRATION.md
426
MIGRATION.md
|
|
@ -1,426 +0,0 @@
|
|||
# Migration Guide
|
||||
|
||||
This guide covers changes between the git tag `release` and the current tree.
|
||||
At `release`, `masque.__version__` was `3.3`; the current tree reports `3.4`.
|
||||
|
||||
Most downstream changes are in `masque/builder/*`, but there are a few other
|
||||
API changes that may require code updates.
|
||||
|
||||
## Routing API: renamed and consolidated
|
||||
|
||||
The routing helpers were consolidated into a single implementation in
|
||||
`masque/builder/pather.py`.
|
||||
|
||||
The biggest migration point is that the old routing verbs were renamed:
|
||||
|
||||
| Old API | New API |
|
||||
| --- | --- |
|
||||
| `Pather.path(...)` | `Pather.trace(...)` |
|
||||
| `Pather.path_to(...)` | `Pather.trace_to(...)` |
|
||||
| `Pather.mpath(...)` | `Pather.trace(...)` / `Pather.trace_to(...)` with multiple ports |
|
||||
| `Pather.pathS(...)` | `Pather.jog(...)` |
|
||||
| `Pather.pathU(...)` | `Pather.uturn(...)` |
|
||||
| `Pather.path_into(...)` | `Pather.trace_into(...)` |
|
||||
| `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` |
|
||||
| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` |
|
||||
| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` |
|
||||
| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` |
|
||||
| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` |
|
||||
| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` |
|
||||
| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` |
|
||||
| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` |
|
||||
|
||||
There are also new convenience wrappers:
|
||||
|
||||
- `straight(...)` for `trace_to(..., ccw=None, ...)`
|
||||
- `ccw(...)` for `trace_to(..., ccw=True, ...)`
|
||||
- `cw(...)` for `trace_to(..., ccw=False, ...)`
|
||||
- `jog(...)` for S-bends
|
||||
- `uturn(...)` for U-bends
|
||||
|
||||
Important: `Pather.path()` is no longer the routing API. It now forwards to
|
||||
`Pattern.path()` and creates a geometric `Path` element. Any old routing code
|
||||
that still calls `pather.path(...)` must be renamed.
|
||||
|
||||
### Common rewrites
|
||||
|
||||
```python
|
||||
# old
|
||||
pather.path('VCC', False, 6_000)
|
||||
pather.path_to('VCC', None, x=0)
|
||||
pather.mpath(['GND', 'VCC'], True, xmax=-10_000, spacing=5_000)
|
||||
pather.pathS('VCC', offset=-2_000, length=8_000)
|
||||
pather.pathU('VCC', offset=4_000, length=5_000)
|
||||
pather.path_into('src', 'dst')
|
||||
pather.path_from('src', 'dst')
|
||||
|
||||
# new
|
||||
pather.cw('VCC', 6_000)
|
||||
pather.straight('VCC', x=0)
|
||||
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||
pather.jog('VCC', offset=-2_000, length=8_000)
|
||||
pather.uturn('VCC', offset=4_000, length=5_000)
|
||||
pather.trace_into('src', 'dst')
|
||||
pather.at('src').trace_into('dst')
|
||||
```
|
||||
|
||||
If you prefer the more explicit spelling, `trace(...)` and `trace_to(...)`
|
||||
remain the underlying primitives:
|
||||
|
||||
```python
|
||||
pather.trace('VCC', False, 6_000)
|
||||
pather.trace_to('VCC', None, x=0)
|
||||
```
|
||||
|
||||
## `PortPather` and `.at(...)`
|
||||
|
||||
Routing can now be written in a fluent style via `.at(...)`, which returns a
|
||||
`PortPather`.
|
||||
|
||||
```python
|
||||
(rpather.at('VCC')
|
||||
.trace(False, length=6_000)
|
||||
.trace_to(None, x=0)
|
||||
)
|
||||
```
|
||||
|
||||
This is additive, not required for migration. Existing code can stay with the
|
||||
non-fluent `Pather` methods after renaming the verbs above.
|
||||
|
||||
Old `PortPather` helper names were also cleaned up:
|
||||
|
||||
| Old API | New API |
|
||||
| --- | --- |
|
||||
| `save_copy(...)` | `mark(...)` |
|
||||
| `rename_to(...)` | `rename(...)` |
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# old
|
||||
pp.save_copy('branch')
|
||||
pp.rename_to('feed')
|
||||
|
||||
# new
|
||||
pp.mark('branch')
|
||||
pp.rename('feed')
|
||||
```
|
||||
|
||||
## Imports and module layout
|
||||
|
||||
`Pather` now provides the remaining builder/routing surface in
|
||||
`masque/builder/pather.py`. The old module files
|
||||
`masque/builder/builder.py` and `masque/builder/renderpather.py` were removed.
|
||||
|
||||
Update imports like this:
|
||||
|
||||
```python
|
||||
# old
|
||||
from masque.builder.builder import Builder
|
||||
from masque.builder.renderpather import RenderPather
|
||||
|
||||
# new
|
||||
from masque.builder import Pather
|
||||
|
||||
builder = Pather(...)
|
||||
deferred = Pather(..., auto_render=False)
|
||||
```
|
||||
|
||||
Top-level imports from `masque` also continue to work.
|
||||
|
||||
`Pather` now defaults to `auto_render=True`, so plain construction replaces the
|
||||
old `Builder` behavior. Use `Pather(..., auto_render=False)` where you
|
||||
previously used `RenderPather`.
|
||||
|
||||
## `BasicTool` was replaced
|
||||
|
||||
`BasicTool` is no longer exported. Use `AutoTool` for reusable straight, bend,
|
||||
transition, and S-bend primitives.
|
||||
|
||||
### Old `BasicTool`
|
||||
|
||||
```python
|
||||
from masque.builder.tools import BasicTool
|
||||
|
||||
tool = BasicTool(
|
||||
straight=(make_straight, 'input', 'output'),
|
||||
bend=(lib.abstract('bend'), 'input', 'output'),
|
||||
transitions={
|
||||
'm2wire': (lib.abstract('via'), 'top', 'bottom'),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### New `AutoTool`
|
||||
|
||||
```python
|
||||
from masque.builder import AutoTool
|
||||
|
||||
tool = (
|
||||
AutoTool()
|
||||
.add_straight(make_straight, 'm1wire', 'input')
|
||||
.add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True)
|
||||
.add_transition(lib.abstract('via'), 'top', 'bottom')
|
||||
)
|
||||
```
|
||||
|
||||
The key differences are:
|
||||
|
||||
- `BasicTool` -> `AutoTool`
|
||||
- `straight=(fn, in_name, out_name)` -> `add_straight(fn, ptype, in_name)`
|
||||
- `bend=(abstract, in_name, out_name)` -> `add_bend(abstract, in_name, out_name)`
|
||||
- transitions are registered with `add_transition(abstract, external_port, internal_port)`
|
||||
- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter
|
||||
|
||||
## Custom `Tool` subclasses
|
||||
|
||||
If you maintain your own `Tool` subclass, the interface changed:
|
||||
|
||||
- `primitive_offers()` is now the planning boundary
|
||||
- `render()` consumes committed primitive render tokens
|
||||
- `Tool.path(...)`, `traceL()`, `traceS()`, `traceU()`, `planL()`,
|
||||
`planS()`, and `planU()` are no longer part of the public `Tool` API
|
||||
|
||||
In practice, a minimal old implementation like:
|
||||
|
||||
```python
|
||||
class MyTool(Tool):
|
||||
def path(self, ccw, length, **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
should now become:
|
||||
|
||||
```python
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from masque import Port
|
||||
from masque.builder import RenderStep, StraightOffer, Tool
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs):
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def endpoint(length):
|
||||
ptype = out_ptype or in_ptype
|
||||
return Port((length, 0), rotation=3.141592653589793, ptype=ptype)
|
||||
|
||||
def commit(length):
|
||||
return {'length': length}
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype or in_ptype,
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
def render(self, batch: Sequence[RenderStep], **kwargs: Any):
|
||||
...
|
||||
```
|
||||
|
||||
If a tool does not provide a primitive kind, return `()` for that kind. `Pather`
|
||||
will compose available primitive offers where the route family allows it.
|
||||
|
||||
### Primitive offers
|
||||
|
||||
Tools describe legal routing primitives through `Tool.primitive_offers()`.
|
||||
`Pather` composes those primitive offers to implement `trace()`, `jog()`,
|
||||
`uturn()`, and `trace_into()`.
|
||||
|
||||
For custom tools, construct the concrete offer class that matches the primitive
|
||||
you are exposing:
|
||||
|
||||
- `StraightOffer` for non-turning length-parameterized primitives
|
||||
- `BendOffer` for single-turn length-parameterized primitives
|
||||
- `SOffer` for S-like jog-parameterized primitives
|
||||
- `UOffer` for U-like jog-parameterized primitives
|
||||
|
||||
`PrimitiveOffer` is the shared base type used for generic annotations and
|
||||
common callback behavior. It is not the normal class users should instantiate.
|
||||
The concrete offer classes carry the semantic fields (`length_domain`,
|
||||
`jog_domain`, `ccw`) so tools do not need to encode primitive identity in
|
||||
strings.
|
||||
|
||||
Minimal straight-only example:
|
||||
|
||||
```python
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from masque import Port
|
||||
from masque.builder import RenderStep, StraightOffer, Tool
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype=None,
|
||||
out_ptype=None,
|
||||
**kwargs,
|
||||
):
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def endpoint(length):
|
||||
ptype = out_ptype or in_ptype
|
||||
return Port((length, 0), rotation=3.141592653589793, ptype=ptype)
|
||||
|
||||
def commit(length):
|
||||
return {'length': length}
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype or in_ptype,
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
def render(self, batch: Sequence[RenderStep], **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
Primitive offers are local planning objects:
|
||||
|
||||
- `endpoint_at(parameter)` returns the local output `Port`
|
||||
- `cost_at(parameter)` returns an additive scalar route-selection cost
|
||||
- `bbox_at(parameter)` returns local primitive bounds when a footprint hook is supplied
|
||||
- `parameterized_bbox` may carry opaque future-router footprint metadata
|
||||
- `commit(parameter)` returns opaque render data consumed later by `render()`
|
||||
- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton
|
||||
- selected parameter values must be finite; domains may use infinite open bounds but not `NaN`
|
||||
- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer
|
||||
|
||||
Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype
|
||||
adapters. Requested `out_ptype` constrains only the final route endpoint; any
|
||||
intermediate ptypes are chosen by the route solver.
|
||||
|
||||
`Tool` subclasses must override `primitive_offers()` and return `()` themselves
|
||||
for recognized unsupported kinds. There is no route-level `plan*()` fallback.
|
||||
Omitted-length S/U behavior comes from direct `SOffer` and `UOffer` endpoint
|
||||
domains or from composed straight/bend primitives.
|
||||
|
||||
Offer constructors accept split `endpoint_planner` and `commit_planner`
|
||||
callbacks. Provide both callbacks or override the offer methods in a subclass;
|
||||
partial callback configurations are rejected during offer construction.
|
||||
|
||||
When writing direct primitive offers, declare the actual endpoint ptype
|
||||
produced by the offer if it can differ from the requested value; `Pather`
|
||||
validates evaluated endpoints against the declared offer ptype.
|
||||
|
||||
Stable imports for custom tool authors live in `masque.builder`. The
|
||||
`masque.builder.planner` module is an internal planner implementation; do not
|
||||
import it from user code.
|
||||
|
||||
`trace_into()` uses the same primitive-offer route selection and now searches
|
||||
bounded route topologies with up to four bend roles. This preserves the common
|
||||
straight, bend, S-like, U-like, and dogleg cases while allowing routes that
|
||||
need an additional bounded bend pair. Among legal bounded candidates,
|
||||
`trace_into()` selects the lowest total primitive-offer cost; bend count and
|
||||
step count are used only to break exact cost ties.
|
||||
|
||||
Explicit-length `jog()` routes may also be satisfied by composing a straight
|
||||
primitive before or after an omitted-length native S primitive. `uturn()` routes
|
||||
may compose a straight primitive before an omitted-length native U primitive.
|
||||
These compositions are used when they are the lowest-cost legal route for the
|
||||
explicit request.
|
||||
|
||||
`AutoTool` can attach `bbox_at()` hooks to its primitive offers by rendering the
|
||||
selected primitive into a temporary pattern and measuring it. If the rendered
|
||||
primitive contains reusable refs, pass the source library as `bbox_library=...`;
|
||||
normal routing does not require this.
|
||||
|
||||
### Omitted-length routing
|
||||
|
||||
Single-port omitted-length calls now evaluate legal primitive routes at their
|
||||
minimum legal length-like parameter, or at their intrinsic endpoint length when
|
||||
the requested offset fixes the primitive geometry. Cost then selects among
|
||||
those minimum-length candidates:
|
||||
|
||||
```python
|
||||
pather.trace('A', None) # minimum straight-like route
|
||||
pather.jog('A', offset=2) # minimum S-like route for that offset
|
||||
pather.uturn('A', offset=4) # minimum U-like route for that offset
|
||||
```
|
||||
|
||||
For U-turns, use explicit `length=0` to request the old zero-public-length
|
||||
shape:
|
||||
|
||||
```python
|
||||
pather.uturn('A', offset=4, length=0)
|
||||
```
|
||||
|
||||
## Transform semantics changed
|
||||
|
||||
The other major user-visible change is that `mirror()` and `rotate()` are now
|
||||
treated more consistently as intrinsic transforms on low-level objects.
|
||||
|
||||
The practical migration rule is:
|
||||
|
||||
- use `mirror()` / `rotate()` when you want to change the object relative to its
|
||||
own origin
|
||||
- use `flip_across(...)`, `rotate_around(...)`, or container-level transforms
|
||||
when you want to move the object in its parent coordinate system
|
||||
|
||||
### Example: `Port`
|
||||
|
||||
Old behavior:
|
||||
|
||||
```python
|
||||
port.mirror(0) # changed both offset and orientation
|
||||
```
|
||||
|
||||
New behavior:
|
||||
|
||||
```python
|
||||
port.mirror(0) # changes orientation only
|
||||
port.flip_across(axis=0) # old "mirror in the parent pattern" behavior
|
||||
```
|
||||
|
||||
### What to audit
|
||||
|
||||
Check code that calls:
|
||||
|
||||
- `Port.mirror(...)`
|
||||
- `Ref.rotate(...)`
|
||||
- `Ref.mirror(...)`
|
||||
- `Label.rotate_around(...)` / `Label.mirror(...)`
|
||||
|
||||
If that code expected offsets or repetition grids to move automatically, it
|
||||
needs updating. For whole-pattern transforms, prefer calling `Pattern.mirror()`
|
||||
or `Pattern.rotate_around(...)` at the container level.
|
||||
|
||||
## Other user-facing changes
|
||||
|
||||
### DXF environments
|
||||
|
||||
If you install the DXF extra, the supported `ezdxf` baseline moved from
|
||||
`~=1.0.2` to `~=1.4`. Any pinned environments should be updated accordingly.
|
||||
|
||||
### New exports
|
||||
|
||||
These are additive, but available now from `masque` and `masque.builder`:
|
||||
|
||||
- `PortPather`
|
||||
- `AutoTool`
|
||||
- `boolean`
|
||||
|
||||
## Minimal migration checklist
|
||||
|
||||
If your code uses the routing stack, do these first:
|
||||
|
||||
1. Replace `path`/`path_to`/`mpath`/`path_into` calls with
|
||||
`trace`/`trace_to`/multi-port `trace`/`trace_into`.
|
||||
2. Replace `BasicTool` with `AutoTool`.
|
||||
3. Fix imports that still reference `masque.builder.builder` or
|
||||
`masque.builder.renderpather`.
|
||||
4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`.
|
||||
|
||||
If your code only uses `Pattern`, `Library`, `place()`, and `plug()` without the
|
||||
routing helpers, you may not need any changes beyond the transform audit and any
|
||||
stale imports.
|
||||
12
README.md
12
README.md
|
|
@ -145,7 +145,7 @@ References are accomplished by listing the target's name, not its `Pattern` obje
|
|||
in order to create a reference, but they also need to access the pattern's ports.
|
||||
* One way to provide this data is through an `Abstract`, generated via
|
||||
`Library.abstract()` or through a `Library.abstract_view()`.
|
||||
* Another way is use `Pather.place()` or `Pather.plug()`, which automatically creates
|
||||
* Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates
|
||||
an `Abstract` from its internally-referenced `Library`.
|
||||
|
||||
|
||||
|
|
@ -193,8 +193,8 @@ my_pattern.ref(new_name, ...) # instantiate the cell
|
|||
# In practice, you may do lots of
|
||||
my_pattern.ref(lib << make_tree(...), ...)
|
||||
|
||||
# With a `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit:
|
||||
my_builder = Pather(library=lib, ...)
|
||||
# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit:
|
||||
my_builder = Builder(library=lib, ...)
|
||||
...
|
||||
my_builder.place(make_tree(...))
|
||||
```
|
||||
|
|
@ -277,6 +277,12 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
|
|||
|
||||
## TODO
|
||||
|
||||
* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath)
|
||||
* PolyCollection & arrow-based read/write
|
||||
* pather and renderpather examples, including .at() (PortPather)
|
||||
* Bus-to-bus connections?
|
||||
* Tests tests tests
|
||||
* Better interface for polygon operations (e.g. with `pyclipper`)
|
||||
- de-embedding
|
||||
- boolean ops
|
||||
* tuple / string layer auto-translation
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from masque.file import gdsii
|
|||
from masque import Arc, Pattern
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main():
|
||||
pat = Pattern()
|
||||
layer = (0, 0)
|
||||
pat.shapes[layer].extend([
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
from masque.file.gdsii.perf import main
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import numpy
|
||||
from pyclipper import (
|
||||
Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO,
|
||||
Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO,
|
||||
scale_to_clipper, scale_from_clipper,
|
||||
)
|
||||
p = Pyclipper()
|
||||
p.AddPaths([
|
||||
|
|
@ -10,8 +12,8 @@ p.AddPaths([
|
|||
], PT_SUBJECT, closed=True)
|
||||
#p.Execute2?
|
||||
#p.Execute?
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO)
|
||||
p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
|
||||
p = Pyclipper()
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from masque import LibraryError
|
||||
|
||||
|
||||
READERS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
'gdsii': ('masque.file.gdsii', ('readfile',)),
|
||||
'gdsii_arrow': ('masque.file.gdsii.arrow', ('readfile', 'arrow_import', 'arrow_convert')),
|
||||
}
|
||||
|
||||
|
||||
def _summarize_library(path: Path, elapsed_s: float, info: dict[str, object], lib: object) -> dict[str, object]:
|
||||
assert hasattr(lib, '__len__')
|
||||
assert hasattr(lib, 'tops')
|
||||
tops = lib.tops() # type: ignore[no-any-return, attr-defined]
|
||||
try:
|
||||
unique_top = lib.top() # type: ignore[no-any-return, attr-defined]
|
||||
except LibraryError:
|
||||
unique_top = None
|
||||
|
||||
return {
|
||||
'path': str(path),
|
||||
'elapsed_s': elapsed_s,
|
||||
'library_name': info['name'],
|
||||
'cell_count': len(lib), # type: ignore[arg-type]
|
||||
'topcells': tops,
|
||||
'topcell': unique_top,
|
||||
}
|
||||
|
||||
|
||||
def _summarize_arrow_import(path: Path, elapsed_s: float, arrow_arr: Any) -> dict[str, object]:
|
||||
libarr = arrow_arr[0]
|
||||
return {
|
||||
'path': str(path),
|
||||
'elapsed_s': elapsed_s,
|
||||
'arrow_rows': len(arrow_arr),
|
||||
'library_name': libarr['lib_name'].as_py(),
|
||||
'cell_count': len(libarr['cells']),
|
||||
'layer_count': len(libarr['layers']),
|
||||
}
|
||||
|
||||
|
||||
def _profile_stage(module: Any, stage: str, path: Path) -> dict[str, object]:
|
||||
start = time.perf_counter()
|
||||
|
||||
if stage == 'readfile':
|
||||
lib, info = module.readfile(path)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return _summarize_library(path, elapsed_s, info, lib)
|
||||
|
||||
if stage == 'arrow_import':
|
||||
if hasattr(module, 'readfile_arrow'):
|
||||
libarr, _info = module.readfile_arrow(path)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return {
|
||||
'path': str(path),
|
||||
'elapsed_s': elapsed_s,
|
||||
'arrow_rows': 1,
|
||||
'library_name': libarr['lib_name'].as_py(),
|
||||
'cell_count': len(libarr['cells']),
|
||||
'layer_count': len(libarr['layers']),
|
||||
}
|
||||
|
||||
arrow_arr = module._read_to_arrow(path)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return _summarize_arrow_import(path, elapsed_s, arrow_arr)
|
||||
|
||||
if stage == 'arrow_convert':
|
||||
arrow_arr = module._read_to_arrow(path)
|
||||
libarr = arrow_arr[0]
|
||||
start = time.perf_counter()
|
||||
lib, info = module.read_arrow(libarr)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return _summarize_library(path, elapsed_s, info, lib)
|
||||
|
||||
raise ValueError(f'Unsupported stage {stage!r}')
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description='Profile GDS readers with a stable end-to-end workload.')
|
||||
parser.add_argument('--reader', choices=sorted(READERS), required=True)
|
||||
parser.add_argument('--stage', default='readfile')
|
||||
parser.add_argument('--path', type=Path, required=True)
|
||||
parser.add_argument('--warmup', type=int, default=1)
|
||||
parser.add_argument('--repeat', type=int, default=1)
|
||||
parser.add_argument('--output-json', type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
module_name, stages = READERS[args.reader]
|
||||
if args.stage not in stages:
|
||||
parser.error(f'reader {args.reader!r} only supports stages: {", ".join(stages)}')
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
path = args.path.expanduser().resolve()
|
||||
|
||||
for _ in range(args.warmup):
|
||||
_profile_stage(module, args.stage, path)
|
||||
|
||||
runs = []
|
||||
for _ in range(args.repeat):
|
||||
runs.append(_profile_stage(module, args.stage, path))
|
||||
|
||||
payload = {
|
||||
'reader': args.reader,
|
||||
'stage': args.stage,
|
||||
'warmup': args.warmup,
|
||||
'repeat': args.repeat,
|
||||
'runs': runs,
|
||||
}
|
||||
rendered = json.dumps(payload, indent=2, sort_keys=True)
|
||||
if args.output_json is not None:
|
||||
args.output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_json.write_text(rendered + '\n')
|
||||
print(rendered)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis
|
|||
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main():
|
||||
lib = Library()
|
||||
|
||||
cell_name = 'ellip_grating'
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
masque Tutorial
|
||||
===============
|
||||
|
||||
These examples are meant to be read roughly in order.
|
||||
|
||||
- Start with `basic_shapes.py` for the core `Pattern` / GDS concepts.
|
||||
- Then read `devices.py` and `library.py` for hierarchical composition and libraries.
|
||||
- Read the `pather*` tutorials separately when you want routing helpers.
|
||||
|
||||
Contents
|
||||
--------
|
||||
|
||||
|
|
@ -14,30 +8,24 @@ Contents
|
|||
* Draw basic geometry
|
||||
* Export to GDS
|
||||
- [devices](devices.py)
|
||||
* Build hierarchical photonic-crystal example devices
|
||||
* Reference other patterns
|
||||
* Add ports to a pattern
|
||||
* Use `Pather` to snap ports together into a circuit
|
||||
* Snap ports together to build a circuit
|
||||
* Check for dangling references
|
||||
- [library](library.py)
|
||||
* Continue from `devices.py` by declaring a mixed library with `BuildLibrary`
|
||||
* Import source-backed GDS cells and register python-generated recipes together
|
||||
* Call `build()` to produce a normal library and report for downstream `Pather` usage and writing
|
||||
* 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
|
||||
* Define a custom `Tool` that exposes primitive routing offers
|
||||
* Use primitive offers to automatically transition between path types
|
||||
- [renderpather](renderpather.py)
|
||||
* Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py),
|
||||
* 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.
|
||||
- [port_pather](port_pather.py)
|
||||
* Use `PortPather` and the `.at()` syntax for more concise routing
|
||||
* Advanced port manipulation and connections
|
||||
|
||||
|
||||
Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating
|
||||
photonic-crystal lattices; it is support code rather than a step-by-step tutorial.
|
||||
Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices.
|
||||
|
||||
|
||||
Running
|
||||
|
|
@ -49,6 +37,3 @@ cd examples/tutorial
|
|||
python3 basic_shapes.py
|
||||
klayout -e basic_shapes.gds
|
||||
```
|
||||
|
||||
Some tutorials depend on outputs from earlier ones. In particular, `library.py`
|
||||
expects `circuit.gds`, which is generated by `devices.py`.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
from collections.abc import Sequence
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import layer_t, Pattern, Circle, Arc, Ref
|
||||
from masque.repetition import Grid
|
||||
from masque import (
|
||||
layer_t, Pattern, Label, Port,
|
||||
Circle, Arc, Polygon,
|
||||
)
|
||||
import masque.file.gdsii
|
||||
|
||||
|
||||
|
|
@ -36,45 +39,6 @@ def hole(
|
|||
return pat
|
||||
|
||||
|
||||
def hole_array(
|
||||
radius: float,
|
||||
num_x: int = 5,
|
||||
num_y: int = 3,
|
||||
pitch: float = 2000,
|
||||
layer: layer_t = (1, 0),
|
||||
) -> Pattern:
|
||||
"""
|
||||
Generate an array of circular holes using `Repetition`.
|
||||
|
||||
Args:
|
||||
radius: Circle radius.
|
||||
num_x, num_y: Number of holes in x and y.
|
||||
pitch: Center-to-center spacing.
|
||||
layer: Layer to draw the holes on.
|
||||
|
||||
Returns:
|
||||
Pattern containing a grid of holes.
|
||||
"""
|
||||
# First, make a pattern for a single hole
|
||||
hpat = hole(radius, layer)
|
||||
|
||||
# Now, create a pattern that references it multiple times using a Grid
|
||||
pat = Pattern()
|
||||
pat.refs['hole'] = [
|
||||
Ref(
|
||||
offset=(0, 0),
|
||||
repetition=Grid(a_vector=(pitch, 0), a_count=num_x,
|
||||
b_vector=(0, pitch), b_count=num_y)
|
||||
)]
|
||||
|
||||
# We can also add transformed references (rotation, mirroring, etc.)
|
||||
pat.refs['hole'].append(
|
||||
Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True)
|
||||
)
|
||||
|
||||
return pat, hpat
|
||||
|
||||
|
||||
def triangle(
|
||||
radius: float,
|
||||
layer: layer_t = (1, 0),
|
||||
|
|
@ -96,7 +60,9 @@ def triangle(
|
|||
]) * radius
|
||||
|
||||
pat = Pattern()
|
||||
pat.polygon(layer, vertices=vertices)
|
||||
pat.shapes[layer].extend([
|
||||
Polygon(offset=(0, 0), vertices=vertices),
|
||||
])
|
||||
return pat
|
||||
|
||||
|
||||
|
|
@ -145,13 +111,9 @@ def main() -> None:
|
|||
lib['smile'] = smile(1000)
|
||||
lib['triangle'] = triangle(1000)
|
||||
|
||||
# Use a Grid to make many holes efficiently
|
||||
lib['grid'], lib['hole'] = hole_array(1000)
|
||||
|
||||
masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS)
|
||||
|
||||
lib['triangle'].visualize()
|
||||
lib['grid'].visualize(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
"""
|
||||
Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Pather`.
|
||||
|
||||
This file uses photonic-crystal components as the concrete example, so some of
|
||||
the geometry-generation code is domain-specific. The tutorial value is in the
|
||||
Masque patterns around it: creating reusable cells, annotating ports, composing
|
||||
hierarchy with references, and snapping ports together to build a larger circuit.
|
||||
"""
|
||||
from collections.abc import Sequence, Mapping
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import (
|
||||
layer_t, Pattern, Ref, Pather, Port, Polygon,
|
||||
Library,
|
||||
layer_t, Pattern, Ref, Label, Builder, Port, Polygon,
|
||||
Library, ILibraryView,
|
||||
)
|
||||
from masque.utils import ports2data
|
||||
from masque.file.gdsii import writefile, check_valid_names
|
||||
|
|
@ -72,9 +64,9 @@ def perturbed_l3(
|
|||
Provided sequence should have same length as `shifts_a`.
|
||||
xy_size: `(x, y)` number of mirror periods in each direction; total size is
|
||||
`2 * n + 1` holes in each direction. Default (10, 10).
|
||||
perturbed_radius: radius of holes perturbed to form an upwards-directed beam
|
||||
perturbed_radius: radius of holes perturbed to form an upwards-driected beam
|
||||
(multiplicative factor). Default 1.1.
|
||||
trench_width: Width of the undercut trenches. Default 1200.
|
||||
trench width: Width of the undercut trenches. Default 1200.
|
||||
|
||||
Returns:
|
||||
`Pattern` object representing the L3 design.
|
||||
|
|
@ -87,15 +79,14 @@ def perturbed_l3(
|
|||
shifts_a=shifts_a,
|
||||
shifts_r=shifts_r)
|
||||
|
||||
# Build the cavity by instancing the supplied `hole` pattern many times.
|
||||
# Using references keeps the pattern compact even though it contains many holes.
|
||||
# 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))
|
||||
for x, y, r in xyr]
|
||||
|
||||
# Add rectangular undercut aids based on the referenced hole extents.
|
||||
# Add rectangular undercut aids
|
||||
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
|
||||
trench_dx = max_xy[0] - min_xy[0]
|
||||
|
||||
|
|
@ -104,7 +95,7 @@ def perturbed_l3(
|
|||
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
|
||||
]
|
||||
|
||||
# Define the interface in Masque terms: two ports at the left/right extents.
|
||||
# Ports are at outer extents of the device (with y=0)
|
||||
extent = lattice_constant * xy_size[0]
|
||||
pat.ports = dict(
|
||||
input=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
|
|
@ -134,17 +125,17 @@ def waveguide(
|
|||
Returns:
|
||||
`Pattern` object representing the waveguide.
|
||||
"""
|
||||
# Generate the normalized lattice locations for the line defect.
|
||||
# Generate hole locations
|
||||
xy = pcgen.waveguide(length=length, num_mirror=mirror_periods)
|
||||
|
||||
# Build the pattern by placing repeated references to the same hole cell.
|
||||
# Build the pattern
|
||||
pat = Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y in xy]
|
||||
|
||||
# Publish the device interface as two ports at the outer edges.
|
||||
# Ports are at outer edges, with y=0
|
||||
extent = lattice_constant * length / 2
|
||||
pat.ports = dict(
|
||||
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
|
|
@ -173,17 +164,17 @@ def bend(
|
|||
`Pattern` object representing the waveguide bend.
|
||||
Ports are named 'left' (input) and 'right' (output).
|
||||
"""
|
||||
# Generate the normalized lattice locations for the bend.
|
||||
# Generate hole locations
|
||||
xy = pcgen.wgbend(num_mirror=mirror_periods)
|
||||
|
||||
# Build the pattern by instancing the shared hole cell.
|
||||
pat = Pattern()
|
||||
# Build the pattern
|
||||
pat= Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y in xy]
|
||||
|
||||
# Publish the bend interface as two ports.
|
||||
# Figure out port locations.
|
||||
extent = lattice_constant * mirror_periods
|
||||
pat.ports = dict(
|
||||
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
|
|
@ -212,17 +203,17 @@ def y_splitter(
|
|||
`Pattern` object representing the y-splitter.
|
||||
Ports are named 'in', 'top', and 'bottom'.
|
||||
"""
|
||||
# Generate the normalized lattice locations for the splitter.
|
||||
# Generate hole locations
|
||||
xy = pcgen.y_splitter(num_mirror=mirror_periods)
|
||||
|
||||
# Build the pattern by instancing the shared hole cell.
|
||||
# Build pattern
|
||||
pat = Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y in xy]
|
||||
|
||||
# Publish the splitter interface as one input and two outputs.
|
||||
# Determine port locations
|
||||
extent = lattice_constant * mirror_periods
|
||||
pat.ports = {
|
||||
'in': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
|
|
@ -236,13 +227,13 @@ def y_splitter(
|
|||
|
||||
|
||||
def main(interactive: bool = True) -> None:
|
||||
# First make a couple of reusable primitive cells.
|
||||
# Generate some basic hole patterns
|
||||
shape_lib = {
|
||||
'smile': basic_shapes.smile(RADIUS),
|
||||
'hole': basic_shapes.hole(RADIUS),
|
||||
}
|
||||
|
||||
# Then build a small library of higher-level devices from those primitives.
|
||||
# Build some devices
|
||||
a = LATTICE_CONSTANT
|
||||
|
||||
devices = {}
|
||||
|
|
@ -254,23 +245,22 @@ 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 the device mapping into a `Library`.
|
||||
# That gives us convenience helpers for hierarchy inspection and abstract views.
|
||||
# Turn our dict of devices into a Library.
|
||||
# This provides some convenience functions in the future!
|
||||
lib = Library(devices)
|
||||
|
||||
#
|
||||
# Build a circuit
|
||||
#
|
||||
# Create a `Pather`, and register the resulting top cell as "my_circuit".
|
||||
circ = Pather(library=lib, name='my_circuit')
|
||||
# Create a `Builder`, and add the circuit to our library as "my_circuit".
|
||||
circ = Builder(library=lib, name='my_circuit')
|
||||
|
||||
# Start by placing a waveguide and renaming its ports to match the circuit-level
|
||||
# names we want to use while assembling the design.
|
||||
# Start by placing a waveguide. Call its ports "in" and "signal".
|
||||
circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
|
||||
|
||||
# Extend the signal path by attaching another waveguide.
|
||||
# Because `wg10` only has one unattached port left after the plug, Masque can
|
||||
# infer that it should keep the name `signal`.
|
||||
# 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:
|
||||
|
|
@ -278,8 +268,8 @@ def main(interactive: bool = True) -> None:
|
|||
# lib['my_circuit'] = circ_pat
|
||||
# circ_pat.place(lib.abstract('wg10'), ...)
|
||||
# circ_pat.plug(lib.abstract('wg10'), ...)
|
||||
# but `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps
|
||||
# the assembly code focused on port-level intent.
|
||||
# but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar
|
||||
# syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality.
|
||||
|
||||
# Attach a y-splitter to the signal path.
|
||||
# Since the y-splitter has 3 ports total, we can't auto-inherit the
|
||||
|
|
@ -291,10 +281,13 @@ def main(interactive: bool = True) -> None:
|
|||
circ.plug('wg05', {'signal1': 'left'})
|
||||
circ.plug('wg05', {'signal2': 'left'})
|
||||
|
||||
# Add a bend to both branches.
|
||||
# Our bend primitive is defined with a specific orientation, so choosing which
|
||||
# port to plug determines whether the path turns clockwise or counterclockwise.
|
||||
# We could also mirror one instance instead of using opposite ports.
|
||||
# Add a bend to both ports.
|
||||
# Our bend's ports "left" and "right" refer to the original counterclockwise
|
||||
# orientation. We want the bends to turn in opposite directions, so we attach
|
||||
# the "right" port to "signal1" to bend clockwise, and the "left" port
|
||||
# to "signal2" to bend counterclockwise.
|
||||
# We could also use `mirrored=(True, False)` to mirror one of the devices
|
||||
# and then use same device port on both paths.
|
||||
circ.plug('bend0', {'signal1': 'right'})
|
||||
circ.plug('bend0', {'signal2': 'left'})
|
||||
|
||||
|
|
@ -303,26 +296,29 @@ def main(interactive: bool = True) -> None:
|
|||
circ.plug('l3cav', {'signal1': 'input'})
|
||||
circ.plug('wg10', {'signal1': 'left'})
|
||||
|
||||
# `signal2` gets a single waveguide of equivalent overall length.
|
||||
# "signal2" just gets a single of equivalent length
|
||||
circ.plug('wg28', {'signal2': 'left'})
|
||||
|
||||
# Now bend both branches back towards each other.
|
||||
# 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'})
|
||||
|
||||
# To join the branches, attach a second y-junction.
|
||||
# This succeeds only if both chosen ports agree on the same translation and
|
||||
# rotation for the inserted device; otherwise Masque raises an exception.
|
||||
# To join the waveguides, we attach a second y-junction.
|
||||
# We plug "signal1" into the "bot" port, and "signal2" into the "top" port.
|
||||
# The remaining port gets named "signal_out".
|
||||
# This operation would raise an exception if the ports did not line up
|
||||
# correctly (i.e. they required different rotations or translations of the
|
||||
# y-junction device).
|
||||
circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
|
||||
|
||||
# Finally, add some more waveguide to "signal_out".
|
||||
circ.plug('wg10', {'signal_out': 'left'})
|
||||
|
||||
# Bake the top-level port metadata into labels so it survives GDS export.
|
||||
# These labels appear on the circuit cell; individual child devices keep their
|
||||
# own port labels in their own cells.
|
||||
# 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)
|
||||
|
||||
# Check if we forgot to include any patterns... ooops!
|
||||
|
|
@ -334,12 +330,12 @@ def main(interactive: bool = True) -> None:
|
|||
lib.add(shape_lib)
|
||||
assert not lib.dangling_refs()
|
||||
|
||||
# We can visualize the design directly, though opening the written GDS is often easier.
|
||||
# We can visualize the design. Usually it's easier to just view the GDS.
|
||||
if interactive:
|
||||
print('Visualizing... this step may be slow')
|
||||
circ.pattern.visualize(lib)
|
||||
|
||||
# Write out only the subtree reachable from our top cell.
|
||||
#Write out to GDS, only keeping patterns referenced by our circuit (including itself)
|
||||
subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use
|
||||
check_valid_names(subtree.keys())
|
||||
writefile(subtree, 'circuit.gds', **GDS_OPTS)
|
||||
|
|
|
|||
|
|
@ -1,115 +1,135 @@
|
|||
"""
|
||||
Tutorial: authoring a mixed library with `BuildLibrary`.
|
||||
|
||||
This example assumes you have already read `devices.py` and generated the
|
||||
`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry
|
||||
itself, but rather how Masque lets you combine imported GDS cells with
|
||||
python-generated recipes, then turn that declaration set into a normal library
|
||||
for downstream assembly and writing.
|
||||
"""
|
||||
from typing import Any
|
||||
from collections.abc import Sequence, Callable
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import BuildLibrary, Pather, Pattern, cell
|
||||
from masque.file.gdsii import writefile
|
||||
from masque.file.gdsii.lazy import readfile
|
||||
from masque import Pattern, Builder, LazyLibrary
|
||||
from masque.file.gdsii import writefile, load_libraryfile
|
||||
|
||||
import pcgen
|
||||
import basic_shapes
|
||||
import devices
|
||||
from devices import ports_to_data, data_to_ports
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
||||
|
||||
def make_mixed_waveguide(lib: BuildLibrary) -> Pattern:
|
||||
"""
|
||||
Recipe which assembles imported and generated cells behind the builder API.
|
||||
"""
|
||||
circ = Pather(library=lib, ports='tri_l3cav')
|
||||
|
||||
# First way to specify what we are plugging in: request an explicit abstract.
|
||||
circ.plug(lib.abstract('wg10'), {'input': 'right'})
|
||||
|
||||
# Second way: use an AbstractView, which behaves like a mapping of names
|
||||
# to abstracts.
|
||||
abstracts = lib.abstract_view()
|
||||
circ.plug(abstracts['wg10'], {'output': 'left'})
|
||||
|
||||
# Third way: let Pather resolve a pattern name through its own library.
|
||||
circ.plug('tri_wg10', {'input': 'right'})
|
||||
circ.plug('tri_wg10', {'output': 'left'})
|
||||
|
||||
return circ.pattern
|
||||
|
||||
|
||||
def main() -> None:
|
||||
builder = BuildLibrary()
|
||||
cells = builder.cells
|
||||
# Define a `LazyLibrary`, which provides lazy evaluation for generating
|
||||
# patterns and lazy-loading of GDS contents.
|
||||
lib = LazyLibrary()
|
||||
|
||||
#
|
||||
# Load some devices from a GDS file
|
||||
#
|
||||
|
||||
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
|
||||
# imported on first materialization, but the raw source remains untouched
|
||||
# until we build the final library.
|
||||
gds_lib, _properties = readfile('circuit.gds')
|
||||
builder.add_source(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1))
|
||||
# Scan circuit.gds and prepare to lazy-load its contents
|
||||
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
|
||||
|
||||
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
|
||||
# Add it into the device library by providing a way to read port info
|
||||
# This maintains the lazy evaluation from above, so no patterns
|
||||
# are actually read yet.
|
||||
lib.add(gds_lib)
|
||||
|
||||
print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys())))
|
||||
|
||||
#
|
||||
# Register some new devices, this time from python code rather than GDS.
|
||||
# Add some new devices to the library, this time from python code rather than GDS
|
||||
#
|
||||
|
||||
cells.triangle = basic_shapes.triangle(devices.RADIUS)
|
||||
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
|
||||
opts: dict[str, Any] = dict(
|
||||
lattice_constant=devices.LATTICE_CONSTANT,
|
||||
hole='triangle',
|
||||
)
|
||||
lattice_constant = devices.LATTICE_CONSTANT,
|
||||
hole = 'triangle',
|
||||
)
|
||||
|
||||
cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts)
|
||||
cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts)
|
||||
cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts)
|
||||
cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts)
|
||||
cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts)
|
||||
cells.tri_l3cav = cell(devices.perturbed_l3)(xy_size=(4, 10), **opts, hole_lib=builder)
|
||||
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder)
|
||||
|
||||
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
|
||||
# Triangle-based variants. These are defined here, but they won't run until they're
|
||||
# retrieved from the library.
|
||||
lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts)
|
||||
lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts)
|
||||
lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts)
|
||||
lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts)
|
||||
lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts)
|
||||
lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib)
|
||||
|
||||
#
|
||||
# Build the declaration set into a normal library.
|
||||
# Build a mixed waveguide with an L3 cavity in the middle
|
||||
#
|
||||
|
||||
built, report = builder.build()
|
||||
print('Built library contains:\n' + pformat(list(built.keys())))
|
||||
print('Build dependency graph:\n' + pformat(report.dependency_graph))
|
||||
# Immediately start building from an instance of the L3 cavity
|
||||
circ2 = Builder(library=lib, ports='tri_l3cav')
|
||||
|
||||
# First way to get abstracts is `lib.abstract(name)`
|
||||
# We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`.
|
||||
circ2.plug(lib.abstract('wg10'), {'input': 'right'})
|
||||
|
||||
# Second way to get abstracts is to use an AbstractView
|
||||
# This also works directly with `Pattern.plug()` / `Pattern.place()`.
|
||||
abstracts = lib.abstract_view()
|
||||
circ2.plug(abstracts['wg10'], {'output': 'left'})
|
||||
|
||||
# Third way to specify an abstract works by automatically getting
|
||||
# it from the library already within the Builder object.
|
||||
# This wouldn't work if we only had a `Pattern` (not a `Builder`).
|
||||
# Just pass the pattern name!
|
||||
circ2.plug('tri_wg10', {'input': 'right'})
|
||||
circ2.plug('tri_wg10', {'output': 'left'})
|
||||
|
||||
# Add the circuit to the device library.
|
||||
lib['mixed_wg_cav'] = circ2.pattern
|
||||
|
||||
|
||||
#
|
||||
# Continue designing against the built library.
|
||||
# Build a device that could plug into our mixed_wg_cav and joins the two ports
|
||||
#
|
||||
|
||||
# The built result behaves like a normal mutable library, so downstream code
|
||||
# can use Pather, abstract views, and writing without going back through the
|
||||
# builder interface.
|
||||
circ = Pather.interface(source='mixed_wg_cav', library=built)
|
||||
circ.plug('tri_bend0', {'input': 'right'})
|
||||
circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
||||
circ.plug('tri_bend0', {'input': 'right'})
|
||||
circ.plug('bend0', {'output': 'left'})
|
||||
circ.plug('bend0', {'output': 'left'})
|
||||
circ.plug('bend0', {'output': 'left'})
|
||||
circ.plug('tri_wg10', {'input': 'right'})
|
||||
circ.plug('tri_wg28', {'input': 'right'})
|
||||
circ.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
||||
built['loop_segment'] = circ.pattern
|
||||
# We'll be designing against an existing device's interface...
|
||||
circ3 = Builder.interface(source=circ2)
|
||||
|
||||
# ... that lets us continue from where we left off.
|
||||
circ3.plug('tri_bend0', {'input': 'right'})
|
||||
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
||||
circ3.plug('tri_bend0', {'input': 'right'})
|
||||
circ3.plug('bend0', {'output': 'left'})
|
||||
circ3.plug('bend0', {'output': 'left'})
|
||||
circ3.plug('bend0', {'output': 'left'})
|
||||
circ3.plug('tri_wg10', {'input': 'right'})
|
||||
circ3.plug('tri_wg28', {'input': 'right'})
|
||||
circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
||||
|
||||
lib['loop_segment'] = circ3.pattern
|
||||
|
||||
#
|
||||
# Write all devices into a GDS file.
|
||||
# Write all devices into a GDS file
|
||||
#
|
||||
print('Writing library to file...')
|
||||
writefile(built, 'library.gds', **GDS_OPTS)
|
||||
writefile(lib, 'library.gds', **GDS_OPTS)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
#
|
||||
#class prout:
|
||||
# def place(
|
||||
# self,
|
||||
# other: Pattern,
|
||||
# label_layer: layer_t = 'WATLAYER',
|
||||
# *,
|
||||
# port_map: Dict[str, str | None] | None = None,
|
||||
# **kwargs,
|
||||
# ) -> 'prout':
|
||||
#
|
||||
# Pattern.place(self, other, port_map=port_map, **kwargs)
|
||||
# name: str | None
|
||||
# for name in other.ports:
|
||||
# if port_map:
|
||||
# assert(name is not None)
|
||||
# name = port_map.get(name, name)
|
||||
# if name is None:
|
||||
# continue
|
||||
# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer)
|
||||
# return self
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
"""
|
||||
Manual wire routing tutorial: Pather and primitive offers
|
||||
Manual wire routing tutorial: Pather and BasicTool
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy
|
||||
from collections.abc import Callable
|
||||
from numpy import pi
|
||||
from masque import Pather, Library, Pattern, Port, layer_t
|
||||
from masque.abstract import Abstract
|
||||
from masque.builder import BendOffer, RenderStep, StraightOffer, Tool
|
||||
from masque.error import BuildError
|
||||
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 masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
||||
|
|
@ -114,327 +107,87 @@ def map_layer(layer: layer_t) -> layer_t:
|
|||
'M2': (20, 0),
|
||||
'V1': (30, 0),
|
||||
}
|
||||
if isinstance(layer, str):
|
||||
return layer_mapping.get(layer, layer)
|
||||
return layer
|
||||
return layer_mapping.get(layer, layer)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireStraightData:
|
||||
length: float
|
||||
out_transition: 'WireTransitionSpec | None' = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireBendData:
|
||||
straight_length: float
|
||||
ccw: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireTransitionSpec:
|
||||
abstract: Abstract
|
||||
in_port_name: str
|
||||
out_port_name: str
|
||||
|
||||
@property
|
||||
def in_port(self) -> Port:
|
||||
return self.abstract.ports[self.in_port_name]
|
||||
|
||||
@property
|
||||
def out_port(self) -> Port:
|
||||
return self.abstract.ports[self.out_port_name]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireTransitionData:
|
||||
spec: WireTransitionSpec
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrimitiveWireTool(Tool):
|
||||
"""
|
||||
Minimal routing tool that exposes local routing primitives directly.
|
||||
|
||||
The high-level `Pather` methods below still decide how to compose straights,
|
||||
bends, and ptype transitions. This tool only describes which one-step
|
||||
primitives it can draw and how selected primitives should be rendered.
|
||||
"""
|
||||
layer: layer_t
|
||||
width: float
|
||||
ptype: str
|
||||
bend: Abstract
|
||||
transitions: Sequence[WireTransitionSpec]
|
||||
|
||||
def _straight_pattern(self, length: float) -> Pattern:
|
||||
return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length)
|
||||
|
||||
@staticmethod
|
||||
def _transition_length(spec: WireTransitionSpec) -> float | None:
|
||||
dxy, angle = spec.in_port.measure_travel(spec.out_port)
|
||||
if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0):
|
||||
return None
|
||||
return float(dxy[0])
|
||||
|
||||
def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]:
|
||||
offers: list[StraightOffer] = []
|
||||
for index, spec in enumerate(self.transitions):
|
||||
if spec.out_port.ptype != self.ptype:
|
||||
continue
|
||||
if in_ptype not in (None, 'unk', spec.in_port.ptype):
|
||||
continue
|
||||
|
||||
length = self._transition_length(spec)
|
||||
if length is None:
|
||||
continue
|
||||
|
||||
def endpoint_planner(
|
||||
parameter: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
length: float = length,
|
||||
) -> Port:
|
||||
_ = parameter
|
||||
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
|
||||
|
||||
def commit_planner(
|
||||
parameter: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
) -> WireTransitionData:
|
||||
_ = parameter
|
||||
return WireTransitionData(spec)
|
||||
|
||||
offers.append(StraightOffer(
|
||||
in_ptype = spec.in_port.ptype,
|
||||
out_ptype = spec.out_port.ptype,
|
||||
priority_bias = index * 1e7,
|
||||
length_domain = (length, length),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
))
|
||||
return tuple(offers)
|
||||
|
||||
def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]:
|
||||
if out_ptype in ('unk', self.ptype):
|
||||
return ()
|
||||
|
||||
offers: list[StraightOffer] = []
|
||||
for index, spec in enumerate(self.transitions):
|
||||
if spec.in_port.ptype != self.ptype:
|
||||
continue
|
||||
if out_ptype is not None and spec.out_port.ptype != out_ptype:
|
||||
continue
|
||||
|
||||
transition_length = self._transition_length(spec)
|
||||
if transition_length is None:
|
||||
continue
|
||||
|
||||
def endpoint_planner(
|
||||
length: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
transition_length: float = transition_length,
|
||||
) -> Port:
|
||||
straight_length = length - transition_length
|
||||
if straight_length < 0:
|
||||
raise BuildError(
|
||||
f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}'
|
||||
)
|
||||
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
|
||||
|
||||
def commit_planner(
|
||||
length: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
transition_length: float = transition_length,
|
||||
) -> WireStraightData:
|
||||
endpoint_planner(length)
|
||||
return WireStraightData(length - transition_length, spec)
|
||||
|
||||
offers.append(StraightOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = spec.out_port.ptype,
|
||||
priority_bias = index * 1e7,
|
||||
length_domain = (transition_length, numpy.inf),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
))
|
||||
return tuple(offers)
|
||||
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes)
|
||||
**kwargs: Any,
|
||||
) -> tuple[StraightOffer | BendOffer, ...]:
|
||||
if kind == 'straight':
|
||||
route_kwargs = dict(kwargs)
|
||||
|
||||
def endpoint_planner(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype=self.ptype)
|
||||
|
||||
def commit_planner(length: float) -> WireStraightData:
|
||||
_ = route_kwargs
|
||||
return WireStraightData(length)
|
||||
|
||||
native_offer = StraightOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = self.ptype,
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
)
|
||||
return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype))
|
||||
|
||||
if kind == 'bend':
|
||||
ccw = bool(kwargs.pop('ccw'))
|
||||
bend_forward = self.width / 2
|
||||
bend_run = bend_forward if ccw else -bend_forward
|
||||
bend_rotation = -pi / 2 if ccw else pi / 2
|
||||
|
||||
def endpoint_planner(length: float) -> Port:
|
||||
straight_length = length - bend_forward
|
||||
if straight_length < 0:
|
||||
raise BuildError(
|
||||
f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}'
|
||||
)
|
||||
return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype)
|
||||
|
||||
def commit_planner(length: float) -> WireBendData:
|
||||
endpoint_planner(length)
|
||||
return WireBendData(straight_length=length - bend_forward, ccw=ccw)
|
||||
|
||||
return (BendOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = self.ptype,
|
||||
ccw = ccw,
|
||||
length_domain = (bend_forward, numpy.inf),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
),)
|
||||
|
||||
if kind in ('s', 'u'):
|
||||
return ()
|
||||
|
||||
raise BuildError(f'Unrecognized primitive offer kind {kind!r}')
|
||||
|
||||
def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None:
|
||||
if numpy.isclose(data.length, 0) and data.out_transition is None:
|
||||
return
|
||||
|
||||
if not numpy.isclose(data.length, 0):
|
||||
tree.top_pattern().plug(
|
||||
self._straight_pattern(data.length),
|
||||
{port_names[1]: 'input'},
|
||||
append=True,
|
||||
)
|
||||
|
||||
if data.out_transition is not None:
|
||||
self._render_transition(tree, port_names, WireTransitionData(data.out_transition))
|
||||
|
||||
def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None:
|
||||
self._render_straight(tree, port_names, WireStraightData(data.straight_length))
|
||||
tree.top_pattern().plug(
|
||||
self.bend,
|
||||
{port_names[1]: 'input'},
|
||||
mirrored=data.ccw,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None:
|
||||
tree.top_pattern().plug(
|
||||
data.spec.abstract,
|
||||
{port_names[1]: data.spec.in_port_name},
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
**kwargs: Any, # noqa: ARG002 (no per-render options in this example tool)
|
||||
) -> ILibrary:
|
||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire')
|
||||
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype)
|
||||
|
||||
for step in batch:
|
||||
assert step.tool == self
|
||||
if isinstance(step.data, WireTransitionData):
|
||||
self._render_transition(tree, port_names, step.data)
|
||||
elif isinstance(step.data, WireStraightData):
|
||||
self._render_straight(tree, port_names, step.data)
|
||||
elif isinstance(step.data, WireBendData):
|
||||
self._render_bend(tree, port_names, step.data)
|
||||
else:
|
||||
raise BuildError(f'Unexpected primitive render data {type(step.data)}')
|
||||
return tree
|
||||
|
||||
|
||||
def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||
"""
|
||||
Create some basic library elements and tools for drawing M1 and M2
|
||||
"""
|
||||
#
|
||||
# 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',
|
||||
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.
|
||||
# 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)
|
||||
#
|
||||
# Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers
|
||||
# directly: it tells `Pather` about native straight/bend primitives and about
|
||||
# via adapters that can transition between M1 and M2 port types.
|
||||
# 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.
|
||||
#
|
||||
via = library.abstract('v1_via')
|
||||
via_transitions = (
|
||||
WireTransitionSpec(via, 'top', 'bottom'),
|
||||
WireTransitionSpec(via, 'bottom', 'top'),
|
||||
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
|
||||
)
|
||||
|
||||
M1_tool = PrimitiveWireTool(
|
||||
layer = 'M1',
|
||||
width = M1_WIDTH,
|
||||
ptype = 'm1wire',
|
||||
bend = library.abstract('m1_bend'),
|
||||
transitions = via_transitions,
|
||||
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
|
||||
)
|
||||
|
||||
M2_tool = PrimitiveWireTool(
|
||||
layer = 'M2',
|
||||
width = M2_WIDTH,
|
||||
ptype = 'm2wire',
|
||||
bend = library.abstract('m2_bend'),
|
||||
transitions = via_transitions,
|
||||
)
|
||||
return library, M1_tool, M2_tool
|
||||
|
||||
|
||||
#
|
||||
# Now we can start building up our library (collection of static cells) and pathing tools.
|
||||
#
|
||||
# If any of the operations below are confusing, you can cross-reference against the deferred
|
||||
# `Pather` tutorial, which handles some things more explicitly (e.g. via placement) and simplifies
|
||||
# others (e.g. geometry definition).
|
||||
#
|
||||
def main() -> None:
|
||||
library, M1_tool, M2_tool = prepare_tools()
|
||||
|
||||
#
|
||||
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
|
||||
# Then, place some pads and start routing wires!
|
||||
|
|
@ -450,25 +203,27 @@ def main() -> None:
|
|||
|
||||
# Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False)
|
||||
# The total distance forward (including the bend's forward component) must be 6um
|
||||
pather.cw('VCC', 6_000)
|
||||
pather.path('VCC', ccw=False, length=6_000)
|
||||
|
||||
# Now path VCC to x=0. This time, don't include any bend.
|
||||
# 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.straight('VCC', x=0)
|
||||
pather.path_to('VCC', ccw=None, x=0)
|
||||
|
||||
# Path GND forward by 5um, turning clockwise 90 degrees.
|
||||
pather.cw('GND', 5_000)
|
||||
# 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.straight('GND', x=pather['VCC'].offset[0])
|
||||
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 Pather, we don't need to place one ourselves.
|
||||
# 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 route GND (the `pather.ccw()` call below).
|
||||
pather.retool(M1_tool, keys='GND')
|
||||
# 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.
|
||||
|
|
@ -476,7 +231,7 @@ def main() -> None:
|
|||
#
|
||||
# Since we recently retooled GND, its path starts with a via down to M1 (included in the distance
|
||||
# calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2.
|
||||
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||
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.
|
||||
|
|
@ -486,37 +241,38 @@ def main() -> None:
|
|||
# The total extension (travel distance along the forward direction) for the longest segment (in
|
||||
# this case the segment being added to GND) should be exactly 50um.
|
||||
# After turning, the wire pitch should be reduced only 1.2um.
|
||||
pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
||||
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 call
|
||||
# that applies to VCC, and for the second call, that applies to GND; the relative lengths of the
|
||||
# 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.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
||||
pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
||||
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.
|
||||
# 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.
|
||||
# With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
|
||||
# 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.straight(['GND', 'VCC'], xmin=-28_000)
|
||||
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.straight('VCC', x=-50_000, out_ptype='m1wire')
|
||||
pather.path_to('VCC', None, -50_000, out_ptype='m1wire')
|
||||
|
||||
# Now extend GND out to x=-50_000, using M2 for a portion of the path.
|
||||
# We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice.
|
||||
with pather.toolctx(M2_tool, keys='GND'):
|
||||
pather.straight('GND', x=-40_000)
|
||||
pather.straight('GND', x=-50_000)
|
||||
with pather.toolctx(M2_tool, keys=['GND']):
|
||||
pather.path_to('GND', None, -40_000)
|
||||
pather.path_to('GND', None, -50_000)
|
||||
|
||||
# Save the pather's pattern into our library
|
||||
library['Pather_and_PrimitiveOffers'] = pather.pattern
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Routines for creating normalized 2D lattices and common photonic crystal
|
||||
cavity designs.
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
from collection.abc import Sequence
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
|
@ -50,7 +50,7 @@ def triangular_lattice(
|
|||
elif origin == 'corner':
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f'Invalid value for `origin`: {origin}')
|
||||
raise Exception(f'Invalid value for `origin`: {origin}')
|
||||
|
||||
return xy[xy[:, 0].argsort(), :]
|
||||
|
||||
|
|
@ -197,12 +197,12 @@ def ln_defect(
|
|||
`[[x0, y0], [x1, y1], ...]` for all the holes
|
||||
"""
|
||||
if defect_length % 2 != 1:
|
||||
raise ValueError('defect_length must be odd!')
|
||||
pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims])
|
||||
raise Exception('defect_length must be odd!')
|
||||
p = triangular_lattice([2 * d + 1 for d in mirror_dims])
|
||||
half_length = numpy.floor(defect_length / 2)
|
||||
hole_nums = numpy.arange(-half_length, half_length + 1)
|
||||
holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True)
|
||||
return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :]
|
||||
holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True)
|
||||
return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ]
|
||||
|
||||
|
||||
def ln_shift_defect(
|
||||
|
|
@ -248,7 +248,7 @@ def ln_shift_defect(
|
|||
for sign in (-1, 1):
|
||||
x_val = sign * (x_removed + ind + 1)
|
||||
which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0)
|
||||
xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
|
||||
xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
|
||||
|
||||
return xyr
|
||||
|
||||
|
|
@ -309,7 +309,7 @@ def l3_shift_perturbed_defect(
|
|||
|
||||
# which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2])
|
||||
perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2)))
|
||||
for xy in perturbed_holes:
|
||||
which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1)
|
||||
xyr[which, 2] = perturbed_radius
|
||||
for row in xyr:
|
||||
if numpy.fabs(row) in perturbed_holes:
|
||||
row[2] = perturbed_radius
|
||||
return xyr
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
"""
|
||||
PortPather tutorial: Using .at() syntax
|
||||
"""
|
||||
from masque import Pather, Pattern, Port, R90
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
from pather import map_layer, prepare_tools
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Reuse the same patterns (pads, bends, vias) and tools as in pather.py
|
||||
library, M1_tool, M2_tool = prepare_tools()
|
||||
|
||||
# Create a deferred Pather and place some initial pads (same as Pather tutorial)
|
||||
rpather = Pather(library, tools=M2_tool, render='deferred')
|
||||
|
||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
||||
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
||||
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||
|
||||
#
|
||||
# Routing with .at() chaining
|
||||
#
|
||||
# The .at(port_name) method returns a PortPather object which wraps the Pather
|
||||
# and remembers the selected port(s). This allows method chaining.
|
||||
|
||||
# Route VCC: 6um South, then West to x=0.
|
||||
# (Note: since the port points North into the pad, trace() moves South by default)
|
||||
(rpather.at('VCC')
|
||||
.trace(False, length=6_000) # Move South, turn West (Clockwise)
|
||||
.trace_to(None, x=0) # Continue West to x=0
|
||||
)
|
||||
|
||||
# Route GND: 5um South, then West to match VCC's x-coordinate.
|
||||
rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x)
|
||||
|
||||
|
||||
#
|
||||
# Tool management and manual plugging
|
||||
#
|
||||
# We can use .retool() to change the tool for specific ports.
|
||||
# We can also use .plug() directly on a PortPather.
|
||||
|
||||
# Manually add a via to GND and switch to M1_tool for subsequent segments
|
||||
(rpather.at('GND')
|
||||
.plug('v1_via', 'top')
|
||||
.retool(M1_tool) # this only retools the 'GND' port
|
||||
)
|
||||
|
||||
# We can also pass multiple ports to .at(), and then route them together.
|
||||
# Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via).
|
||||
(rpather.at(['GND', 'VCC'])
|
||||
.trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
|
||||
.retool(M1_tool) # Retools both GND and VCC
|
||||
.set_spacing(1_200) # Default bundle spacing for later bends
|
||||
.trace(True, emax=50_000) # Turn East, moves 50um extension
|
||||
.trace(False, emin=1_000) # U-turn back South
|
||||
.trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing
|
||||
)
|
||||
|
||||
# Retool VCC back to M2 and move both to x=-28k
|
||||
rpather.at('VCC').retool(M2_tool)
|
||||
rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000)
|
||||
|
||||
# Final segments to -50k
|
||||
rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire')
|
||||
with rpather.at('GND').toolctx(M2_tool):
|
||||
rpather.at('GND').trace_to(None, x=-40_000)
|
||||
rpather.at('GND').trace_to(None, x=-50_000)
|
||||
|
||||
|
||||
#
|
||||
# Branching with mark and fork
|
||||
#
|
||||
# .mark(new_name) creates a port copy and keeps the original selected.
|
||||
# .fork(new_name) creates a port copy and selects the new one.
|
||||
|
||||
# Create a tap on GND
|
||||
(rpather.at('GND')
|
||||
.trace(None, length=5_000) # Move GND further West
|
||||
.mark('GND_TAP') # Mark this location for a later branch
|
||||
.jog(offset=-10_000, length=10_000) # Continue GND with an S-bend
|
||||
)
|
||||
|
||||
# Branch VCC and follow the new branch
|
||||
(rpather.at('VCC')
|
||||
.trace(None, length=5_000)
|
||||
.fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH'
|
||||
.trace(True, length=5_000) # VCC_BRANCH turns South
|
||||
)
|
||||
# The original 'VCC' port remains at x=-55k, y=VCC.y
|
||||
|
||||
|
||||
#
|
||||
# Port set management: add, drop, rename, delete
|
||||
#
|
||||
|
||||
# Route the GND_TAP we saved earlier.
|
||||
(rpather.at('GND_TAP')
|
||||
.retool(M1_tool)
|
||||
.trace(True, length=10_000) # Turn South
|
||||
.rename('GND_FEED') # Give it a more descriptive name
|
||||
.retool(M1_tool) # Re-apply tool to the new name
|
||||
)
|
||||
|
||||
# We can manage the active set of ports in a PortPather
|
||||
pp = rpather.at(['VCC_BRANCH', 'GND_FEED'])
|
||||
pp.select('GND') # Now tracking 3 ports
|
||||
pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND
|
||||
pp.trace(None, each=5_000) # Move both 5um forward (length > transition size)
|
||||
|
||||
# We can also delete ports from the pather entirely
|
||||
rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead)
|
||||
|
||||
|
||||
#
|
||||
# Advanced Connections: trace_into
|
||||
#
|
||||
# trace_into routes FROM the selected port TO a target port.
|
||||
|
||||
# Create a destination component
|
||||
dest_ports = {
|
||||
'in_A': Port((0, 0), rotation=R90, ptype='m2wire'),
|
||||
'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire')
|
||||
}
|
||||
library['dest'] = Pattern(ports=dest_ports)
|
||||
# Place dest so that its ports are to the West and South of our current wires.
|
||||
# Rotating by pi/2 makes the ports face West (pointing East).
|
||||
rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'})
|
||||
|
||||
# Connect GND_FEED to DEST_A
|
||||
# Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice.
|
||||
rpather.at('GND_FEED').trace_into('DEST_A')
|
||||
|
||||
# Connect VCC_BRANCH to DEST_B
|
||||
rpather.at('VCC_BRANCH').trace_into('DEST_B')
|
||||
|
||||
|
||||
#
|
||||
# Direct Port Transformations and Metadata
|
||||
#
|
||||
(rpather.at('GND')
|
||||
.set_ptype('m1wire') # Change metadata
|
||||
.translate((1000, 0)) # Shift the port 1um East
|
||||
.rotate(R90 / 2) # Rotate it 45 degrees
|
||||
.set_rotation(R90) # Force it to face West
|
||||
)
|
||||
|
||||
# Demonstrate .plugged() to acknowledge a manual connection
|
||||
# (Normally used when you place components so their ports perfectly overlap)
|
||||
rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2'))
|
||||
rpather.at('TMP1').plugged('TMP2') # Removes both ports
|
||||
|
||||
|
||||
#
|
||||
# Rendering and Saving
|
||||
#
|
||||
# Since routing is deferred, we must call .render() to generate the geometry.
|
||||
rpather.render()
|
||||
|
||||
library['PortPather_Tutorial'] = rpather.pattern
|
||||
library.map_layers(map_layer)
|
||||
writefile(library, 'port_pather.gds', **GDS_OPTS)
|
||||
print("Tutorial complete. Output written to port_pather.gds")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"""
|
||||
Manual wire routing tutorial: deferred Pather and PathTool
|
||||
Manual wire routing tutorial: RenderPather an PathTool
|
||||
"""
|
||||
from masque import Pather, Library
|
||||
from masque.builder import 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
|
||||
|
|
@ -11,9 +12,9 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via
|
|||
|
||||
def main() -> None:
|
||||
#
|
||||
# To illustrate deferred routing with `Pather`, we use `PathTool` instead
|
||||
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
||||
# but when used with `Pather(render='deferred')`, it can consolidate multiple routing steps into
|
||||
# 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`
|
||||
|
|
@ -24,68 +25,66 @@ def main() -> None:
|
|||
library = Library()
|
||||
library['pad'] = make_pad()
|
||||
library['v1_via'] = make_via(
|
||||
layer_top = 'M2',
|
||||
layer_via = 'V1',
|
||||
layer_bot = 'M1',
|
||||
width_top = M2_WIDTH,
|
||||
width_via = V1_WIDTH,
|
||||
width_bot = M1_WIDTH,
|
||||
ptype_bot = 'm1wire',
|
||||
ptype_top = 'm2wire',
|
||||
layer_top='M2',
|
||||
layer_via='V1',
|
||||
layer_bot='M1',
|
||||
width_top=M2_WIDTH,
|
||||
width_via=V1_WIDTH,
|
||||
width_bot=M1_WIDTH,
|
||||
ptype_bot='m1wire',
|
||||
ptype_top='m2wire',
|
||||
)
|
||||
|
||||
# `PathTool` is more limited than `AutoTool`. It only generates one type of shape
|
||||
# `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 = Pather(tools=M2_ptool, library=library, render='deferred')
|
||||
rpather = RenderPather(tools=M2_ptool, library=library)
|
||||
|
||||
# As in the pather tutorial, we make some pads and labels...
|
||||
# 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.cw('VCC', 6_000)
|
||||
rpather.straight('VCC', x=0)
|
||||
rpather.cw('GND', 5_000)
|
||||
rpather.straight('GND', x=rpather.pattern['VCC'].x)
|
||||
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.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||
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.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
||||
rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
||||
rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
||||
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.straight(['GND', 'VCC'], xmin=-28_000)
|
||||
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.
|
||||
v1pat = library['v1_via']
|
||||
via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x)
|
||||
|
||||
# alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0]
|
||||
# would take into account the port orientations if we didn't already know they're along x
|
||||
rpather.straight('VCC', x=-50_000 + via_size)
|
||||
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'})
|
||||
|
||||
# Render the path we defined
|
||||
rpather.render()
|
||||
library['Deferred_Pather_and_PathTool'] = rpather.pattern
|
||||
library['RenderPather_and_PathTool'] = rpather.pattern
|
||||
|
||||
|
||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ from .error import (
|
|||
from .shapes import (
|
||||
Shape as Shape,
|
||||
Polygon as Polygon,
|
||||
RectCollection as RectCollection,
|
||||
Path as Path,
|
||||
Circle as Circle,
|
||||
Arc as Arc,
|
||||
|
|
@ -56,23 +55,16 @@ from .pattern import (
|
|||
map_targets as map_targets,
|
||||
chain_elements as chain_elements,
|
||||
)
|
||||
from .utils.boolean import boolean as boolean
|
||||
|
||||
from .library import (
|
||||
ILibraryView as ILibraryView,
|
||||
ILibrary as ILibrary,
|
||||
LibraryView as LibraryView,
|
||||
Library as Library,
|
||||
OverlayLibrary as OverlayLibrary,
|
||||
PortsLibraryView as PortsLibraryView,
|
||||
BuildLibrary as BuildLibrary,
|
||||
BuildReport as BuildReport,
|
||||
CellProvenance as CellProvenance,
|
||||
LazyLibrary as LazyLibrary,
|
||||
AbstractView as AbstractView,
|
||||
TreeView as TreeView,
|
||||
Tree as Tree,
|
||||
cell as cell,
|
||||
)
|
||||
from .ports import (
|
||||
Port as Port,
|
||||
|
|
@ -80,9 +72,12 @@ from .ports import (
|
|||
)
|
||||
from .abstract import Abstract as Abstract
|
||||
from .builder import (
|
||||
Builder as Builder,
|
||||
Tool as Tool,
|
||||
Pather as Pather,
|
||||
RenderPather as RenderPather,
|
||||
RenderStep as RenderStep,
|
||||
SimpleTool as SimpleTool,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
PortPather as PortPather,
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@ from numpy.typing import ArrayLike
|
|||
from .ref import Ref
|
||||
from .ports import PortList, Port
|
||||
from .utils import rotation_matrix_2d
|
||||
from .traits import Mirrorable
|
||||
|
||||
#if TYPE_CHECKING:
|
||||
# from .builder import Builder, Tool
|
||||
# from .library import ILibrary
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Abstract(PortList, Mirrorable):
|
||||
class Abstract(PortList):
|
||||
"""
|
||||
An `Abstract` is a container for a name and associated ports.
|
||||
|
||||
|
|
@ -128,18 +131,50 @@ class Abstract(PortList, Mirrorable):
|
|||
port.rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the Abstract across an axis through its origin.
|
||||
Mirror the offsets of all shapes, labels, and refs across an axis
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.flip_across(axis=axis)
|
||||
port.offset[across_axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def mirror_ports(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror each port's rotation across an axis, relative to its
|
||||
offset
|
||||
|
||||
Args:
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.mirror(across_axis)
|
||||
return self
|
||||
|
||||
def mirror(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the Pattern across an axis
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.mirror_ports(across_axis)
|
||||
self.mirror_port_offsets(across_axis)
|
||||
return self
|
||||
|
||||
def apply_ref_transform(self, ref: Ref) -> Self:
|
||||
|
|
@ -157,8 +192,6 @@ class Abstract(PortList, Mirrorable):
|
|||
self.mirror()
|
||||
self.rotate_ports(ref.rotation)
|
||||
self.rotate_port_offsets(ref.rotation)
|
||||
if ref.scale != 1:
|
||||
self.scale_by(ref.scale)
|
||||
self.translate_ports(ref.offset)
|
||||
return self
|
||||
|
||||
|
|
@ -176,8 +209,6 @@ class Abstract(PortList, Mirrorable):
|
|||
# TODO test undo_ref_transform
|
||||
"""
|
||||
self.translate_ports(-ref.offset)
|
||||
if ref.scale != 1:
|
||||
self.scale_by(1 / ref.scale)
|
||||
self.rotate_port_offsets(-ref.rotation)
|
||||
self.rotate_ports(-ref.rotation)
|
||||
if ref.mirrored:
|
||||
|
|
|
|||
|
|
@ -1,58 +1,12 @@
|
|||
"""
|
||||
Builder helpers for port-based assembly and primitive-offer routing.
|
||||
|
||||
A routing `Tool` describes the primitive route families it can provide by
|
||||
returning `PrimitiveOffer` objects. Each offer is a parameterized planning
|
||||
candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost,
|
||||
optional footprint metadata, and a commit hook for producing tool-specific
|
||||
render data after a concrete parameter has been selected.
|
||||
|
||||
`Pather` owns user-facing route operations such as `trace()`, `jog()`,
|
||||
`uturn()`, and `trace_into()`. For each operation, it asks the active `Tool` for
|
||||
offers and passes those offers plus route constraints to the internal router.
|
||||
The router selects a sequence of internal selected primitives, each pairing an
|
||||
offer with a concrete parameter, endpoint, and cost.
|
||||
|
||||
Route commit is separate from route selection. Once a route is selected,
|
||||
`Pather` calls `offer.commit(parameter)` only for the selected primitives and
|
||||
stores the returned opaque tool payload in `RenderStep.data`. Later,
|
||||
`Pather.render()` batches compatible `RenderStep`s and calls `Tool.render()` to
|
||||
turn those committed payloads into geometry.
|
||||
|
||||
`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract.
|
||||
`RenderStep` is `Pather`'s deferred-render record, and
|
||||
`masque.builder.planner` is an internal planner implementation rather than a
|
||||
stable public API.
|
||||
|
||||
The practical layering is:
|
||||
- user code drives `Pather` and chooses Tools per port or by default,
|
||||
- Tools describe local legal motion primitives without touching Pather state,
|
||||
- the internal router composes those primitives into high-level route shapes,
|
||||
- Pather applies the prepared result to ports, deferred render queues, and the
|
||||
target pattern/library.
|
||||
|
||||
Code outside the builder package should prefer the exports here over importing
|
||||
from `masque.builder.planner`. The planner package is intentionally available
|
||||
for tests and internal maintenance, but it is not the compatibility boundary
|
||||
for custom Tools.
|
||||
"""
|
||||
|
||||
from .pather import (
|
||||
Pather as Pather,
|
||||
PortPather as PortPather,
|
||||
)
|
||||
from .builder import Builder as Builder
|
||||
from .pather import Pather as Pather
|
||||
from .renderpather import RenderPather as RenderPather
|
||||
from .pather_mixin import PortPather as PortPather
|
||||
from .utils import ell as ell
|
||||
from .tools import (
|
||||
Tool as Tool,
|
||||
RenderStep as RenderStep,
|
||||
SimpleTool as SimpleTool,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
RenderStep as RenderStep,
|
||||
PrimitiveKind as PrimitiveKind,
|
||||
GeneratedEndpointFn as GeneratedEndpointFn,
|
||||
PrimitiveOffer as PrimitiveOffer,
|
||||
StraightOffer as StraightOffer,
|
||||
BendOffer as BendOffer,
|
||||
SOffer as SOffer,
|
||||
UOffer as UOffer,
|
||||
circular_arc_sbend_endpoint as circular_arc_sbend_endpoint,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
448
masque/builder/builder.py
Normal file
448
masque/builder/builder.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"""
|
||||
Simplified Pattern assembly (`Builder`)
|
||||
"""
|
||||
from typing import Self
|
||||
from collections.abc import Iterable, Sequence, Mapping
|
||||
import copy
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import ILibrary, TreeView
|
||||
from ..error import BuildError
|
||||
from ..ports import PortList, Port
|
||||
from ..abstract import Abstract
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Builder(PortList):
|
||||
"""
|
||||
A `Builder` is a helper object used for snapping together multiple
|
||||
lower-level patterns at their `Port`s.
|
||||
|
||||
The `Builder` mostly just holds context, in the form of a `Library`,
|
||||
in addition to its underlying pattern. This simplifies some calls
|
||||
to `plug` and `place`, by making the library implicit.
|
||||
|
||||
`Builder` can also be `set_dead()`, at which point further calls to `plug()`
|
||||
and `place()` are ignored (intended for debugging).
|
||||
|
||||
|
||||
Examples: Creating a Builder
|
||||
===========================
|
||||
- `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes
|
||||
an empty pattern, adds the given ports, and places it into `library`
|
||||
under the name `'mypat'`.
|
||||
|
||||
- `Builder(library)` makes an empty pattern with no ports. The pattern
|
||||
is not added into `library` and must later be added with e.g.
|
||||
`library['mypat'] = builder.pattern`
|
||||
|
||||
- `Builder(library, pattern=pattern, name='mypat')` uses an existing
|
||||
pattern (including its ports) and sets `library['mypat'] = pattern`.
|
||||
|
||||
- `Builder.interface(other_pat, port_map=['A', 'B'], library=library)`
|
||||
makes a new (empty) pattern, copies over ports 'A' and 'B' from
|
||||
`other_pat`, and creates additional ports 'in_A' and 'in_B' facing
|
||||
in the opposite directions. This can be used to build a device which
|
||||
can plug into `other_pat` (using the 'in_*' ports) but which does not
|
||||
itself include `other_pat` as a subcomponent.
|
||||
|
||||
- `Builder.interface(other_builder, ...)` does the same thing as
|
||||
`Builder.interface(other_builder.pattern, ...)` but also uses
|
||||
`other_builder.library` as its library by default.
|
||||
|
||||
|
||||
Examples: Adding to a pattern
|
||||
=============================
|
||||
- `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
|
||||
instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B'
|
||||
of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports
|
||||
are removed and any unconnected ports from `subdevice` are added to
|
||||
`my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.
|
||||
|
||||
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
|
||||
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
|
||||
argument is provided, and the `thru` argument is not explicitly
|
||||
set to `False`, the unconnected port of `wire` is automatically renamed to
|
||||
'myport'. This allows easy extension of existing ports without changing
|
||||
their names or having to provide `map_out` each time `plug` is called.
|
||||
|
||||
- `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
|
||||
instantiates `pad` at the specified (x, y) offset and with the specified
|
||||
rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is
|
||||
renamed to 'gnd' so that further routing can use this signal or net name
|
||||
rather than the port name on the original `pad` device.
|
||||
"""
|
||||
__slots__ = ('pattern', 'library', '_dead')
|
||||
|
||||
pattern: Pattern
|
||||
""" Layout of this device """
|
||||
|
||||
library: ILibrary
|
||||
"""
|
||||
Library from which patterns should be referenced
|
||||
"""
|
||||
|
||||
_dead: bool
|
||||
""" If True, plug()/place() are skipped (for debugging)"""
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self.pattern.ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, value: dict[str, Port]) -> None:
|
||||
self.pattern.ports = value
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: ILibrary,
|
||||
*,
|
||||
pattern: Pattern | None = None,
|
||||
ports: str | Mapping[str, Port] | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
library: The library from which referenced patterns will be taken
|
||||
pattern: The pattern which will be modified by subsequent operations.
|
||||
If `None` (default), a new pattern is created.
|
||||
ports: Allows specifying the initial set of ports, if `pattern` does
|
||||
not already have any ports (or is not provided). May be a string,
|
||||
in which case it is interpreted as a name in `library`.
|
||||
Default `None` (no ports).
|
||||
name: If specified, `library[name]` is set to `self.pattern`.
|
||||
"""
|
||||
self._dead = False
|
||||
self.library = library
|
||||
if pattern is not None:
|
||||
self.pattern = pattern
|
||||
else:
|
||||
self.pattern = Pattern()
|
||||
|
||||
if ports is not None:
|
||||
if self.pattern.ports:
|
||||
raise BuildError('Ports supplied for pattern with pre-existing ports!')
|
||||
if isinstance(ports, str):
|
||||
ports = library.abstract(ports).ports
|
||||
|
||||
self.pattern.ports.update(copy.deepcopy(dict(ports)))
|
||||
|
||||
if name is not None:
|
||||
library[name] = self.pattern
|
||||
|
||||
@classmethod
|
||||
def interface(
|
||||
cls: type['Builder'],
|
||||
source: PortList | Mapping[str, Port] | str,
|
||||
*,
|
||||
library: ILibrary | None = None,
|
||||
in_prefix: str = 'in_',
|
||||
out_prefix: str = '',
|
||||
port_map: dict[str, str] | Sequence[str] | None = None,
|
||||
name: str | None = None,
|
||||
) -> 'Builder':
|
||||
"""
|
||||
Wrapper for `Pattern.interface()`, which returns a Builder instead.
|
||||
|
||||
Args:
|
||||
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
||||
from which to create the interface. May be a pattern name if
|
||||
`library` is provided.
|
||||
library: Library from which existing patterns should be referenced,
|
||||
and to which the new one should be added (if named). If not provided,
|
||||
`source.library` must exist and will be used.
|
||||
in_prefix: Prepended to port names for newly-created ports with
|
||||
reversed directions compared to the current device.
|
||||
out_prefix: Prepended to port names for ports which are directly
|
||||
copied from the current device.
|
||||
port_map: Specification for ports to copy into the new device:
|
||||
- If `None`, all ports are copied.
|
||||
- If a sequence, only the listed ports are copied
|
||||
- If a mapping, the listed ports (keys) are copied and
|
||||
renamed (to the values).
|
||||
|
||||
Returns:
|
||||
The new builder, with an empty pattern and 2x as many ports as
|
||||
listed in port_map.
|
||||
|
||||
Raises:
|
||||
`PortError` if `port_map` contains port names not present in the
|
||||
current device.
|
||||
`PortError` if applying the prefixes results in duplicate port
|
||||
names.
|
||||
"""
|
||||
if library is None:
|
||||
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
|
||||
library = source.library
|
||||
else:
|
||||
raise BuildError('No library was given, and `source.library` does not have one either.')
|
||||
|
||||
if isinstance(source, str):
|
||||
source = library.abstract(source).ports
|
||||
|
||||
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
|
||||
new = Builder(library=library, pattern=pat, name=name)
|
||||
return new
|
||||
|
||||
@wraps(Pattern.label)
|
||||
def label(self, *args, **kwargs) -> Self:
|
||||
self.pattern.label(*args, **kwargs)
|
||||
return self
|
||||
|
||||
@wraps(Pattern.ref)
|
||||
def ref(self, *args, **kwargs) -> Self:
|
||||
self.pattern.ref(*args, **kwargs)
|
||||
return self
|
||||
|
||||
@wraps(Pattern.polygon)
|
||||
def polygon(self, *args, **kwargs) -> Self:
|
||||
self.pattern.polygon(*args, **kwargs)
|
||||
return self
|
||||
|
||||
@wraps(Pattern.rect)
|
||||
def rect(self, *args, **kwargs) -> Self:
|
||||
self.pattern.rect(*args, **kwargs)
|
||||
return self
|
||||
|
||||
# Note: We're a superclass of `Pather`, where path() means something different,
|
||||
# so we shouldn't wrap Pattern.path()
|
||||
#@wraps(Pattern.path)
|
||||
#def path(self, *args, **kwargs) -> Self:
|
||||
# self.pattern.path(*args, **kwargs)
|
||||
# return self
|
||||
|
||||
def plug(
|
||||
self,
|
||||
other: Abstract | str | Pattern | TreeView,
|
||||
map_in: dict[str, str],
|
||||
map_out: dict[str, str | None] | None = None,
|
||||
*,
|
||||
mirrored: bool = False,
|
||||
thru: bool | str = True,
|
||||
set_rotation: bool | None = None,
|
||||
append: bool = False,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
) -> Self:
|
||||
"""
|
||||
Wrapper around `Pattern.plug` which allows a string for `other`.
|
||||
|
||||
The `Builder`'s library is used to dereference the string (or `Abstract`, if
|
||||
one is passed with `append=True`). If a `TreeView` is passed, it is first
|
||||
added into `self.library`.
|
||||
|
||||
Args:
|
||||
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
|
||||
device to be instatiated. If it is a `TreeView`, it is first
|
||||
added into `self.library`, after which the topcell is plugged;
|
||||
an equivalent statement is `self.plug(self.library << other, ...)`.
|
||||
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||
port connections between the two devices.
|
||||
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||
new names for ports in `other`.
|
||||
mirrored: Enables mirroring `other` across the x axis prior to
|
||||
connecting any ports.
|
||||
thru: If map_in specifies only a single port, `thru` provides a mechainsm
|
||||
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
|
||||
- If True (default), and `other` has only two ports total, and map_out
|
||||
doesn't specify a name for the other port, its name is set to the key
|
||||
in `map_in`, i.e. 'myport'.
|
||||
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
|
||||
An error is raised if that entry already exists.
|
||||
|
||||
This makes it easy to extend a pattern with simple 2-port devices
|
||||
(e.g. wires) without providing `map_out` each time `plug` is
|
||||
called. See "Examples" above for more info. Default `True`.
|
||||
set_rotation: If the necessary rotation cannot be determined from
|
||||
the ports being connected (i.e. all pairs have at least one
|
||||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `other` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||
ptypes are always allowed to connect, as is `'unk'` with
|
||||
any other ptypte. Non-allowed ptype connections will emit a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other_names`.
|
||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
`PortError` if the specified port mapping is not achieveable (the ports
|
||||
do not line up)
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping plug() since device is dead')
|
||||
return self
|
||||
|
||||
if not isinstance(other, str | Abstract | Pattern):
|
||||
# We got a Tree; add it into self.library and grab an Abstract for it
|
||||
other = self.library << other
|
||||
|
||||
if isinstance(other, str):
|
||||
other = self.library.abstract(other)
|
||||
if append and isinstance(other, Abstract):
|
||||
other = self.library[other.name]
|
||||
|
||||
self.pattern.plug(
|
||||
other = other,
|
||||
map_in = map_in,
|
||||
map_out = map_out,
|
||||
mirrored = mirrored,
|
||||
thru = thru,
|
||||
set_rotation = set_rotation,
|
||||
append = append,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
return self
|
||||
|
||||
def place(
|
||||
self,
|
||||
other: Abstract | str | Pattern | TreeView,
|
||||
*,
|
||||
offset: ArrayLike = (0, 0),
|
||||
rotation: float = 0,
|
||||
pivot: ArrayLike = (0, 0),
|
||||
mirrored: bool = False,
|
||||
port_map: dict[str, str | None] | None = None,
|
||||
skip_port_check: bool = False,
|
||||
append: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.
|
||||
|
||||
The `Builder`'s library is used to dereference the string (or `Abstract`, if
|
||||
one is passed with `append=True`). If a `TreeView` is passed, it is first
|
||||
added into `self.library`.
|
||||
|
||||
Args:
|
||||
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
|
||||
device to be instatiated. If it is a `TreeView`, it is first
|
||||
added into `self.library`, after which the topcell is plugged;
|
||||
an equivalent statement is `self.plug(self.library << other, ...)`.
|
||||
offset: Offset at which to place the instance. Default (0, 0).
|
||||
rotation: Rotation applied to the instance before placement. Default 0.
|
||||
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
||||
Rotation is applied prior to translation (`offset`).
|
||||
mirrored: Whether theinstance should be mirrored across the x axis.
|
||||
Mirroring is applied before translation and rotation.
|
||||
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||
new names for ports in the instantiated device. New names can be
|
||||
`None`, which will delete those ports.
|
||||
skip_port_check: Can be used to skip the internal call to `check_ports`,
|
||||
in case it has already been performed elsewhere.
|
||||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other.ports`.
|
||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping place() since device is dead')
|
||||
return self
|
||||
|
||||
if not isinstance(other, str | Abstract | Pattern):
|
||||
# We got a Tree; add it into self.library and grab an Abstract for it
|
||||
other = self.library << other
|
||||
|
||||
if isinstance(other, str):
|
||||
other = self.library.abstract(other)
|
||||
if append and isinstance(other, Abstract):
|
||||
other = self.library[other.name]
|
||||
|
||||
self.pattern.place(
|
||||
other = other,
|
||||
offset = offset,
|
||||
rotation = rotation,
|
||||
pivot = pivot,
|
||||
mirrored = mirrored,
|
||||
port_map = port_map,
|
||||
skip_port_check = skip_port_check,
|
||||
append = append,
|
||||
)
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
"""
|
||||
Translate the pattern and all ports.
|
||||
|
||||
Args:
|
||||
offset: (x, y) distance to translate by
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.translate_elements(offset)
|
||||
return self
|
||||
|
||||
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
|
||||
"""
|
||||
Rotate the pattern and all ports.
|
||||
|
||||
Args:
|
||||
angle: angle (radians, counterclockwise) to rotate by
|
||||
pivot: location to rotate around
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.rotate_around(pivot, angle)
|
||||
for port in self.ports.values():
|
||||
port.rotate_around(pivot, angle)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the pattern and all ports across the specified axis.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across (x=0, y=1)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.mirror(axis)
|
||||
return self
|
||||
|
||||
def set_dead(self) -> Self:
|
||||
"""
|
||||
Disallows further changes through `plug()` or `place()`.
|
||||
This is meant for debugging:
|
||||
```
|
||||
dev.plug(a, ...)
|
||||
dev.set_dead() # added for debug purposes
|
||||
dev.plug(b, ...) # usually raises an error, but now skipped
|
||||
dev.plug(c, ...) # also skipped
|
||||
dev.pattern.visualize() # shows the device as of the set_dead() call
|
||||
```
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self._dead = True
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
s = f'<Builder {self.pattern} L({len(self.library)})>'
|
||||
return s
|
||||
|
||||
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""Logging helpers for Pather."""
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Iterator, Sequence
|
||||
import logging
|
||||
import numpy
|
||||
from contextlib import contextmanager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .pather import Pather
|
||||
|
||||
|
||||
def _format_log_args(**kwargs) -> str:
|
||||
arg_strs = []
|
||||
for k, v in kwargs.items():
|
||||
if isinstance(v, str | int | float | bool | None):
|
||||
arg_strs.append(f"{k}={v}")
|
||||
elif isinstance(v, numpy.ndarray):
|
||||
arg_strs.append(f"{k}={v.tolist()}")
|
||||
elif isinstance(v, list | tuple) and len(v) <= 10:
|
||||
arg_strs.append(f"{k}={v}")
|
||||
else:
|
||||
arg_strs.append(f"{k}=...")
|
||||
return ", ".join(arg_strs)
|
||||
|
||||
|
||||
class PatherLogger:
|
||||
"""
|
||||
Encapsulates state for Pather diagnostic logging.
|
||||
"""
|
||||
debug: bool
|
||||
indent: int
|
||||
depth: int
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
self.debug = debug
|
||||
self.indent = 0
|
||||
self.depth = 0
|
||||
|
||||
def _log(self, module_name: str, msg: str) -> None:
|
||||
if self.debug and self.depth <= 1:
|
||||
log_obj = logging.getLogger(module_name)
|
||||
log_obj.info(' ' * self.indent + msg)
|
||||
|
||||
@contextmanager
|
||||
def log_operation(
|
||||
self,
|
||||
pather: 'Pather',
|
||||
op: str,
|
||||
portspec: str | Sequence[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[None]:
|
||||
if not self.debug or self.depth > 0:
|
||||
self.depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.depth -= 1
|
||||
return
|
||||
|
||||
target = f"({portspec})" if portspec else ""
|
||||
module_name = pather.__class__.__module__
|
||||
self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}")
|
||||
|
||||
before_ports = {name: port.copy() for name, port in pather.ports.items()}
|
||||
self.depth += 1
|
||||
self.indent += 1
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
after_ports = pather.ports
|
||||
for name in sorted(after_ports.keys()):
|
||||
if name not in before_ports or after_ports[name] != before_ports[name]:
|
||||
self._log(module_name, f"Port {name}: {pather.ports[name].describe()}")
|
||||
for name in sorted(before_ports.keys()):
|
||||
if name not in after_ports:
|
||||
self._log(module_name, f"Port {name}: removed")
|
||||
|
||||
self.indent -= 1
|
||||
self.depth -= 1
|
||||
File diff suppressed because it is too large
Load diff
677
masque/builder/pather_mixin.py
Normal file
677
masque/builder/pather_mixin.py
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
from typing import Self, overload
|
||||
from collections.abc import Sequence, Iterator, Iterable
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from abc import abstractmethod, ABCMeta
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import ILibrary, TreeView
|
||||
from ..error import PortError, BuildError
|
||||
from ..utils import SupportsBool
|
||||
from ..abstract import Abstract
|
||||
from .tools import Tool
|
||||
from .utils import ell
|
||||
from ..ports import PortList
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PatherMixin(PortList, metaclass=ABCMeta):
|
||||
pattern: Pattern
|
||||
""" Layout of this device """
|
||||
|
||||
library: ILibrary
|
||||
""" Library from which patterns should be referenced """
|
||||
|
||||
_dead: bool
|
||||
""" If True, plug()/place() are skipped (for debugging) """
|
||||
|
||||
tools: dict[str | None, Tool]
|
||||
"""
|
||||
Tool objects are used to dynamically generate new single-use Devices
|
||||
(e.g wires or waveguides) to be plugged into this device.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def path(
|
||||
self,
|
||||
portspec: str,
|
||||
ccw: SupportsBool | None,
|
||||
length: float,
|
||||
*,
|
||||
plug_into: str | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def pathS(
|
||||
self,
|
||||
portspec: str,
|
||||
length: float,
|
||||
jog: float,
|
||||
*,
|
||||
plug_into: str | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def plug(
|
||||
self,
|
||||
other: Abstract | str | Pattern | TreeView,
|
||||
map_in: dict[str, str],
|
||||
map_out: dict[str, str | None] | None = None,
|
||||
*,
|
||||
mirrored: bool = False,
|
||||
thru: bool | str = True,
|
||||
set_rotation: bool | None = None,
|
||||
append: bool = False,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
) -> Self:
|
||||
pass
|
||||
|
||||
def retool(
|
||||
self,
|
||||
tool: Tool,
|
||||
keys: str | Sequence[str | None] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Update the `Tool` which will be used when generating `Pattern`s for the ports
|
||||
given by `keys`.
|
||||
|
||||
Args:
|
||||
tool: The new `Tool` to use for the given ports.
|
||||
keys: Which ports the tool should apply to. `None` indicates the default tool,
|
||||
used when there is no matching entry in `self.tools` for the port in question.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if keys is None or isinstance(keys, str):
|
||||
self.tools[keys] = tool
|
||||
else:
|
||||
for key in keys:
|
||||
self.tools[key] = tool
|
||||
return self
|
||||
|
||||
@contextmanager
|
||||
def toolctx(
|
||||
self,
|
||||
tool: Tool,
|
||||
keys: str | Sequence[str | None] | None = None,
|
||||
) -> Iterator[Self]:
|
||||
"""
|
||||
Context manager for temporarily `retool`-ing and reverting the `retool`
|
||||
upon exiting the context.
|
||||
|
||||
Args:
|
||||
tool: The new `Tool` to use for the given ports.
|
||||
keys: Which ports the tool should apply to. `None` indicates the default tool,
|
||||
used when there is no matching entry in `self.tools` for the port in question.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if keys is None or isinstance(keys, str):
|
||||
keys = [keys]
|
||||
saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None`
|
||||
try:
|
||||
yield self.retool(tool=tool, keys=keys)
|
||||
finally:
|
||||
for kk, tt in saved_tools.items():
|
||||
if tt is None:
|
||||
# delete if present
|
||||
self.tools.pop(kk, None)
|
||||
else:
|
||||
self.tools[kk] = tt
|
||||
|
||||
def path_to(
|
||||
self,
|
||||
portspec: str,
|
||||
ccw: SupportsBool | None,
|
||||
position: float | None = None,
|
||||
*,
|
||||
x: float | None = None,
|
||||
y: float | None = None,
|
||||
plug_into: str | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
"""
|
||||
Build a "wire"/"waveguide" extending from the port `portspec`, with the aim
|
||||
of ending exactly at a target position.
|
||||
|
||||
The wire will travel so that the output port will be placed at exactly the target
|
||||
position along the input port's axis. There can be an unspecified (tool-dependent)
|
||||
offset in the perpendicular direction. The output port will be rotated (or not)
|
||||
based on the `ccw` parameter.
|
||||
|
||||
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
||||
|
||||
Args:
|
||||
portspec: The name of the port into which the wire will be plugged.
|
||||
ccw: If `None`, the output should be along the same axis as the input.
|
||||
Otherwise, cast to bool and turn counterclockwise if True
|
||||
and clockwise otherwise.
|
||||
position: The final port position, along the input's axis only.
|
||||
(There may be a tool-dependent offset along the other axis.)
|
||||
Only one of `position`, `x`, and `y` may be specified.
|
||||
x: The final port position along the x axis.
|
||||
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
|
||||
BuildError will be raised.
|
||||
y: The final port position along the y axis.
|
||||
`portspec` must refer to a vertical port if `y` is passed, otherwise a
|
||||
BuildError will be raised.
|
||||
plug_into: If not None, attempts to plug the wire's output port into the provided
|
||||
port on `self`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend
|
||||
is present).
|
||||
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
|
||||
BuildError if more than one of `x`, `y`, and `position` is specified.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping path_to() since device is dead')
|
||||
return self
|
||||
|
||||
pos_count = sum(vv is not None for vv in (position, x, y))
|
||||
if pos_count > 1:
|
||||
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
|
||||
if pos_count < 1:
|
||||
raise BuildError('One of `position`, `x`, and `y` must be specified')
|
||||
|
||||
port = self.pattern[portspec]
|
||||
if port.rotation is None:
|
||||
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
|
||||
|
||||
if not numpy.isclose(port.rotation % (pi / 2), 0):
|
||||
raise BuildError('path_to was asked to route from non-manhattan port')
|
||||
|
||||
is_horizontal = numpy.isclose(port.rotation % pi, 0)
|
||||
if is_horizontal:
|
||||
if y is not None:
|
||||
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
|
||||
if position is None:
|
||||
position = x
|
||||
else:
|
||||
if x is not None:
|
||||
raise BuildError('Asked to path to x-coordinate, but port is vertical')
|
||||
if position is None:
|
||||
position = y
|
||||
|
||||
x0, y0 = port.offset
|
||||
if is_horizontal:
|
||||
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
|
||||
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
|
||||
length = numpy.abs(position - x0)
|
||||
else:
|
||||
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
|
||||
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
|
||||
length = numpy.abs(position - y0)
|
||||
|
||||
return self.path(
|
||||
portspec,
|
||||
ccw,
|
||||
length,
|
||||
plug_into = plug_into,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def path_into(
|
||||
self,
|
||||
portspec_src: str,
|
||||
portspec_dst: str,
|
||||
*,
|
||||
out_ptype: str | None = None,
|
||||
plug_destination: bool = True,
|
||||
thru: str | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
"""
|
||||
Create a "wire"/"waveguide" traveling between the ports `portspec_src` and
|
||||
`portspec_dst`, and `plug` it into both (or just the source port).
|
||||
|
||||
Only unambiguous scenarios are allowed:
|
||||
- Straight connector between facing ports
|
||||
- Single 90 degree bend
|
||||
- Jog between facing ports
|
||||
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
|
||||
|
||||
By default, the destination's `pytpe` will be used as the `out_ptype` for the
|
||||
wire, and the `portspec_dst` will be plugged (i.e. removed).
|
||||
|
||||
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
||||
|
||||
Args:
|
||||
portspec_src: The name of the starting port into which the wire will be plugged.
|
||||
portspec_dst: The name of the destination port.
|
||||
out_ptype: Passed to the pathing tool in order to specify the desired port type
|
||||
to be generated at the destination end. If `None` (default), the destination
|
||||
port's `ptype` will be used.
|
||||
thru: If not `None`, the port by this name will be rename to `portspec_src`.
|
||||
This can be used when routing a signal through a pre-placed 2-port device.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
PortError if either port does not have a specified rotation.
|
||||
BuildError if and invalid port config is encountered:
|
||||
- Non-manhattan ports
|
||||
- U-bend
|
||||
- Destination too close to (or behind) source
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping path_into() since device is dead')
|
||||
return self
|
||||
|
||||
port_src = self.pattern[portspec_src]
|
||||
port_dst = self.pattern[portspec_dst]
|
||||
|
||||
if out_ptype is None:
|
||||
out_ptype = port_dst.ptype
|
||||
|
||||
if port_src.rotation is None:
|
||||
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
|
||||
if port_dst.rotation is None:
|
||||
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
|
||||
|
||||
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
|
||||
raise BuildError('path_into was asked to route from non-manhattan port')
|
||||
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
|
||||
raise BuildError('path_into was asked to route to non-manhattan port')
|
||||
|
||||
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
|
||||
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
|
||||
xs, ys = port_src.offset
|
||||
xd, yd = port_dst.offset
|
||||
|
||||
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
|
||||
|
||||
dst_extra_args = {'out_ptype': out_ptype}
|
||||
if plug_destination:
|
||||
dst_extra_args['plug_into'] = portspec_dst
|
||||
|
||||
src_args = {**kwargs}
|
||||
dst_args = {**src_args, **dst_extra_args}
|
||||
if src_is_horizontal and not dst_is_horizontal:
|
||||
# single bend should suffice
|
||||
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
|
||||
self.path_to(portspec_src, None, y=yd, **dst_args)
|
||||
elif dst_is_horizontal and not src_is_horizontal:
|
||||
# single bend should suffice
|
||||
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
|
||||
self.path_to(portspec_src, None, x=xd, **dst_args)
|
||||
elif numpy.isclose(angle, pi):
|
||||
if src_is_horizontal and ys == yd:
|
||||
# straight connector
|
||||
self.path_to(portspec_src, None, x=xd, **dst_args)
|
||||
elif not src_is_horizontal and xs == xd:
|
||||
# straight connector
|
||||
self.path_to(portspec_src, None, y=yd, **dst_args)
|
||||
else:
|
||||
# S-bend, delegate to implementations
|
||||
(travel, jog), _ = port_src.measure_travel(port_dst)
|
||||
self.pathS(portspec_src, -travel, -jog, **dst_args)
|
||||
elif numpy.isclose(angle, 0):
|
||||
raise BuildError('Don\'t know how to route a U-bend yet (TODO)!')
|
||||
else:
|
||||
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
|
||||
|
||||
if thru is not None:
|
||||
self.rename_ports({thru: portspec_src})
|
||||
|
||||
return self
|
||||
|
||||
def mpath(
|
||||
self,
|
||||
portspec: str | Sequence[str],
|
||||
ccw: SupportsBool | None,
|
||||
*,
|
||||
spacing: float | ArrayLike | None = None,
|
||||
set_rotation: float | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
"""
|
||||
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
|
||||
of "wires or "waveguides".
|
||||
|
||||
The wires will travel so that the output ports will be placed at well-defined
|
||||
locations along the axis of their input ports, but may have arbitrary (tool-
|
||||
dependent) offsets in the perpendicular direction.
|
||||
|
||||
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
|
||||
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
|
||||
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
|
||||
which is required when `ccw` is not `None`. The final position of bundle as a
|
||||
whole can be set in a number of ways:
|
||||
|
||||
=A>---------------------------V turn direction: `ccw=False`
|
||||
=B>-------------V |
|
||||
=C>-----------------------V |
|
||||
=D=>----------------V |
|
||||
|
|
||||
|
||||
x---x---x---x `spacing` (can be scalar or array)
|
||||
|
||||
<--------------> `emin=`
|
||||
<------> `bound_type='min_past_furthest', bound=`
|
||||
<--------------------------------> `emax=`
|
||||
x `pmin=`
|
||||
x `pmax=`
|
||||
|
||||
- `emin=`, equivalent to `bound_type='min_extension', bound=`
|
||||
The total extension value for the furthest-out port (B in the diagram).
|
||||
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
|
||||
The total extension value for the closest-in port (C in the diagram).
|
||||
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
|
||||
The coordinate of the innermost bend (D's bend).
|
||||
The x/y versions throw an error if they do not match the port axis (for debug)
|
||||
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
|
||||
The coordinate of the outermost bend (A's bend).
|
||||
The x/y versions throw an error if they do not match the port axis (for debug)
|
||||
- `bound_type='min_past_furthest', bound=`:
|
||||
The distance between furthest out-port (B) and the innermost bend (D's bend).
|
||||
|
||||
If `ccw=None`, final output positions (along the input axis) of all wires will be
|
||||
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
|
||||
required. In this case, `emin=` and `emax=` are equivalent to each other, and
|
||||
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
|
||||
|
||||
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
||||
|
||||
Args:
|
||||
portspec: The names of the ports which are to be routed.
|
||||
ccw: If `None`, the outputs should be along the same axis as the inputs.
|
||||
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
|
||||
and clockwise otherwise.
|
||||
spacing: Center-to-center distance between output ports along the input port's axis.
|
||||
Must be provided if (and only if) `ccw` is not `None`.
|
||||
set_rotation: If the provided ports have `rotation=None`, this can be used
|
||||
to set a rotation for them.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
BuildError if the implied length for any wire is too close to fit the bend
|
||||
(if a bend is requested).
|
||||
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
|
||||
match the axis of `portspec`.
|
||||
BuildError if an incorrect bound type or spacing is specified.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping mpath() since device is dead')
|
||||
return self
|
||||
|
||||
bound_types = set()
|
||||
if 'bound_type' in kwargs:
|
||||
bound_types.add(kwargs.pop('bound_type'))
|
||||
bound = kwargs.pop('bound')
|
||||
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
|
||||
if bt in kwargs:
|
||||
bound_types.add(bt)
|
||||
bound = kwargs.pop(bt)
|
||||
|
||||
if not bound_types:
|
||||
raise BuildError('No bound type specified for mpath')
|
||||
if len(bound_types) > 1:
|
||||
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
|
||||
bound_type = tuple(bound_types)[0]
|
||||
|
||||
if isinstance(portspec, str):
|
||||
portspec = [portspec]
|
||||
ports = self.pattern[tuple(portspec)]
|
||||
|
||||
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
|
||||
|
||||
#if container:
|
||||
# assert not getattr(self, 'render'), 'Containers not implemented for RenderPather'
|
||||
# bld = self.interface(source=ports, library=self.library, tools=self.tools)
|
||||
# for port_name, length in extensions.items():
|
||||
# bld.path(port_name, ccw, length, **kwargs)
|
||||
# self.library[container] = bld.pattern
|
||||
# self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
|
||||
#else:
|
||||
for port_name, length in extensions.items():
|
||||
self.path(port_name, ccw, length, **kwargs)
|
||||
return self
|
||||
|
||||
# TODO def bus_join()?
|
||||
|
||||
def flatten(self) -> Self:
|
||||
"""
|
||||
Flatten the contained pattern, using the contained library to resolve references.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.flatten(self.library)
|
||||
return self
|
||||
|
||||
def at(self, portspec: str | Iterable[str]) -> 'PortPather':
|
||||
return PortPather(portspec, self)
|
||||
|
||||
|
||||
class PortPather:
|
||||
"""
|
||||
Port state manager
|
||||
|
||||
This class provides a convenient way to perform multiple pathing operations on a
|
||||
set of ports without needing to repeatedly pass their names.
|
||||
"""
|
||||
ports: list[str]
|
||||
pather: PatherMixin
|
||||
|
||||
def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None:
|
||||
self.ports = [ports] if isinstance(ports, str) else list(ports)
|
||||
self.pather = pather
|
||||
|
||||
#
|
||||
# Delegate to pather
|
||||
#
|
||||
def retool(self, tool: Tool) -> Self:
|
||||
self.pather.retool(tool, keys=self.ports)
|
||||
return self
|
||||
|
||||
@contextmanager
|
||||
def toolctx(self, tool: Tool) -> Iterator[Self]:
|
||||
with self.pather.toolctx(tool, keys=self.ports):
|
||||
yield self
|
||||
|
||||
def path(self, *args, **kwargs) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
logger.warning('Use path_each() when pathing multiple ports independently')
|
||||
for port in self.ports:
|
||||
self.pather.path(port, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def path_each(self, *args, **kwargs) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather.path(port, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def pathS(self, *args, **kwargs) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
logger.warning('Use pathS_each() when pathing multiple ports independently')
|
||||
for port in self.ports:
|
||||
self.pather.pathS(port, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def pathS_each(self, *args, **kwargs) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather.pathS(port, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def path_to(self, *args, **kwargs) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
logger.warning('Use path_each_to() when pathing multiple ports independently')
|
||||
for port in self.ports:
|
||||
self.pather.path_to(port, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def path_each_to(self, *args, **kwargs) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather.path_to(port, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def mpath(self, *args, **kwargs) -> Self:
|
||||
self.pather.mpath(self.ports, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def path_into(self, *args, **kwargs) -> Self:
|
||||
""" Path_into, using the current port as the source """
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.')
|
||||
self.pather.path_into(self.ports[0], *args, **kwargs)
|
||||
return self
|
||||
|
||||
def path_from(self, *args, **kwargs) -> Self:
|
||||
""" Path_into, using the current port as the destination """
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.')
|
||||
thru = kwargs.pop('thru', None)
|
||||
self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs)
|
||||
if thru is not None:
|
||||
self.rename_from(thru)
|
||||
return self
|
||||
|
||||
def plug(
|
||||
self,
|
||||
other: Abstract | str,
|
||||
other_port: str,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.'
|
||||
'Use the pather or pattern directly to plug multiple ports.')
|
||||
self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def plugged(self, other_port: str) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.')
|
||||
self.pather.plugged({self.ports[0]: other_port})
|
||||
return self
|
||||
|
||||
#
|
||||
# Delegate to port
|
||||
#
|
||||
def set_ptype(self, ptype: str) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather[port].set_ptype(ptype)
|
||||
return self
|
||||
|
||||
def translate(self, *args, **kwargs) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather[port].translate(*args, **kwargs)
|
||||
return self
|
||||
|
||||
def mirror(self, *args, **kwargs) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather[port].mirror(*args, **kwargs)
|
||||
return self
|
||||
|
||||
def rotate(self, rotation: float) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather[port].rotate(rotation)
|
||||
return self
|
||||
|
||||
def set_rotation(self, rotation: float | None) -> Self:
|
||||
for port in self.ports:
|
||||
self.pather[port].set_rotation(rotation)
|
||||
return self
|
||||
|
||||
def rename_to(self, new_name: str) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
BuildError('Use rename_ports() for >1 port')
|
||||
self.pather.rename_ports({self.ports[0]: new_name})
|
||||
self.ports[0] = new_name
|
||||
return self
|
||||
|
||||
def rename_from(self, old_name: str) -> Self:
|
||||
if len(self.ports) > 1:
|
||||
BuildError('Use rename_ports() for >1 port')
|
||||
self.pather.rename_ports({old_name: self.ports[0]})
|
||||
return self
|
||||
|
||||
def rename_ports(self, name_map: dict[str, str | None]) -> Self:
|
||||
self.pather.rename_ports(name_map)
|
||||
self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None]
|
||||
return self
|
||||
|
||||
def add_ports(self, ports: Iterable[str]) -> Self:
|
||||
ports = list(ports)
|
||||
conflicts = set(ports) & set(self.ports)
|
||||
if conflicts:
|
||||
raise BuildError(f'ports {conflicts} already selected')
|
||||
self.ports += ports
|
||||
return self
|
||||
|
||||
def add_port(self, port: str, index: int | None = None) -> Self:
|
||||
if port in self.ports:
|
||||
raise BuildError(f'{port=} already selected')
|
||||
if index is not None:
|
||||
self.ports.insert(index, port)
|
||||
else:
|
||||
self.ports.append(port)
|
||||
return self
|
||||
|
||||
def drop_port(self, port: str) -> Self:
|
||||
if port not in self.ports:
|
||||
raise BuildError(f'{port=} already not selected')
|
||||
self.ports = [pp for pp in self.ports if pp != port]
|
||||
return self
|
||||
|
||||
def into_copy(self, new_name: str, src: str | None = None) -> Self:
|
||||
""" Copy a port and replace it with the copy """
|
||||
if not self.ports:
|
||||
raise BuildError('Have no ports to copy')
|
||||
if len(self.ports) == 1:
|
||||
src = self.ports[0]
|
||||
elif src is None:
|
||||
raise BuildError('Must specify src when >1 port is available')
|
||||
if src not in self.ports:
|
||||
raise BuildError(f'{src=} not available')
|
||||
self.pather.ports[new_name] = self.pather[src].copy()
|
||||
self.ports = [(new_name if pp == src else pp) for pp in self.ports]
|
||||
return self
|
||||
|
||||
def save_copy(self, new_name: str, src: str | None = None) -> Self:
|
||||
""" Copy a port and but keep using the original """
|
||||
if not self.ports:
|
||||
raise BuildError('Have no ports to copy')
|
||||
if len(self.ports) == 1:
|
||||
src = self.ports[0]
|
||||
elif src is None:
|
||||
raise BuildError('Must specify src when >1 port is available')
|
||||
if src not in self.ports:
|
||||
raise BuildError(f'{src=} not available')
|
||||
self.pather.ports[new_name] = self.pather[src].copy()
|
||||
return self
|
||||
|
||||
@overload
|
||||
def delete(self, name: None) -> None: ...
|
||||
|
||||
@overload
|
||||
def delete(self, name: str) -> Self: ...
|
||||
|
||||
def delete(self, name: str | None = None) -> Self | None:
|
||||
if name is None:
|
||||
for pp in self.ports:
|
||||
del self.pather.ports[pp]
|
||||
return None
|
||||
del self.pather.ports[name]
|
||||
self.ports = [pp for pp in self.ports if pp != name]
|
||||
return self
|
||||
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"""
|
||||
Simplified primitive-offer route planner used by `Pather`.
|
||||
|
||||
This package is the Pather-facing route-selection implementation. It keeps
|
||||
the public Tool contract narrow: offers are evaluated during planning, and
|
||||
offer commits are deferred until after a complete route is selected.
|
||||
"""
|
||||
from .interface import (
|
||||
PreparedRouteAction as PreparedRouteAction,
|
||||
PreparedRouteResult as PreparedRouteResult,
|
||||
RoutePlanningError as RoutePlanningError,
|
||||
RoutePortContext as RoutePortContext,
|
||||
route_error_is_fatal as route_error_is_fatal,
|
||||
)
|
||||
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
|
||||
from .planner import RoutingPlanner as RoutingPlanner
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
"""
|
||||
Argument validation and bound resolution for Pather routing calls.
|
||||
|
||||
This module keeps user-facing mode validation outside the solver. It converts
|
||||
single-port positional bounds into local travel lengths and derives multi-port
|
||||
S/U bundle specs before primitive offers are considered.
|
||||
|
||||
The solver expects one coherent route intent at a time. This module enforces
|
||||
that public routing modes are not mixed: explicit length, per-port `each`,
|
||||
positional bounds, and bundle bounds are mutually constrained before any Tool
|
||||
offers are queried. Multi-port S/U bundles are also normalized here into exact
|
||||
per-port public lengths and offsets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: TC001,TC002,TC003
|
||||
from typing import Any
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ...error import BuildError, PortError
|
||||
from ...ports import Port
|
||||
from ...utils import rotation_matrix_2d
|
||||
from .interface import RoutePortContext
|
||||
|
||||
|
||||
POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position')
|
||||
BUNDLE_BOUND_KEYS: tuple[str, ...] = (
|
||||
'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest',
|
||||
)
|
||||
|
||||
|
||||
def resolved_position_bound(
|
||||
port: Port,
|
||||
bounds: Mapping[str, Any],
|
||||
*,
|
||||
allow_length: bool,
|
||||
) -> tuple[str, Any, float] | None:
|
||||
"""Resolve a single positional bound for a single port into a travel length."""
|
||||
present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None]
|
||||
if not present:
|
||||
return None
|
||||
if len(present) > 1:
|
||||
keys = ', '.join(key for key, _value in present)
|
||||
raise BuildError(f'Provide exactly one positional bound; got {keys}')
|
||||
if not allow_length and bounds.get('length') is not None:
|
||||
raise BuildError('length cannot be combined with a positional bound')
|
||||
|
||||
key, value = present[0]
|
||||
if port.rotation is None:
|
||||
raise BuildError('Ports must have rotation')
|
||||
is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9))
|
||||
if is_horiz:
|
||||
if key == 'y':
|
||||
raise BuildError('Port is horizontal')
|
||||
target = Port((value, port.offset[1]), rotation=None)
|
||||
else:
|
||||
if key == 'x':
|
||||
raise BuildError('Port is vertical')
|
||||
target = Port((port.offset[0], value), rotation=None)
|
||||
(travel, _jog), _ = port.measure_travel(target)
|
||||
return key, value, -float(travel)
|
||||
|
||||
|
||||
def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]:
|
||||
"""Return keys whose bound value is explicitly present and non-None."""
|
||||
return [key for key in keys if bounds.get(key) is not None]
|
||||
|
||||
|
||||
def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]:
|
||||
"""Return active multi-port trace bound keys."""
|
||||
return present_keys(bounds, BUNDLE_BOUND_KEYS)
|
||||
|
||||
|
||||
def validate_trace_args(
|
||||
portspec: Sequence[str],
|
||||
*,
|
||||
length: float | None,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Validate mutually-exclusive `trace()` routing modes.
|
||||
|
||||
A trace request is either an explicit single-port length, an `each` length
|
||||
for all ports, a single-port omitted-length solve, or a bundle solve with
|
||||
exactly one bundle bound.
|
||||
"""
|
||||
bundle_bounds = present_bundle_bounds(bounds)
|
||||
if len(bundle_bounds) > 1:
|
||||
args = ', '.join(bundle_bounds)
|
||||
raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}')
|
||||
|
||||
invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds
|
||||
invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds
|
||||
|
||||
if length is not None:
|
||||
if len(portspec) > 1:
|
||||
raise BuildError('length only allowed with a single port')
|
||||
if spacing is not None:
|
||||
invalid_with_length.append('spacing')
|
||||
if invalid_with_length:
|
||||
args = ', '.join(invalid_with_length)
|
||||
raise BuildError(f'length cannot be combined with other routing bounds: {args}')
|
||||
return
|
||||
|
||||
if bounds.get('each') is not None:
|
||||
if spacing is not None:
|
||||
invalid_with_each.append('spacing')
|
||||
if invalid_with_each:
|
||||
args = ', '.join(invalid_with_each)
|
||||
raise BuildError(f'each cannot be combined with other routing bounds: {args}')
|
||||
return
|
||||
|
||||
if not bundle_bounds and len(portspec) == 1:
|
||||
if spacing is not None:
|
||||
raise BuildError('spacing cannot be combined with omitted-length single-port trace()')
|
||||
invalid = present_keys(bounds, ('set_rotation',))
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}')
|
||||
return
|
||||
|
||||
if not bundle_bounds:
|
||||
raise BuildError('No bound type specified for trace()')
|
||||
|
||||
|
||||
def validate_trace_to_positional_args(
|
||||
*,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Reject bound combinations that cannot be mixed with a single positional `trace_to()` target."""
|
||||
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
|
||||
if spacing is not None:
|
||||
invalid.append('spacing')
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}')
|
||||
|
||||
|
||||
def validate_jog_args(
|
||||
portspec: Sequence[str],
|
||||
*,
|
||||
length: float | None,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Validate `jog()` mode constraints before S-route planning.
|
||||
|
||||
Single-port jogs may derive length from a positional bound. Multi-port jogs
|
||||
require spacing and cannot combine omitted length with positional bounds.
|
||||
"""
|
||||
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
|
||||
if len(portspec) == 1 and spacing is not None:
|
||||
invalid.append('spacing')
|
||||
if len(portspec) > 1 and length is None:
|
||||
invalid += present_keys(bounds, POSITION_KEYS)
|
||||
if length is not None:
|
||||
invalid = present_keys(bounds, POSITION_KEYS) + invalid
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}')
|
||||
return
|
||||
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Unsupported routing bounds for jog(): {args}')
|
||||
|
||||
|
||||
def validate_uturn_args(
|
||||
portspec: Sequence[str],
|
||||
*,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Validate `uturn()` arguments, which do not support positional or bundle-bound keywords."""
|
||||
invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds)
|
||||
if len(portspec) == 1 and spacing is not None:
|
||||
invalid.append('spacing')
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Unsupported routing bounds for uturn(): {args}')
|
||||
|
||||
|
||||
def su_bundle_specs(
|
||||
contexts: Sequence[RoutePortContext],
|
||||
offset: float,
|
||||
length: float,
|
||||
spacing: float | ArrayLike | None,
|
||||
*,
|
||||
route_name: str,
|
||||
) -> tuple[tuple[str, float, float], ...]:
|
||||
"""
|
||||
Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs.
|
||||
|
||||
Ports are ordered from the inside of the first bend outward. The first spec
|
||||
receives the requested base route; later specs add cumulative spacing to
|
||||
both route length and lateral offset so the bundle keeps the requested
|
||||
separation.
|
||||
"""
|
||||
if spacing is None:
|
||||
raise BuildError(f'Must provide spacing for multi-port {route_name}()')
|
||||
|
||||
ports = {context.portspec: context.port for context in contexts}
|
||||
has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool)
|
||||
if not has_rotation.all():
|
||||
raise PortError(f'Ports must have rotation for multi-port {route_name}()')
|
||||
|
||||
rotations = numpy.array([port.rotation for port in ports.values()], dtype=float)
|
||||
if not numpy.allclose(rotations[0], rotations):
|
||||
port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()}
|
||||
raise BuildError(
|
||||
f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n'
|
||||
+ pformat(port_rotations)
|
||||
)
|
||||
|
||||
direction = rotations[0] + pi
|
||||
rot_matrix = rotation_matrix_2d(-direction)
|
||||
orig_offsets = numpy.array([port.offset for port in ports.values()])
|
||||
rot_offsets = (rot_matrix @ orig_offsets.T).T
|
||||
|
||||
first_ccw = bool(offset > 0)
|
||||
y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
|
||||
|
||||
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
|
||||
steps = numpy.zeros(len(ports), dtype=float)
|
||||
if spacing_arr.size == 1:
|
||||
steps[1:] = spacing_arr[0]
|
||||
elif spacing_arr.size == len(ports) - 1:
|
||||
steps[1:] = spacing_arr
|
||||
else:
|
||||
raise BuildError(
|
||||
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
|
||||
f'got length {spacing_arr.size}'
|
||||
)
|
||||
if not numpy.all(numpy.isfinite(steps)):
|
||||
raise BuildError('spacing must contain only finite values')
|
||||
|
||||
names = tuple(ports.keys())
|
||||
ordered_spacings = numpy.cumsum(steps)
|
||||
anchor_y = float(rot_offsets[y_order[0], 1])
|
||||
specs: list[tuple[str, float, float]] = []
|
||||
for order_index, port_index in enumerate(y_order):
|
||||
spacing_offset = float(ordered_spacings[order_index])
|
||||
start_y = float(rot_offsets[port_index, 1])
|
||||
specs.append((
|
||||
names[port_index],
|
||||
float(length) + spacing_offset,
|
||||
float(offset) - start_y + anchor_y + spacing_offset,
|
||||
))
|
||||
return tuple(specs)
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""
|
||||
Planner/Pather exchange types.
|
||||
|
||||
`Pather` snapshots live routing state into these records before calling the
|
||||
planner. The planner returns prepared actions that `Pather` can apply without
|
||||
needing to know solver internals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: TC001
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...error import BuildError
|
||||
from ...ports import Port
|
||||
from ..tools import RenderStep, Tool
|
||||
|
||||
|
||||
class RoutePlanningError(BuildError):
|
||||
"""Route-planning error with fallback policy metadata."""
|
||||
|
||||
fatal: bool
|
||||
|
||||
def __init__(self, *args: object, fatal: bool = False) -> None:
|
||||
super().__init__(*args)
|
||||
self.fatal = fatal
|
||||
|
||||
|
||||
def route_error_is_fatal(err: Exception) -> bool:
|
||||
"""Return true when a planning error should bypass dead-Pather fallback."""
|
||||
return bool(getattr(err, 'fatal', False))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutePortContext:
|
||||
"""
|
||||
Immutable planning view of one live Pather port.
|
||||
|
||||
`port` is a copy of the live port so failed route selection leaves Pather
|
||||
state unchanged. `tool` is the already-resolved routing Tool for this
|
||||
portspec.
|
||||
"""
|
||||
portspec: str
|
||||
"""Live Pather port name being planned."""
|
||||
port: Port
|
||||
"""Copied live port used as immutable route input."""
|
||||
tool: Tool
|
||||
"""Resolved Tool for this port."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedRouteAction:
|
||||
"""
|
||||
Prepared mutation for one routed Pather port.
|
||||
|
||||
The planner has already committed selected primitive offers into
|
||||
`render_steps` and computed the final live port. `plug_into`, when set,
|
||||
names the destination port to consume after the route endpoint is applied.
|
||||
"""
|
||||
portspec: str
|
||||
"""Live Pather port name to update."""
|
||||
render_steps: tuple[RenderStep, ...]
|
||||
"""Committed route steps to append to Pather's pending render queue."""
|
||||
final_port: Port
|
||||
"""Final live port value after all route steps."""
|
||||
plug_into: str | None = None
|
||||
"""Optional destination port to consume after the final port is applied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedRouteResult:
|
||||
"""
|
||||
Complete prepared result for one Pather routing operation.
|
||||
|
||||
`actions` are applied first. `renames` are deferred until after all route
|
||||
actions so trace-into/thru behavior can be represented without exposing the
|
||||
solver's selected primitive sequence to Pather.
|
||||
"""
|
||||
actions: tuple[PreparedRouteAction, ...]
|
||||
"""Prepared per-port route mutations."""
|
||||
renames: tuple[tuple[str, str], ...] = ()
|
||||
"""Deferred `(old_name, new_name)` port renames applied after actions."""
|
||||
File diff suppressed because it is too large
Load diff
646
masque/builder/renderpather.py
Normal file
646
masque/builder/renderpather.py
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
"""
|
||||
Pather with batched (multi-step) rendering
|
||||
"""
|
||||
from typing import Self
|
||||
from collections.abc import Sequence, Mapping, MutableMapping, Iterable
|
||||
import copy
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
from pprint import pformat
|
||||
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import ILibrary, TreeView
|
||||
from ..error import BuildError
|
||||
from ..ports import PortList, Port
|
||||
from ..abstract import Abstract
|
||||
from ..utils import SupportsBool
|
||||
from .tools import Tool, RenderStep
|
||||
from .pather_mixin import PatherMixin
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RenderPather(PatherMixin):
|
||||
"""
|
||||
`RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath`
|
||||
functions to plan out wire paths without incrementally generating the layout. Instead,
|
||||
it waits until `render` is called, at which point it draws all the planned segments
|
||||
simultaneously. This allows it to e.g. draw each wire using a single `Path` or
|
||||
`Polygon` shape instead of multiple rectangles.
|
||||
|
||||
`RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific
|
||||
dimensions and build the final geometry for each wire. `Tool.planL` provides the
|
||||
output port data (relative to the input) for each segment. The tool, input and output
|
||||
ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for
|
||||
each port. When `render` is called, it bundles `RenderStep`s into batches which use
|
||||
the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build
|
||||
the geometry.
|
||||
|
||||
See `Pather` for routing examples. After routing is complete, `render` must be called
|
||||
to generate the final geometry.
|
||||
"""
|
||||
__slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', )
|
||||
|
||||
pattern: Pattern
|
||||
""" Layout of this device """
|
||||
|
||||
library: ILibrary
|
||||
""" Library from which patterns should be referenced """
|
||||
|
||||
_dead: bool
|
||||
""" If True, plug()/place() are skipped (for debugging) """
|
||||
|
||||
paths: defaultdict[str, list[RenderStep]]
|
||||
""" Per-port list of operations, to be used by `render` """
|
||||
|
||||
tools: dict[str | None, Tool]
|
||||
"""
|
||||
Tool objects are used to dynamically generate new single-use Devices
|
||||
(e.g wires or waveguides) to be plugged into this device.
|
||||
"""
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self.pattern.ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, value: dict[str, Port]) -> None:
|
||||
self.pattern.ports = value
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: ILibrary,
|
||||
*,
|
||||
pattern: Pattern | None = None,
|
||||
ports: str | Mapping[str, Port] | None = None,
|
||||
tools: Tool | MutableMapping[str | None, Tool] | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
library: The library from which referenced patterns will be taken,
|
||||
and where new patterns (e.g. generated by the `tools`) will be placed.
|
||||
pattern: The pattern which will be modified by subsequent operations.
|
||||
If `None` (default), a new pattern is created.
|
||||
ports: Allows specifying the initial set of ports, if `pattern` does
|
||||
not already have any ports (or is not provided). May be a string,
|
||||
in which case it is interpreted as a name in `library`.
|
||||
Default `None` (no ports).
|
||||
tools: A mapping of {port: tool} which specifies what `Tool` should be used
|
||||
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
|
||||
are called. Relies on `Tool.planL` and `Tool.render` implementations.
|
||||
name: If specified, `library[name]` is set to `self.pattern`.
|
||||
"""
|
||||
self._dead = False
|
||||
self.paths = defaultdict(list)
|
||||
self.library = library
|
||||
if pattern is not None:
|
||||
self.pattern = pattern
|
||||
else:
|
||||
self.pattern = Pattern()
|
||||
|
||||
if ports is not None:
|
||||
if self.pattern.ports:
|
||||
raise BuildError('Ports supplied for pattern with pre-existing ports!')
|
||||
if isinstance(ports, str):
|
||||
ports = library.abstract(ports).ports
|
||||
|
||||
self.pattern.ports.update(copy.deepcopy(dict(ports)))
|
||||
|
||||
if name is not None:
|
||||
library[name] = self.pattern
|
||||
|
||||
if tools is None:
|
||||
self.tools = {}
|
||||
elif isinstance(tools, Tool):
|
||||
self.tools = {None: tools}
|
||||
else:
|
||||
self.tools = dict(tools)
|
||||
|
||||
@classmethod
|
||||
def interface(
|
||||
cls: type['RenderPather'],
|
||||
source: PortList | Mapping[str, Port] | str,
|
||||
*,
|
||||
library: ILibrary | None = None,
|
||||
tools: Tool | MutableMapping[str | None, Tool] | None = None,
|
||||
in_prefix: str = 'in_',
|
||||
out_prefix: str = '',
|
||||
port_map: dict[str, str] | Sequence[str] | None = None,
|
||||
name: str | None = None,
|
||||
) -> 'RenderPather':
|
||||
"""
|
||||
Wrapper for `Pattern.interface()`, which returns a RenderPather instead.
|
||||
|
||||
Args:
|
||||
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
||||
from which to create the interface. May be a pattern name if
|
||||
`library` is provided.
|
||||
library: Library from which existing patterns should be referenced,
|
||||
and to which the new one should be added (if named). If not provided,
|
||||
`source.library` must exist and will be used.
|
||||
tools: `Tool`s which will be used by the pather for generating new wires
|
||||
or waveguides (via `path`/`path_to`/`mpath`).
|
||||
in_prefix: Prepended to port names for newly-created ports with
|
||||
reversed directions compared to the current device.
|
||||
out_prefix: Prepended to port names for ports which are directly
|
||||
copied from the current device.
|
||||
port_map: Specification for ports to copy into the new device:
|
||||
- If `None`, all ports are copied.
|
||||
- If a sequence, only the listed ports are copied
|
||||
- If a mapping, the listed ports (keys) are copied and
|
||||
renamed (to the values).
|
||||
|
||||
Returns:
|
||||
The new `RenderPather`, with an empty pattern and 2x as many ports as
|
||||
listed in port_map.
|
||||
|
||||
Raises:
|
||||
`PortError` if `port_map` contains port names not present in the
|
||||
current device.
|
||||
`PortError` if applying the prefixes results in duplicate port
|
||||
names.
|
||||
"""
|
||||
if library is None:
|
||||
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
|
||||
library = source.library
|
||||
else:
|
||||
raise BuildError('No library provided (and not present in `source.library`')
|
||||
|
||||
if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
|
||||
tools = source.tools
|
||||
|
||||
if isinstance(source, str):
|
||||
source = library.abstract(source).ports
|
||||
|
||||
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
|
||||
new = RenderPather(library=library, pattern=pat, name=name, tools=tools)
|
||||
return new
|
||||
|
||||
def __repr__(self) -> str:
|
||||
s = f'<RenderPather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
|
||||
return s
|
||||
|
||||
def plug(
|
||||
self,
|
||||
other: Abstract | str | Pattern | TreeView,
|
||||
map_in: dict[str, str],
|
||||
map_out: dict[str, str | None] | None = None,
|
||||
*,
|
||||
mirrored: bool = False,
|
||||
thru: bool | str = True,
|
||||
set_rotation: bool | None = None,
|
||||
append: bool = False,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
) -> Self:
|
||||
"""
|
||||
Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P'
|
||||
for any affected ports. This separates any future `RenderStep`s on the
|
||||
same port into a new batch, since the plugged device interferes with drawing.
|
||||
|
||||
Args:
|
||||
other: An `Abstract`, string, or `Pattern` describing the device to be instatiated.
|
||||
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||
port connections between the two devices.
|
||||
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||
new names for ports in `other`.
|
||||
mirrored: Enables mirroring `other` across the x axis prior to
|
||||
connecting any ports.
|
||||
thru: If map_in specifies only a single port, `thru` provides a mechainsm
|
||||
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
|
||||
- If True (default), and `other` has only two ports total, and map_out
|
||||
doesn't specify a name for the other port, its name is set to the key
|
||||
in `map_in`, i.e. 'myport'.
|
||||
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
|
||||
An error is raised if that entry already exists.
|
||||
|
||||
This makes it easy to extend a pattern with simple 2-port devices
|
||||
(e.g. wires) without providing `map_out` each time `plug` is
|
||||
called. See "Examples" above for more info. Default `True`.
|
||||
set_rotation: If the necessary rotation cannot be determined from
|
||||
the ports being connected (i.e. all pairs have at least one
|
||||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `other` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||
ptypes are always allowed to connect, as is `'unk'` with
|
||||
any other ptypte. Non-allowed ptype connections will emit a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other_names`.
|
||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
`PortError` if the specified port mapping is not achieveable (the ports
|
||||
do not line up)
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping plug() since device is dead')
|
||||
return self
|
||||
|
||||
other_tgt: Pattern | Abstract
|
||||
if isinstance(other, str):
|
||||
other_tgt = self.library.abstract(other)
|
||||
if append and isinstance(other, Abstract):
|
||||
other_tgt = self.library[other.name]
|
||||
|
||||
# get rid of plugged ports
|
||||
for kk in map_in:
|
||||
if kk in self.paths:
|
||||
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
|
||||
|
||||
plugged = map_in.values()
|
||||
for name, port in other_tgt.ports.items():
|
||||
if name in plugged:
|
||||
continue
|
||||
new_name = map_out.get(name, name) if map_out is not None else name
|
||||
if new_name is not None and new_name in self.paths:
|
||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||
|
||||
self.pattern.plug(
|
||||
other = other_tgt,
|
||||
map_in = map_in,
|
||||
map_out = map_out,
|
||||
mirrored = mirrored,
|
||||
thru = thru,
|
||||
set_rotation = set_rotation,
|
||||
append = append,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def place(
|
||||
self,
|
||||
other: Abstract | str,
|
||||
*,
|
||||
offset: ArrayLike = (0, 0),
|
||||
rotation: float = 0,
|
||||
pivot: ArrayLike = (0, 0),
|
||||
mirrored: bool = False,
|
||||
port_map: dict[str, str | None] | None = None,
|
||||
skip_port_check: bool = False,
|
||||
append: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P'
|
||||
for any affected ports. This separates any future `RenderStep`s on the
|
||||
same port into a new batch, since the placed device interferes with drawing.
|
||||
|
||||
Note that mirroring is applied before rotation; translation (`offset`) is applied last.
|
||||
|
||||
Args:
|
||||
other: An `Abstract` or `Pattern` describing the device to be instatiated.
|
||||
offset: Offset at which to place the instance. Default (0, 0).
|
||||
rotation: Rotation applied to the instance before placement. Default 0.
|
||||
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
||||
Rotation is applied prior to translation (`offset`).
|
||||
mirrored: Whether theinstance should be mirrored across the x axis.
|
||||
Mirroring is applied before translation and rotation.
|
||||
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||
new names for ports in the instantiated pattern. New names can be
|
||||
`None`, which will delete those ports.
|
||||
skip_port_check: Can be used to skip the internal call to `check_ports`,
|
||||
in case it has already been performed elsewhere.
|
||||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other.ports`.
|
||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping place() since device is dead')
|
||||
return self
|
||||
|
||||
other_tgt: Pattern | Abstract
|
||||
if isinstance(other, str):
|
||||
other_tgt = self.library.abstract(other)
|
||||
if append and isinstance(other, Abstract):
|
||||
other_tgt = self.library[other.name]
|
||||
|
||||
for name, port in other_tgt.ports.items():
|
||||
new_name = port_map.get(name, name) if port_map is not None else name
|
||||
if new_name is not None and new_name in self.paths:
|
||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
||||
|
||||
self.pattern.place(
|
||||
other = other_tgt,
|
||||
offset = offset,
|
||||
rotation = rotation,
|
||||
pivot = pivot,
|
||||
mirrored = mirrored,
|
||||
port_map = port_map,
|
||||
skip_port_check = skip_port_check,
|
||||
append = append,
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def plugged(
|
||||
self,
|
||||
connections: dict[str, str],
|
||||
) -> Self:
|
||||
for aa, bb in connections.items():
|
||||
porta = self.ports[aa]
|
||||
portb = self.ports[bb]
|
||||
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
|
||||
self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
|
||||
PortList.plugged(self, connections)
|
||||
return self
|
||||
|
||||
def path(
|
||||
self,
|
||||
portspec: str,
|
||||
ccw: SupportsBool | None,
|
||||
length: float,
|
||||
*,
|
||||
plug_into: str | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
"""
|
||||
Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim
|
||||
of traveling exactly `length` distance.
|
||||
|
||||
The wire will travel `length` distance along the port's axis, an an unspecified
|
||||
(tool-dependent) distance in the perpendicular direction. The output port will
|
||||
be rotated (or not) based on the `ccw` parameter.
|
||||
|
||||
`RenderPather.render` must be called after all paths have been fully planned.
|
||||
|
||||
Args:
|
||||
portspec: The name of the port into which the wire will be plugged.
|
||||
ccw: If `None`, the output should be along the same axis as the input.
|
||||
Otherwise, cast to bool and turn counterclockwise if True
|
||||
and clockwise otherwise.
|
||||
length: The total distance from input to output, along the input's axis only.
|
||||
(There may be a tool-dependent offset along the other axis.)
|
||||
plug_into: If not None, attempts to plug the wire's output port into the provided
|
||||
port on `self`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
BuildError if `distance` is too small to fit the bend (if a bend is present).
|
||||
LibraryError if no valid name could be picked for the pattern.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping path() since device is dead')
|
||||
return self
|
||||
|
||||
port = self.pattern[portspec]
|
||||
in_ptype = port.ptype
|
||||
port_rot = port.rotation
|
||||
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
|
||||
|
||||
tool = self.tools.get(portspec, self.tools[None])
|
||||
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
|
||||
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
|
||||
|
||||
# Update port
|
||||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(port.offset)
|
||||
|
||||
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
|
||||
self.paths[portspec].append(step)
|
||||
|
||||
self.pattern.ports[portspec] = out_port.copy()
|
||||
|
||||
if plug_into is not None:
|
||||
self.plugged({portspec: plug_into})
|
||||
|
||||
return self
|
||||
|
||||
def pathS(
|
||||
self,
|
||||
portspec: str,
|
||||
length: float,
|
||||
jog: float,
|
||||
*,
|
||||
plug_into: str | None = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
"""
|
||||
Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
|
||||
of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
|
||||
left of direction of travel).
|
||||
|
||||
The output port will have the same orientation as the source port (`portspec`).
|
||||
|
||||
`RenderPather.render` must be called after all paths have been fully planned.
|
||||
|
||||
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
|
||||
raises a NotImplementedError.
|
||||
|
||||
Args:
|
||||
portspec: The name of the port into which the wire will be plugged.
|
||||
jog: Total manhattan distance perpendicular to the direction of travel.
|
||||
Positive values are to the left of the direction of travel.
|
||||
length: The total manhattan distance from input to output, along the input's axis only.
|
||||
(There may be a tool-dependent offset along the other axis.)
|
||||
plug_into: If not None, attempts to plug the wire's output port into the provided
|
||||
port on `self`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
|
||||
LibraryError if no valid name could be picked for the pattern.
|
||||
"""
|
||||
if self._dead:
|
||||
logger.error('Skipping pathS() since device is dead')
|
||||
return self
|
||||
|
||||
port = self.pattern[portspec]
|
||||
in_ptype = port.ptype
|
||||
port_rot = port.rotation
|
||||
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
|
||||
|
||||
tool = self.tools.get(portspec, self.tools[None])
|
||||
|
||||
# check feasibility, get output port and data
|
||||
try:
|
||||
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
|
||||
except NotImplementedError:
|
||||
# Fall back to drawing two L-bends
|
||||
ccw0 = jog > 0
|
||||
kwargs_no_out = (kwargs | {'out_ptype': None})
|
||||
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes
|
||||
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
|
||||
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
|
||||
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
|
||||
|
||||
kwargs_plug = kwargs | {'plug_into': plug_into}
|
||||
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
||||
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
||||
return self
|
||||
|
||||
out_port.rotate_around((0, 0), pi + port_rot)
|
||||
out_port.translate(port.offset)
|
||||
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
|
||||
self.paths[portspec].append(step)
|
||||
self.pattern.ports[portspec] = out_port.copy()
|
||||
|
||||
if plug_into is not None:
|
||||
self.plugged({portspec: plug_into})
|
||||
return self
|
||||
|
||||
|
||||
def render(
|
||||
self,
|
||||
append: bool = True,
|
||||
) -> Self:
|
||||
"""
|
||||
Generate the geometry which has been planned out with `path`/`path_to`/etc.
|
||||
|
||||
Args:
|
||||
append: If `True`, the rendered geometry will be directly appended to
|
||||
`self.pattern`. Note that it will not be flattened, so if only one
|
||||
layer of hierarchy is eliminated.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
lib = self.library
|
||||
tool_port_names = ('A', 'B')
|
||||
pat = Pattern()
|
||||
|
||||
def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
|
||||
assert batch[0].tool is not None
|
||||
name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
|
||||
pat.ports[portspec] = batch[0].start_port.copy()
|
||||
if append:
|
||||
pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append)
|
||||
del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened
|
||||
else:
|
||||
pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append)
|
||||
|
||||
for portspec, steps in self.paths.items():
|
||||
batch: list[RenderStep] = []
|
||||
for step in steps:
|
||||
appendable_op = step.opcode in ('L', 'S', 'U')
|
||||
same_tool = batch and step.tool == batch[0].tool
|
||||
|
||||
# If we can't continue a batch, render it
|
||||
if batch and (not appendable_op or not same_tool):
|
||||
render_batch(portspec, batch, append)
|
||||
batch = []
|
||||
|
||||
# batch is emptied already if we couldn't continue it
|
||||
if appendable_op:
|
||||
batch.append(step)
|
||||
|
||||
# Opcodes which break the batch go below this line
|
||||
if not appendable_op and portspec in pat.ports:
|
||||
del pat.ports[portspec]
|
||||
|
||||
#If the last batch didn't end yet
|
||||
if batch:
|
||||
render_batch(portspec, batch, append)
|
||||
|
||||
self.paths.clear()
|
||||
pat.ports.clear()
|
||||
self.pattern.append(pat)
|
||||
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
"""
|
||||
Translate the pattern and all ports.
|
||||
|
||||
Args:
|
||||
offset: (x, y) distance to translate by
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.translate_elements(offset)
|
||||
return self
|
||||
|
||||
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
|
||||
"""
|
||||
Rotate the pattern and all ports.
|
||||
|
||||
Args:
|
||||
angle: angle (radians, counterclockwise) to rotate by
|
||||
pivot: location to rotate around
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.rotate_around(pivot, angle)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int) -> Self:
|
||||
"""
|
||||
Mirror the pattern and all ports across the specified axis.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across (x=0, y=1)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.mirror(axis)
|
||||
return self
|
||||
|
||||
def set_dead(self) -> Self:
|
||||
"""
|
||||
Disallows further changes through `plug()` or `place()`.
|
||||
This is meant for debugging:
|
||||
```
|
||||
dev.plug(a, ...)
|
||||
dev.set_dead() # added for debug purposes
|
||||
dev.plug(b, ...) # usually raises an error, but now skipped
|
||||
dev.plug(c, ...) # also skipped
|
||||
dev.pattern.visualize() # shows the device as of the set_dead() call
|
||||
```
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self._dead = True
|
||||
return self
|
||||
|
||||
@wraps(Pattern.label)
|
||||
def label(self, *args, **kwargs) -> Self:
|
||||
self.pattern.label(*args, **kwargs)
|
||||
return self
|
||||
|
||||
@wraps(Pattern.ref)
|
||||
def ref(self, *args, **kwargs) -> Self:
|
||||
self.pattern.ref(*args, **kwargs)
|
||||
return self
|
||||
|
||||
@wraps(Pattern.polygon)
|
||||
def polygon(self, *args, **kwargs) -> Self:
|
||||
self.pattern.polygon(*args, **kwargs)
|
||||
return self
|
||||
|
||||
@wraps(Pattern.rect)
|
||||
def rect(self, *args, **kwargs) -> Self:
|
||||
self.pattern.rect(*args, **kwargs)
|
||||
return self
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -46,7 +46,7 @@ def ell(
|
|||
ccw: Turn direction. `True` means counterclockwise, `False` means clockwise,
|
||||
and `None` means no bend. If `None`, spacing must remain `None` or `0` (default),
|
||||
Otherwise, spacing must be set to a non-`None` value.
|
||||
bound_type: Method used for determining the travel distance; see diagram above.
|
||||
bound_method: Method used for determining the travel distance; see diagram above.
|
||||
Valid values are:
|
||||
- 'min_extension' or 'emin':
|
||||
The total extension value for the furthest-out port (B in the diagram).
|
||||
|
|
@ -64,7 +64,7 @@ def ell(
|
|||
the x- and y- axes. If specifying a position, it is projected onto
|
||||
the extension direction.
|
||||
|
||||
bound: Value associated with `bound_type`, see above.
|
||||
bound_value: Value associated with `bound_type`, see above.
|
||||
spacing: Distance between adjacent channels. Can be scalar, resulting in evenly
|
||||
spaced channels, or a vector with length one less than `ports`, allowing
|
||||
non-uniform spacing.
|
||||
|
|
@ -84,7 +84,7 @@ def ell(
|
|||
raise BuildError('Empty port list passed to `ell()`')
|
||||
|
||||
if ccw is None:
|
||||
if spacing is not None and not numpy.allclose(spacing, 0):
|
||||
if spacing is not None and not numpy.isclose(spacing, 0):
|
||||
raise BuildError('Spacing must be 0 or None when ccw=None')
|
||||
spacing = 0
|
||||
elif spacing is None:
|
||||
|
|
@ -106,7 +106,7 @@ def ell(
|
|||
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
|
||||
+ pformat(port_rotations))
|
||||
else:
|
||||
if set_rotation is None:
|
||||
if set_rotation is not None:
|
||||
raise BuildError('set_rotation must be specified if no ports have rotations!')
|
||||
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
||||
|
||||
|
|
@ -132,17 +132,8 @@ def ell(
|
|||
if spacing is None:
|
||||
ch_offsets = numpy.zeros_like(y_order)
|
||||
else:
|
||||
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
|
||||
steps = numpy.zeros_like(y_order)
|
||||
if spacing_arr.size == 1:
|
||||
steps[1:] = spacing_arr[0]
|
||||
elif spacing_arr.size == len(ports) - 1:
|
||||
steps[1:] = spacing_arr
|
||||
else:
|
||||
raise BuildError(
|
||||
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
|
||||
f'got length {spacing_arr.size}'
|
||||
)
|
||||
steps[1:] = spacing
|
||||
ch_offsets = numpy.cumsum(steps)[y_ind]
|
||||
|
||||
x_start = rot_offsets[:, 0]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import gzip
|
|||
import numpy
|
||||
import ezdxf
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace
|
||||
from ezdxf.entities import LWPolyline, Polyline, Text, Insert
|
||||
|
||||
from .utils import is_gzipped, tmpfile
|
||||
from .. import Pattern, Ref, PatternError, Label
|
||||
|
|
@ -55,7 +55,8 @@ def write(
|
|||
tuple: (1, 2) -> '1.2'
|
||||
str: '1.2' -> '1.2' (no change)
|
||||
|
||||
Shape repetitions are expanded into individual DXF entities.
|
||||
DXF does not support shape repetition (only block repeptition). Please call
|
||||
library.wrap_repeated_shapes() before writing to file.
|
||||
|
||||
Other functions you may want to call:
|
||||
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
||||
|
|
@ -192,37 +193,8 @@ def read(
|
|||
|
||||
top_name, top_pat = _read_block(msp)
|
||||
mlib = Library({top_name: top_pat})
|
||||
|
||||
blocks_by_name = {
|
||||
bb.name: bb
|
||||
for bb in lib.blocks
|
||||
if not bb.is_any_layout
|
||||
}
|
||||
|
||||
referenced: set[str] = set()
|
||||
pending = [msp]
|
||||
seen_blocks: set[str] = set()
|
||||
while pending:
|
||||
block = pending.pop()
|
||||
block_name = getattr(block, 'name', None)
|
||||
if block_name is not None and block_name in seen_blocks:
|
||||
continue
|
||||
if block_name is not None:
|
||||
seen_blocks.add(block_name)
|
||||
for element in block:
|
||||
if not isinstance(element, Insert):
|
||||
continue
|
||||
target = element.dxfattribs().get('name')
|
||||
if target is None or target in referenced:
|
||||
continue
|
||||
referenced.add(target)
|
||||
if target in blocks_by_name:
|
||||
pending.append(blocks_by_name[target])
|
||||
|
||||
for bb in lib.blocks:
|
||||
if bb.is_any_layout:
|
||||
continue
|
||||
if bb.name.startswith('_') and bb.name not in referenced:
|
||||
if bb.name == '*Model_Space':
|
||||
continue
|
||||
name, pat = _read_block(bb)
|
||||
mlib[name] = pat
|
||||
|
|
@ -241,60 +213,32 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
|||
if isinstance(element, LWPolyline | Polyline):
|
||||
if isinstance(element, LWPolyline):
|
||||
points = numpy.asarray(element.get_points())
|
||||
is_closed = element.closed
|
||||
else:
|
||||
elif isinstance(element, Polyline):
|
||||
points = numpy.asarray([pp.xyz for pp in element.points()])
|
||||
is_closed = element.is_closed
|
||||
attr = element.dxfattribs()
|
||||
layer = attr.get('layer', DEFAULT_LAYER)
|
||||
|
||||
width = 0
|
||||
if isinstance(element, LWPolyline):
|
||||
# ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge)
|
||||
if points.shape[1] >= 5:
|
||||
if (points[:, 4] != 0).any():
|
||||
raise PatternError('LWPolyline has bulge (not yet representable in masque!)')
|
||||
if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any():
|
||||
raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)')
|
||||
width = points[0, 2]
|
||||
elif points.shape[1] == 3:
|
||||
# width used to be in column 2
|
||||
width = points[0, 2]
|
||||
if points.shape[1] == 2:
|
||||
raise PatternError('Invalid or unimplemented polygon?')
|
||||
|
||||
if width == 0:
|
||||
width = attr.get('const_width', 0)
|
||||
if points.shape[1] > 2:
|
||||
if (points[0, 2] != points[:, 2]).any():
|
||||
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
|
||||
if points.shape[1] == 4 and (points[:, 3] != 0).any():
|
||||
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
|
||||
|
||||
verts = points[:, :2]
|
||||
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
|
||||
verts = numpy.vstack((verts, verts[0]))
|
||||
width = points[0, 2]
|
||||
if width == 0:
|
||||
width = attr.get('const_width', 0)
|
||||
|
||||
shape: Path | Polygon
|
||||
if width == 0 and is_closed:
|
||||
# Use Polygon if it has at least 3 unique vertices
|
||||
shape_verts = verts[:-1] if len(verts) > 1 else verts
|
||||
if len(shape_verts) >= 3:
|
||||
shape = Polygon(vertices=shape_verts)
|
||||
shape: Path | Polygon
|
||||
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
|
||||
shape = Polygon(vertices=points[:-1, :2])
|
||||
else:
|
||||
shape = Path(width=width, vertices=verts)
|
||||
else:
|
||||
shape = Path(width=width, vertices=verts)
|
||||
shape = Path(width=width, vertices=points[:, :2])
|
||||
|
||||
pat.shapes[layer].append(shape)
|
||||
elif isinstance(element, Solid | Trace):
|
||||
attr = element.dxfattribs()
|
||||
layer = attr.get('layer', DEFAULT_LAYER)
|
||||
points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4)
|
||||
if element.has_dxf_attrib(f'vtx{i}')])
|
||||
if len(points) >= 3:
|
||||
# If vtx2 == vtx3, it's a triangle. ezdxf handles this.
|
||||
if len(points) == 4 and numpy.allclose(points[2], points[3]):
|
||||
verts = points[:3, :2]
|
||||
# DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals!
|
||||
elif len(points) == 4:
|
||||
verts = points[[0, 1, 3, 2], :2]
|
||||
else:
|
||||
verts = points[:, :2]
|
||||
pat.shapes[layer].append(Polygon(vertices=verts))
|
||||
|
||||
elif isinstance(element, Text):
|
||||
args = dict(
|
||||
offset=numpy.asarray(element.get_placement()[1])[:2],
|
||||
|
|
@ -329,57 +273,12 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
|||
)
|
||||
|
||||
if 'column_count' in attr:
|
||||
col_spacing = attr['column_spacing']
|
||||
row_spacing = attr['row_spacing']
|
||||
col_count = attr['column_count']
|
||||
row_count = attr['row_count']
|
||||
local_x = numpy.array((col_spacing, 0.0))
|
||||
local_y = numpy.array((0.0, row_spacing))
|
||||
inv_rot = rotation_matrix_2d(-rotation)
|
||||
|
||||
candidates = (
|
||||
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
|
||||
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
|
||||
args['repetition'] = Grid(
|
||||
a_vector=(attr['column_spacing'], 0),
|
||||
b_vector=(0, attr['row_spacing']),
|
||||
a_count=attr['column_count'],
|
||||
b_count=attr['row_count'],
|
||||
)
|
||||
repetition = None
|
||||
for a_vector, b_vector, a_count, b_count in candidates:
|
||||
rotated_a = rotation_matrix_2d(rotation) @ a_vector
|
||||
rotated_b = rotation_matrix_2d(rotation) @ b_vector
|
||||
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
|
||||
and a_count == col_count
|
||||
and b_count == row_count):
|
||||
repetition = Grid(
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
break
|
||||
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
|
||||
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
|
||||
and b_count == col_count
|
||||
and a_count == row_count):
|
||||
repetition = Grid(
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
break
|
||||
|
||||
if repetition is None:
|
||||
repetition = Grid(
|
||||
a_vector=inv_rot @ local_x,
|
||||
b_vector=inv_rot @ local_y,
|
||||
a_count=col_count,
|
||||
b_count=row_count,
|
||||
)
|
||||
args['repetition'] = repetition
|
||||
pat.ref(**args)
|
||||
else:
|
||||
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
|
||||
|
|
@ -404,23 +303,15 @@ def _mrefs_to_drefs(
|
|||
elif isinstance(rep, Grid):
|
||||
a = rep.a_vector
|
||||
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
|
||||
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
|
||||
# which ARE then rotated by the block reference's rotation.
|
||||
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
|
||||
# AND the grid is already manhattan.
|
||||
|
||||
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
|
||||
rotated_a = rotation_matrix_2d(ref.rotation) @ a
|
||||
rotated_b = rotation_matrix_2d(ref.rotation) @ b
|
||||
|
||||
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
|
||||
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
|
||||
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
|
||||
if rotated_a[1] == 0 and rotated_b[0] == 0:
|
||||
attribs['column_count'] = rep.a_count
|
||||
attribs['row_count'] = rep.b_count
|
||||
attribs['column_spacing'] = rotated_a[0]
|
||||
attribs['row_spacing'] = rotated_b[1]
|
||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
||||
elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8):
|
||||
elif rotated_a[0] == 0 and rotated_b[1] == 0:
|
||||
attribs['column_count'] = rep.b_count
|
||||
attribs['row_count'] = rep.a_count
|
||||
attribs['column_spacing'] = rotated_b[0]
|
||||
|
|
@ -453,23 +344,16 @@ def _shapes_to_elements(
|
|||
for layer, sseq in shapes.items():
|
||||
attribs = dict(layer=_mlayer2dxf(layer))
|
||||
for shape in sseq:
|
||||
displacements = [numpy.zeros(2)]
|
||||
if shape.repetition is not None:
|
||||
displacements = shape.repetition.displacements
|
||||
raise PatternError(
|
||||
'Shape repetitions are not supported by DXF.'
|
||||
' Please call library.wrap_repeated_shapes() before writing to file.'
|
||||
)
|
||||
|
||||
for dd in displacements:
|
||||
if isinstance(shape, Path):
|
||||
# preserve path.
|
||||
# Note: DXF paths don't support endcaps well, so this is still a bit limited.
|
||||
xy = shape.vertices + dd
|
||||
attribs_path = {**attribs}
|
||||
if shape.width > 0:
|
||||
attribs_path['const_width'] = shape.width
|
||||
block.add_lwpolyline(xy, dxfattribs=attribs_path)
|
||||
else:
|
||||
for polygon in shape.to_polygons():
|
||||
xy_open = polygon.vertices + dd
|
||||
block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs)
|
||||
for polygon in shape.to_polygons():
|
||||
xy_open = polygon.vertices
|
||||
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
|
||||
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
|
||||
|
||||
|
||||
def _labels_to_texts(
|
||||
|
|
@ -479,17 +363,11 @@ def _labels_to_texts(
|
|||
for layer, lseq in labels.items():
|
||||
attribs = dict(layer=_mlayer2dxf(layer))
|
||||
for label in lseq:
|
||||
if label.repetition is None:
|
||||
block.add_text(
|
||||
label.string,
|
||||
dxfattribs=attribs
|
||||
).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||
else:
|
||||
for dd in label.repetition.displacements:
|
||||
block.add_text(
|
||||
label.string,
|
||||
dxfattribs=attribs
|
||||
).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||
xy = label.offset
|
||||
block.add_text(
|
||||
label.string,
|
||||
dxfattribs=attribs
|
||||
).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||
|
||||
|
||||
def _mlayer2dxf(layer: layer_t) -> str:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ Notes:
|
|||
from typing import IO, cast, Any
|
||||
from collections.abc import Iterable, Mapping, Callable
|
||||
from types import MappingProxyType
|
||||
import io
|
||||
import mmap
|
||||
import logging
|
||||
import pathlib
|
||||
import gzip
|
||||
|
|
@ -33,12 +35,12 @@ from numpy.typing import ArrayLike, NDArray
|
|||
import klamath
|
||||
from klamath import records
|
||||
|
||||
from ..utils import is_gzipped, tmpfile
|
||||
from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||
from ...shapes import Polygon, Path, RectCollection
|
||||
from ...repetition import Grid
|
||||
from ...utils import layer_t, annotations_t
|
||||
from ...library import Library, ILibrary
|
||||
from .utils import is_gzipped, tmpfile
|
||||
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||
from ..shapes import Polygon, Path
|
||||
from ..repetition import Grid
|
||||
from ..utils import layer_t, annotations_t
|
||||
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -80,7 +82,7 @@ def write(
|
|||
datatype is chosen to be `shape.layer[1]` if available,
|
||||
otherwise `0`
|
||||
|
||||
GDS does not support shape repetition (only cell repetition). Please call
|
||||
GDS does not support shape repetition (only cell repeptition). Please call
|
||||
`library.wrap_repeated_shapes()` before writing to file.
|
||||
|
||||
Other functions you may want to call:
|
||||
|
|
@ -321,40 +323,26 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_
|
|||
else:
|
||||
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
||||
|
||||
vertices = gpath.xy.astype(float)
|
||||
annotations = _properties_to_annotations(gpath.properties)
|
||||
cap_extensions = None
|
||||
mpath = Path(
|
||||
vertices=gpath.xy.astype(float),
|
||||
width=gpath.width,
|
||||
cap=cap,
|
||||
offset=numpy.zeros(2),
|
||||
annotations=_properties_to_annotations(gpath.properties),
|
||||
raw=raw_mode,
|
||||
)
|
||||
if cap == Path.Cap.SquareCustom:
|
||||
cap_extensions = numpy.asarray(gpath.extension, dtype=float)
|
||||
|
||||
if raw_mode:
|
||||
mpath = Path._from_raw(
|
||||
vertices=vertices,
|
||||
width=gpath.width,
|
||||
cap=cap,
|
||||
cap_extensions=cap_extensions,
|
||||
annotations=annotations,
|
||||
)
|
||||
else:
|
||||
mpath = Path(
|
||||
vertices=vertices,
|
||||
width=gpath.width,
|
||||
cap=cap,
|
||||
cap_extensions=cap_extensions,
|
||||
offset=numpy.zeros(2),
|
||||
annotations=annotations,
|
||||
)
|
||||
mpath.cap_extensions = gpath.extension
|
||||
return gpath.layer, mpath
|
||||
|
||||
|
||||
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
|
||||
vertices = boundary.xy[:-1].astype(float)
|
||||
annotations = _properties_to_annotations(boundary.properties)
|
||||
if raw_mode:
|
||||
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
|
||||
else:
|
||||
poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations)
|
||||
return boundary.layer, poly
|
||||
return boundary.layer, Polygon(
|
||||
vertices=boundary.xy[:-1].astype(float),
|
||||
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]:
|
||||
|
|
@ -465,7 +453,7 @@ def _shapes_to_elements(
|
|||
|
||||
extension: tuple[int, int]
|
||||
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
||||
extension = tuple(rint_cast(shape.cap_extensions))
|
||||
extension = tuple(shape.cap_extensions) # type: ignore
|
||||
else:
|
||||
extension = (0, 0)
|
||||
|
||||
|
|
@ -478,20 +466,6 @@ def _shapes_to_elements(
|
|||
properties=properties,
|
||||
)
|
||||
elements.append(path)
|
||||
elif isinstance(shape, RectCollection):
|
||||
for rect in shape.rects:
|
||||
xy_closed = numpy.empty((5, 2), dtype=numpy.int32)
|
||||
xy_closed[0] = rint_cast((rect[0], rect[1]))
|
||||
xy_closed[1] = rint_cast((rect[0], rect[3]))
|
||||
xy_closed[2] = rint_cast((rect[2], rect[3]))
|
||||
xy_closed[3] = rint_cast((rect[2], rect[1]))
|
||||
xy_closed[4] = xy_closed[0]
|
||||
boundary = klamath.elements.Boundary(
|
||||
layer=(layer, data_type),
|
||||
xy=xy_closed,
|
||||
properties=properties,
|
||||
)
|
||||
elements.append(boundary)
|
||||
elif isinstance(shape, Polygon):
|
||||
polygon = shape
|
||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||
|
|
@ -540,6 +514,112 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
|
|||
return texts
|
||||
|
||||
|
||||
def load_library(
|
||||
stream: IO[bytes],
|
||||
*,
|
||||
full_load: bool = False,
|
||||
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
|
||||
) -> tuple[LazyLibrary, dict[str, Any]]:
|
||||
"""
|
||||
Scan a GDSII stream to determine what structures are present, and create
|
||||
a library from them. This enables deferred reading of structures
|
||||
on an as-needed basis.
|
||||
All structures are loaded as secondary
|
||||
|
||||
Args:
|
||||
stream: Seekable stream. Position 0 should be the start of the file.
|
||||
The caller should leave the stream open while the library
|
||||
is still in use, since the library will need to access it
|
||||
in order to read the structure contents.
|
||||
full_load: If True, force all structures to be read immediately rather
|
||||
than as-needed. Since data is read sequentially from the file, this
|
||||
will be faster than using the resulting library's `precache` method.
|
||||
postprocess: If given, this function is used to post-process each
|
||||
pattern *upon first load only*.
|
||||
|
||||
Returns:
|
||||
LazyLibrary object, allowing for deferred load of structures.
|
||||
Additional library info (dict, same format as from `read`).
|
||||
"""
|
||||
stream.seek(0)
|
||||
lib = LazyLibrary()
|
||||
|
||||
if full_load:
|
||||
# Full load approach (immediately load everything)
|
||||
patterns, library_info = read(stream)
|
||||
for name, pattern in patterns.items():
|
||||
if postprocess is not None:
|
||||
lib[name] = postprocess(lib, name, pattern)
|
||||
else:
|
||||
lib[name] = pattern
|
||||
return lib, library_info
|
||||
|
||||
# Normal approach (scan and defer load)
|
||||
library_info = _read_header(stream)
|
||||
structs = klamath.library.scan_structs(stream)
|
||||
|
||||
for name_bytes, pos in structs.items():
|
||||
name = name_bytes.decode('ASCII')
|
||||
|
||||
def mkstruct(pos: int = pos, name: str = name) -> Pattern:
|
||||
stream.seek(pos)
|
||||
pat = read_elements(stream, raw_mode=True)
|
||||
if postprocess is not None:
|
||||
pat = postprocess(lib, name, pat)
|
||||
return pat
|
||||
|
||||
lib[name] = mkstruct
|
||||
|
||||
return lib, library_info
|
||||
|
||||
|
||||
def load_libraryfile(
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool = True,
|
||||
full_load: bool = False,
|
||||
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
|
||||
) -> tuple[LazyLibrary, dict[str, Any]]:
|
||||
"""
|
||||
Wrapper for `load_library()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically decompress the file if it is gzipped.
|
||||
|
||||
NOTE that any streams/mmaps opened will remain open until ALL of the
|
||||
`PatternGenerator` objects in the library are garbage collected.
|
||||
|
||||
Args:
|
||||
path: filename or path to read from
|
||||
use_mmap: If `True`, will attempt to memory-map the file instead
|
||||
of buffering. In the case of gzipped files, the file
|
||||
is decompressed into a python `bytes` object in memory
|
||||
and reopened as an `io.BytesIO` stream.
|
||||
full_load: If `True`, immediately loads all data. See `load_library`.
|
||||
postprocess: Passed to `load_library`
|
||||
|
||||
Returns:
|
||||
LazyLibrary object, allowing for deferred load of structures.
|
||||
Additional library info (dict, same format as from `read`).
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
stream: IO[bytes]
|
||||
if is_gzipped(path):
|
||||
if use_mmap:
|
||||
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
|
||||
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
|
||||
stream = io.BytesIO(gz_stream.read()) # type: ignore
|
||||
else:
|
||||
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
|
||||
stream = io.BufferedReader(gz_stream) # type: ignore
|
||||
else: # noqa: PLR5501
|
||||
if use_mmap:
|
||||
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
|
||||
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
|
||||
else:
|
||||
stream = path.open(mode='rb') # noqa: SIM115
|
||||
return load_library(stream, full_load=full_load, postprocess=postprocess)
|
||||
|
||||
|
||||
def check_valid_names(
|
||||
names: Iterable[str],
|
||||
max_length: int = 32,
|
||||
|
|
@ -552,7 +632,6 @@ def check_valid_names(
|
|||
max_length: Max allowed length
|
||||
|
||||
"""
|
||||
names = tuple(names)
|
||||
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
|
||||
|
||||
bad_chars = [
|
||||
|
|
@ -569,7 +648,7 @@ def check_valid_names(
|
|||
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
||||
|
||||
if bad_lengths:
|
||||
logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths))
|
||||
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
|
||||
|
||||
if bad_chars or bad_lengths:
|
||||
raise LibraryError('Library contains invalid names, see log above')
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
"""
|
||||
GDSII file format readers and writers.
|
||||
"""
|
||||
from .klamath import check_valid_names as check_valid_names
|
||||
from .klamath import read as read
|
||||
from .klamath import read_elements as read_elements
|
||||
from .klamath import readfile as readfile
|
||||
from .klamath import rint_cast as rint_cast
|
||||
from .klamath import write as write
|
||||
from .klamath import writefile as writefile
|
||||
|
|
@ -1,878 +0,0 @@
|
|||
# ruff: noqa: ARG001, F401
|
||||
"""
|
||||
GDSII file format readers and writers using the `TODO` library.
|
||||
|
||||
Note that GDSII references follow the same convention as `masque`,
|
||||
with this order of operations:
|
||||
1. Mirroring
|
||||
2. Rotation
|
||||
3. Scaling
|
||||
4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)
|
||||
|
||||
Scaling, rotation, and mirroring apply to individual instances, not grid
|
||||
vectors or offsets.
|
||||
|
||||
Notes:
|
||||
* absolute positioning is not supported
|
||||
* PLEX is not supported
|
||||
* ELFLAGS are not supported
|
||||
* GDS does not support library- or structure-level annotations
|
||||
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
|
||||
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
|
||||
|
||||
TODO writing
|
||||
TODO warn on boxes, nodes
|
||||
"""
|
||||
from typing import IO, cast, Any
|
||||
from collections.abc import Iterable, Mapping, Callable
|
||||
from importlib.machinery import EXTENSION_SUFFIXES
|
||||
import importlib.util
|
||||
import mmap
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import gzip
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
from pprint import pformat
|
||||
|
||||
from klamath.basic import KlamathError
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
import pyarrow
|
||||
from pyarrow.cffi import ffi
|
||||
|
||||
from ..utils import is_gzipped, tmpfile
|
||||
from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||
from ...shapes import Polygon, Path, PolyCollection, RectCollection
|
||||
from ...repetition import Grid
|
||||
from ...utils import layer_t, annotations_t
|
||||
from ...library import LazyLibrary, Library, ILibrary, ILibraryView
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ffi.cdef(
|
||||
"""
|
||||
const char* last_error_message(void);
|
||||
int read_path(const char* path, struct ArrowArray* array, struct ArrowSchema* schema);
|
||||
int scan_bytes(uint8_t* data, size_t size, struct ArrowArray* array, struct ArrowSchema* schema);
|
||||
int read_cells_bytes(
|
||||
uint8_t* data,
|
||||
size_t size,
|
||||
uint64_t* ranges,
|
||||
size_t range_count,
|
||||
struct ArrowArray* array,
|
||||
struct ArrowSchema* schema
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
clib: Any | None = None
|
||||
|
||||
|
||||
path_cap_map = {
|
||||
0: Path.Cap.Flush,
|
||||
1: Path.Cap.Circle,
|
||||
2: Path.Cap.Square,
|
||||
4: Path.Cap.SquareCustom,
|
||||
}
|
||||
|
||||
|
||||
def _packed_layer_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int16]:
|
||||
layer = (values >> numpy.uint32(16)).astype(numpy.uint16).view(numpy.int16)
|
||||
dtype = (values & numpy.uint32(0xffff)).astype(numpy.uint16).view(numpy.int16)
|
||||
return numpy.stack((layer, dtype), axis=-1)
|
||||
|
||||
|
||||
def _packed_counts_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int64]:
|
||||
a_count = (values >> numpy.uint32(16)).astype(numpy.uint16).astype(numpy.int64)
|
||||
b_count = (values & numpy.uint32(0xffff)).astype(numpy.uint16).astype(numpy.int64)
|
||||
return numpy.stack((a_count, b_count), axis=-1)
|
||||
|
||||
|
||||
def _packed_xy_u64_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int32]:
|
||||
xx = (values >> numpy.uint64(32)).astype(numpy.uint32).view(numpy.int32)
|
||||
yy = (values & numpy.uint64(0xffff_ffff)).astype(numpy.uint32).view(numpy.int32)
|
||||
return numpy.stack((xx, yy), axis=-1)
|
||||
|
||||
|
||||
def _local_library_filename() -> str:
|
||||
if sys.platform.startswith('linux'):
|
||||
return 'libklamath_rs_ext.so'
|
||||
if sys.platform == 'darwin':
|
||||
return 'libklamath_rs_ext.dylib'
|
||||
if sys.platform == 'win32':
|
||||
return 'klamath_rs_ext.dll'
|
||||
raise OSError(f'Unsupported platform for klamath_rs_ext: {sys.platform!r}')
|
||||
|
||||
|
||||
def _installed_library_candidates() -> list[pathlib.Path]:
|
||||
candidates: list[pathlib.Path] = []
|
||||
|
||||
try:
|
||||
spec = importlib.util.find_spec('klamath_rs_ext.klamath_rs_ext')
|
||||
except ModuleNotFoundError:
|
||||
spec = None
|
||||
if spec is not None and spec.origin is not None:
|
||||
candidates.append(pathlib.Path(spec.origin))
|
||||
|
||||
try:
|
||||
pkg_spec = importlib.util.find_spec('klamath_rs_ext')
|
||||
except ModuleNotFoundError:
|
||||
pkg_spec = None
|
||||
if pkg_spec is not None and pkg_spec.submodule_search_locations is not None:
|
||||
for location in pkg_spec.submodule_search_locations:
|
||||
pkg_dir = pathlib.Path(location)
|
||||
for suffix in EXTENSION_SUFFIXES:
|
||||
candidates.extend(sorted(pkg_dir.glob(f'klamath_rs_ext*{suffix}')))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _repo_library_candidates() -> list[pathlib.Path]:
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[3]
|
||||
library_name = _local_library_filename()
|
||||
return [
|
||||
repo_root / 'klamath-rs' / 'target' / 'release' / library_name,
|
||||
repo_root / 'klamath-rs' / 'target' / 'debug' / library_name,
|
||||
]
|
||||
|
||||
|
||||
def find_klamath_rs_library() -> pathlib.Path | None:
|
||||
env_path = os.environ.get('KLAMATH_RS_EXT_LIB')
|
||||
if env_path:
|
||||
candidate = pathlib.Path(env_path).expanduser()
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
|
||||
seen: set[pathlib.Path] = set()
|
||||
for candidate in _installed_library_candidates() + _repo_library_candidates():
|
||||
resolved = candidate.expanduser()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
if resolved.exists():
|
||||
return resolved.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return find_klamath_rs_library() is not None
|
||||
|
||||
|
||||
def _get_clib() -> Any:
|
||||
global clib # noqa: PLW0603
|
||||
if clib is None:
|
||||
lib_path = find_klamath_rs_library()
|
||||
if lib_path is None:
|
||||
raise ImportError(
|
||||
'Could not locate klamath_rs_ext shared library. '
|
||||
'Build klamath-rs with `cargo build --release --manifest-path klamath-rs/Cargo.toml` '
|
||||
'or set KLAMATH_RS_EXT_LIB to the built library path.'
|
||||
)
|
||||
clib = ffi.dlopen(str(lib_path))
|
||||
return clib
|
||||
|
||||
|
||||
def _read_annotations(
|
||||
prop_offs: NDArray[numpy.integer[Any]],
|
||||
prop_key: NDArray[numpy.integer[Any]],
|
||||
prop_val: list[str],
|
||||
ee: int,
|
||||
) -> annotations_t:
|
||||
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
|
||||
if prop_ii >= prop_ff:
|
||||
return None
|
||||
return {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}
|
||||
|
||||
|
||||
def _read_to_arrow(
|
||||
filename: str | pathlib.Path,
|
||||
) -> pyarrow.Array:
|
||||
path = pathlib.Path(filename).expanduser().resolve()
|
||||
ptr_array = ffi.new('struct ArrowArray[]', 1)
|
||||
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
|
||||
if is_gzipped(path):
|
||||
with gzip.open(path, mode='rb') as src:
|
||||
data = src.read()
|
||||
with tempfile.NamedTemporaryFile(suffix='.gds', delete=False) as tmp_stream:
|
||||
tmp_stream.write(data)
|
||||
tmp_name = tmp_stream.name
|
||||
try:
|
||||
_call_native(_get_clib().read_path(tmp_name.encode(), ptr_array, ptr_schema), 'read_path')
|
||||
finally:
|
||||
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
||||
else:
|
||||
_call_native(_get_clib().read_path(str(path).encode(), ptr_array, ptr_schema), 'read_path')
|
||||
return _import_arrow_array(ptr_array, ptr_schema)
|
||||
|
||||
|
||||
def _import_arrow_array(ptr_array: Any, ptr_schema: Any) -> pyarrow.Array:
|
||||
iptr_schema = int(ffi.cast('uintptr_t', ptr_schema))
|
||||
iptr_array = int(ffi.cast('uintptr_t', ptr_array))
|
||||
return pyarrow.Array._import_from_c(iptr_array, iptr_schema)
|
||||
|
||||
|
||||
def _call_native(status: int, action: str) -> None:
|
||||
if status == 0:
|
||||
return
|
||||
|
||||
err_ptr = _get_clib().last_error_message()
|
||||
if err_ptr == ffi.NULL:
|
||||
raise KlamathError(f'{action} failed')
|
||||
|
||||
message = ffi.string(err_ptr).decode(errors='replace')
|
||||
raise KlamathError(message)
|
||||
|
||||
|
||||
def _scan_buffer_to_arrow(buffer: bytes | mmap.mmap | memoryview) -> pyarrow.Array:
|
||||
ptr_array = ffi.new('struct ArrowArray[]', 1)
|
||||
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
|
||||
buf_view = memoryview(buffer)
|
||||
cbuf = ffi.from_buffer('uint8_t[]', buf_view)
|
||||
_call_native(_get_clib().scan_bytes(cbuf, len(buf_view), ptr_array, ptr_schema), 'scan_bytes')
|
||||
return _import_arrow_array(ptr_array, ptr_schema)
|
||||
|
||||
|
||||
def _read_selected_cells_to_arrow(
|
||||
buffer: bytes | mmap.mmap | memoryview,
|
||||
ranges: NDArray[numpy.uint64],
|
||||
) -> pyarrow.Array:
|
||||
ptr_array = ffi.new('struct ArrowArray[]', 1)
|
||||
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
|
||||
buf_view = memoryview(buffer)
|
||||
cbuf = ffi.from_buffer('uint8_t[]', buf_view)
|
||||
flat_ranges = numpy.require(ranges, dtype=numpy.uint64, requirements=('C_CONTIGUOUS', 'ALIGNED'))
|
||||
cranges = ffi.from_buffer('uint64_t[]', flat_ranges)
|
||||
_call_native(
|
||||
_get_clib().read_cells_bytes(cbuf, len(buf_view), cranges, int(flat_ranges.shape[0]), ptr_array, ptr_schema),
|
||||
'read_cells_bytes',
|
||||
)
|
||||
return _import_arrow_array(ptr_array, ptr_schema)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
"""
|
||||
Read a GDSII file from a path into `masque.Library` / `Pattern` objects.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to read.
|
||||
|
||||
For callers that can consume Arrow directly, prefer `readfile_arrow()`
|
||||
to skip Python `Pattern` construction entirely.
|
||||
"""
|
||||
arrow_arr = _read_to_arrow(filename)
|
||||
assert len(arrow_arr) == 1
|
||||
|
||||
results = read_arrow(arrow_arr[0])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def readfile_arrow(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[pyarrow.StructScalar, dict[str, Any]]:
|
||||
"""
|
||||
Read a GDSII file into the native Arrow representation without converting
|
||||
it into `masque.Library` / `Pattern` objects.
|
||||
|
||||
This is the lowest-overhead public read path exposed by this module.
|
||||
|
||||
Args:
|
||||
filename: Filename to read.
|
||||
|
||||
Returns:
|
||||
- Arrow struct scalar for the library payload
|
||||
- dict of GDSII library info
|
||||
"""
|
||||
arrow_arr = _read_to_arrow(filename)
|
||||
assert len(arrow_arr) == 1
|
||||
libarr = arrow_arr[0]
|
||||
return libarr, _read_header(libarr)
|
||||
|
||||
|
||||
def read_arrow(
|
||||
libarr: pyarrow.Array,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
"""
|
||||
# TODO check GDSII file for cycles!
|
||||
Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
|
||||
translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
|
||||
are translated into Ref objects.
|
||||
|
||||
Additional library info is returned in a dict, containing:
|
||||
'name': name of the library
|
||||
'meters_per_unit': number of meters per database unit (all values are in database units)
|
||||
'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
|
||||
per database unit
|
||||
|
||||
Args:
|
||||
libarr: Arrow library payload as returned by `readfile_arrow()`.
|
||||
|
||||
Returns:
|
||||
- dict of pattern_name:Patterns generated from GDSII structures
|
||||
- dict of GDSII library info
|
||||
"""
|
||||
library_info = _read_header(libarr)
|
||||
|
||||
layer_names_np = _packed_layer_u32_to_pairs(libarr['layers'].values.to_numpy())
|
||||
layer_tups = [(int(pair[0]), int(pair[1])) for pair in layer_names_np]
|
||||
|
||||
cell_ids = libarr['cells'].values.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
|
||||
def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]:
|
||||
el = libarr['cells'].values.field(geom_type)
|
||||
elem = dict(
|
||||
offsets = el.offsets.to_numpy(),
|
||||
xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)),
|
||||
xy_off = el.values.field('xy').offsets.to_numpy() // 2,
|
||||
layer_inds = el.values.field('layer').to_numpy(),
|
||||
prop_off = el.values.field('properties').offsets.to_numpy(),
|
||||
prop_key = el.values.field('properties').values.field('key').to_numpy(),
|
||||
prop_val = el.values.field('properties').values.field('value').to_pylist(),
|
||||
)
|
||||
return elem
|
||||
|
||||
def get_boundary_batches(libarr: pyarrow.Array) -> dict[str, Any]:
|
||||
batches = libarr['cells'].values.field('boundary_batches')
|
||||
return dict(
|
||||
offsets = batches.offsets.to_numpy(),
|
||||
layer_inds = batches.values.field('layer').to_numpy(),
|
||||
vert_arr = batches.values.field('vertices').values.to_numpy().reshape((-1, 2)),
|
||||
vert_off = batches.values.field('vertices').offsets.to_numpy() // 2,
|
||||
poly_off = batches.values.field('vertex_offsets').offsets.to_numpy(),
|
||||
poly_offsets = batches.values.field('vertex_offsets').values.to_numpy(),
|
||||
)
|
||||
|
||||
def get_rect_batches(libarr: pyarrow.Array) -> dict[str, Any]:
|
||||
batches = libarr['cells'].values.field('rect_batches')
|
||||
return dict(
|
||||
offsets = batches.offsets.to_numpy(),
|
||||
layer_inds = batches.values.field('layer').to_numpy(),
|
||||
rect_arr = batches.values.field('rects').values.to_numpy().reshape((-1, 4)),
|
||||
rect_off = batches.values.field('rects').offsets.to_numpy() // 4,
|
||||
)
|
||||
|
||||
def get_boundary_props(libarr: pyarrow.Array) -> dict[str, Any]:
|
||||
boundaries = libarr['cells'].values.field('boundary_props')
|
||||
return dict(
|
||||
offsets = boundaries.offsets.to_numpy(),
|
||||
layer_inds = boundaries.values.field('layer').to_numpy(),
|
||||
vert_arr = boundaries.values.field('vertices').values.to_numpy().reshape((-1, 2)),
|
||||
vert_off = boundaries.values.field('vertices').offsets.to_numpy() // 2,
|
||||
prop_off = boundaries.values.field('properties').offsets.to_numpy(),
|
||||
prop_key = boundaries.values.field('properties').values.field('key').to_numpy(),
|
||||
prop_val = boundaries.values.field('properties').values.field('value').to_pylist(),
|
||||
)
|
||||
|
||||
def get_refs(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]:
|
||||
refs = libarr['cells'].values.field(geom_type)
|
||||
values = refs.values
|
||||
elem = dict(
|
||||
offsets = refs.offsets.to_numpy(),
|
||||
targets = values.field('target').to_numpy(),
|
||||
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()),
|
||||
invert_y = values.field('invert_y').to_numpy(zero_copy_only=False),
|
||||
angle_rad = values.field('angle_rad').to_numpy(),
|
||||
scale = values.field('scale').to_numpy(),
|
||||
)
|
||||
if has_repetition:
|
||||
elem.update(dict(
|
||||
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()),
|
||||
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
|
||||
counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()),
|
||||
))
|
||||
return elem
|
||||
|
||||
def get_ref_props(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]:
|
||||
refs = libarr['cells'].values.field(geom_type)
|
||||
values = refs.values
|
||||
elem = dict(
|
||||
offsets = refs.offsets.to_numpy(),
|
||||
targets = values.field('target').to_numpy(),
|
||||
xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()),
|
||||
invert_y = values.field('invert_y').to_numpy(zero_copy_only=False),
|
||||
angle_rad = values.field('angle_rad').to_numpy(),
|
||||
scale = values.field('scale').to_numpy(),
|
||||
prop_off = values.field('properties').offsets.to_numpy(),
|
||||
prop_key = values.field('properties').values.field('key').to_numpy(),
|
||||
prop_val = values.field('properties').values.field('value').to_pylist(),
|
||||
)
|
||||
if has_repetition:
|
||||
elem.update(dict(
|
||||
xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()),
|
||||
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()),
|
||||
counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()),
|
||||
))
|
||||
return elem
|
||||
|
||||
txt = libarr['cells'].values.field('texts')
|
||||
texts = dict(
|
||||
offsets = txt.offsets.to_numpy(),
|
||||
layer_inds = txt.values.field('layer').to_numpy(),
|
||||
xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()),
|
||||
string = txt.values.field('string').to_pylist(),
|
||||
prop_off = txt.values.field('properties').offsets.to_numpy(),
|
||||
prop_key = txt.values.field('properties').values.field('key').to_numpy(),
|
||||
prop_val = txt.values.field('properties').values.field('value').to_pylist(),
|
||||
)
|
||||
|
||||
elements = dict(
|
||||
srefs = get_refs(libarr, 'srefs', has_repetition=False),
|
||||
arefs = get_refs(libarr, 'arefs', has_repetition=True),
|
||||
sref_props = get_ref_props(libarr, 'sref_props', has_repetition=False),
|
||||
aref_props = get_ref_props(libarr, 'aref_props', has_repetition=True),
|
||||
rect_batches = get_rect_batches(libarr),
|
||||
boundary_batches = get_boundary_batches(libarr),
|
||||
boundary_props = get_boundary_props(libarr),
|
||||
paths = get_geom(libarr, 'paths'),
|
||||
texts = texts,
|
||||
)
|
||||
|
||||
paths = libarr['cells'].values.field('paths')
|
||||
elements['paths'].update(dict(
|
||||
width = paths.values.field('width').fill_null(0).to_numpy(),
|
||||
path_type = paths.values.field('path_type').fill_null(0).to_numpy(),
|
||||
extensions = numpy.stack((
|
||||
paths.values.field('extension_start').fill_null(0).to_numpy(),
|
||||
paths.values.field('extension_end').fill_null(0).to_numpy(),
|
||||
), axis=-1),
|
||||
))
|
||||
|
||||
global_args = dict(
|
||||
cell_names = cell_names,
|
||||
layer_tups = layer_tups,
|
||||
)
|
||||
|
||||
mlib = Library()
|
||||
for cc in range(len(libarr['cells'])):
|
||||
name = cell_names[int(cell_ids[cc])]
|
||||
pat = Pattern()
|
||||
_rect_batches_to_rectcollections(pat, global_args, elements['rect_batches'], cc)
|
||||
_boundary_batches_to_polygons(pat, global_args, elements['boundary_batches'], cc)
|
||||
_boundary_props_to_polygons(pat, global_args, elements['boundary_props'], cc)
|
||||
_gpaths_to_mpaths(pat, global_args, elements['paths'], cc)
|
||||
_srefs_to_mrefs(pat, global_args, elements['srefs'], cc)
|
||||
_arefs_to_mrefs(pat, global_args, elements['arefs'], cc)
|
||||
_sref_props_to_mrefs(pat, global_args, elements['sref_props'], cc)
|
||||
_aref_props_to_mrefs(pat, global_args, elements['aref_props'], cc)
|
||||
_texts_to_labels(pat, global_args, elements['texts'], cc)
|
||||
mlib[name] = pat
|
||||
|
||||
return mlib, library_info
|
||||
|
||||
|
||||
def _read_header(libarr: pyarrow.Array) -> dict[str, Any]:
|
||||
"""
|
||||
Read the file header and create the library_info dict.
|
||||
"""
|
||||
library_info = dict(
|
||||
name = libarr['lib_name'].as_py(),
|
||||
meters_per_unit = libarr['meters_per_db_unit'].as_py(),
|
||||
logical_units_per_unit = libarr['user_units_per_db_unit'].as_py(),
|
||||
)
|
||||
return library_info
|
||||
|
||||
|
||||
def _srefs_to_mrefs(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
cell_names = global_args['cell_names']
|
||||
elem_off = elem['offsets']
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if elem_count == 0:
|
||||
return
|
||||
|
||||
start = elem_off[cc]
|
||||
stop = elem_off[cc + 1]
|
||||
elem_targets = elem['targets'][start:stop]
|
||||
elem_xy = elem['xy'][start:stop]
|
||||
elem_invert_y = elem['invert_y'][start:stop]
|
||||
elem_angle_rad = elem['angle_rad'][start:stop]
|
||||
elem_scale = elem['scale'][start:stop]
|
||||
|
||||
_append_plain_refs_sorted(
|
||||
pat=pat,
|
||||
cell_names=cell_names,
|
||||
elem_targets=elem_targets,
|
||||
elem_xy=elem_xy,
|
||||
elem_invert_y=elem_invert_y,
|
||||
elem_angle_rad=elem_angle_rad,
|
||||
elem_scale=elem_scale,
|
||||
)
|
||||
|
||||
|
||||
def _append_plain_refs_sorted(
|
||||
*,
|
||||
pat: Pattern,
|
||||
cell_names: list[str],
|
||||
elem_targets: NDArray[numpy.integer[Any]],
|
||||
elem_xy: NDArray[numpy.integer[Any]],
|
||||
elem_invert_y: NDArray[numpy.bool_ | numpy.bool],
|
||||
elem_angle_rad: NDArray[numpy.floating[Any]],
|
||||
elem_scale: NDArray[numpy.floating[Any]],
|
||||
) -> None:
|
||||
elem_count = len(elem_targets)
|
||||
if elem_count == 0:
|
||||
return
|
||||
|
||||
target_start = 0
|
||||
while target_start < elem_count:
|
||||
target_id = int(elem_targets[target_start])
|
||||
target_stop = target_start + 1
|
||||
while target_stop < elem_count and elem_targets[target_stop] == target_id:
|
||||
target_stop += 1
|
||||
|
||||
append_refs = pat.refs[cell_names[target_id]].extend
|
||||
append_refs(
|
||||
Ref._from_raw(
|
||||
offset=elem_xy[ee],
|
||||
mirrored=elem_invert_y[ee],
|
||||
rotation=elem_angle_rad[ee],
|
||||
scale=elem_scale[ee],
|
||||
repetition=None,
|
||||
annotations=None,
|
||||
)
|
||||
for ee in range(target_start, target_stop)
|
||||
)
|
||||
|
||||
target_start = target_stop
|
||||
|
||||
|
||||
def _arefs_to_mrefs(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
cell_names = global_args['cell_names']
|
||||
elem_off = elem['offsets']
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if elem_count == 0:
|
||||
return
|
||||
|
||||
start = elem_off[cc]
|
||||
stop = elem_off[cc + 1]
|
||||
elem_targets = elem['targets'][start:stop]
|
||||
elem_xy = elem['xy'][start:stop]
|
||||
elem_invert_y = elem['invert_y'][start:stop]
|
||||
elem_angle_rad = elem['angle_rad'][start:stop]
|
||||
elem_scale = elem['scale'][start:stop]
|
||||
elem_xy0 = elem['xy0'][start:stop]
|
||||
elem_xy1 = elem['xy1'][start:stop]
|
||||
elem_counts = elem['counts'][start:stop]
|
||||
|
||||
if len(elem_targets) == 0:
|
||||
return
|
||||
|
||||
target = None
|
||||
append_ref: Callable[[Ref], Any] | None = None
|
||||
for ee in range(len(elem_targets)):
|
||||
target_id = int(elem_targets[ee])
|
||||
if target != target_id:
|
||||
target = target_id
|
||||
append_ref = pat.refs[cell_names[target_id]].append
|
||||
assert append_ref is not None
|
||||
a_count, b_count = elem_counts[ee]
|
||||
append_ref(Ref._from_raw(
|
||||
offset=elem_xy[ee],
|
||||
mirrored=elem_invert_y[ee],
|
||||
rotation=elem_angle_rad[ee],
|
||||
scale=elem_scale[ee],
|
||||
repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count),
|
||||
annotations=None,
|
||||
))
|
||||
|
||||
|
||||
def _sref_props_to_mrefs(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
cell_names = global_args['cell_names']
|
||||
elem_off = elem['offsets']
|
||||
prop_key = elem['prop_key']
|
||||
prop_val = elem['prop_val']
|
||||
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if elem_count == 0:
|
||||
return
|
||||
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)
|
||||
prop_offs = elem['prop_off'][elem_slc]
|
||||
elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]]
|
||||
|
||||
for ee in range(elem_count):
|
||||
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
|
||||
ref = Ref._from_raw(
|
||||
offset=elem_xy[ee],
|
||||
mirrored=elem_invert_y[ee],
|
||||
rotation=elem_angle_rad[ee],
|
||||
scale=elem_scale[ee],
|
||||
repetition=None,
|
||||
annotations=annotations,
|
||||
)
|
||||
pat.refs[cell_names[int(elem_targets[ee])]].append(ref)
|
||||
|
||||
|
||||
def _aref_props_to_mrefs(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
cell_names = global_args['cell_names']
|
||||
elem_off = elem['offsets']
|
||||
prop_key = elem['prop_key']
|
||||
prop_val = elem['prop_val']
|
||||
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if elem_count == 0:
|
||||
return
|
||||
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)
|
||||
prop_offs = elem['prop_off'][elem_slc]
|
||||
elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_xy0 = elem['xy0'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_xy1 = elem['xy1'][elem_off[cc]:elem_off[cc + 1]]
|
||||
elem_counts = elem['counts'][elem_off[cc]:elem_off[cc + 1]]
|
||||
|
||||
for ee in range(elem_count):
|
||||
a_count, b_count = elem_counts[ee]
|
||||
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
|
||||
ref = Ref._from_raw(
|
||||
offset=elem_xy[ee],
|
||||
mirrored=elem_invert_y[ee],
|
||||
rotation=elem_angle_rad[ee],
|
||||
scale=elem_scale[ee],
|
||||
repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count),
|
||||
annotations=annotations,
|
||||
)
|
||||
pat.refs[cell_names[int(elem_targets[ee])]].append(ref)
|
||||
|
||||
|
||||
def _texts_to_labels(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
elem_off = elem['offsets'] # which elements belong to each cell
|
||||
xy = elem['xy']
|
||||
layer_tups = global_args['layer_tups']
|
||||
layer_inds = elem['layer_inds']
|
||||
prop_key = elem['prop_key']
|
||||
prop_val = elem['prop_val']
|
||||
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
|
||||
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
|
||||
elem_xy = xy[elem_slc][:elem_count]
|
||||
elem_layer_inds = layer_inds[elem_slc][:elem_count]
|
||||
elem_strings = elem['string'][elem_slc][:elem_count]
|
||||
|
||||
for ee in range(elem_count):
|
||||
layer = layer_tups[int(elem_layer_inds[ee])]
|
||||
offset = elem_xy[ee]
|
||||
string = elem_strings[ee]
|
||||
|
||||
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
|
||||
mlabel = Label._from_raw(string=string, offset=offset, annotations=annotations)
|
||||
pat.labels[layer].append(mlabel)
|
||||
|
||||
|
||||
def _gpaths_to_mpaths(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
elem_off = elem['offsets'] # which elements belong to each cell
|
||||
xy_val = elem['xy_arr']
|
||||
layer_tups = global_args['layer_tups']
|
||||
layer_inds = elem['layer_inds']
|
||||
prop_key = elem['prop_key']
|
||||
prop_val = elem['prop_val']
|
||||
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem
|
||||
xy_offs = elem['xy_off'][elem_slc] # which xy coords belong to each element
|
||||
prop_offs = elem['prop_off'][elem_slc] # which props belong to each element
|
||||
elem_layer_inds = layer_inds[elem_slc][:elem_count]
|
||||
elem_widths = elem['width'][elem_slc][:elem_count]
|
||||
elem_path_types = elem['path_type'][elem_slc][:elem_count]
|
||||
elem_extensions = elem['extensions'][elem_slc][:elem_count]
|
||||
|
||||
for ee in range(elem_count):
|
||||
layer = layer_tups[int(elem_layer_inds[ee])]
|
||||
vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]]
|
||||
width = elem_widths[ee]
|
||||
cap_int = int(elem_path_types[ee])
|
||||
if cap_int not in path_cap_map:
|
||||
raise PatternError(f'Unrecognized path type: {cap_int}')
|
||||
cap = path_cap_map[cap_int]
|
||||
if cap_int == 4:
|
||||
cap_extensions = elem_extensions[ee]
|
||||
else:
|
||||
cap_extensions = None
|
||||
|
||||
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
|
||||
path = Path._from_raw(
|
||||
vertices=vertices,
|
||||
width=width,
|
||||
cap=cap,
|
||||
cap_extensions=cap_extensions,
|
||||
annotations=annotations,
|
||||
)
|
||||
pat.shapes[layer].append(path)
|
||||
|
||||
|
||||
def _boundary_batches_to_polygons(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
elem_off = elem['offsets'] # which elements belong to each cell
|
||||
vert_arr = elem['vert_arr']
|
||||
vert_off = elem['vert_off']
|
||||
layer_inds = elem['layer_inds']
|
||||
layer_tups = global_args['layer_tups']
|
||||
poly_off = elem['poly_off']
|
||||
poly_offsets = elem['poly_offsets']
|
||||
|
||||
batch_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if batch_count == 0:
|
||||
return
|
||||
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1) # +1 to capture ending location for last elem
|
||||
elem_vert_off = vert_off[elem_slc]
|
||||
elem_poly_off = poly_off[elem_slc]
|
||||
elem_layer_inds = layer_inds[elem_slc][:batch_count]
|
||||
|
||||
for bb in range(batch_count):
|
||||
layer = layer_tups[int(elem_layer_inds[bb])]
|
||||
vertices = vert_arr[elem_vert_off[bb]:elem_vert_off[bb + 1]]
|
||||
vertex_offsets = poly_offsets[elem_poly_off[bb]:elem_poly_off[bb + 1]]
|
||||
|
||||
if vertex_offsets.size == 1:
|
||||
poly = Polygon._from_raw(vertices=vertices, annotations=None)
|
||||
pat.shapes[layer].append(poly)
|
||||
else:
|
||||
polys = PolyCollection._from_raw(vertex_lists=vertices, vertex_offsets=vertex_offsets, annotations=None)
|
||||
pat.shapes[layer].append(polys)
|
||||
|
||||
|
||||
def _rect_batches_to_rectcollections(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
elem_off = elem['offsets']
|
||||
rect_arr = elem['rect_arr']
|
||||
rect_off = elem['rect_off']
|
||||
layer_inds = elem['layer_inds']
|
||||
layer_tups = global_args['layer_tups']
|
||||
|
||||
batch_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if batch_count == 0:
|
||||
return
|
||||
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1)
|
||||
elem_rect_off = rect_off[elem_slc]
|
||||
elem_layer_inds = layer_inds[elem_slc][:batch_count]
|
||||
|
||||
for bb in range(batch_count):
|
||||
layer = layer_tups[int(elem_layer_inds[bb])]
|
||||
rects = rect_arr[elem_rect_off[bb]:elem_rect_off[bb + 1]]
|
||||
rect_collection = RectCollection._from_raw(rects=rects, annotations=None)
|
||||
pat.shapes[layer].append(rect_collection)
|
||||
|
||||
|
||||
def _boundary_props_to_polygons(
|
||||
pat: Pattern,
|
||||
global_args: dict[str, Any],
|
||||
elem: dict[str, Any],
|
||||
cc: int,
|
||||
) -> None:
|
||||
elem_off = elem['offsets']
|
||||
vert_arr = elem['vert_arr']
|
||||
vert_off = elem['vert_off']
|
||||
layer_inds = elem['layer_inds']
|
||||
layer_tups = global_args['layer_tups']
|
||||
prop_key = elem['prop_key']
|
||||
prop_val = elem['prop_val']
|
||||
|
||||
elem_count = elem_off[cc + 1] - elem_off[cc]
|
||||
if elem_count == 0:
|
||||
return
|
||||
|
||||
elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1)
|
||||
elem_vert_off = vert_off[elem_slc]
|
||||
prop_offs = elem['prop_off'][elem_slc]
|
||||
elem_layer_inds = layer_inds[elem_slc][:elem_count]
|
||||
|
||||
for ee in range(elem_count):
|
||||
layer = layer_tups[int(elem_layer_inds[ee])]
|
||||
vertices = vert_arr[elem_vert_off[ee]:elem_vert_off[ee + 1]]
|
||||
annotations = _read_annotations(prop_offs, prop_key, prop_val, ee)
|
||||
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
|
||||
pat.shapes[layer].append(poly)
|
||||
|
||||
|
||||
#def _properties_to_annotations(properties: pyarrow.Array) -> annotations_t:
|
||||
# return {prop['key'].as_py(): prop['value'].as_py() for prop in properties}
|
||||
|
||||
|
||||
def check_valid_names(
|
||||
names: Iterable[str],
|
||||
max_length: int = 32,
|
||||
) -> None:
|
||||
"""
|
||||
Check all provided names to see if they're valid GDSII cell names.
|
||||
|
||||
Args:
|
||||
names: Collection of names to check
|
||||
max_length: Max allowed length
|
||||
|
||||
"""
|
||||
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
|
||||
|
||||
bad_chars = [
|
||||
name for name in names
|
||||
if not set(name).issubset(allowed_chars)
|
||||
]
|
||||
|
||||
bad_lengths = [
|
||||
name for name in names
|
||||
if len(name) > max_length
|
||||
]
|
||||
|
||||
if bad_chars:
|
||||
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
||||
|
||||
if bad_lengths:
|
||||
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
|
||||
|
||||
if bad_chars or bad_lengths:
|
||||
raise LibraryError('Library contains invalid names, see log above')
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
"""
|
||||
Classic source-backed lazy GDSII reader built on the pure-python klamath path.
|
||||
|
||||
This module provides the non-Arrow half of Masque's lazy GDS architecture:
|
||||
|
||||
- `GdsLibrarySource` scans a GDS stream once to discover library metadata,
|
||||
struct order, and child edges without materializing every cell.
|
||||
- cells are materialized on demand through the classic `gdsii` decoder
|
||||
whenever a caller indexes the lazy view
|
||||
- the source can be wrapped in `PortsLibraryView` or merged through
|
||||
`OverlayLibrary`
|
||||
|
||||
The public surface intentionally parallels `gdsii.lazy_arrow` closely so that
|
||||
callers can swap between the classic and Arrow-backed implementations with
|
||||
minimal changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import IO, TYPE_CHECKING, Any, cast
|
||||
from collections import defaultdict
|
||||
import gzip
|
||||
import io
|
||||
import logging
|
||||
import mmap
|
||||
import pathlib
|
||||
|
||||
import klamath
|
||||
import numpy
|
||||
from klamath import records
|
||||
|
||||
from . import klamath as gdsii
|
||||
from .lazy_write import write as write, writefile as writefile
|
||||
from ..utils import is_gzipped
|
||||
from ...error import LibraryError
|
||||
from ...library import (
|
||||
ILibraryView,
|
||||
LibraryView,
|
||||
PortsLibraryView,
|
||||
dangling_mode_t,
|
||||
)
|
||||
from ...utils import apply_transforms
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ...pattern import Pattern
|
||||
from ...ports import Port
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SourceHandle:
|
||||
""" Owns the underlying stream and any companion file handle for a source. """
|
||||
path: pathlib.Path | None
|
||||
stream: IO[bytes]
|
||||
handle: IO[bytes] | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
self.stream.close()
|
||||
if self.handle is not None and self.handle is not self.stream:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CellScan:
|
||||
""" Scan-time metadata for one cell in the source stream. """
|
||||
offset: int
|
||||
children: set[str]
|
||||
|
||||
|
||||
def _open_source_stream(
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool,
|
||||
) -> _SourceHandle:
|
||||
path = pathlib.Path(filename).expanduser().resolve()
|
||||
if is_gzipped(path):
|
||||
if use_mmap:
|
||||
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
|
||||
with gzip.open(path, mode='rb') as stream:
|
||||
data = stream.read()
|
||||
return _SourceHandle(path=path, stream=io.BytesIO(data))
|
||||
stream = cast('IO[bytes]', gzip.open(path, mode='rb')) # noqa: SIM115
|
||||
return _SourceHandle(path=path, stream=stream)
|
||||
|
||||
if use_mmap:
|
||||
handle = path.open(mode='rb', buffering=0)
|
||||
mapped = cast('IO[bytes]', mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ))
|
||||
return _SourceHandle(path=path, stream=mapped, handle=handle)
|
||||
|
||||
stream = path.open(mode='rb')
|
||||
return _SourceHandle(path=path, stream=stream)
|
||||
|
||||
|
||||
def _scan_library(
|
||||
stream: IO[bytes],
|
||||
) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]:
|
||||
library_info = gdsii._read_header(stream)
|
||||
order: list[str] = []
|
||||
cells: dict[str, _CellScan] = {}
|
||||
|
||||
found_struct = records.BGNSTR.skip_past(stream)
|
||||
while found_struct:
|
||||
name = records.STRNAME.skip_and_read(stream).decode('ASCII')
|
||||
offset = stream.tell()
|
||||
elements = klamath.library.read_elements(stream)
|
||||
children = {
|
||||
element.struct_name.decode('ASCII')
|
||||
for element in elements
|
||||
if isinstance(element, klamath.elements.Reference)
|
||||
}
|
||||
order.append(name)
|
||||
cells[name] = _CellScan(offset=offset, children=children)
|
||||
found_struct = records.BGNSTR.skip_past(stream)
|
||||
|
||||
return library_info, order, cells
|
||||
|
||||
|
||||
class GdsLibrarySource(ILibraryView):
|
||||
"""
|
||||
Read-only library backed by a seekable GDS stream.
|
||||
|
||||
Cells are scanned once up front to discover order and child edges, then
|
||||
materialized one at a time through the classic `gdsii.read_elements` path.
|
||||
|
||||
The source owns the stream lifetime, preserves on-disk ordering through
|
||||
`source_order()`, and answers graph queries from scan metadata whenever
|
||||
possible so callers can inspect hierarchy without forcing a full load.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: _SourceHandle,
|
||||
library_info: dict[str, Any],
|
||||
cell_order: Sequence[str],
|
||||
cells: dict[str, _CellScan],
|
||||
) -> None:
|
||||
self.path = source.path
|
||||
self.library_info = library_info
|
||||
self._source = source
|
||||
self._cell_order = tuple(cell_order)
|
||||
self._cells = cells
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
self._lookups_in_progress: list[str] = []
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool = True,
|
||||
) -> GdsLibrarySource:
|
||||
source = _open_source_stream(filename, use_mmap=use_mmap)
|
||||
source.stream.seek(0)
|
||||
library_info, cell_order, cells = _scan_library(source.stream)
|
||||
return cls(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self._materialize_pattern(key, persist=True)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._cell_order)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cell_order)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._cells
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return self._cell_order
|
||||
|
||||
def materialize_many(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool = True,
|
||||
) -> LibraryView:
|
||||
mats = {
|
||||
name: self._materialize_pattern(name, persist=persist)
|
||||
for name in dict.fromkeys(names)
|
||||
}
|
||||
return LibraryView(mats)
|
||||
|
||||
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||
if name in self._cache:
|
||||
return self._cache[name]
|
||||
|
||||
if name not in self._cells:
|
||||
raise KeyError(name)
|
||||
|
||||
if name in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||
raise LibraryError(
|
||||
f'Detected circular reference or recursive lookup of "{name}".\n'
|
||||
f'Lookup chain: {chain}\n'
|
||||
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
||||
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
|
||||
)
|
||||
|
||||
self._lookups_in_progress.append(name)
|
||||
try:
|
||||
self._source.stream.seek(self._cells[name].offset)
|
||||
pat = gdsii.read_elements(self._source.stream, raw_mode=True)
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
|
||||
if persist:
|
||||
self._cache[name] = pat
|
||||
return pat
|
||||
|
||||
def _raw_children(self, name: str) -> set[str]:
|
||||
return set(self._cells[name].children)
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
graph: dict[str, set[str]] = {}
|
||||
for name in self._cell_order:
|
||||
if name in self._cache:
|
||||
graph[name] = {child for child, refs in self._cache[name].refs.items() if child is not None and refs}
|
||||
else:
|
||||
graph[name] = self._raw_children(name)
|
||||
|
||||
existing = set(graph)
|
||||
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
||||
if dangling == 'error':
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
||||
return graph
|
||||
if dangling == 'ignore':
|
||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||
|
||||
for child in dangling_refs:
|
||||
graph.setdefault(cast('str', child), set())
|
||||
return graph
|
||||
|
||||
def parent_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||
existing = set(self.keys())
|
||||
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||
for parent, children in child_graph.items():
|
||||
for child in children:
|
||||
if child in existing or dangling == 'include':
|
||||
igraph.setdefault(child, set()).add(parent)
|
||||
if dangling == 'error':
|
||||
raw = self.child_graph(dangling='include')
|
||||
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||
return igraph
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
) -> ILibraryView:
|
||||
if isinstance(tops, str):
|
||||
tops = (tops,)
|
||||
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||
keep |= set(tops)
|
||||
return self.materialize_many(tuple(keep), persist=True)
|
||||
|
||||
def tops(self) -> list[str]:
|
||||
graph = self.child_graph(dangling='ignore')
|
||||
names = set(graph)
|
||||
not_toplevel: set[str] = set()
|
||||
for children in graph.values():
|
||||
not_toplevel |= children
|
||||
return list(names - not_toplevel)
|
||||
|
||||
def with_ports_from_data(
|
||||
self,
|
||||
*,
|
||||
layers: Sequence[tuple[int, int] | int],
|
||||
max_depth: int = 0,
|
||||
skip_subcells: bool = True,
|
||||
ports: Mapping[str, Mapping[str, Port]] | None = None,
|
||||
replace: bool = False,
|
||||
) -> PortsLibraryView:
|
||||
return PortsLibraryView(
|
||||
self,
|
||||
layers=layers,
|
||||
max_depth=max_depth,
|
||||
skip_subcells=skip_subcells,
|
||||
ports=ports,
|
||||
replace=replace,
|
||||
)
|
||||
|
||||
def with_port_overrides(
|
||||
self,
|
||||
ports: Mapping[str, Mapping[str, Port]],
|
||||
*,
|
||||
replace: bool = False,
|
||||
) -> PortsLibraryView:
|
||||
return PortsLibraryView(
|
||||
self,
|
||||
ports=ports,
|
||||
replace=replace,
|
||||
)
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
||||
if parent_graph is None:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return instances
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return instances
|
||||
|
||||
for parent in parent_graph.get(name, set()):
|
||||
if parent in self._cache:
|
||||
for ref in self._cache[parent].refs.get(name, []):
|
||||
instances[parent].append(ref.as_transforms())
|
||||
continue
|
||||
pat = self._materialize_pattern(parent, persist=False)
|
||||
for ref in pat.refs.get(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,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
if order is None:
|
||||
order = self.child_order(dangling=graph_mode)
|
||||
if parent_graph is None:
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return {}
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return {}
|
||||
|
||||
self_keys = set(self.keys())
|
||||
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, dangling=dangling).items():
|
||||
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||
|
||||
for next_name in order:
|
||||
if next_name not in transforms:
|
||||
continue
|
||||
if not parent_graph.get(next_name, set()) & self_keys:
|
||||
continue
|
||||
|
||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||
inners = transforms.pop(next_name)
|
||||
for parent, outer in outers.items():
|
||||
outer_tf = numpy.concatenate(outer)
|
||||
for path, inner in inners:
|
||||
combined = apply_transforms(outer_tf, inner)
|
||||
transforms[parent].append(((next_name,) + path, combined))
|
||||
|
||||
result = {}
|
||||
for parent, targets in transforms.items():
|
||||
for path, instances in targets:
|
||||
result[(parent,) + path] = instances
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
self._source.close()
|
||||
|
||||
def __enter__(self) -> GdsLibrarySource:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def read(
|
||||
stream: IO[bytes],
|
||||
) -> tuple[GdsLibrarySource, dict[str, Any]]:
|
||||
source = _SourceHandle(path=None, stream=stream)
|
||||
stream.seek(0)
|
||||
library_info, cell_order, cells = _scan_library(stream)
|
||||
lib = GdsLibrarySource(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
|
||||
return lib, library_info
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool = True,
|
||||
) -> tuple[GdsLibrarySource, dict[str, Any]]:
|
||||
lib = GdsLibrarySource.from_file(filename, use_mmap=use_mmap)
|
||||
return lib, lib.library_info
|
||||
|
|
@ -1,541 +0,0 @@
|
|||
"""
|
||||
Lazy GDSII readers and writers backed by native Arrow scan/materialize paths.
|
||||
|
||||
This module is intentionally separate from `gdsii.arrow` so the eager read path
|
||||
keeps its current behavior and performance profile.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import IO, TYPE_CHECKING, Any, cast
|
||||
from collections import defaultdict
|
||||
import gzip
|
||||
import logging
|
||||
import mmap
|
||||
import pathlib
|
||||
|
||||
import numpy
|
||||
|
||||
from . import arrow
|
||||
from .lazy_write import write as write, writefile as writefile
|
||||
from ..utils import is_gzipped
|
||||
from ...library import (
|
||||
ILibraryView,
|
||||
LibraryView,
|
||||
PortsLibraryView,
|
||||
dangling_mode_t,
|
||||
)
|
||||
from ...utils import apply_transforms
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
|
||||
from numpy.typing import NDArray
|
||||
import pyarrow
|
||||
|
||||
from ...pattern import Pattern
|
||||
from ...ports import Port
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StructRange:
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SourceBuffer:
|
||||
path: pathlib.Path
|
||||
data: bytes | mmap.mmap
|
||||
handle: IO[bytes] | None = None
|
||||
|
||||
def raw_slice(self, start: int, end: int) -> bytes:
|
||||
return self.data[start:end]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScanRefs:
|
||||
offsets: NDArray[numpy.integer[Any]]
|
||||
targets: NDArray[numpy.integer[Any]]
|
||||
xy: NDArray[numpy.int32]
|
||||
xy0: NDArray[numpy.int32]
|
||||
xy1: NDArray[numpy.int32]
|
||||
counts: NDArray[numpy.int64]
|
||||
invert_y: NDArray[numpy.bool_ | numpy.bool]
|
||||
angle_rad: NDArray[numpy.floating[Any]]
|
||||
scale: NDArray[numpy.floating[Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CellScan:
|
||||
cell_id: int
|
||||
struct_range: _StructRange
|
||||
ref_start: int
|
||||
ref_stop: int
|
||||
children: set[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScanPayload:
|
||||
libarr: pyarrow.StructScalar
|
||||
library_info: dict[str, Any]
|
||||
cell_names: list[str]
|
||||
cell_order: list[str]
|
||||
cells: dict[str, _CellScan]
|
||||
refs: _ScanRefs
|
||||
|
||||
def is_available() -> bool:
|
||||
return arrow.is_available()
|
||||
|
||||
|
||||
def _open_source_buffer(path: pathlib.Path) -> _SourceBuffer:
|
||||
if is_gzipped(path):
|
||||
with gzip.open(path, mode='rb') as stream:
|
||||
data = stream.read()
|
||||
return _SourceBuffer(path=path, data=data)
|
||||
|
||||
handle = path.open(mode='rb', buffering=0)
|
||||
mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
return _SourceBuffer(path=path, data=mapped, handle=handle)
|
||||
|
||||
|
||||
def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload:
|
||||
library_info = arrow._read_header(libarr)
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
|
||||
cells = libarr['cells']
|
||||
cell_values = cells.values
|
||||
cell_ids = cell_values.field('id').to_numpy()
|
||||
struct_starts = cell_values.field('struct_start_offset').to_numpy()
|
||||
struct_ends = cell_values.field('struct_end_offset').to_numpy()
|
||||
|
||||
refs = cell_values.field('refs')
|
||||
ref_values = refs.values
|
||||
ref_offsets = refs.offsets.to_numpy()
|
||||
targets = ref_values.field('target').to_numpy()
|
||||
xy = arrow._packed_xy_u64_to_pairs(ref_values.field('xy').to_numpy())
|
||||
xy0 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy0').to_numpy())
|
||||
xy1 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy1').to_numpy())
|
||||
counts = arrow._packed_counts_u32_to_pairs(ref_values.field('counts').to_numpy())
|
||||
invert_y = ref_values.field('invert_y').to_numpy(zero_copy_only=False)
|
||||
angle_rad = ref_values.field('angle_rad').to_numpy()
|
||||
scale = ref_values.field('scale').to_numpy()
|
||||
|
||||
ref_payload = _ScanRefs(
|
||||
offsets=ref_offsets,
|
||||
targets=targets,
|
||||
xy=xy,
|
||||
xy0=xy0,
|
||||
xy1=xy1,
|
||||
counts=counts,
|
||||
invert_y=invert_y,
|
||||
angle_rad=angle_rad,
|
||||
scale=scale,
|
||||
)
|
||||
|
||||
cell_order = [cell_names[int(cell_id)] for cell_id in cell_ids]
|
||||
cell_scan: dict[str, _CellScan] = {}
|
||||
for cc, name in enumerate(cell_order):
|
||||
ref_start = int(ref_offsets[cc])
|
||||
ref_stop = int(ref_offsets[cc + 1])
|
||||
children = {
|
||||
cell_names[int(target)]
|
||||
for target in targets[ref_start:ref_stop]
|
||||
}
|
||||
cell_scan[name] = _CellScan(
|
||||
cell_id=int(cell_ids[cc]),
|
||||
struct_range=_StructRange(int(struct_starts[cc]), int(struct_ends[cc])),
|
||||
ref_start=ref_start,
|
||||
ref_stop=ref_stop,
|
||||
children=children,
|
||||
)
|
||||
|
||||
return _ScanPayload(
|
||||
libarr=libarr,
|
||||
library_info=library_info,
|
||||
cell_names=cell_names,
|
||||
cell_order=cell_order,
|
||||
cells=cell_scan,
|
||||
refs=ref_payload,
|
||||
)
|
||||
|
||||
def _make_ref_rows(
|
||||
xy: NDArray[numpy.integer[Any]],
|
||||
angle_rad: NDArray[numpy.floating[Any]],
|
||||
invert_y: NDArray[numpy.bool_ | numpy.bool],
|
||||
scale: NDArray[numpy.floating[Any]],
|
||||
) -> NDArray[numpy.float64]:
|
||||
rows = numpy.empty((len(xy), 5), dtype=float)
|
||||
rows[:, :2] = xy
|
||||
rows[:, 2] = angle_rad
|
||||
rows[:, 3] = invert_y.astype(float)
|
||||
rows[:, 4] = scale
|
||||
return rows
|
||||
|
||||
|
||||
def _expand_aref_row(
|
||||
xy: NDArray[numpy.integer[Any]],
|
||||
xy0: NDArray[numpy.integer[Any]],
|
||||
xy1: NDArray[numpy.integer[Any]],
|
||||
counts: NDArray[numpy.integer[Any]],
|
||||
angle_rad: float,
|
||||
invert_y: bool,
|
||||
scale: float,
|
||||
) -> NDArray[numpy.float64]:
|
||||
a_count = int(counts[0])
|
||||
b_count = int(counts[1])
|
||||
aa, bb = numpy.meshgrid(numpy.arange(a_count), numpy.arange(b_count), indexing='ij')
|
||||
displacements = aa.reshape(-1, 1) * xy0[None, :] + bb.reshape(-1, 1) * xy1[None, :]
|
||||
rows = numpy.empty((displacements.shape[0], 5), dtype=float)
|
||||
rows[:, :2] = xy + displacements
|
||||
rows[:, 2] = angle_rad
|
||||
rows[:, 3] = float(invert_y)
|
||||
rows[:, 4] = scale
|
||||
return rows
|
||||
|
||||
|
||||
class ArrowLibrary(ILibraryView):
|
||||
"""
|
||||
Read-only library backed by the native lazy Arrow scan schema.
|
||||
|
||||
Materializing a cell via `__getitem__` caches a real `Pattern` for that cell.
|
||||
Cached cells are treated as edited for future writes from this module.
|
||||
"""
|
||||
|
||||
path: pathlib.Path
|
||||
library_info: dict[str, Any]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
path: pathlib.Path,
|
||||
payload: _ScanPayload,
|
||||
source: _SourceBuffer,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.library_info = payload.library_info
|
||||
self._payload = payload
|
||||
self._source = source
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename: str | pathlib.Path) -> ArrowLibrary:
|
||||
path = pathlib.Path(filename).expanduser().resolve()
|
||||
source = _open_source_buffer(path)
|
||||
scan_arr = arrow._scan_buffer_to_arrow(source.data)
|
||||
assert len(scan_arr) == 1
|
||||
payload = _extract_scan_payload(scan_arr[0])
|
||||
return cls(path=path, payload=payload, source=source)
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self._materialize_pattern(key, persist=True)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._payload.cell_order)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._payload.cell_order)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._payload.cells
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return tuple(self._payload.cell_order)
|
||||
|
||||
def raw_struct_bytes(self, name: str) -> bytes:
|
||||
struct_range = self._payload.cells[name].struct_range
|
||||
return self._source.raw_slice(struct_range.start, struct_range.end)
|
||||
|
||||
def can_copy_raw_struct(self, name: str) -> bool:
|
||||
return name not in self._cache
|
||||
|
||||
def materialize_many(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool = True,
|
||||
) -> LibraryView:
|
||||
mats = self._materialize_patterns(names, persist=persist)
|
||||
return LibraryView(mats)
|
||||
|
||||
def _materialize_patterns(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool,
|
||||
) -> dict[str, Pattern]:
|
||||
ordered_names = list(dict.fromkeys(names))
|
||||
missing = [name for name in ordered_names if name not in self._payload.cells]
|
||||
if missing:
|
||||
raise KeyError(missing[0])
|
||||
|
||||
materialized: dict[str, Pattern] = {}
|
||||
uncached = [name for name in ordered_names if name not in self._cache]
|
||||
if uncached:
|
||||
ranges = numpy.asarray(
|
||||
[
|
||||
[
|
||||
self._payload.cells[name].struct_range.start,
|
||||
self._payload.cells[name].struct_range.end,
|
||||
]
|
||||
for name in uncached
|
||||
],
|
||||
dtype=numpy.uint64,
|
||||
)
|
||||
arrow_arr = arrow._read_selected_cells_to_arrow(self._source.data, ranges)
|
||||
assert len(arrow_arr) == 1
|
||||
selected_lib, _info = arrow.read_arrow(arrow_arr[0])
|
||||
for name in uncached:
|
||||
pat = selected_lib[name]
|
||||
materialized[name] = pat
|
||||
if persist:
|
||||
self._cache[name] = pat
|
||||
|
||||
for name in ordered_names:
|
||||
if name in self._cache:
|
||||
materialized[name] = self._cache[name]
|
||||
return materialized
|
||||
|
||||
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||
return self._materialize_patterns((name,), persist=persist)[name]
|
||||
|
||||
def _raw_children(self, name: str) -> set[str]:
|
||||
return set(self._payload.cells[name].children)
|
||||
|
||||
def _collect_raw_transforms(self, cell: _CellScan, target_id: int) -> list[NDArray[numpy.float64]]:
|
||||
refs = self._payload.refs
|
||||
start = cell.ref_start
|
||||
stop = cell.ref_stop
|
||||
if stop <= start:
|
||||
return []
|
||||
|
||||
targets = refs.targets[start:stop]
|
||||
mask = targets == target_id
|
||||
if not mask.any():
|
||||
return []
|
||||
|
||||
rows: list[NDArray[numpy.float64]] = []
|
||||
counts = refs.counts[start:stop]
|
||||
unit_mask = mask & (counts[:, 0] == 1) & (counts[:, 1] == 1)
|
||||
if unit_mask.any():
|
||||
rows.append(_make_ref_rows(
|
||||
refs.xy[start:stop][unit_mask],
|
||||
refs.angle_rad[start:stop][unit_mask],
|
||||
refs.invert_y[start:stop][unit_mask],
|
||||
refs.scale[start:stop][unit_mask],
|
||||
))
|
||||
|
||||
aref_indices = numpy.nonzero(mask & ~unit_mask)[0]
|
||||
for idx in aref_indices:
|
||||
abs_idx = start + int(idx)
|
||||
rows.append(_expand_aref_row(
|
||||
xy=refs.xy[abs_idx],
|
||||
xy0=refs.xy0[abs_idx],
|
||||
xy1=refs.xy1[abs_idx],
|
||||
counts=refs.counts[abs_idx],
|
||||
angle_rad=float(refs.angle_rad[abs_idx]),
|
||||
invert_y=bool(refs.invert_y[abs_idx]),
|
||||
scale=float(refs.scale[abs_idx]),
|
||||
))
|
||||
return rows
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
graph: dict[str, set[str]] = {}
|
||||
for name in self._payload.cell_order:
|
||||
if name in self._cache:
|
||||
graph[name] = {child for child, refs in self._cache[name].refs.items() if child is not None and refs}
|
||||
else:
|
||||
graph[name] = self._raw_children(name)
|
||||
|
||||
existing = set(graph)
|
||||
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
||||
if dangling == 'error':
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
||||
return graph
|
||||
if dangling == 'ignore':
|
||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||
|
||||
for child in dangling_refs:
|
||||
graph.setdefault(cast('str', child), set())
|
||||
return graph
|
||||
|
||||
def parent_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||
existing = set(self.keys())
|
||||
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||
for parent, children in child_graph.items():
|
||||
for child in children:
|
||||
if child in existing or dangling == 'include':
|
||||
igraph.setdefault(child, set()).add(parent)
|
||||
if dangling == 'error':
|
||||
raw = self.child_graph(dangling='include')
|
||||
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||
return igraph
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
) -> ILibraryView:
|
||||
if isinstance(tops, str):
|
||||
tops = (tops,)
|
||||
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||
keep |= set(tops)
|
||||
return self.materialize_many(tuple(keep), persist=True)
|
||||
|
||||
def tops(self) -> list[str]:
|
||||
graph = self.child_graph(dangling='ignore')
|
||||
names = set(graph)
|
||||
not_toplevel: set[str] = set()
|
||||
for children in graph.values():
|
||||
not_toplevel |= children
|
||||
return list(names - not_toplevel)
|
||||
|
||||
def with_ports_from_data(
|
||||
self,
|
||||
*,
|
||||
layers: Sequence[tuple[int, int] | int],
|
||||
max_depth: int = 0,
|
||||
skip_subcells: bool = True,
|
||||
ports: Mapping[str, Mapping[str, Port]] | None = None,
|
||||
replace: bool = False,
|
||||
) -> PortsLibraryView:
|
||||
return PortsLibraryView(
|
||||
self,
|
||||
layers=layers,
|
||||
max_depth=max_depth,
|
||||
skip_subcells=skip_subcells,
|
||||
ports=ports,
|
||||
replace=replace,
|
||||
)
|
||||
|
||||
def with_port_overrides(
|
||||
self,
|
||||
ports: Mapping[str, Mapping[str, Port]],
|
||||
*,
|
||||
replace: bool = False,
|
||||
) -> PortsLibraryView:
|
||||
return PortsLibraryView(
|
||||
self,
|
||||
ports=ports,
|
||||
replace=replace,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
data = self._source.data
|
||||
if isinstance(data, mmap.mmap):
|
||||
data.close()
|
||||
if self._source.handle is not None:
|
||||
self._source.handle.close()
|
||||
self._source.handle = None
|
||||
|
||||
def __enter__(self) -> ArrowLibrary:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
||||
if parent_graph is None:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return instances
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return instances
|
||||
|
||||
target_id = self._payload.cells.get(name)
|
||||
for parent in parent_graph.get(name, set()):
|
||||
if parent in self._cache:
|
||||
for ref in self._cache[parent].refs.get(name, []):
|
||||
instances[parent].append(ref.as_transforms())
|
||||
continue
|
||||
|
||||
if target_id is None or parent not in self._payload.cells:
|
||||
continue
|
||||
rows = self._collect_raw_transforms(self._payload.cells[parent], target_id.cell_id)
|
||||
if rows:
|
||||
instances[parent].extend(rows)
|
||||
return instances
|
||||
|
||||
def find_refs_global(
|
||||
self,
|
||||
name: str,
|
||||
order: list[str] | None = None,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
if order is None:
|
||||
order = self.child_order(dangling=graph_mode)
|
||||
if parent_graph is None:
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return {}
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return {}
|
||||
|
||||
self_keys = set(self.keys())
|
||||
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, dangling=dangling).items():
|
||||
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||
|
||||
for next_name in order:
|
||||
if next_name not in transforms:
|
||||
continue
|
||||
if not parent_graph.get(next_name, set()) & self_keys:
|
||||
continue
|
||||
|
||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||
inners = transforms.pop(next_name)
|
||||
for parent, outer in outers.items():
|
||||
outer_tf = numpy.concatenate(outer)
|
||||
for path, inner in inners:
|
||||
combined = apply_transforms(outer_tf, inner)
|
||||
transforms[parent].append(((next_name,) + path, combined))
|
||||
|
||||
result = {}
|
||||
for parent, targets in transforms.items():
|
||||
for path, instances in targets:
|
||||
full_path = (parent,) + path
|
||||
result[full_path] = instances
|
||||
return result
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||
lib = ArrowLibrary.from_file(filename)
|
||||
return lib, lib.library_info
|
||||
|
||||
|
||||
def load_libraryfile(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||
return readfile(filename)
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
"""
|
||||
GDS write helpers for source-backed lazy GDS views.
|
||||
|
||||
The generic mutable overlay and ports-importing view live in `masque.library`.
|
||||
This module preserves source-backed GDS copy-through behavior where possible,
|
||||
falling back to normal pattern serialization when a cell has been materialized
|
||||
or remapped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import IO, TYPE_CHECKING, Any, cast
|
||||
import gzip
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
import klamath
|
||||
|
||||
from . import klamath as gdsii
|
||||
from ..utils import tmpfile
|
||||
from ...error import LibraryError
|
||||
from ...library import ILibraryView, OverlayLibrary
|
||||
from ...library.overlay import _SourceEntry, _materialize_detached_pattern
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ...pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_write_info(
|
||||
library: Mapping[str, Pattern] | ILibraryView,
|
||||
*,
|
||||
meters_per_unit: float | None,
|
||||
logical_units_per_unit: float | None,
|
||||
library_name: str | None,
|
||||
) -> tuple[float, float, str]:
|
||||
if meters_per_unit is not None and logical_units_per_unit is not None and library_name is not None:
|
||||
return meters_per_unit, logical_units_per_unit, library_name
|
||||
|
||||
infos: list[dict[str, Any]] = []
|
||||
stack: list[Mapping[str, Pattern] | ILibraryView] = [library]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
info = getattr(current, 'library_info', None)
|
||||
if isinstance(info, dict):
|
||||
infos.append(info)
|
||||
if isinstance(current, OverlayLibrary):
|
||||
stack.extend(reversed([layer.library for layer in current._layers]))
|
||||
|
||||
if infos:
|
||||
unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos}
|
||||
if len(unit_pairs) > 1:
|
||||
raise LibraryError('Merged lazy GDS sources must have identical units before writing')
|
||||
info = infos[0]
|
||||
meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit
|
||||
logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit
|
||||
name = info['name'] if library_name is None else library_name
|
||||
return meters, logical, name
|
||||
|
||||
if meters_per_unit is None or logical_units_per_unit is None or library_name is None:
|
||||
raise LibraryError('meters_per_unit, logical_units_per_unit, and library_name are required for non-GDS-backed lazy writes')
|
||||
return meters_per_unit, logical_units_per_unit, library_name
|
||||
|
||||
|
||||
def _can_copy_raw_cell(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bool:
|
||||
can_copy = getattr(library, 'can_copy_raw_struct', None)
|
||||
if not callable(can_copy):
|
||||
return False
|
||||
return bool(can_copy(name))
|
||||
|
||||
|
||||
def _raw_struct_bytes(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bytes:
|
||||
reader = getattr(library, 'raw_struct_bytes', None)
|
||||
if not callable(reader):
|
||||
raise TypeError('raw_struct_bytes')
|
||||
return cast('bytes', reader(name))
|
||||
|
||||
|
||||
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
|
||||
elements: list[klamath.elements.Element] = []
|
||||
elements += gdsii._shapes_to_elements(pat.shapes)
|
||||
elements += gdsii._labels_to_texts(pat.labels)
|
||||
elements += gdsii._mrefs_to_grefs(pat.refs)
|
||||
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
|
||||
|
||||
|
||||
def write(
|
||||
library: Mapping[str, Pattern] | ILibraryView,
|
||||
stream: IO[bytes],
|
||||
*,
|
||||
meters_per_unit: float | None = None,
|
||||
logical_units_per_unit: float | None = None,
|
||||
library_name: str | None = None,
|
||||
) -> None:
|
||||
meters_per_unit, logical_units_per_unit, library_name = _get_write_info(
|
||||
library,
|
||||
meters_per_unit=meters_per_unit,
|
||||
logical_units_per_unit=logical_units_per_unit,
|
||||
library_name=library_name,
|
||||
)
|
||||
|
||||
header = klamath.library.FileHeader(
|
||||
name=library_name.encode('ASCII'),
|
||||
user_units_per_db_unit=logical_units_per_unit,
|
||||
meters_per_db_unit=meters_per_unit,
|
||||
)
|
||||
header.write(stream)
|
||||
|
||||
if isinstance(library, OverlayLibrary):
|
||||
for name in library.source_order():
|
||||
entry = library._entries[name]
|
||||
can_copy_overlay = False
|
||||
if isinstance(entry, _SourceEntry) and name == entry.source_name:
|
||||
layer = library._layers[entry.layer_index]
|
||||
children = layer.child_graph.get(entry.source_name, set())
|
||||
can_copy_overlay = (
|
||||
_can_copy_raw_cell(layer.library, entry.source_name)
|
||||
and all(library._effective_target(layer, child) == child for child in children)
|
||||
)
|
||||
if can_copy_overlay:
|
||||
stream.write(_raw_struct_bytes(layer.library, entry.source_name))
|
||||
else:
|
||||
_write_pattern_struct(stream, name, library._materialize_pattern(name, persist=False))
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
return
|
||||
|
||||
if hasattr(library, 'raw_struct_bytes'):
|
||||
for name in library.source_order():
|
||||
if _can_copy_raw_cell(library, name):
|
||||
stream.write(_raw_struct_bytes(library, name))
|
||||
else:
|
||||
_write_pattern_struct(stream, name, _materialize_detached_pattern(cast('ILibraryView', library), name))
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
return
|
||||
|
||||
gdsii.write(cast('Mapping[str, Pattern]', library), stream, meters_per_unit, logical_units_per_unit, library_name)
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern] | ILibraryView,
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
meters_per_unit: float | None = None,
|
||||
logical_units_per_unit: float | None = None,
|
||||
library_name: str | None = None,
|
||||
) -> None:
|
||||
path = pathlib.Path(filename)
|
||||
|
||||
with tmpfile(path) as base_stream:
|
||||
streams: tuple[Any, ...] = (base_stream,)
|
||||
if path.suffix == '.gz':
|
||||
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
|
||||
streams = (stream,) + streams
|
||||
else:
|
||||
stream = base_stream
|
||||
|
||||
try:
|
||||
write(
|
||||
library,
|
||||
stream,
|
||||
meters_per_unit=meters_per_unit,
|
||||
logical_units_per_unit=logical_units_per_unit,
|
||||
library_name=library_name,
|
||||
)
|
||||
finally:
|
||||
for ss in streams:
|
||||
ss.close()
|
||||
|
|
@ -1,626 +0,0 @@
|
|||
"""
|
||||
Synthetic GDS fixture generation for reader/writer performance testing.
|
||||
|
||||
The presets here are intentionally hierarchical and deterministic. They aim to
|
||||
approximate a pair of real-world layout families discussed during GDS reader and
|
||||
writer work:
|
||||
|
||||
* `many_cells`: tens of thousands of cells, moderate reference count, very heavy
|
||||
box usage after flattening, and moderate polygon density.
|
||||
* `many_instances`: a much smaller cell library with very high reference count,
|
||||
similar box density, and far fewer polygons.
|
||||
|
||||
Fixtures are written by streaming structures through `klamath` directly so large
|
||||
benchmark files can be produced without first materializing an equally large
|
||||
`masque.Library` in Python.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
|
||||
import numpy
|
||||
import klamath
|
||||
from klamath import elements
|
||||
|
||||
|
||||
EMPTY_PROPERTIES: dict[int, bytes] = {}
|
||||
METERS_PER_DB_UNIT = 1e-9
|
||||
USER_UNITS_PER_DB_UNIT = 1e-3
|
||||
TOTAL_LAYERS = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixturePreset:
|
||||
name: str
|
||||
total_layers: int
|
||||
box_layers: int
|
||||
heavy_box_layers: int
|
||||
polygon_layers: int
|
||||
box_cells: int
|
||||
poly_cells: int
|
||||
box_wrappers: int
|
||||
poly_wrappers: int
|
||||
box_clusters: int
|
||||
poly_clusters: int
|
||||
box_cluster_refs: int
|
||||
poly_cluster_refs: int
|
||||
top_direct_box_refs: int
|
||||
top_direct_poly_refs: int
|
||||
heavy_boxes_per_cell: int
|
||||
regular_boxes_per_cell: int
|
||||
polygons_per_cell: int
|
||||
path_stride: int
|
||||
text_stride: int
|
||||
box_cluster_array: tuple[int, int]
|
||||
top_box_array: tuple[int, int]
|
||||
poly_cluster_array: tuple[int, int]
|
||||
top_poly_array: tuple[int, int]
|
||||
rare_annotation_stride: int
|
||||
|
||||
|
||||
PRESETS: dict[str, FixturePreset] = {
|
||||
'many_cells': FixturePreset(
|
||||
name='many_cells',
|
||||
total_layers=TOTAL_LAYERS,
|
||||
box_layers=20,
|
||||
heavy_box_layers=3,
|
||||
polygon_layers=20,
|
||||
box_cells=17_000,
|
||||
poly_cells=6_000,
|
||||
box_wrappers=18_000,
|
||||
poly_wrappers=6_000,
|
||||
box_clusters=2_000,
|
||||
poly_clusters=999,
|
||||
box_cluster_refs=24,
|
||||
poly_cluster_refs=16,
|
||||
top_direct_box_refs=21_000,
|
||||
top_direct_poly_refs=7_000,
|
||||
heavy_boxes_per_cell=6,
|
||||
regular_boxes_per_cell=2,
|
||||
polygons_per_cell=50,
|
||||
path_stride=2,
|
||||
text_stride=3,
|
||||
box_cluster_array=(24, 16),
|
||||
top_box_array=(8, 8),
|
||||
poly_cluster_array=(4, 2),
|
||||
top_poly_array=(3, 2),
|
||||
rare_annotation_stride=1_250,
|
||||
),
|
||||
'many_instances': FixturePreset(
|
||||
name='many_instances',
|
||||
total_layers=TOTAL_LAYERS,
|
||||
box_layers=25,
|
||||
heavy_box_layers=3,
|
||||
polygon_layers=10,
|
||||
box_cells=2_500,
|
||||
poly_cells=500,
|
||||
box_wrappers=1_000,
|
||||
poly_wrappers=500,
|
||||
box_clusters=1_000,
|
||||
poly_clusters=499,
|
||||
box_cluster_refs=1_200,
|
||||
poly_cluster_refs=400,
|
||||
top_direct_box_refs=102_001,
|
||||
top_direct_poly_refs=0,
|
||||
heavy_boxes_per_cell=40,
|
||||
regular_boxes_per_cell=16,
|
||||
polygons_per_cell=60,
|
||||
path_stride=1,
|
||||
text_stride=2,
|
||||
box_cluster_array=(1, 1),
|
||||
top_box_array=(1, 1),
|
||||
poly_cluster_array=(1, 1),
|
||||
top_poly_array=(1, 1),
|
||||
rare_annotation_stride=250,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixtureManifest:
|
||||
preset: str
|
||||
scale: float
|
||||
gds_path: str
|
||||
library_name: str
|
||||
cells: int
|
||||
refs: int
|
||||
layers: int
|
||||
box_layers: int
|
||||
heavy_box_layers: list[list[int]]
|
||||
polygon_layers: list[list[int]]
|
||||
hierarchical_boxes_per_heavy_layer: int
|
||||
hierarchical_boxes_per_regular_layer: int
|
||||
hierarchical_polygons_total: int
|
||||
hierarchical_paths_total: int
|
||||
hierarchical_texts_total: int
|
||||
flattened_box_placements: int
|
||||
flattened_poly_placements: int
|
||||
estimated_flat_boxes_per_heavy_layer: int
|
||||
estimated_flat_polygons_per_active_polygon_layer: int
|
||||
|
||||
|
||||
def _scaled_count(value: int, scale: float, minimum: int = 0) -> int:
|
||||
if value == 0:
|
||||
return 0
|
||||
scaled = int(math.ceil(value * scale))
|
||||
return max(minimum, scaled)
|
||||
|
||||
|
||||
def _scaled_preset(preset: FixturePreset, scale: float) -> FixturePreset:
|
||||
if scale <= 0:
|
||||
raise ValueError(f'scale must be positive, got {scale!r}')
|
||||
|
||||
return FixturePreset(
|
||||
name=preset.name,
|
||||
total_layers=preset.total_layers,
|
||||
box_layers=min(preset.box_layers, preset.total_layers),
|
||||
heavy_box_layers=min(preset.heavy_box_layers, preset.box_layers),
|
||||
polygon_layers=min(preset.polygon_layers, preset.total_layers),
|
||||
box_cells=_scaled_count(preset.box_cells, scale, minimum=1),
|
||||
poly_cells=_scaled_count(preset.poly_cells, scale, minimum=1),
|
||||
box_wrappers=_scaled_count(preset.box_wrappers, scale),
|
||||
poly_wrappers=_scaled_count(preset.poly_wrappers, scale),
|
||||
box_clusters=_scaled_count(preset.box_clusters, scale, minimum=1),
|
||||
poly_clusters=_scaled_count(preset.poly_clusters, scale, minimum=1),
|
||||
box_cluster_refs=_scaled_count(preset.box_cluster_refs, scale, minimum=1),
|
||||
poly_cluster_refs=_scaled_count(preset.poly_cluster_refs, scale, minimum=1),
|
||||
top_direct_box_refs=_scaled_count(preset.top_direct_box_refs, scale),
|
||||
top_direct_poly_refs=_scaled_count(preset.top_direct_poly_refs, scale),
|
||||
heavy_boxes_per_cell=max(1, preset.heavy_boxes_per_cell),
|
||||
regular_boxes_per_cell=max(1, preset.regular_boxes_per_cell),
|
||||
polygons_per_cell=max(1, preset.polygons_per_cell),
|
||||
path_stride=max(1, preset.path_stride),
|
||||
text_stride=max(1, preset.text_stride),
|
||||
box_cluster_array=preset.box_cluster_array,
|
||||
top_box_array=preset.top_box_array,
|
||||
poly_cluster_array=preset.poly_cluster_array,
|
||||
top_poly_array=preset.top_poly_array,
|
||||
rare_annotation_stride=max(1, _scaled_count(preset.rare_annotation_stride, scale, minimum=1)),
|
||||
)
|
||||
|
||||
|
||||
def _rect_xy(xmin: int, ymin: int, xmax: int, ymax: int) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]:
|
||||
return numpy.array(
|
||||
[[xmin, ymin], [xmin, ymax], [xmax, ymax], [xmax, ymin], [xmin, ymin]],
|
||||
dtype=numpy.int32,
|
||||
)
|
||||
|
||||
|
||||
def _poly_xy(points: list[tuple[int, int]]) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]:
|
||||
closed = points + [points[0]]
|
||||
return numpy.array(closed, dtype=numpy.int32)
|
||||
|
||||
|
||||
def _sref(
|
||||
target: str,
|
||||
xy: tuple[int, int],
|
||||
properties: dict[int, bytes] | None = None,
|
||||
) -> elements.Reference:
|
||||
return klamath.library.Reference(
|
||||
struct_name=target.encode('ASCII'),
|
||||
invert_y=False,
|
||||
mag=1.0,
|
||||
angle_deg=0.0,
|
||||
xy=numpy.array([xy], dtype=numpy.int32),
|
||||
colrow=None,
|
||||
properties=EMPTY_PROPERTIES if properties is None else properties,
|
||||
)
|
||||
|
||||
|
||||
def _aref(
|
||||
target: str,
|
||||
origin: tuple[int, int],
|
||||
counts: tuple[int, int],
|
||||
step: tuple[int, int],
|
||||
properties: dict[int, bytes] | None = None,
|
||||
) -> elements.Reference:
|
||||
cols, rows = counts
|
||||
dx, dy = step
|
||||
xy = numpy.array(
|
||||
[
|
||||
origin,
|
||||
(origin[0] + cols * dx, origin[1]),
|
||||
(origin[0], origin[1] + rows * dy),
|
||||
],
|
||||
dtype=numpy.int32,
|
||||
)
|
||||
return klamath.library.Reference(
|
||||
struct_name=target.encode('ASCII'),
|
||||
invert_y=False,
|
||||
mag=1.0,
|
||||
angle_deg=0.0,
|
||||
xy=xy,
|
||||
colrow=(cols, rows),
|
||||
properties=EMPTY_PROPERTIES if properties is None else properties,
|
||||
)
|
||||
|
||||
|
||||
def _annotation(index: int) -> dict[int, bytes]:
|
||||
return {1: f'perf-{index}'.encode('ASCII')}
|
||||
|
||||
|
||||
def _make_box_cell(index: int, cfg: FixturePreset) -> list[elements.Element]:
|
||||
cell_elements: list[elements.Element] = []
|
||||
xbase = (index % 17) * 600
|
||||
ybase = (index // 17) * 180
|
||||
|
||||
for layer in range(cfg.heavy_box_layers):
|
||||
for box_idx in range(cfg.heavy_boxes_per_cell):
|
||||
x0 = xbase + box_idx * 22
|
||||
y0 = ybase + layer * 40
|
||||
width = 10 + ((index + box_idx + layer) % 7) * 6
|
||||
height = 10 + ((index * 3 + box_idx + layer) % 5) * 8
|
||||
properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 and box_idx == 0 and layer == 0 else EMPTY_PROPERTIES
|
||||
cell_elements.append(elements.Boundary(
|
||||
layer=(layer, 0),
|
||||
xy=_rect_xy(x0, y0, x0 + width, y0 + height),
|
||||
properties=properties,
|
||||
))
|
||||
|
||||
for layer in range(cfg.heavy_box_layers, cfg.box_layers):
|
||||
for box_idx in range(cfg.regular_boxes_per_cell):
|
||||
x0 = xbase + box_idx * 38
|
||||
y0 = ybase + (layer - cfg.heavy_box_layers) * 28 + 400
|
||||
width = 18 + ((index + layer + box_idx) % 9) * 4
|
||||
height = 12 + ((index + 2 * layer + box_idx) % 6) * 5
|
||||
cell_elements.append(elements.Boundary(
|
||||
layer=(layer, 0),
|
||||
xy=_rect_xy(x0, y0, x0 + width, y0 + height),
|
||||
properties=EMPTY_PROPERTIES,
|
||||
))
|
||||
|
||||
return cell_elements
|
||||
|
||||
|
||||
def _make_poly_cell(index: int, cfg: FixturePreset) -> list[elements.Element]:
|
||||
cell_elements: list[elements.Element] = []
|
||||
xbase = (index % 19) * 900
|
||||
ybase = (index // 19) * 260
|
||||
|
||||
for poly_idx in range(cfg.polygons_per_cell):
|
||||
layer = poly_idx % cfg.polygon_layers
|
||||
dx = xbase + (poly_idx % 5) * 120
|
||||
dy = ybase + (poly_idx // 5) * 80
|
||||
size = 18 + ((index + poly_idx + layer) % 11) * 7
|
||||
points = [
|
||||
(dx, dy),
|
||||
(dx + size, dy + size // 5),
|
||||
(dx + size + size // 3, dy + size),
|
||||
(dx + size // 2, dy + size + size // 2),
|
||||
(dx - size // 4, dy + size // 2),
|
||||
]
|
||||
properties = _annotation(index) if poly_idx == 0 and index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES
|
||||
cell_elements.append(elements.Boundary(
|
||||
layer=(layer, 0),
|
||||
xy=_poly_xy(points),
|
||||
properties=properties,
|
||||
))
|
||||
|
||||
if index % cfg.path_stride == 0:
|
||||
layer = index % cfg.polygon_layers
|
||||
cell_elements.append(elements.Path(
|
||||
layer=(layer, 1),
|
||||
path_type=2,
|
||||
width=12 + (index % 5) * 4,
|
||||
extension=(0, 0),
|
||||
xy=numpy.array(
|
||||
[
|
||||
[xbase, ybase + 900],
|
||||
[xbase + 240, ybase + 930],
|
||||
[xbase + 420, ybase + 960],
|
||||
],
|
||||
dtype=numpy.int32,
|
||||
),
|
||||
properties=EMPTY_PROPERTIES,
|
||||
))
|
||||
|
||||
if index % cfg.text_stride == 0:
|
||||
layer = index % cfg.polygon_layers
|
||||
properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES
|
||||
cell_elements.append(elements.Text(
|
||||
layer=(layer, 2),
|
||||
presentation=0,
|
||||
path_type=0,
|
||||
width=0,
|
||||
invert_y=False,
|
||||
mag=1.0,
|
||||
angle_deg=0.0,
|
||||
xy=numpy.array([[xbase + 64, ybase + 1536]], dtype=numpy.int32),
|
||||
string=f'T{index:05d}'.encode('ASCII'),
|
||||
properties=properties,
|
||||
))
|
||||
|
||||
return cell_elements
|
||||
|
||||
|
||||
def _write_struct(stream: Any, name: str, cell_elements: list[elements.Element]) -> None:
|
||||
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=cell_elements)
|
||||
|
||||
|
||||
def _box_name(index: int) -> str:
|
||||
return f'box_{index:05d}'
|
||||
|
||||
|
||||
def _poly_name(index: int) -> str:
|
||||
return f'poly_{index:05d}'
|
||||
|
||||
|
||||
def _box_wrapper_name(index: int) -> str:
|
||||
return f'box_wrap_{index:05d}'
|
||||
|
||||
|
||||
def _poly_wrapper_name(index: int) -> str:
|
||||
return f'poly_wrap_{index:05d}'
|
||||
|
||||
|
||||
def _box_cluster_name(index: int) -> str:
|
||||
return f'box_cluster_{index:05d}'
|
||||
|
||||
|
||||
def _poly_cluster_name(index: int) -> str:
|
||||
return f'poly_cluster_{index:05d}'
|
||||
|
||||
|
||||
def _write_box_cells(stream: Any, cfg: FixturePreset) -> None:
|
||||
for idx in range(cfg.box_cells):
|
||||
_write_struct(stream, _box_name(idx), _make_box_cell(idx, cfg))
|
||||
|
||||
|
||||
def _write_poly_cells(stream: Any, cfg: FixturePreset) -> None:
|
||||
for idx in range(cfg.poly_cells):
|
||||
_write_struct(stream, _poly_name(idx), _make_poly_cell(idx, cfg))
|
||||
|
||||
|
||||
def _write_wrappers(stream: Any, cfg: FixturePreset) -> None:
|
||||
for idx in range(cfg.box_wrappers):
|
||||
target = _box_name(idx % cfg.box_cells)
|
||||
origin = ((idx % 97) * 2_000, (idx // 97) * 2_000)
|
||||
_write_struct(stream, _box_wrapper_name(idx), [_sref(target, origin)])
|
||||
|
||||
for idx in range(cfg.poly_wrappers):
|
||||
target = _poly_name(idx % cfg.poly_cells)
|
||||
origin = ((idx % 61) * 3_200, (idx // 61) * 3_200)
|
||||
_write_struct(stream, _poly_wrapper_name(idx), [_sref(target, origin)])
|
||||
|
||||
|
||||
def _write_box_clusters(stream: Any, cfg: FixturePreset) -> None:
|
||||
array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4))
|
||||
for idx in range(cfg.box_clusters):
|
||||
cell_elements: list[elements.Element] = []
|
||||
for ref_idx in range(cfg.box_cluster_refs):
|
||||
target = _box_name((idx * cfg.box_cluster_refs + ref_idx) % cfg.box_cells)
|
||||
origin = (
|
||||
(ref_idx % 6) * 48_000,
|
||||
(ref_idx // 6) * 48_000,
|
||||
)
|
||||
if ref_idx < array_refs:
|
||||
cell_elements.append(_aref(target, origin, cfg.box_cluster_array, (720, 900)))
|
||||
else:
|
||||
cell_elements.append(_sref(target, origin))
|
||||
_write_struct(stream, _box_cluster_name(idx), cell_elements)
|
||||
|
||||
|
||||
def _write_poly_clusters(stream: Any, cfg: FixturePreset) -> None:
|
||||
array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2)
|
||||
for idx in range(cfg.poly_clusters):
|
||||
cell_elements: list[elements.Element] = []
|
||||
for ref_idx in range(cfg.poly_cluster_refs):
|
||||
target = _poly_name((idx * cfg.poly_cluster_refs + ref_idx) % cfg.poly_cells)
|
||||
origin = (
|
||||
(ref_idx % 10) * 96_000,
|
||||
(ref_idx // 10) * 96_000,
|
||||
)
|
||||
if ref_idx < array_refs:
|
||||
cell_elements.append(_aref(target, origin, cfg.poly_cluster_array, (12_000, 8_500)))
|
||||
else:
|
||||
cell_elements.append(_sref(target, origin))
|
||||
_write_struct(stream, _poly_cluster_name(idx), cell_elements)
|
||||
|
||||
|
||||
def _top_box_refs(cfg: FixturePreset) -> list[elements.Reference]:
|
||||
refs: list[elements.Reference] = []
|
||||
|
||||
for idx in range(cfg.box_wrappers):
|
||||
refs.append(_sref(
|
||||
_box_wrapper_name(idx),
|
||||
((idx % 240) * 240_000, (idx // 240) * 240_000),
|
||||
))
|
||||
|
||||
for idx in range(cfg.box_clusters):
|
||||
refs.append(_sref(
|
||||
_box_cluster_name(idx),
|
||||
((idx % 100) * 800_000, (idx // 100) * 800_000 + 14_000_000),
|
||||
))
|
||||
|
||||
for idx in range(cfg.top_direct_box_refs):
|
||||
target = _box_name(idx % cfg.box_cells)
|
||||
origin = (
|
||||
(idx % 150) * 160_000,
|
||||
(idx // 150) * 160_000 + 26_000_000,
|
||||
)
|
||||
if cfg.top_box_array == (1, 1):
|
||||
refs.append(_sref(target, origin))
|
||||
else:
|
||||
refs.append(_aref(target, origin, cfg.top_box_array, (1_100, 1_350)))
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def _top_poly_refs(cfg: FixturePreset) -> list[elements.Reference]:
|
||||
refs: list[elements.Reference] = []
|
||||
|
||||
for idx in range(cfg.poly_wrappers):
|
||||
refs.append(_sref(
|
||||
_poly_wrapper_name(idx),
|
||||
((idx % 180) * 360_000, (idx // 180) * 360_000 + 44_000_000),
|
||||
))
|
||||
|
||||
for idx in range(cfg.poly_clusters):
|
||||
refs.append(_sref(
|
||||
_poly_cluster_name(idx),
|
||||
((idx % 70) * 1_100_000, (idx // 70) * 1_100_000 + 58_000_000),
|
||||
))
|
||||
|
||||
for idx in range(cfg.top_direct_poly_refs):
|
||||
target = _poly_name(idx % cfg.poly_cells)
|
||||
origin = (
|
||||
(idx % 110) * 420_000,
|
||||
(idx // 110) * 420_000 + 72_000_000,
|
||||
)
|
||||
if cfg.top_poly_array == (1, 1):
|
||||
refs.append(_sref(target, origin))
|
||||
else:
|
||||
refs.append(_aref(target, origin, cfg.top_poly_array, (16_000, 14_000)))
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def _write_top(stream: Any, cfg: FixturePreset) -> None:
|
||||
cell_elements: list[elements.Element] = []
|
||||
cell_elements.extend(_top_box_refs(cfg))
|
||||
cell_elements.extend(_top_poly_refs(cfg))
|
||||
_write_struct(stream, 'TOP', cell_elements)
|
||||
|
||||
|
||||
def fixture_manifest(path: str | Path, preset: str, scale: float = 1.0) -> FixtureManifest:
|
||||
base = PRESETS[preset]
|
||||
cfg = _scaled_preset(base, scale)
|
||||
|
||||
box_cluster_array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4))
|
||||
box_cluster_array_mult = cfg.box_cluster_array[0] * cfg.box_cluster_array[1]
|
||||
box_cluster_ref_instances = (
|
||||
box_cluster_array_refs * box_cluster_array_mult
|
||||
+ (cfg.box_cluster_refs - box_cluster_array_refs)
|
||||
)
|
||||
poly_cluster_array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2)
|
||||
poly_cluster_array_mult = cfg.poly_cluster_array[0] * cfg.poly_cluster_array[1]
|
||||
poly_cluster_ref_instances = (
|
||||
poly_cluster_array_refs * poly_cluster_array_mult
|
||||
+ (cfg.poly_cluster_refs - poly_cluster_array_refs)
|
||||
)
|
||||
|
||||
flattened_box_placements = (
|
||||
cfg.box_wrappers
|
||||
+ cfg.box_clusters * box_cluster_ref_instances
|
||||
+ cfg.top_direct_box_refs * cfg.top_box_array[0] * cfg.top_box_array[1]
|
||||
)
|
||||
flattened_poly_placements = (
|
||||
cfg.poly_wrappers
|
||||
+ cfg.poly_clusters * poly_cluster_ref_instances
|
||||
+ cfg.top_direct_poly_refs * cfg.top_poly_array[0] * cfg.top_poly_array[1]
|
||||
)
|
||||
polygon_layers = max(1, cfg.polygon_layers)
|
||||
polys_per_layer = (cfg.poly_cells * cfg.polygons_per_cell) // polygon_layers
|
||||
|
||||
return FixtureManifest(
|
||||
preset=cfg.name,
|
||||
scale=scale,
|
||||
gds_path=str(Path(path)),
|
||||
library_name=f'masque-perf-{cfg.name}',
|
||||
cells=cfg.box_cells + cfg.poly_cells + cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters + 1,
|
||||
refs=(
|
||||
cfg.box_wrappers
|
||||
+ cfg.poly_wrappers
|
||||
+ cfg.box_clusters * cfg.box_cluster_refs
|
||||
+ cfg.poly_clusters * cfg.poly_cluster_refs
|
||||
+ cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters
|
||||
+ cfg.top_direct_box_refs + cfg.top_direct_poly_refs
|
||||
),
|
||||
layers=cfg.total_layers,
|
||||
box_layers=cfg.box_layers,
|
||||
heavy_box_layers=[[layer, 0] for layer in range(cfg.heavy_box_layers)],
|
||||
polygon_layers=[[layer, 0] for layer in range(cfg.polygon_layers)],
|
||||
hierarchical_boxes_per_heavy_layer=cfg.box_cells * cfg.heavy_boxes_per_cell,
|
||||
hierarchical_boxes_per_regular_layer=cfg.box_cells * cfg.regular_boxes_per_cell,
|
||||
hierarchical_polygons_total=cfg.poly_cells * cfg.polygons_per_cell,
|
||||
hierarchical_paths_total=(cfg.poly_cells - 1) // cfg.path_stride + 1,
|
||||
hierarchical_texts_total=(cfg.poly_cells - 1) // cfg.text_stride + 1,
|
||||
flattened_box_placements=flattened_box_placements,
|
||||
flattened_poly_placements=flattened_poly_placements,
|
||||
estimated_flat_boxes_per_heavy_layer=flattened_box_placements * cfg.heavy_boxes_per_cell,
|
||||
estimated_flat_polygons_per_active_polygon_layer=flattened_poly_placements * polys_per_layer // cfg.poly_cells if cfg.poly_cells else 0,
|
||||
)
|
||||
|
||||
|
||||
def write_fixture(
|
||||
path: str | Path,
|
||||
*,
|
||||
preset: str,
|
||||
scale: float = 1.0,
|
||||
write_manifest: bool = True,
|
||||
) -> FixtureManifest:
|
||||
if preset not in PRESETS:
|
||||
known = ', '.join(sorted(PRESETS))
|
||||
raise KeyError(f'unknown preset {preset!r}; expected one of: {known}')
|
||||
|
||||
manifest = fixture_manifest(path, preset, scale)
|
||||
cfg = _scaled_preset(PRESETS[preset], scale)
|
||||
output = Path(path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with output.open('wb') as stream:
|
||||
header = klamath.library.FileHeader(
|
||||
name=manifest.library_name.encode('ASCII'),
|
||||
user_units_per_db_unit=USER_UNITS_PER_DB_UNIT,
|
||||
meters_per_db_unit=METERS_PER_DB_UNIT,
|
||||
)
|
||||
header.write(stream)
|
||||
_write_box_cells(stream, cfg)
|
||||
_write_poly_cells(stream, cfg)
|
||||
_write_wrappers(stream, cfg)
|
||||
_write_box_clusters(stream, cfg)
|
||||
_write_poly_clusters(stream, cfg)
|
||||
_write_top(stream, cfg)
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
|
||||
if write_manifest:
|
||||
manifest_path = output.with_suffix(output.suffix + '.json')
|
||||
manifest_path.write_text(json.dumps(asdict(manifest), indent=2, sort_keys=True) + '\n')
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description='Generate synthetic GDS fixtures for GDS reader/writer performance work.')
|
||||
parser.add_argument(
|
||||
'preset',
|
||||
nargs='?',
|
||||
default='many_cells',
|
||||
choices=sorted(PRESETS),
|
||||
help='Fixture family to generate.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'output',
|
||||
nargs='?',
|
||||
help='Output .gds path. Defaults to build/gds_perf/<preset>.gds',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--scale',
|
||||
type=float,
|
||||
default=1.0,
|
||||
help='Scale the preset counts down or up while keeping the same shape mix. Default: 1.0',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-manifest',
|
||||
action='store_true',
|
||||
help='Do not write the sidecar JSON manifest.',
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
output = Path(args.output) if args.output is not None else Path('build/gds_perf') / f'{args.preset}.gds'
|
||||
manifest = write_fixture(output, preset=args.preset, scale=args.scale, write_manifest=not args.no_manifest)
|
||||
print(json.dumps(asdict(manifest), indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -120,10 +120,10 @@ def build(
|
|||
layer, data_type = _mlayer2oas(layer_num)
|
||||
lib.layers += [
|
||||
fatrec.LayerName(
|
||||
nstring = name,
|
||||
layer_interval = (layer, layer),
|
||||
type_interval = (data_type, data_type),
|
||||
is_textlayer = tt,
|
||||
nstring=name,
|
||||
layer_interval=(layer, layer),
|
||||
type_interval=(data_type, data_type),
|
||||
is_textlayer=tt,
|
||||
)
|
||||
for tt in (True, False)]
|
||||
|
||||
|
|
@ -182,8 +182,8 @@ def writefile(
|
|||
Args:
|
||||
library: A {name: Pattern} mapping of patterns to write.
|
||||
filename: Filename to save to.
|
||||
*args: passed to `oasis.build()`
|
||||
**kwargs: passed to `oasis.build()`
|
||||
*args: passed to `oasis.write`
|
||||
**kwargs: passed to `oasis.write`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
|
||||
|
|
@ -213,9 +213,9 @@ def readfile(
|
|||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to load from.
|
||||
*args: passed to `oasis.read()`
|
||||
**kwargs: passed to `oasis.read()`
|
||||
filename: Filename to save to.
|
||||
*args: passed to `oasis.read`
|
||||
**kwargs: passed to `oasis.read`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
|
|
@ -286,11 +286,11 @@ def read(
|
|||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
vertices = vertices,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
annotations = annotations,
|
||||
repetition = repetition,
|
||||
vertices=vertices,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
annotations=annotations,
|
||||
repetition=repetition,
|
||||
)
|
||||
elif isinstance(element, fatrec.Path):
|
||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
|
||||
|
|
@ -310,13 +310,13 @@ def read(
|
|||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.path(
|
||||
vertices = vertices,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
annotations = annotations,
|
||||
width = element.get_half_width() * 2,
|
||||
cap = cap,
|
||||
vertices=vertices,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
width=element.get_half_width() * 2,
|
||||
cap=cap,
|
||||
**path_args,
|
||||
)
|
||||
|
||||
|
|
@ -325,11 +325,11 @@ def read(
|
|||
height = element.get_height()
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||
annotations = annotations,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||
annotations=annotations,
|
||||
)
|
||||
|
||||
elif isinstance(element, fatrec.Trapezoid):
|
||||
|
|
@ -440,11 +440,11 @@ def read(
|
|||
else:
|
||||
string = str_or_ref.string
|
||||
pat.label(
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
annotations = annotations,
|
||||
string = string,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
string=string,
|
||||
)
|
||||
|
||||
else:
|
||||
|
|
@ -549,35 +549,33 @@ def _shapes_to_elements(
|
|||
offset = rint_cast(shape.offset + rep_offset)
|
||||
radius = rint_cast(shape.radius)
|
||||
circle = fatrec.Circle(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
radius = cast('int', radius),
|
||||
x = offset[0],
|
||||
y = offset[1],
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
radius=cast('int', radius),
|
||||
x=offset[0],
|
||||
y=offset[1],
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
)
|
||||
elements.append(circle)
|
||||
elif isinstance(shape, Path):
|
||||
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
|
||||
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
|
||||
half_width = rint_cast(shape.width / 2)
|
||||
path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup
|
||||
if path_type is None:
|
||||
raise PatternError(f'OASIS writer does not support path cap {shape.cap}')
|
||||
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,
|
||||
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:
|
||||
|
|
@ -585,13 +583,13 @@ def _shapes_to_elements(
|
|||
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
||||
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
||||
elements.append(fatrec.Polygon(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
point_list = cast('list[list[int]]', points),
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
point_list=cast('list[list[int]]', points),
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
))
|
||||
return elements
|
||||
|
||||
|
|
@ -608,13 +606,13 @@ def _labels_to_texts(
|
|||
xy = rint_cast(label.offset + rep_offset)
|
||||
properties = annotations_to_properties(label.annotations)
|
||||
texts.append(fatrec.Text(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
string = label.string,
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
layer=layer,
|
||||
datatype=datatype,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
string=label.string,
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
))
|
||||
return texts
|
||||
|
||||
|
|
@ -624,12 +622,10 @@ def repetition_fata2masq(
|
|||
) -> Repetition | None:
|
||||
mrep: Repetition | None
|
||||
if isinstance(rep, fatamorgana.GridRepetition):
|
||||
mrep = Grid(
|
||||
a_vector = rep.a_vector,
|
||||
b_vector = rep.b_vector,
|
||||
a_count = rep.a_count,
|
||||
b_count = rep.b_count,
|
||||
)
|
||||
mrep = Grid(a_vector=rep.a_vector,
|
||||
b_vector=rep.b_vector,
|
||||
a_count=rep.a_count,
|
||||
b_count=rep.b_count)
|
||||
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
||||
displacements = numpy.cumsum(numpy.column_stack((
|
||||
rep.x_displacements,
|
||||
|
|
@ -651,19 +647,14 @@ def repetition_masq2fata(
|
|||
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
||||
if isinstance(rep, Grid):
|
||||
a_vector = rint_cast(rep.a_vector)
|
||||
a_count = int(rep.a_count)
|
||||
if rep.b_count > 1:
|
||||
b_vector = rint_cast(rep.b_vector)
|
||||
b_count = int(rep.b_count)
|
||||
else:
|
||||
b_vector = None
|
||||
b_count = None
|
||||
|
||||
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
|
||||
a_count = rint_cast(rep.a_count)
|
||||
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
|
||||
frep = fatamorgana.GridRepetition(
|
||||
a_vector = a_vector,
|
||||
b_vector = b_vector,
|
||||
a_count = a_count,
|
||||
b_count = b_count,
|
||||
a_vector=cast('list[int]', a_vector),
|
||||
b_vector=cast('list[int] | None', b_vector),
|
||||
a_count=cast('int', a_count),
|
||||
b_count=cast('int | None', b_count),
|
||||
)
|
||||
offset = (0, 0)
|
||||
elif isinstance(rep, Arbitrary):
|
||||
|
|
@ -716,9 +707,13 @@ def properties_to_annotations(
|
|||
string = repr(value)
|
||||
logger.warning(f'Converting property value for key ({key}) to string ({string})')
|
||||
values.append(string)
|
||||
annotations.setdefault(key, []).extend(values)
|
||||
annotations[key] = values
|
||||
return annotations
|
||||
|
||||
properties = [fatrec.Property(key, vals, is_standard=False)
|
||||
for key, vals in annotations.items()]
|
||||
return properties
|
||||
|
||||
|
||||
def check_valid_names(
|
||||
names: Iterable[str],
|
||||
|
|
|
|||
|
|
@ -10,59 +10,25 @@ import svgwrite # type: ignore
|
|||
|
||||
from .utils import mangle_name
|
||||
from .. import Pattern
|
||||
from ..utils import rotation_matrix_2d
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ref_to_svg_transform(ref) -> str:
|
||||
linear = rotation_matrix_2d(ref.rotation) * ref.scale
|
||||
if ref.mirrored:
|
||||
linear = linear @ numpy.diag((1.0, -1.0))
|
||||
|
||||
a = linear[0, 0]
|
||||
b = linear[1, 0]
|
||||
c = linear[0, 1]
|
||||
d = linear[1, 1]
|
||||
e = ref.offset[0]
|
||||
f = ref.offset[1]
|
||||
return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})'
|
||||
|
||||
|
||||
def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]:
|
||||
svg_ids: dict[str, str] = {}
|
||||
seen_ids: set[str] = set()
|
||||
for name in names:
|
||||
base_id = mangle_name(name)
|
||||
svg_id = base_id
|
||||
suffix = 1
|
||||
while svg_id in seen_ids:
|
||||
suffix += 1
|
||||
svg_id = f'{base_id}_{suffix}'
|
||||
seen_ids.add(svg_id)
|
||||
svg_ids[name] = svg_id
|
||||
return svg_ids
|
||||
|
||||
|
||||
def _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]:
|
||||
return {name: pat.deepcopy() for name, pat in library.items()}
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern],
|
||||
top: str,
|
||||
filename: str,
|
||||
custom_attributes: bool = False,
|
||||
annotate_ports: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Write a Pattern to an SVG file, by first calling .polygonize() on a detached
|
||||
materialized copy
|
||||
Write a Pattern to an SVG file, by first calling .polygonize() on it
|
||||
to change the shapes into polygons, and then writing patterns as SVG
|
||||
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
|
||||
as <use> elements.
|
||||
|
||||
Note that this function modifies the Pattern.
|
||||
|
||||
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
|
||||
is written to the relevant elements.
|
||||
|
||||
|
|
@ -74,21 +40,17 @@ def writefile(
|
|||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
library: Mapping of pattern names to patterns.
|
||||
top: Name of the top-level pattern to render.
|
||||
pattern: Pattern to write to file. Modified by this function.
|
||||
filename: Filename to write to.
|
||||
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
||||
SVG elements.
|
||||
annotate_ports: If True, draw an arrow for each port (similar to
|
||||
`Pattern.visualize(..., ports=True)`).
|
||||
"""
|
||||
detached = _detached_library(library)
|
||||
pattern = detached[top]
|
||||
pattern = library[top]
|
||||
|
||||
# Polygonize pattern
|
||||
pattern.polygonize()
|
||||
|
||||
bounds = pattern.get_bounds(library=detached)
|
||||
bounds = pattern.get_bounds(library=library)
|
||||
if bounds is None:
|
||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||
|
|
@ -101,11 +63,10 @@ def writefile(
|
|||
# Create file
|
||||
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
||||
debug=(not custom_attributes))
|
||||
svg_ids = _make_svg_ids(detached)
|
||||
|
||||
# Now create a group for each pattern and add in any Boundary and Use elements
|
||||
for name, pat in detached.items():
|
||||
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
|
||||
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:
|
||||
|
|
@ -118,37 +79,16 @@ def writefile(
|
|||
|
||||
svg_group.add(path)
|
||||
|
||||
if annotate_ports:
|
||||
# Draw arrows for the ports, pointing into the device (per port definition)
|
||||
for port_name, port in pat.ports.items():
|
||||
if port.rotation is not None:
|
||||
p1 = port.offset
|
||||
angle = port.rotation
|
||||
size = 1.0 # arrow size
|
||||
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||
|
||||
# head
|
||||
head_angle = 0.5
|
||||
h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)])
|
||||
h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)])
|
||||
|
||||
line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2)
|
||||
head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2)
|
||||
|
||||
svg_group.add(line)
|
||||
svg_group.add(head)
|
||||
svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green'))
|
||||
|
||||
for target, refs in pat.refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
for ref in refs:
|
||||
transform = _ref_to_svg_transform(ref)
|
||||
use = svg.use(href='#' + svg_ids[target], transform=transform)
|
||||
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)
|
||||
|
||||
svg.defs.add(svg_group)
|
||||
svg.add(svg.use(href='#' + svg_ids[top]))
|
||||
svg.add(svg.use(href='#' + mangle_name(top)))
|
||||
svg.save()
|
||||
|
||||
|
||||
|
|
@ -163,21 +103,21 @@ def writefile_inverted(
|
|||
box and drawing the polygons with reverse vertex order inside it, all within
|
||||
one `<path>` element.
|
||||
|
||||
Note that this function modifies the Pattern.
|
||||
|
||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
library: Mapping of pattern names to patterns.
|
||||
top: Name of the top-level pattern to render.
|
||||
pattern: Pattern to write to file. Modified by this function.
|
||||
filename: Filename to write to.
|
||||
"""
|
||||
detached = _detached_library(library)
|
||||
pattern = detached[top]
|
||||
pattern = library[top]
|
||||
|
||||
# Polygonize and flatten pattern
|
||||
pattern.polygonize().flatten(detached)
|
||||
pattern.polygonize().flatten(library)
|
||||
|
||||
bounds = pattern.get_bounds(library=detached)
|
||||
bounds = pattern.get_bounds(library=library)
|
||||
if bounds is None:
|
||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||
|
|
|
|||
|
|
@ -33,12 +33,6 @@ def preflight(
|
|||
Run a standard set of useful operations and checks, usually done immediately prior
|
||||
to writing to a file (or immediately after reading).
|
||||
|
||||
Note that this helper is not copy-isolating. When `sort=True`, it constructs a new
|
||||
`Library` wrapper around the same `Pattern` objects after sorting them in place, so
|
||||
later mutating preflight steps such as `prune_empty_patterns` and
|
||||
`wrap_repeated_shapes` may still mutate caller-owned patterns. Callers that need
|
||||
isolation should deep-copy the library before calling `preflight()`.
|
||||
|
||||
Args:
|
||||
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
|
||||
Default True. Useful for reproducible builds.
|
||||
|
|
@ -81,8 +75,7 @@ def preflight(
|
|||
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
|
||||
|
||||
if prune_empty_patterns:
|
||||
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
|
||||
pruned = lib.prune_empty(dangling=prune_dangling)
|
||||
pruned = lib.prune_empty()
|
||||
if pruned:
|
||||
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
||||
logger.debug('Pruned: ' + pformat(pruned))
|
||||
|
|
@ -151,11 +144,7 @@ def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]:
|
|||
path = pathlib.Path(path)
|
||||
suffixes = ''.join(path.suffixes)
|
||||
with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream:
|
||||
try:
|
||||
yield tmp_stream
|
||||
except Exception:
|
||||
pathlib.Path(tmp_stream.name).unlink(missing_ok=True)
|
||||
raise
|
||||
yield tmp_stream
|
||||
|
||||
try:
|
||||
shutil.move(tmp_stream.name, path)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray
|
|||
|
||||
from .repetition import Repetition
|
||||
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable
|
||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
|
||||
from .traits import AnnotatableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
|
||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
|
||||
"""
|
||||
A text annotation with a position (but no size; it is not drawn)
|
||||
"""
|
||||
|
|
@ -53,36 +53,17 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
self.repetition = repetition
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
string: str,
|
||||
*,
|
||||
offset: NDArray[numpy.float64],
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._string = string
|
||||
new._offset = offset
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
return type(self)(
|
||||
string=self.string,
|
||||
offset=self.offset.copy(),
|
||||
repetition=self.repetition,
|
||||
annotations=copy.copy(self.annotations),
|
||||
)
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations, memo)
|
||||
return new
|
||||
|
||||
def __lt__(self, other: 'Label') -> bool:
|
||||
|
|
@ -95,8 +76,6 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (
|
||||
self.string == other.string
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
|
|
@ -117,34 +96,10 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
"""
|
||||
pivot = numpy.asarray(pivot, dtype=float)
|
||||
self.translate(-pivot)
|
||||
if self.repetition is not None:
|
||||
self.repetition.rotate(rotation)
|
||||
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
|
||||
"""
|
||||
Extrinsic transformation: Flip the label across a line in the pattern's
|
||||
coordinate system. This affects both the label's offset and its
|
||||
repetition grid.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x).
|
||||
x: Vertical line x=val to mirror across.
|
||||
y: Horizontal line y=val to mirror across.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
|
||||
self.translate(-pivot)
|
||||
if self.repetition is not None:
|
||||
self.repetition.mirror(axis)
|
||||
self.offset[1 - axis] *= -1
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Return the bounds of the label.
|
||||
|
|
|
|||
|
|
@ -1,36 +1,98 @@
|
|||
"""Core library interfaces."""
|
||||
from __future__ import annotations
|
||||
"""
|
||||
Library classes for managing unique name->pattern mappings and deferred loading or execution.
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Mapping, MutableMapping, Sequence
|
||||
from graphlib import CycleError, TopologicalSorter
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, Self, cast
|
||||
import copy
|
||||
Classes include:
|
||||
- `ILibraryView`: Defines a general interface for read-only name->pattern mappings.
|
||||
- `LibraryView`: An implementation of `ILibraryView` backed by an arbitrary `Mapping`.
|
||||
Can be used to wrap any arbitrary `Mapping` to give it all the functionality in `ILibraryView`
|
||||
- `ILibrary`: Defines a general interface for mutable name->pattern mappings.
|
||||
- `Library`: An implementation of `ILibrary` backed by an arbitrary `MutableMapping`.
|
||||
Can be used to wrap any arbitrary `MutableMapping` to give it all the functionality in `ILibrary`.
|
||||
By default, uses a `dict` as the underylingmapping.
|
||||
- `LazyLibrary`: An implementation of `ILibrary` which enables on-demand loading or generation
|
||||
of patterns.
|
||||
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
|
||||
library. Generated with `ILibraryView.abstract_view()`.
|
||||
"""
|
||||
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
|
||||
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
|
||||
import logging
|
||||
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 ..abstract import Abstract
|
||||
from ..error import LibraryError, PatternError
|
||||
from ..pattern import Pattern, map_layers
|
||||
from ..shapes import Polygon, Shape
|
||||
from ..utils import apply_transforms, layer_t
|
||||
from .utils import SINGLE_USE_PREFIX, TreeView, b64suffix, dangling_mode_t, _rename_patterns, visitor_function_t
|
||||
from .error import LibraryError, PatternError
|
||||
from .utils import layer_t, apply_transforms
|
||||
from .shapes import Shape, Polygon
|
||||
from .label import Label
|
||||
from .abstract import Abstract
|
||||
from .pattern import map_layers
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ..label import Label
|
||||
from .pattern import Pattern
|
||||
|
||||
|
||||
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?
|
||||
|
||||
|
||||
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):
|
||||
return name
|
||||
|
||||
stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
|
||||
return lib.get_name(SINGLE_USE_PREFIX + stem)
|
||||
|
||||
|
||||
class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||
"""
|
||||
Interface for a read-only library.
|
||||
|
|
@ -47,7 +109,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
def __repr__(self) -> str:
|
||||
return '<ILibraryView with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
def abstract_view(self) -> AbstractView:
|
||||
def abstract_view(self) -> 'AbstractView':
|
||||
"""
|
||||
Returns:
|
||||
An AbstractView into this library
|
||||
|
|
@ -66,15 +128,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
"""
|
||||
return Abstract(name=name, ports=self[name].ports)
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
"""
|
||||
Return names in the library's preferred source order.
|
||||
|
||||
Source-backed views may override this to preserve on-disk ordering
|
||||
without materializing patterns.
|
||||
"""
|
||||
return tuple(self.keys())
|
||||
|
||||
def dangling_refs(
|
||||
self,
|
||||
tops: str | Sequence[str] | None = None,
|
||||
|
|
@ -124,8 +177,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
|
||||
if isinstance(tops, str):
|
||||
tops = (tops,)
|
||||
tops = set(tops)
|
||||
skip |= tops # don't re-visit tops
|
||||
|
||||
# Get referenced patterns for all tops
|
||||
targets = set()
|
||||
|
|
@ -135,16 +186,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
# Perform recursive lookups, but only once for each name
|
||||
for target in targets - skip:
|
||||
assert target is not None
|
||||
skip.add(target)
|
||||
if target in self:
|
||||
targets |= self.referenced_patterns(target, skip=skip)
|
||||
skip.add(target)
|
||||
|
||||
return targets
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
) -> ILibraryView:
|
||||
) -> 'ILibraryView':
|
||||
"""
|
||||
Return a new `ILibraryView`, containing only the specified patterns and the patterns they
|
||||
reference (recursively).
|
||||
|
|
@ -162,8 +213,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||
keep |= set(tops)
|
||||
|
||||
from .mapping import LibraryView # noqa: PLC0415
|
||||
|
||||
filtered = {kk: vv for kk, vv in self.items() if kk in keep}
|
||||
new = LibraryView(filtered)
|
||||
return new
|
||||
|
|
@ -215,7 +264,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
tops: str | Sequence[str],
|
||||
flatten_ports: bool = False,
|
||||
dangling_ok: bool = False,
|
||||
) -> dict[str, Pattern]:
|
||||
) -> dict[str, 'Pattern']:
|
||||
"""
|
||||
Returns copies of all `tops` patterns with all refs
|
||||
removed and replaced with equivalent shapes.
|
||||
|
|
@ -242,9 +291,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
def flatten_single(name: str) -> None:
|
||||
flattened[name] = None
|
||||
pat = self[name].deepcopy()
|
||||
refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items())
|
||||
|
||||
for target, refs in refs_by_target:
|
||||
for target in pat.refs:
|
||||
if target is None:
|
||||
continue
|
||||
if dangling_ok and target not in self:
|
||||
|
|
@ -255,16 +303,10 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
target_pat = flattened[target]
|
||||
if target_pat is None:
|
||||
raise PatternError(f'Circular reference in {name} to {target}')
|
||||
ports_only = flatten_ports and bool(target_pat.ports)
|
||||
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
|
||||
if target_pat.is_empty(): # avoid some extra allocations
|
||||
continue
|
||||
|
||||
for ref in refs:
|
||||
if flatten_ports and ref.repetition is not None and target_pat.ports:
|
||||
raise PatternError(
|
||||
f'Cannot flatten ports from repeated ref to {target!r}; '
|
||||
'flatten with flatten_ports=False or expand/rename the ports manually first.'
|
||||
)
|
||||
for ref in pat.refs[target]:
|
||||
p = ref.as_pattern(pattern=target_pat)
|
||||
if not flatten_ports:
|
||||
p.ports.clear()
|
||||
|
|
@ -361,7 +403,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}')
|
||||
return tops[0]
|
||||
|
||||
def top_pattern(self) -> Pattern:
|
||||
def top_pattern(self) -> 'Pattern':
|
||||
"""
|
||||
Shorthand for self[self.top()]
|
||||
|
||||
|
|
@ -370,24 +412,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
"""
|
||||
return self[self.top()]
|
||||
|
||||
@staticmethod
|
||||
def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError:
|
||||
dangling_list = sorted(dangling)
|
||||
return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list))
|
||||
|
||||
def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]:
|
||||
existing = set(self.keys())
|
||||
graph: dict[str, set[str]] = {}
|
||||
dangling: set[str] = set()
|
||||
for name, pat in self.items():
|
||||
children = {child for child, refs in pat.refs.items() if child is not None and refs}
|
||||
graph[name] = children
|
||||
dangling |= children - existing
|
||||
return graph, dangling
|
||||
|
||||
def dfs(
|
||||
self,
|
||||
pattern: Pattern,
|
||||
pattern: 'Pattern',
|
||||
visit_before: visitor_function_t | None = None,
|
||||
visit_after: visitor_function_t | None = None,
|
||||
*,
|
||||
|
|
@ -439,11 +466,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
memo = {}
|
||||
|
||||
if transform is None or transform is True:
|
||||
transform = numpy.array([0, 0, 0, 0, 1], dtype=float)
|
||||
transform = numpy.zeros(4)
|
||||
elif transform is not False:
|
||||
transform = numpy.asarray(transform, dtype=float)
|
||||
if transform.size == 4:
|
||||
transform = numpy.append(transform, 1.0)
|
||||
|
||||
original_pattern = pattern
|
||||
|
||||
|
|
@ -465,12 +490,12 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
|
||||
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,
|
||||
pattern=self[target],
|
||||
visit_before=visit_before,
|
||||
visit_after=visit_after,
|
||||
hierarchy=hierarchy + (target,),
|
||||
transform=ref_transform,
|
||||
memo=memo,
|
||||
)
|
||||
|
||||
if visit_after is not None:
|
||||
|
|
@ -486,99 +511,50 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
raise LibraryError('visit_* functions returned a new `Pattern` object'
|
||||
' but no top-level name was provided in `hierarchy`')
|
||||
|
||||
del cast('ILibrary', self)[name]
|
||||
cast('ILibrary', self)[name] = pattern
|
||||
|
||||
return self
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
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).
|
||||
|
||||
Only non-empty ref lists with non-`None` targets are treated as graph edges.
|
||||
|
||||
Args:
|
||||
dangling: How refs to missing targets are handled. `'error'` raises,
|
||||
`'ignore'` drops those edges, and `'include'` exposes them as
|
||||
synthetic leaf nodes.
|
||||
|
||||
Returns:
|
||||
Mapping from pattern name to a set of all pattern names it references.
|
||||
"""
|
||||
graph, dangling_refs = self._raw_child_graph()
|
||||
if dangling == 'error':
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(dangling_refs, 'building child graph')
|
||||
return graph
|
||||
if dangling == 'ignore':
|
||||
existing = set(graph)
|
||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||
|
||||
for target in dangling_refs:
|
||||
graph.setdefault(target, set())
|
||||
graph = {name: set(pat.refs.keys()) for name, pat in self.items()}
|
||||
return graph
|
||||
|
||||
def parent_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
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).
|
||||
|
||||
Args:
|
||||
dangling: How refs to missing targets are handled. `'error'` raises,
|
||||
`'ignore'` drops those targets, and `'include'` adds them as
|
||||
synthetic keys whose values are their existing parents.
|
||||
|
||||
Returns:
|
||||
Mapping from pattern name to a set of all patterns which reference it.
|
||||
"""
|
||||
child_graph, dangling_refs = self._raw_child_graph()
|
||||
if dangling == 'error' and dangling_refs:
|
||||
raise self._dangling_refs_error(dangling_refs, 'building parent graph')
|
||||
|
||||
existing = set(child_graph)
|
||||
igraph: dict[str, set[str]] = {name: set() for name in existing}
|
||||
for parent, children in child_graph.items():
|
||||
for child in children:
|
||||
if child in existing:
|
||||
igraph[child].add(parent)
|
||||
elif dangling == 'include':
|
||||
igraph.setdefault(child, set()).add(parent)
|
||||
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,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> list[str]:
|
||||
def child_order(self) -> list[str]:
|
||||
"""
|
||||
Return a topologically sorted list of graph node names.
|
||||
Return a topologically sorted list of all contained pattern names.
|
||||
Child (referenced) patterns will appear before their parents.
|
||||
|
||||
Args:
|
||||
dangling: Passed to `child_graph()`.
|
||||
|
||||
Return:
|
||||
Topologically sorted list of pattern names.
|
||||
"""
|
||||
try:
|
||||
return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order()))
|
||||
except CycleError as exc:
|
||||
cycle = exc.args[1] if len(exc.args) > 1 else None
|
||||
if cycle is None:
|
||||
raise LibraryError('Cycle found while building child order') from exc
|
||||
raise LibraryError(f'Cycle found while building child order: {cycle}') from exc
|
||||
return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order()))
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
"""
|
||||
Find the location and orientation of all refs pointing to `name`.
|
||||
|
|
@ -591,8 +567,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
The provided graph may be for a superset of `self` (i.e. it may
|
||||
contain additional patterns which are not present in self; they
|
||||
will be ignored).
|
||||
dangling: How refs to missing targets are handled if `parent_graph`
|
||||
is not provided. `'include'` also allows querying missing names.
|
||||
|
||||
Returns:
|
||||
Mapping of {parent_name: transform_list}, where transform_list
|
||||
|
|
@ -601,18 +575,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
"""
|
||||
instances = defaultdict(list)
|
||||
if parent_graph is None:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return instances
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return instances
|
||||
|
||||
for parent in parent_graph.get(name, set()):
|
||||
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]:
|
||||
|
|
@ -625,7 +589,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
name: str,
|
||||
order: list[str] | None = None,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||
"""
|
||||
Find the absolute (top-level) location and orientation of all refs (including
|
||||
|
|
@ -642,28 +605,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
The provided graph may be for a superset of `self` (i.e. it may
|
||||
contain additional patterns which are not present in self; they
|
||||
will be ignored).
|
||||
dangling: How refs to missing targets are handled if `order` or
|
||||
`parent_graph` are not provided. `'include'` also allows
|
||||
querying missing names.
|
||||
|
||||
Returns:
|
||||
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
|
||||
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
|
||||
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
|
||||
"""
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
if order is None:
|
||||
order = self.child_order(dangling=graph_mode)
|
||||
if parent_graph is None:
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return {}
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return {}
|
||||
return {}
|
||||
if order is None:
|
||||
order = self.child_order()
|
||||
if parent_graph is None:
|
||||
parent_graph = self.parent_graph()
|
||||
|
||||
self_keys = set(self.keys())
|
||||
|
||||
|
|
@ -672,16 +625,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
NDArray[numpy.float64]
|
||||
]]]
|
||||
transforms = defaultdict(list)
|
||||
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||
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.get(next_name, set()) & self_keys:
|
||||
if not parent_graph[next_name] & self_keys:
|
||||
continue
|
||||
|
||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||
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:
|
||||
|
|
@ -717,7 +670,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
value: 'Pattern | Callable[[], Pattern]',
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -726,36 +679,9 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
||||
pass
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
other: Abstract | str | Pattern | TreeView,
|
||||
append: bool = False,
|
||||
) -> Abstract | Pattern:
|
||||
"""
|
||||
Resolve another device (name, Abstract, Pattern, or TreeView) into an Abstract or Pattern.
|
||||
If it is a TreeView, it is first added into this library.
|
||||
|
||||
Args:
|
||||
other: The device to resolve.
|
||||
append: If True and `other` is an `Abstract`, returns the full `Pattern` from the library.
|
||||
|
||||
Returns:
|
||||
An `Abstract` or `Pattern` object.
|
||||
"""
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
if not isinstance(other, str | Abstract | Pattern):
|
||||
# We got a TreeView; add it into self and grab its topcell as an Abstract
|
||||
other = self << other
|
||||
|
||||
if isinstance(other, str):
|
||||
other = self.abstract(other)
|
||||
if append and isinstance(other, Abstract):
|
||||
other = self[other.name]
|
||||
return other
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
|
|
@ -774,11 +700,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
if old_name not in self:
|
||||
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||
if old_name == new_name:
|
||||
return self
|
||||
|
||||
self[new_name] = self[old_name]
|
||||
del self[old_name]
|
||||
if move_references:
|
||||
|
|
@ -803,9 +724,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
if old_target == new_target:
|
||||
return self
|
||||
|
||||
for pattern in self.values():
|
||||
if old_target in pattern.refs:
|
||||
pattern.refs[new_target].extend(pattern.refs[old_target])
|
||||
|
|
@ -834,7 +752,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
pattern.labels = map_layers(pattern.labels, map_layer)
|
||||
return self
|
||||
|
||||
def mkpat(self, name: str) -> tuple[str, Pattern]:
|
||||
def mkpat(self, name: str) -> tuple[str, 'Pattern']:
|
||||
"""
|
||||
Convenience method to create an empty pattern, add it to the library,
|
||||
and return both the pattern and name.
|
||||
|
|
@ -845,15 +763,15 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
Returns:
|
||||
(name, pattern) tuple
|
||||
"""
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
from .pattern import Pattern
|
||||
pat = Pattern()
|
||||
self[name] = pat
|
||||
return name, pat
|
||||
|
||||
def add(
|
||||
self,
|
||||
other: Mapping[str, Pattern],
|
||||
rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns,
|
||||
other: Mapping[str, 'Pattern'],
|
||||
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
|
||||
mutate_other: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
|
|
@ -879,25 +797,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
(default).
|
||||
|
||||
Returns:
|
||||
A mapping of `{old_name: new_name}` for all names in `other` which were
|
||||
renamed while being added. Unchanged names are omitted.
|
||||
A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged
|
||||
names map to themselves.
|
||||
|
||||
Raises:
|
||||
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
|
||||
"""
|
||||
from ..pattern import map_targets # noqa: PLC0415
|
||||
from .mapping import Library # noqa: PLC0415
|
||||
|
||||
from .pattern import map_targets
|
||||
duplicates = set(self.keys()) & set(other.keys())
|
||||
|
||||
if not duplicates:
|
||||
if mutate_other:
|
||||
temp = other
|
||||
else:
|
||||
temp = Library(copy.deepcopy(dict(other)))
|
||||
|
||||
for key in temp:
|
||||
self._merge(key, temp, key)
|
||||
for key in other:
|
||||
self._merge(key, other, key)
|
||||
return {}
|
||||
|
||||
if mutate_other:
|
||||
|
|
@ -936,8 +847,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
Raises:
|
||||
LibraryError if there is more than one topcell in `other`.
|
||||
"""
|
||||
from .mapping import LibraryView # noqa: PLC0415
|
||||
|
||||
if len(other) == 1:
|
||||
name = next(iter(other))
|
||||
else:
|
||||
|
|
@ -954,7 +863,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
new_name = rename_map.get(name, name)
|
||||
return new_name
|
||||
|
||||
def __le__(self, other: Mapping[str, Pattern]) -> Abstract:
|
||||
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.
|
||||
|
|
@ -1000,7 +909,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
# This currently simplifies globally (same shape in different patterns is
|
||||
# merged into the same ref target).
|
||||
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
from .pattern import Pattern
|
||||
|
||||
if exclude_types is None:
|
||||
exclude_types = ()
|
||||
|
|
@ -1009,18 +918,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
def label2name(label: tuple) -> str: # noqa: ARG001
|
||||
return self.get_name(SINGLE_USE_PREFIX + 'shape')
|
||||
|
||||
used_names = set(self.keys())
|
||||
|
||||
def reserve_target_name(label: tuple) -> str:
|
||||
base_name = label2name(label)
|
||||
name = base_name
|
||||
ii = sum(1 for nn in used_names if nn.startswith(base_name)) if base_name in used_names else 0
|
||||
while name in used_names or name == '':
|
||||
name = base_name + b64suffix(ii)
|
||||
ii += 1
|
||||
used_names.add(name)
|
||||
return name
|
||||
|
||||
shape_counts: MutableMapping[tuple, int] = defaultdict(int)
|
||||
shape_funcs = {}
|
||||
|
||||
|
|
@ -1037,7 +934,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
shape_counts[label] += 1
|
||||
|
||||
shape_pats = {}
|
||||
target_names = {}
|
||||
for label, count in shape_counts.items():
|
||||
if count < threshold:
|
||||
continue
|
||||
|
|
@ -1046,7 +942,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
shape_pat = Pattern()
|
||||
shape_pat.shapes[label[-1]] += [shape_func()]
|
||||
shape_pats[label] = shape_pat
|
||||
target_names[label] = reserve_target_name(label)
|
||||
|
||||
# ## Second pass ##
|
||||
for pat in tuple(self.values()):
|
||||
|
|
@ -1071,14 +966,14 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
# For repeated shapes, create a `Pattern` holding a normalized shape object,
|
||||
# and add `pat.refs` entries for each occurrence in pat. Also, note down that
|
||||
# we should delete the `pat.shapes` entries for which we made `Ref`s.
|
||||
shapes_to_remove = []
|
||||
for label, shape_entries in shape_table.items():
|
||||
layer = label[-1]
|
||||
target = target_names[label]
|
||||
shapes_to_remove = []
|
||||
target = label2name(label)
|
||||
for ii, values in shape_entries:
|
||||
offset, scale, rotation, mirror_x = values
|
||||
pat.ref(target=target, offset=offset, scale=scale,
|
||||
rotation=rotation, mirrored=mirror_x)
|
||||
rotation=rotation, mirrored=(mirror_x, False))
|
||||
shapes_to_remove.append(ii)
|
||||
|
||||
# Remove any shapes for which we have created refs.
|
||||
|
|
@ -1086,13 +981,13 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
del pat.shapes[layer][ii]
|
||||
|
||||
for ll, pp in shape_pats.items():
|
||||
self[target_names[ll]] = pp
|
||||
self[label2name(ll)] = pp
|
||||
|
||||
return self
|
||||
|
||||
def wrap_repeated_shapes(
|
||||
self,
|
||||
name_func: Callable[[Pattern, Shape | Label], str] | None = None,
|
||||
name_func: Callable[['Pattern', Shape | Label], str] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Wraps all shapes and labels with a non-`None` `repetition` attribute
|
||||
|
|
@ -1107,7 +1002,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
from .pattern import Pattern
|
||||
|
||||
if name_func is None:
|
||||
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
|
||||
|
|
@ -1141,25 +1036,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
|
||||
return self
|
||||
|
||||
def resolve_repeated_refs(self, name: str | None = None) -> Self:
|
||||
"""
|
||||
Expand all repeated references into multiple individual references.
|
||||
Alters the library in-place.
|
||||
|
||||
Args:
|
||||
name: If specified, only resolve repeated refs in this pattern.
|
||||
Otherwise, resolve in all patterns.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if name is not None:
|
||||
self[name].resolve_repeated_refs()
|
||||
else:
|
||||
for pat in self.values():
|
||||
pat.resolve_repeated_refs()
|
||||
return self
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
|
|
@ -1189,19 +1065,17 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
def prune_empty(
|
||||
self,
|
||||
repeat: bool = True,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> set[str]:
|
||||
"""
|
||||
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
|
||||
|
||||
Args:
|
||||
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
|
||||
dangling: Passed to `parent_graph()`.
|
||||
|
||||
Returns:
|
||||
A set containing the names of all deleted patterns
|
||||
"""
|
||||
parent_graph = self.parent_graph(dangling=dangling)
|
||||
parent_graph = self.parent_graph()
|
||||
empty = {name for name, pat in self.items() if pat.is_empty()}
|
||||
trimmed = set()
|
||||
while empty:
|
||||
|
|
@ -1238,6 +1112,246 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
del pat.refs[key]
|
||||
return self
|
||||
|
||||
|
||||
class LibraryView(ILibraryView):
|
||||
"""
|
||||
Default implementation for a read-only library.
|
||||
|
||||
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
|
||||
This library is backed by an arbitrary python object which implements the `Mapping` interface.
|
||||
"""
|
||||
mapping: Mapping[str, 'Pattern']
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: Mapping[str, 'Pattern'],
|
||||
) -> None:
|
||||
self.mapping = mapping
|
||||
|
||||
def __getitem__(self, key: str) -> 'Pattern':
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<LibraryView ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
|
||||
class Library(ILibrary):
|
||||
"""
|
||||
Default implementation for a writeable library.
|
||||
|
||||
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
|
||||
This library is backed by an arbitrary python object which implements the `MutableMapping` interface.
|
||||
"""
|
||||
mapping: MutableMapping[str, 'Pattern']
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: MutableMapping[str, 'Pattern'] | None = None,
|
||||
) -> None:
|
||||
if mapping is None:
|
||||
self.mapping = {}
|
||||
else:
|
||||
self.mapping = mapping
|
||||
|
||||
def __getitem__(self, key: str) -> 'Pattern':
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: 'Pattern | Callable[[], Pattern]',
|
||||
) -> None:
|
||||
if key in self.mapping:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
|
||||
value = value() if callable(value) else value
|
||||
self.mapping[key] = value
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.mapping[key]
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
||||
self[key_self] = other[key_other]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
@classmethod
|
||||
def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']:
|
||||
"""
|
||||
Create a new Library and immediately add a pattern
|
||||
|
||||
Args:
|
||||
name: The name for the new pattern (usually the name of the topcell).
|
||||
|
||||
Returns:
|
||||
The newly created `Library` and the newly created `Pattern`
|
||||
"""
|
||||
from .pattern import Pattern
|
||||
tree = cls()
|
||||
pat = Pattern()
|
||||
tree[name] = pat
|
||||
return tree, pat
|
||||
|
||||
|
||||
class LazyLibrary(ILibrary):
|
||||
"""
|
||||
This class is usually used to create a library of Patterns by mapping names to
|
||||
functions which generate or load the relevant `Pattern` object as-needed.
|
||||
|
||||
TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid?
|
||||
"""
|
||||
mapping: dict[str, Callable[[], 'Pattern']]
|
||||
cache: dict[str, 'Pattern']
|
||||
_lookups_in_progress: set[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.mapping = {}
|
||||
self.cache = {}
|
||||
self._lookups_in_progress = set()
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: 'Pattern | Callable[[], Pattern]',
|
||||
) -> None:
|
||||
if key in self.mapping:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
|
||||
if callable(value):
|
||||
value_func = value
|
||||
else:
|
||||
value_func = lambda: cast('Pattern', value) # noqa: E731
|
||||
|
||||
self.mapping[key] = value_func
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.mapping[key]
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __getitem__(self, key: str) -> 'Pattern':
|
||||
logger.debug(f'loading {key}')
|
||||
if key in self.cache:
|
||||
logger.debug(f'found {key} in cache')
|
||||
return self.cache[key]
|
||||
|
||||
if key in self._lookups_in_progress:
|
||||
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
|
||||
)
|
||||
|
||||
self._lookups_in_progress.add(key)
|
||||
func = self.mapping[key]
|
||||
pat = func()
|
||||
self._lookups_in_progress.remove(key)
|
||||
self.cache[key] = pat
|
||||
return pat
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
||||
if isinstance(other, LazyLibrary):
|
||||
self.mapping[key_self] = other.mapping[key_other]
|
||||
if key_other in other.cache:
|
||||
self.cache[key_self] = other.cache[key_other]
|
||||
else:
|
||||
self[key_self] = other[key_other]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<LazyLibrary with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
move_references: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Rename a pattern.
|
||||
|
||||
Args:
|
||||
old_name: Current name for the pattern
|
||||
new_name: New name for the pattern
|
||||
move_references: Whether to scan all refs in the pattern and
|
||||
move them to point to `new_name` as necessary.
|
||||
Default `False`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self[new_name] = self.mapping[old_name] # copy over function
|
||||
if old_name in self.cache:
|
||||
self.cache[new_name] = self.cache[old_name]
|
||||
del self[old_name]
|
||||
|
||||
if move_references:
|
||||
self.move_references(old_name, new_name)
|
||||
|
||||
return self
|
||||
|
||||
def move_references(self, old_target: str, new_target: str) -> Self:
|
||||
"""
|
||||
Change all references pointing at `old_target` into references pointing at `new_target`.
|
||||
|
||||
Args:
|
||||
old_target: Current reference target
|
||||
new_target: New target for the reference
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.precache()
|
||||
for pattern in self.cache.values():
|
||||
if old_target in pattern.refs:
|
||||
pattern.refs[new_target].extend(pattern.refs[old_target])
|
||||
del pattern.refs[old_target]
|
||||
return self
|
||||
|
||||
def precache(self) -> Self:
|
||||
"""
|
||||
Force all patterns into the cache
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for key in self.mapping:
|
||||
_ = self[key] # want to trigger our own __getitem__
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'LazyLibrary':
|
||||
raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)')
|
||||
|
||||
|
||||
class AbstractView(Mapping[str, Abstract]):
|
||||
"""
|
||||
A read-only mapping from names to `Abstract` objects.
|
||||
|
|
@ -1257,3 +1371,20 @@ class AbstractView(Mapping[str, Abstract]):
|
|||
|
||||
def __len__(self) -> int:
|
||||
return self.library.__len__()
|
||||
|
||||
|
||||
def b64suffix(ii: int) -> str:
|
||||
"""
|
||||
Turn an integer into a base64-equivalent suffix.
|
||||
|
||||
This could be done with base64.b64encode, but this way is faster for many small `ii`.
|
||||
"""
|
||||
def i2a(nn: int) -> str:
|
||||
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]
|
||||
|
||||
parts = ['$', i2a(ii % 64)]
|
||||
ii >>= 6
|
||||
while ii:
|
||||
parts.append(i2a(ii % 64))
|
||||
ii >>= 6
|
||||
return ''.join(parts)
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"""Library classes for managing name-to-pattern mappings."""
|
||||
from .utils import (
|
||||
SINGLE_USE_PREFIX as SINGLE_USE_PREFIX,
|
||||
Tree as Tree,
|
||||
TreeView as TreeView,
|
||||
b64suffix as b64suffix,
|
||||
dangling_mode_t as dangling_mode_t,
|
||||
visitor_function_t as visitor_function_t,
|
||||
)
|
||||
from .base import (
|
||||
AbstractView as AbstractView,
|
||||
ILibrary as ILibrary,
|
||||
ILibraryView as ILibraryView,
|
||||
)
|
||||
from .mapping import (
|
||||
Library as Library,
|
||||
LibraryView as LibraryView,
|
||||
)
|
||||
from .overlay import (
|
||||
OverlayLibrary as OverlayLibrary,
|
||||
PortsLibraryView as PortsLibraryView,
|
||||
)
|
||||
from .build import (
|
||||
BuildLibrary as BuildLibrary,
|
||||
BuildReport as BuildReport,
|
||||
CellProvenance as CellProvenance,
|
||||
cell as cell,
|
||||
)
|
||||
from .lazy import LazyLibrary as LazyLibrary
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
"""Two-phase build library implementation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import wraps
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, Any, Literal, Self, cast
|
||||
import copy
|
||||
|
||||
from ..error import BuildError, LibraryError
|
||||
from .base import ILibrary, ILibraryView
|
||||
from .utils import TreeView, dangling_mode_t, _plan_source_names, _rename_patterns, _source_rename_map
|
||||
from .mapping import Library, LibraryView
|
||||
from .overlay import OverlayLibrary
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
|
||||
from ..abstract import Abstract
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
_ACTIVE_BUILD_SESSIONS: ContextVar[dict[int, _BuildSessionLibrary] | None] = ContextVar(
|
||||
'masque_active_build_sessions',
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CellProvenance:
|
||||
"""
|
||||
Provenance record for one cell in a completed build output.
|
||||
|
||||
Each output name in a `BuildReport` maps to one `CellProvenance`. The
|
||||
record captures both where the cell came from and how its visible name was
|
||||
chosen.
|
||||
|
||||
Attributes:
|
||||
requested_name: First name requested for this cell during the build.
|
||||
kind: Whether the cell came from a declaration, helper emission, or an
|
||||
imported source library.
|
||||
owner_declared_name: Declared cell responsible for this output cell, if
|
||||
any. Imported source cells leave this as `None`.
|
||||
build_chain: Declared-cell dependency chain that was active when the
|
||||
cell was emitted.
|
||||
"""
|
||||
requested_name: str
|
||||
kind: Literal['declared', 'helper', 'source']
|
||||
owner_declared_name: str | None
|
||||
build_chain: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildReport:
|
||||
"""
|
||||
Immutable summary of one `BuildLibrary.validate()` or `.build()` run.
|
||||
|
||||
The report is designed to answer two questions after a build completes:
|
||||
which declared cells depended on which other declared cells, and where each
|
||||
output cell came from.
|
||||
|
||||
Attributes:
|
||||
requested_roots: Roots explicitly requested for the run. A full
|
||||
`build()` uses all declared cells.
|
||||
provenance: Mapping from final output name to provenance metadata.
|
||||
dependency_graph: Declared-cell dependency graph discovered through
|
||||
library-mediated reads and explicit recipe hints.
|
||||
"""
|
||||
requested_roots: tuple[str, ...]
|
||||
provenance: Mapping[str, CellProvenance]
|
||||
dependency_graph: Mapping[str, frozenset[str]]
|
||||
|
||||
@dataclass
|
||||
class _BuildRecipe:
|
||||
""" Captured deferred call to a pattern factory. """
|
||||
func: Callable[..., Pattern]
|
||||
args: tuple[Any, ...]
|
||||
kwargs: dict[str, Any]
|
||||
explicit_dependencies: tuple[str, ...] = ()
|
||||
|
||||
def depends_on(self, *names: str) -> _BuildRecipe:
|
||||
self.explicit_dependencies += tuple(names)
|
||||
return self
|
||||
|
||||
|
||||
def cell(func: Callable[..., Pattern]) -> Callable[..., _BuildRecipe]:
|
||||
"""
|
||||
Wrap a plain pattern factory so calls return deferred build recipes.
|
||||
|
||||
Use as either `cell(fn)(...)` or `@cell`.
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> _BuildRecipe:
|
||||
return _BuildRecipe(func=func, args=args, kwargs=kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BuildCellsView:
|
||||
"""
|
||||
Attribute-based declaration namespace for `BuildLibrary`.
|
||||
|
||||
This is the ergonomic authoring surface exposed as `builder.cells`. It is
|
||||
intentionally write-focused: attribute assignment and deletion register
|
||||
declarations, while attribute reads fail with guidance to build first and
|
||||
use the returned library.
|
||||
"""
|
||||
|
||||
def __init__(self, library: BuildLibrary) -> None:
|
||||
object.__setattr__(self, '_library', library)
|
||||
|
||||
def __getattr__(self, name: str) -> Pattern:
|
||||
raise BuildError(
|
||||
f'BuildLibrary.cells.{name} is write-only during authoring. '
|
||||
'Call build() and index the returned library instead.'
|
||||
)
|
||||
|
||||
def __setattr__(self, name: str, value: Pattern | _BuildRecipe) -> None:
|
||||
if name.startswith('_'):
|
||||
object.__setattr__(self, name, value)
|
||||
return
|
||||
self._library[name] = value
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
if name.startswith('_'):
|
||||
raise AttributeError(name)
|
||||
del self._library[name]
|
||||
|
||||
|
||||
class BuildLibrary(ILibrary):
|
||||
"""
|
||||
Two-phase declaration surface for mixed imported/generated libraries.
|
||||
|
||||
A `BuildLibrary` collects three kinds of inputs:
|
||||
- direct declared `Pattern` objects
|
||||
- deferred recipes created with `cell(...)`
|
||||
- imported source-backed library views added with `add_source(...)`
|
||||
|
||||
The builder itself is not a normal readable library during authoring.
|
||||
Instead, `validate()` and `build()` create a temporary build-session library
|
||||
that recipes can read from and write helper cells into while dependencies
|
||||
are resolved. `build()` then freezes the builder on success and returns the
|
||||
built library plus a `BuildReport`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.cells = BuildCellsView(self)
|
||||
self._frozen = False
|
||||
self._declarations: dict[str, Pattern | _BuildRecipe] = {}
|
||||
self._sources: list[tuple[ILibraryView, dict[str, str]]] = []
|
||||
self._names: dict[str, None] = {}
|
||||
|
||||
def _active_session(self) -> _BuildSessionLibrary | None:
|
||||
sessions = _ACTIVE_BUILD_SESSIONS.get()
|
||||
if sessions is None:
|
||||
return None
|
||||
return sessions.get(id(self))
|
||||
|
||||
def _require_active_session(self, operation: str) -> _BuildSessionLibrary:
|
||||
session = self._active_session()
|
||||
if session is None:
|
||||
raise BuildError(
|
||||
f'BuildLibrary.{operation}() is only available while validate() or build() is running. '
|
||||
'Use the built output library for reads.'
|
||||
)
|
||||
return session
|
||||
|
||||
def _assert_editable(self) -> None:
|
||||
if self._frozen:
|
||||
raise BuildError('This BuildLibrary has already been built successfully and is now frozen.')
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
return iter(session)
|
||||
return iter(self._names)
|
||||
|
||||
def __len__(self) -> int:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
return len(session)
|
||||
return len(self._names)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
return key in session
|
||||
return key in self._names
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self._require_active_session('__getitem__')[key]
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | _BuildRecipe,
|
||||
) -> None:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
session[key] = value
|
||||
return
|
||||
|
||||
self._assert_editable()
|
||||
if key in self._names:
|
||||
raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!')
|
||||
|
||||
if isinstance(value, _BuildRecipe):
|
||||
declaration = value
|
||||
else:
|
||||
if callable(value):
|
||||
raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.')
|
||||
declaration = value
|
||||
|
||||
self._declarations[key] = declaration
|
||||
self._names[key] = None
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
del session[key]
|
||||
return
|
||||
|
||||
self._assert_editable()
|
||||
if key not in self._declarations:
|
||||
raise KeyError(key)
|
||||
del self._declarations[key]
|
||||
del self._names[key]
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
session._merge(key_self, other, key_other)
|
||||
return
|
||||
self[key_self] = copy.deepcopy(other[key_other])
|
||||
|
||||
def add(
|
||||
self,
|
||||
other: Mapping[str, Pattern],
|
||||
rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns,
|
||||
mutate_other: bool = False,
|
||||
) -> dict[str, str]:
|
||||
from ..pattern import map_targets # noqa: PLC0415
|
||||
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
return session.add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||
|
||||
self._assert_editable()
|
||||
|
||||
source_backed = isinstance(other, ILibraryView) and not isinstance(other, Library | LibraryView)
|
||||
if source_backed:
|
||||
if mutate_other:
|
||||
raise BuildError('BuildLibrary.add(..., mutate_other=True) is not supported for source-backed inputs.')
|
||||
return self.add_source(
|
||||
other,
|
||||
rename_theirs = rename_theirs,
|
||||
rename_when = 'conflict',
|
||||
)
|
||||
|
||||
source_order = tuple(other.keys())
|
||||
source_to_visible = _plan_source_names(
|
||||
self,
|
||||
source_order,
|
||||
self._names,
|
||||
rename_theirs = rename_theirs,
|
||||
rename_when = 'conflict',
|
||||
)
|
||||
rename_map = _source_rename_map(source_to_visible)
|
||||
|
||||
if mutate_other:
|
||||
temp = other
|
||||
else:
|
||||
temp = Library(copy.deepcopy(dict(other)))
|
||||
|
||||
for source_name in source_order:
|
||||
visible_name = source_to_visible[source_name]
|
||||
pattern = temp[source_name]
|
||||
if rename_map:
|
||||
pattern.refs = map_targets(
|
||||
pattern.refs,
|
||||
lambda target: cast('dict[str | None, str | None]', rename_map).get(target, target),
|
||||
)
|
||||
self[visible_name] = pattern
|
||||
|
||||
return rename_map
|
||||
|
||||
def __lshift__(self, other: TreeView) -> str:
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
return session << other
|
||||
|
||||
self._assert_editable()
|
||||
if len(other) == 1:
|
||||
name = next(iter(other))
|
||||
elif isinstance(other, ILibraryView) and not isinstance(other, Library | LibraryView):
|
||||
source_order = other.source_order()
|
||||
child_graph = other.child_graph(dangling='include')
|
||||
referenced = set().union(*child_graph.values()) if child_graph else set()
|
||||
tops = [candidate for candidate in source_order if candidate not in referenced]
|
||||
if len(tops) != 1:
|
||||
raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}')
|
||||
name = tops[0]
|
||||
else:
|
||||
return super().__lshift__(other)
|
||||
|
||||
rename_map = self.add(other)
|
||||
return rename_map.get(name, name)
|
||||
|
||||
def __le__(self, other: Mapping[str, Pattern]) -> Abstract:
|
||||
if self._active_session() is not None:
|
||||
return super().__le__(other)
|
||||
raise BuildError('BuildLibrary.__le__() is only available while validate() or build() is running.')
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
move_references: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Rename a helper cell during an active build session.
|
||||
|
||||
During authoring, declared cells must be registered under their
|
||||
intended final names and imported source cells must be renamed through
|
||||
`add_source(...)`.
|
||||
"""
|
||||
session = self._active_session()
|
||||
if session is not None:
|
||||
session.rename(old_name, new_name, move_references=move_references)
|
||||
return self
|
||||
|
||||
self._assert_editable()
|
||||
if old_name == new_name:
|
||||
return self
|
||||
if old_name in self._declarations:
|
||||
raise BuildError(
|
||||
f'Cannot rename declared build cell "{old_name}" during authoring. '
|
||||
'Register it under the intended final name instead.'
|
||||
)
|
||||
if old_name not in self._names:
|
||||
raise LibraryError(f'"{old_name}" does not exist in the builder.')
|
||||
raise BuildError(
|
||||
f'Cannot rename imported source cell "{old_name}" during authoring. '
|
||||
'Choose visible source names with add_source(..., rename_theirs=..., rename_when=...).'
|
||||
)
|
||||
|
||||
def abstract(self, name: str) -> Abstract:
|
||||
return self._require_active_session('abstract').abstract(name)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
other: Abstract | str | Pattern | TreeView,
|
||||
append: bool = False,
|
||||
) -> Abstract | Pattern:
|
||||
return self._require_active_session('resolve').resolve(other, append=append)
|
||||
|
||||
def add_source(
|
||||
self,
|
||||
source: Mapping[str, Pattern] | ILibraryView,
|
||||
*,
|
||||
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
||||
rename_when: Literal['conflict', 'always'] = 'conflict',
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Register an imported source-backed library with the builder.
|
||||
|
||||
The source is not materialized immediately. Its names are scanned once
|
||||
to reserve visible builder names, then the source is read again when a
|
||||
build session starts. The source's cell membership must not be
|
||||
structurally mutated between `add_source()` and `build()`/`validate()`.
|
||||
Source cells may be renamed on entry to avoid collisions with existing
|
||||
declarations or other imported sources.
|
||||
|
||||
Args:
|
||||
rename_theirs: Function used to choose visible names for imported
|
||||
source cells.
|
||||
rename_when: If `'conflict'`, only conflicting names are renamed.
|
||||
If `'always'`, every imported source name is passed through
|
||||
`rename_theirs`.
|
||||
|
||||
Returns:
|
||||
Mapping of `{source_name: visible_name}` for imported names that
|
||||
were renamed while being added.
|
||||
"""
|
||||
if self._active_session() is not None:
|
||||
raise BuildError('BuildLibrary.add_source() is only available while authoring, not during validate() or build().')
|
||||
self._assert_editable()
|
||||
|
||||
view = source if isinstance(source, ILibraryView) else LibraryView(source)
|
||||
source_order = tuple(view.source_order())
|
||||
source_to_visible = _plan_source_names(
|
||||
self,
|
||||
source_order,
|
||||
self._names,
|
||||
rename_theirs = rename_theirs,
|
||||
rename_when = rename_when,
|
||||
)
|
||||
|
||||
self._sources.append((view, dict(source_to_visible)))
|
||||
for source_name in source_order:
|
||||
visible = source_to_visible[source_name]
|
||||
self._names[visible] = None
|
||||
return _source_rename_map(source_to_visible)
|
||||
|
||||
def validate(
|
||||
self,
|
||||
names: Sequence[str] | None = None,
|
||||
*,
|
||||
allow_dangling: bool = False,
|
||||
) -> BuildReport:
|
||||
"""
|
||||
Run the full build logic and return a `BuildReport` without producing output.
|
||||
|
||||
This is a dry run over the same dependency resolution and recipe
|
||||
execution path used by `build()`. Any generated library is discarded
|
||||
after validation completes.
|
||||
"""
|
||||
_session, report = self._run_build(names=names, allow_dangling=allow_dangling)
|
||||
return report
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
output: Literal['overlay', 'library'] = 'overlay',
|
||||
allow_dangling: bool = False,
|
||||
) -> tuple[ILibrary, BuildReport]:
|
||||
"""
|
||||
Materialize declarations and return a usable output library plus report.
|
||||
|
||||
Args:
|
||||
output: `'overlay'` preserves imported source-backed cells where
|
||||
possible, while `'library'` eagerly materializes the full
|
||||
result.
|
||||
allow_dangling: If `False`, fail the build when the completed
|
||||
library still contains dangling references.
|
||||
"""
|
||||
if output not in ('overlay', 'library'):
|
||||
raise ValueError(f'Unknown build output mode: {output!r}')
|
||||
self._assert_editable()
|
||||
session, report = self._run_build(names=None, allow_dangling=allow_dangling)
|
||||
if output == 'library':
|
||||
built_output = session.to_library()
|
||||
else:
|
||||
built_output = session.to_overlay()
|
||||
self._frozen = True
|
||||
return built_output, report
|
||||
|
||||
def _run_build(
|
||||
self,
|
||||
*,
|
||||
names: Sequence[str] | None,
|
||||
allow_dangling: bool,
|
||||
) -> tuple[_BuildSessionLibrary, BuildReport]:
|
||||
roots = tuple(dict.fromkeys(names if names is not None else self._declarations.keys()))
|
||||
unknown = [name for name in roots if name not in self._names]
|
||||
if unknown:
|
||||
raise BuildError(f'Unknown build roots requested: {unknown}')
|
||||
|
||||
session = _BuildSessionLibrary(self)
|
||||
sessions = dict(_ACTIVE_BUILD_SESSIONS.get() or {})
|
||||
sessions[id(self)] = session
|
||||
token = _ACTIVE_BUILD_SESSIONS.set(sessions)
|
||||
try:
|
||||
session.materialize_many(roots)
|
||||
if not allow_dangling:
|
||||
session.child_graph(dangling='error')
|
||||
finally:
|
||||
_ACTIVE_BUILD_SESSIONS.reset(token)
|
||||
|
||||
report = session.build_report(roots)
|
||||
return session, report
|
||||
|
||||
|
||||
class _BuildSessionLibrary(ILibrary):
|
||||
"""
|
||||
Internal overlay-backed library used while a `BuildLibrary` is executing.
|
||||
|
||||
This object provides the mutable-library surface that recipes expect while
|
||||
also tracking declared-cell dependencies, helper-cell provenance, and
|
||||
imported source cells. It exists only for the duration of a validation or
|
||||
build run.
|
||||
"""
|
||||
|
||||
def __init__(self, builder: BuildLibrary) -> None:
|
||||
self._builder = builder
|
||||
self._overlay = OverlayLibrary()
|
||||
self._built: set[str] = set()
|
||||
self._declared_stack: list[str] = []
|
||||
self._names = dict(builder._names)
|
||||
self._provenance: dict[str, CellProvenance] = {}
|
||||
self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set)
|
||||
self._install_sources()
|
||||
|
||||
def _install_sources(self) -> None:
|
||||
for source_library, source_to_visible in self._builder._sources:
|
||||
source_order = source_library.source_order()
|
||||
expected_names = set(source_to_visible)
|
||||
actual_names = set(source_order)
|
||||
if actual_names != expected_names:
|
||||
added_names = sorted(actual_names - expected_names)
|
||||
removed_names = sorted(expected_names - actual_names)
|
||||
detail = []
|
||||
if added_names:
|
||||
detail.append(f'added={added_names}')
|
||||
if removed_names:
|
||||
detail.append(f'removed={removed_names}')
|
||||
raise BuildError(
|
||||
'Imported source library changed after add_source() was called '
|
||||
f'({", ".join(detail)}). '
|
||||
'Do not structurally mutate source libraries between add_source() and build()/validate().'
|
||||
)
|
||||
|
||||
def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = source_to_visible) -> str:
|
||||
return mapping[name]
|
||||
|
||||
self._overlay.add_source(
|
||||
source_library,
|
||||
rename_theirs = rename_source,
|
||||
rename_when = 'always',
|
||||
)
|
||||
|
||||
for source_name in source_order:
|
||||
visible_name = source_to_visible[source_name]
|
||||
self._provenance[visible_name] = CellProvenance(
|
||||
requested_name = source_name,
|
||||
kind = 'source',
|
||||
owner_declared_name = None,
|
||||
build_chain = (),
|
||||
)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._names)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._names)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._names
|
||||
|
||||
def _current_declared(self) -> str | None:
|
||||
if not self._declared_stack:
|
||||
return None
|
||||
return self._declared_stack[-1]
|
||||
|
||||
def _record_dependency(self, target: str) -> None:
|
||||
current = self._current_declared()
|
||||
if current is None or current == target or target not in self._builder._declarations:
|
||||
return
|
||||
self._dependency_graph[current].add(target)
|
||||
|
||||
def _guard_mutable_output_name(self, key: str, *, operation: str) -> None:
|
||||
if key in self._builder._declarations:
|
||||
raise BuildError(f'Cannot {operation} declared build cell "{key}" during an active build session.')
|
||||
|
||||
provenance = self._provenance.get(key)
|
||||
if provenance is not None and provenance.kind == 'source':
|
||||
raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.')
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
move_references: bool = False,
|
||||
) -> Self:
|
||||
if old_name == new_name:
|
||||
return self
|
||||
if old_name not in self._overlay:
|
||||
if old_name in self._builder._declarations:
|
||||
self._guard_mutable_output_name(old_name, operation='rename')
|
||||
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||
|
||||
self._guard_mutable_output_name(old_name, operation='rename')
|
||||
if new_name in self._names:
|
||||
raise LibraryError(f'"{new_name}" already exists in the library.')
|
||||
|
||||
self._overlay.rename(old_name, new_name, move_references=move_references)
|
||||
self._names = {
|
||||
new_name if name == old_name else name: None
|
||||
for name in self._names
|
||||
}
|
||||
|
||||
provenance = self._provenance.pop(old_name)
|
||||
self._provenance[new_name] = provenance
|
||||
return self
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
if key in self._builder._declarations:
|
||||
self._record_dependency(key)
|
||||
self._ensure_declared(key)
|
||||
return self._overlay[key]
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self._overlay:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
current = self._current_declared()
|
||||
if key in self._builder._declarations and key != current:
|
||||
raise LibraryError(f'"{key}" is reserved for a declared cell and cannot be used as a helper name.')
|
||||
|
||||
pattern = value() if callable(value) else value
|
||||
self._overlay[key] = pattern
|
||||
self._names.setdefault(key, None)
|
||||
|
||||
kind: Literal['declared', 'helper']
|
||||
if current is not None and key == current:
|
||||
kind = 'declared'
|
||||
else:
|
||||
kind = 'helper'
|
||||
|
||||
self._provenance[key] = CellProvenance(
|
||||
requested_name = key,
|
||||
kind = kind,
|
||||
owner_declared_name = current if kind == 'helper' else key,
|
||||
build_chain = tuple(self._declared_stack),
|
||||
)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
if key not in self._overlay:
|
||||
if key in self._builder._declarations:
|
||||
self._guard_mutable_output_name(key, operation='delete')
|
||||
raise KeyError(key)
|
||||
|
||||
self._guard_mutable_output_name(key, operation='delete')
|
||||
if key in self._overlay:
|
||||
del self._overlay[key]
|
||||
self._names.pop(key, None)
|
||||
self._provenance.pop(key, None)
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
self[key_self] = copy.deepcopy(other[key_other])
|
||||
|
||||
def add(
|
||||
self,
|
||||
other: Mapping[str, Pattern],
|
||||
rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns,
|
||||
mutate_other: bool = False,
|
||||
) -> dict[str, str]:
|
||||
rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||
|
||||
current = self._current_declared()
|
||||
for old_name, new_name in rename_map.items():
|
||||
if new_name in self._provenance:
|
||||
self._provenance[new_name] = replace(
|
||||
self._provenance[new_name],
|
||||
requested_name = old_name,
|
||||
owner_declared_name = current if current is not None else self._provenance[new_name].owner_declared_name,
|
||||
)
|
||||
return rename_map
|
||||
|
||||
def _wrap_error(self, name: str, exc: Exception) -> BuildError:
|
||||
chain = tuple(self._declared_stack)
|
||||
msg = [f'Failed while building declared cell "{name}"']
|
||||
if chain:
|
||||
msg.append(f'Dependency chain: {" -> ".join(chain)}')
|
||||
msg.append(f'Cause: {exc}')
|
||||
return BuildError('\n'.join(msg))
|
||||
|
||||
def _ensure_named(self, name: str) -> None:
|
||||
if name in self._builder._declarations:
|
||||
self._record_dependency(name)
|
||||
self._ensure_declared(name)
|
||||
return
|
||||
if name in self._overlay:
|
||||
return
|
||||
raise BuildError(f'Missing dependency "{name}"')
|
||||
|
||||
def _ensure_declared(self, name: str) -> None:
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
|
||||
if name in self._built:
|
||||
return
|
||||
if name in self._declared_stack:
|
||||
chain = ' -> '.join(self._declared_stack + [name])
|
||||
raise BuildError(f'Cycle detected while building declared cells: {chain}')
|
||||
|
||||
declaration = self._builder._declarations[name]
|
||||
self._declared_stack.append(name)
|
||||
try:
|
||||
if isinstance(declaration, _BuildRecipe):
|
||||
for dep in declaration.explicit_dependencies:
|
||||
self._ensure_named(dep)
|
||||
pattern = declaration.func(*declaration.args, **declaration.kwargs)
|
||||
if not isinstance(pattern, Pattern):
|
||||
raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') # noqa: TRY301
|
||||
else:
|
||||
pattern = declaration.deepcopy()
|
||||
|
||||
if name in self._overlay:
|
||||
if self._overlay[name] is not pattern:
|
||||
raise BuildError( # noqa: TRY301
|
||||
f'Recipe for "{name}" wrote a different pattern into the session under its own name.'
|
||||
)
|
||||
else:
|
||||
self[name] = pattern
|
||||
self._built.add(name)
|
||||
except Exception as exc:
|
||||
raise self._wrap_error(name, exc) from exc
|
||||
finally:
|
||||
self._declared_stack.pop()
|
||||
|
||||
def materialize_many(self, names: Sequence[str]) -> None:
|
||||
for name in dict.fromkeys(names):
|
||||
self._ensure_named(name)
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return self._overlay.source_order()
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
return self._overlay.child_graph(dangling=dangling)
|
||||
|
||||
def parent_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
return self._overlay.parent_graph(dangling=dangling)
|
||||
|
||||
def build_report(self, requested_roots: Sequence[str]) -> BuildReport:
|
||||
dependency_graph = {
|
||||
name: frozenset(self._dependency_graph.get(name, set()))
|
||||
for name in self._builder._declarations
|
||||
if name in self._dependency_graph or name in requested_roots
|
||||
}
|
||||
return BuildReport(
|
||||
requested_roots = tuple(dict.fromkeys(requested_roots)),
|
||||
provenance = dict(self._provenance),
|
||||
dependency_graph = dependency_graph,
|
||||
)
|
||||
|
||||
def to_overlay(self) -> ILibrary:
|
||||
return self._overlay
|
||||
|
||||
def to_library(self) -> Library:
|
||||
mapping = {name: self._overlay[name] for name in self._overlay.source_order()}
|
||||
return Library(mapping)
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
"""Closure-backed lazy library implementation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, Self, cast
|
||||
import logging
|
||||
|
||||
from ..error import LibraryError
|
||||
from .base import ILibrary
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LazyLibrary(ILibrary):
|
||||
"""
|
||||
This class is usually used to create a library of Patterns by mapping names to
|
||||
functions which generate or load the relevant `Pattern` object as-needed.
|
||||
|
||||
TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid?
|
||||
"""
|
||||
mapping: dict[str, Callable[[], Pattern]]
|
||||
cache: dict[str, Pattern]
|
||||
_lookups_in_progress: list[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.mapping = {}
|
||||
self.cache = {}
|
||||
self._lookups_in_progress = []
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self.mapping:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
|
||||
if callable(value):
|
||||
value_func = value
|
||||
else:
|
||||
value_func = lambda: cast('Pattern', value) # noqa: E731
|
||||
|
||||
self.mapping[key] = value_func
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.mapping[key]
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
logger.debug(f'loading {key}')
|
||||
if key in self.cache:
|
||||
logger.debug(f'found {key} in cache')
|
||||
return self.cache[key]
|
||||
|
||||
if key in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [key])
|
||||
raise LibraryError(
|
||||
f'Detected circular reference or recursive lookup of "{key}".\n'
|
||||
f'Lookup chain: {chain}\n'
|
||||
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
||||
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
|
||||
)
|
||||
|
||||
self._lookups_in_progress.append(key)
|
||||
try:
|
||||
func = self.mapping[key]
|
||||
pat = func()
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
self.cache[key] = pat
|
||||
return pat
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
if isinstance(other, LazyLibrary):
|
||||
self.mapping[key_self] = other.mapping[key_other]
|
||||
if key_other in other.cache:
|
||||
self.cache[key_self] = other.cache[key_other]
|
||||
else:
|
||||
self[key_self] = other[key_other]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<LazyLibrary with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
move_references: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Rename a pattern.
|
||||
|
||||
Args:
|
||||
old_name: Current name for the pattern
|
||||
new_name: New name for the pattern
|
||||
move_references: Whether to scan all refs in the pattern and
|
||||
move them to point to `new_name` as necessary.
|
||||
Default `False`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if old_name not in self.mapping:
|
||||
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||
if old_name == new_name:
|
||||
return self
|
||||
|
||||
self[new_name] = self.mapping[old_name] # copy over function
|
||||
if old_name in self.cache:
|
||||
self.cache[new_name] = self.cache[old_name]
|
||||
del self[old_name]
|
||||
|
||||
if move_references:
|
||||
self.move_references(old_name, new_name)
|
||||
|
||||
return self
|
||||
|
||||
def move_references(self, old_target: str, new_target: str) -> Self:
|
||||
"""
|
||||
Change all references pointing at `old_target` into references pointing at `new_target`.
|
||||
|
||||
Args:
|
||||
old_target: Current reference target
|
||||
new_target: New target for the reference
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if old_target == new_target:
|
||||
return self
|
||||
|
||||
self.precache()
|
||||
for pattern in self.cache.values():
|
||||
if old_target in pattern.refs:
|
||||
pattern.refs[new_target].extend(pattern.refs[old_target])
|
||||
del pattern.refs[old_target]
|
||||
return self
|
||||
|
||||
def precache(self) -> Self:
|
||||
"""
|
||||
Force all patterns into the cache
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for key in self.mapping:
|
||||
_ = self[key] # want to trigger our own __getitem__
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> LazyLibrary:
|
||||
raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)')
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"""Concrete mapping-backed library implementations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, Self
|
||||
|
||||
from ..error import LibraryError
|
||||
from .base import ILibrary, ILibraryView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping, MutableMapping
|
||||
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
class LibraryView(ILibraryView):
|
||||
"""
|
||||
Default implementation for a read-only library.
|
||||
|
||||
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
|
||||
This library is backed by an arbitrary python object which implements the `Mapping` interface.
|
||||
"""
|
||||
mapping: Mapping[str, Pattern]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: Mapping[str, Pattern],
|
||||
) -> None:
|
||||
self.mapping = mapping
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<LibraryView ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
|
||||
class Library(ILibrary):
|
||||
"""
|
||||
Default implementation for a writeable library.
|
||||
|
||||
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
|
||||
This library is backed by an arbitrary python object which implements the `MutableMapping` interface.
|
||||
"""
|
||||
mapping: MutableMapping[str, Pattern]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: MutableMapping[str, Pattern] | None = None,
|
||||
) -> None:
|
||||
if mapping is None:
|
||||
self.mapping = {}
|
||||
else:
|
||||
self.mapping = mapping
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self.mapping:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
|
||||
value = value() if callable(value) else value
|
||||
self.mapping[key] = value
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.mapping[key]
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
self[key_self] = other[key_other]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
@classmethod
|
||||
def mktree(cls: type[Self], name: str) -> tuple[Self, Pattern]:
|
||||
"""
|
||||
Create a new Library and immediately add a pattern
|
||||
|
||||
Args:
|
||||
name: The name for the new pattern (usually the name of the topcell).
|
||||
|
||||
Returns:
|
||||
The newly created `Library` and the newly created `Pattern`
|
||||
"""
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
tree = cls()
|
||||
pat = Pattern()
|
||||
tree[name] = pat
|
||||
return tree, pat
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
"""Overlay and ports-importing library views."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
import copy
|
||||
|
||||
import numpy
|
||||
|
||||
from ..error import LibraryError
|
||||
from ..pattern import Pattern, map_targets
|
||||
from ..utils import apply_transforms, layer_t
|
||||
from .base import ILibrary, ILibraryView
|
||||
from .utils import dangling_mode_t, _plan_source_names, _source_rename_map
|
||||
from .mapping import LibraryView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SourceLayer:
|
||||
""" One imported source layer tracked by an `OverlayLibrary`. """
|
||||
library: ILibraryView
|
||||
source_to_visible: dict[str, str]
|
||||
visible_to_source: dict[str, str]
|
||||
child_graph: dict[str, set[str]]
|
||||
order: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SourceEntry:
|
||||
""" Reference to a single visible source-backed cell in an overlay. """
|
||||
layer_index: int
|
||||
source_name: str
|
||||
|
||||
|
||||
def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern:
|
||||
func = getattr(view, '_materialize_pattern', None)
|
||||
if callable(func):
|
||||
return cast('Pattern', func(name, persist=False))
|
||||
return view[name].deepcopy()
|
||||
|
||||
|
||||
class PortsLibraryView(ILibraryView):
|
||||
"""
|
||||
Read-only view which imports or applies ports on first materialization.
|
||||
|
||||
The wrapped source remains untouched; this view owns a separate processed
|
||||
cache so direct-copy workflows can continue to use the raw source view.
|
||||
|
||||
Graph queries, source ordering, and copy-through capabilities are delegated
|
||||
to the wrapped source whenever possible, while `__getitem__` and
|
||||
`materialize_many()` return port-imported patterns.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ILibraryView,
|
||||
*,
|
||||
layers: Sequence[layer_t] = (),
|
||||
max_depth: int = 0,
|
||||
skip_subcells: bool = True,
|
||||
ports: Mapping[str, Mapping[str, Port]] | None = None,
|
||||
replace: bool = False,
|
||||
) -> None:
|
||||
self._source = source
|
||||
self._layers = tuple(layers)
|
||||
self._max_depth = max_depth
|
||||
self._skip_subcells = skip_subcells
|
||||
self._ports = {
|
||||
name: copy.deepcopy(dict(cell_ports))
|
||||
for name, cell_ports in (ports or {}).items()
|
||||
}
|
||||
self._replace = replace
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
self._lookups_in_progress: list[str] = []
|
||||
if hasattr(source, 'library_info'):
|
||||
self.library_info = cast('dict[str, Any]', source.library_info)
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self._materialize_pattern(key, persist=True)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._source)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._source)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._source
|
||||
|
||||
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||
from ..utils.ports2data import data_to_ports # noqa: PLC0415
|
||||
|
||||
if name in self._cache:
|
||||
return self._cache[name]
|
||||
|
||||
if name in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||
raise LibraryError(
|
||||
f'Detected circular reference or recursive lookup of "{name}".\n'
|
||||
f'Lookup chain: {chain}\n'
|
||||
'This may be caused by an invalid (cyclical) reference, or buggy code.'
|
||||
)
|
||||
|
||||
self._lookups_in_progress.append(name)
|
||||
try:
|
||||
pat = _materialize_detached_pattern(self._source, name)
|
||||
if self._layers:
|
||||
pat = data_to_ports(
|
||||
layers=self._layers,
|
||||
library=self,
|
||||
pattern=pat,
|
||||
name=name,
|
||||
max_depth=self._max_depth,
|
||||
skip_subcells=self._skip_subcells,
|
||||
)
|
||||
if name in self._ports:
|
||||
ports = copy.deepcopy(self._ports[name])
|
||||
if self._replace:
|
||||
pat.ports = ports
|
||||
else:
|
||||
pat.ports.update(ports)
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
|
||||
if persist:
|
||||
self._cache[name] = pat
|
||||
return pat
|
||||
|
||||
def materialize_many(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool = True,
|
||||
) -> LibraryView:
|
||||
mats = {
|
||||
name: self._materialize_pattern(name, persist=persist)
|
||||
for name in dict.fromkeys(names)
|
||||
}
|
||||
return LibraryView(mats)
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return self._source.source_order()
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
return self._source.child_graph(dangling=dangling)
|
||||
|
||||
def parent_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
return self._source.parent_graph(dangling=dangling)
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
) -> ILibraryView:
|
||||
if isinstance(tops, str):
|
||||
tops = (tops,)
|
||||
keep = cast('set[str]', self._source.referenced_patterns(tops) - {None})
|
||||
keep |= set(tops)
|
||||
return self.materialize_many(tuple(keep), persist=True)
|
||||
|
||||
def tops(self) -> list[str]:
|
||||
return self._source.tops()
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
finder = getattr(self._source, 'find_refs_local', None)
|
||||
if callable(finder):
|
||||
return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling))
|
||||
return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
|
||||
|
||||
def find_refs_global(
|
||||
self,
|
||||
name: str,
|
||||
order: list[str] | None = None,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||
finder = getattr(self._source, 'find_refs_global', None)
|
||||
if callable(finder):
|
||||
return cast(
|
||||
'dict[tuple[str, ...], NDArray[numpy.float64]]',
|
||||
finder(name, order=order, parent_graph=parent_graph, dangling=dangling),
|
||||
)
|
||||
return super().find_refs_global(name, order=order, parent_graph=parent_graph, dangling=dangling)
|
||||
|
||||
def raw_struct_bytes(self, name: str) -> bytes:
|
||||
reader = getattr(self._source, 'raw_struct_bytes', None)
|
||||
if not callable(reader):
|
||||
raise TypeError('raw_struct_bytes')
|
||||
return cast('bytes', reader(name))
|
||||
|
||||
def can_copy_raw_struct(self, name: str) -> bool:
|
||||
can_copy = getattr(self._source, 'can_copy_raw_struct', None)
|
||||
if not callable(can_copy):
|
||||
return False
|
||||
return bool(can_copy(name))
|
||||
|
||||
def close(self) -> None:
|
||||
closer = getattr(self._source, 'close', None)
|
||||
if callable(closer):
|
||||
closer()
|
||||
|
||||
def __enter__(self) -> PortsLibraryView:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class OverlayLibrary(ILibrary):
|
||||
"""
|
||||
Mutable overlay over one or more source libraries.
|
||||
|
||||
Source-backed cells remain lazy until accessed through `__getitem__`, at
|
||||
which point that visible cell is promoted into an overlay-owned materialized
|
||||
`Pattern`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._layers: list[_SourceLayer] = []
|
||||
self._entries: dict[str, Pattern | _SourceEntry] = {}
|
||||
self._order: list[str] = []
|
||||
self._target_remap: dict[str, str] = {}
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return (name for name in self._order if name in self._entries)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._entries
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self._materialize_pattern(key, persist=True)
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self._entries:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
pattern = value() if callable(value) else value
|
||||
self._entries[key] = pattern
|
||||
if key not in self._order:
|
||||
self._order.append(key)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
if key not in self._entries:
|
||||
raise KeyError(key)
|
||||
del self._entries[key]
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
self[key_self] = copy.deepcopy(other[key_other])
|
||||
|
||||
def add_source(
|
||||
self,
|
||||
source: Mapping[str, Pattern] | ILibraryView,
|
||||
*,
|
||||
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
||||
rename_when: Literal['conflict', 'always'] = 'conflict',
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Add a source-backed library layer.
|
||||
|
||||
Args:
|
||||
rename_theirs: Function used to choose visible names for imported
|
||||
source cells.
|
||||
rename_when: If `'conflict'`, only conflicting names are renamed.
|
||||
If `'always'`, every imported source name is passed through
|
||||
`rename_theirs`.
|
||||
"""
|
||||
view = source if isinstance(source, ILibraryView) else LibraryView(source)
|
||||
source_order = list(view.source_order())
|
||||
child_graph = view.child_graph(dangling='include')
|
||||
|
||||
source_to_visible = _plan_source_names(
|
||||
self,
|
||||
source_order,
|
||||
self._entries,
|
||||
rename_theirs = rename_theirs,
|
||||
rename_when = rename_when,
|
||||
)
|
||||
visible_to_source = {visible: source_name for source_name, visible in source_to_visible.items()}
|
||||
|
||||
layer = _SourceLayer(
|
||||
library=view,
|
||||
source_to_visible=source_to_visible,
|
||||
visible_to_source=visible_to_source,
|
||||
child_graph=child_graph,
|
||||
order=[source_to_visible[name] for name in source_order],
|
||||
)
|
||||
layer_index = len(self._layers)
|
||||
self._layers.append(layer)
|
||||
|
||||
for source_name, visible_name in source_to_visible.items():
|
||||
self._entries[visible_name] = _SourceEntry(layer_index=layer_index, source_name=source_name)
|
||||
if visible_name not in self._order:
|
||||
self._order.append(visible_name)
|
||||
|
||||
return _source_rename_map(source_to_visible)
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
move_references: bool = False,
|
||||
) -> OverlayLibrary:
|
||||
if old_name not in self._entries:
|
||||
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||
if old_name == new_name:
|
||||
return self
|
||||
if new_name in self._entries:
|
||||
raise LibraryError(f'"{new_name}" already exists in the library.')
|
||||
|
||||
entry = self._entries.pop(old_name)
|
||||
self._entries[new_name] = entry
|
||||
if isinstance(entry, _SourceEntry):
|
||||
layer = self._layers[entry.layer_index]
|
||||
layer.source_to_visible[entry.source_name] = new_name
|
||||
del layer.visible_to_source[old_name]
|
||||
layer.visible_to_source[new_name] = entry.source_name
|
||||
|
||||
idx = self._order.index(old_name)
|
||||
self._order[idx] = new_name
|
||||
|
||||
if move_references:
|
||||
self.move_references(old_name, new_name)
|
||||
return self
|
||||
|
||||
def _resolve_target(self, target: str) -> str:
|
||||
seen: set[str] = set()
|
||||
current = target
|
||||
while current in self._target_remap:
|
||||
if current in seen:
|
||||
raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}')
|
||||
seen.add(current)
|
||||
current = self._target_remap[current]
|
||||
return current
|
||||
|
||||
def _set_target_remap(self, old_target: str, new_target: str) -> None:
|
||||
resolved_new = self._resolve_target(new_target)
|
||||
if resolved_new == old_target:
|
||||
raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}')
|
||||
self._target_remap[old_target] = resolved_new
|
||||
for key in list(self._target_remap):
|
||||
self._target_remap[key] = self._resolve_target(self._target_remap[key])
|
||||
|
||||
def move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
|
||||
if old_target == new_target:
|
||||
return self
|
||||
self._set_target_remap(old_target, new_target)
|
||||
for entry in list(self._entries.values()):
|
||||
if isinstance(entry, Pattern) and old_target in entry.refs:
|
||||
entry.refs[new_target].extend(entry.refs[old_target])
|
||||
del entry.refs[old_target]
|
||||
return self
|
||||
|
||||
def _effective_target(self, layer: _SourceLayer, target: str) -> str:
|
||||
visible = layer.source_to_visible.get(target, target)
|
||||
return self._resolve_target(visible)
|
||||
|
||||
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||
if name not in self._entries:
|
||||
raise KeyError(name)
|
||||
entry = self._entries[name]
|
||||
if isinstance(entry, Pattern):
|
||||
return entry
|
||||
|
||||
layer = self._layers[entry.layer_index]
|
||||
source_pat = layer.library[entry.source_name].deepcopy()
|
||||
|
||||
def remap(target: str | None) -> str | None:
|
||||
return None if target is None else self._effective_target(layer, target)
|
||||
|
||||
if source_pat.refs:
|
||||
source_pat.refs = map_targets(source_pat.refs, remap)
|
||||
pat = source_pat
|
||||
if persist:
|
||||
self._entries[name] = pat
|
||||
return pat
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
graph: dict[str, set[str]] = {}
|
||||
for name in self._order:
|
||||
if name not in self._entries:
|
||||
continue
|
||||
entry = self._entries[name]
|
||||
if isinstance(entry, Pattern):
|
||||
graph[name] = {child for child, refs in entry.refs.items() if child is not None and refs}
|
||||
continue
|
||||
layer = self._layers[entry.layer_index]
|
||||
children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())}
|
||||
graph[name] = children
|
||||
|
||||
existing = set(graph)
|
||||
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
||||
if dangling == 'error':
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
||||
return graph
|
||||
if dangling == 'ignore':
|
||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||
|
||||
for child in dangling_refs:
|
||||
graph.setdefault(cast('str', child), set())
|
||||
return graph
|
||||
|
||||
def parent_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||
existing = set(self.keys())
|
||||
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||
for parent, children in child_graph.items():
|
||||
for child in children:
|
||||
if child in existing or dangling == 'include':
|
||||
igraph.setdefault(child, set()).add(parent)
|
||||
if dangling == 'error':
|
||||
raw = self.child_graph(dangling='include')
|
||||
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||
return igraph
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
) -> ILibraryView:
|
||||
if isinstance(tops, str):
|
||||
tops = (tops,)
|
||||
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||
keep |= set(tops)
|
||||
return LibraryView({name: self[name] for name in keep})
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
||||
if parent_graph is None:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return instances
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return instances
|
||||
|
||||
for parent in parent_graph.get(name, set()):
|
||||
pat = self._materialize_pattern(parent, persist=False)
|
||||
for ref in pat.refs.get(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,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
if order is None:
|
||||
order = self.child_order(dangling=graph_mode)
|
||||
if parent_graph is None:
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return {}
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return {}
|
||||
|
||||
self_keys = set(self.keys())
|
||||
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, dangling=dangling).items():
|
||||
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||
|
||||
for next_name in order:
|
||||
if next_name not in transforms:
|
||||
continue
|
||||
if not parent_graph.get(next_name, set()) & self_keys:
|
||||
continue
|
||||
|
||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||
inners = transforms.pop(next_name)
|
||||
for parent, outer in outers.items():
|
||||
outer_tf = numpy.concatenate(outer)
|
||||
for path, inner in inners:
|
||||
combined = apply_transforms(outer_tf, inner)
|
||||
transforms[parent].append(((next_name,) + path, combined))
|
||||
|
||||
result = {}
|
||||
for parent, targets in transforms.items():
|
||||
for path, instances in targets:
|
||||
result[(parent,) + path] = instances
|
||||
return result
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return tuple(name for name in self._order if name in self._entries)
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
"""Shared types and helpers for library implementations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
|
||||
from collections.abc import Callable, Container, Mapping, MutableMapping, Sequence
|
||||
|
||||
from ..error import LibraryError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ..pattern import Pattern
|
||||
from .base import ILibraryView
|
||||
|
||||
|
||||
class visitor_function_t(Protocol):
|
||||
""" Signature for `Library.dfs()` visitor functions. """
|
||||
def __call__(
|
||||
self,
|
||||
pattern: Pattern,
|
||||
hierarchy: tuple[str | None, ...],
|
||||
memo: dict,
|
||||
transform: NDArray[numpy.float64] | Literal[False],
|
||||
) -> Pattern:
|
||||
...
|
||||
|
||||
|
||||
TreeView: TypeAlias = Mapping[str, 'Pattern']
|
||||
""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """
|
||||
|
||||
Tree: TypeAlias = MutableMapping[str, 'Pattern']
|
||||
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
|
||||
|
||||
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
||||
""" How helpers should handle refs whose targets are not present in the library. """
|
||||
SINGLE_USE_PREFIX = '_'
|
||||
"""
|
||||
Names starting with this prefix are assumed to refer to single-use patterns,
|
||||
which may be renamed automatically by `ILibrary.add()` (via
|
||||
`rename_theirs=_rename_patterns()` )
|
||||
"""
|
||||
# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
|
||||
|
||||
|
||||
def _rename_patterns(lib: ILibraryView, name: str) -> str:
|
||||
"""
|
||||
The default `rename_theirs` function for `ILibrary.add`.
|
||||
|
||||
Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as
|
||||
"one-offs" for which name conflicts should be automatically resolved.
|
||||
Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)`
|
||||
where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`.
|
||||
Names lacking the prefix are directly returned (not renamed).
|
||||
|
||||
Args:
|
||||
lib: The library into which `name` is to be added (but is presumed to conflict)
|
||||
name: The original name, to be modified
|
||||
|
||||
Returns:
|
||||
The new name, not guaranteed to be conflict-free!
|
||||
"""
|
||||
if not name.startswith(SINGLE_USE_PREFIX):
|
||||
return name
|
||||
|
||||
stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
|
||||
return lib.get_name(SINGLE_USE_PREFIX + stem)
|
||||
|
||||
|
||||
def _plan_source_names(
|
||||
target: ILibraryView,
|
||||
source_order: Sequence[str],
|
||||
existing_names: Container[str],
|
||||
*,
|
||||
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
||||
rename_when: Literal['conflict', 'always'] = 'conflict',
|
||||
) -> dict[str, str]:
|
||||
if rename_when not in ('conflict', 'always'):
|
||||
raise ValueError(f'Unknown source rename mode: {rename_when!r}')
|
||||
if rename_when == 'always' and rename_theirs is None:
|
||||
raise TypeError('rename_theirs is required when rename_when="always"')
|
||||
|
||||
source_to_visible: dict[str, str] = {}
|
||||
visible_names: set[str] = set()
|
||||
|
||||
for name in source_order:
|
||||
visible = name
|
||||
if rename_when == 'always':
|
||||
assert rename_theirs is not None
|
||||
visible = rename_theirs(target, name)
|
||||
elif visible in existing_names or visible in visible_names:
|
||||
if rename_theirs is None:
|
||||
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
||||
visible = rename_theirs(target, name)
|
||||
if visible in existing_names or visible in visible_names:
|
||||
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
||||
source_to_visible[name] = visible
|
||||
visible_names.add(visible)
|
||||
|
||||
return source_to_visible
|
||||
|
||||
|
||||
def _source_rename_map(source_to_visible: Mapping[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
source_name: visible_name
|
||||
for source_name, visible_name in source_to_visible.items()
|
||||
if source_name != visible_name
|
||||
}
|
||||
|
||||
def b64suffix(ii: int) -> str:
|
||||
"""
|
||||
Turn an integer into a base64-equivalent suffix.
|
||||
|
||||
This could be done with base64.b64encode, but this way is faster for many small `ii`.
|
||||
"""
|
||||
def i2a(nn: int) -> str:
|
||||
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]
|
||||
|
||||
parts = ['$', i2a(ii % 64)]
|
||||
ii >>= 6
|
||||
while ii:
|
||||
parts.append(i2a(ii % 64))
|
||||
ii >>= 6
|
||||
return ''.join(parts)
|
||||
|
|
@ -26,7 +26,6 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab
|
|||
from .ports import Port, PortList
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -38,8 +37,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
or provide equivalent functions.
|
||||
|
||||
`Pattern` also stores a dict of `Port`s, which can be used to "snap" together points.
|
||||
See `Pattern.plug()` and `Pattern.place()`, as well as `builder.Pather`
|
||||
and `ports.PortsList`.
|
||||
See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes
|
||||
`builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`.
|
||||
|
||||
For convenience, ports can be read out using square brackets:
|
||||
- `pattern['A'] == Port((0, 0), 0)`
|
||||
|
|
@ -172,8 +171,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
return s
|
||||
|
||||
def __copy__(self) -> 'Pattern':
|
||||
logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! '
|
||||
'Consider using .deepcopy() if this was not intended.')
|
||||
logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!')
|
||||
new = Pattern(
|
||||
annotations=copy.deepcopy(self.annotations),
|
||||
ports=copy.deepcopy(self.ports),
|
||||
|
|
@ -200,7 +198,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
def __lt__(self, other: 'Pattern') -> bool:
|
||||
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
|
||||
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
||||
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
||||
|
||||
|
|
@ -214,7 +212,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
return refs_ours < refs_theirs
|
||||
|
||||
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
|
||||
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
||||
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
||||
|
||||
|
|
@ -223,21 +221,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
for _, _, layer in self_layerkeys:
|
||||
shapes_ours = tuple(sorted(self.shapes[layer]))
|
||||
shapes_theirs = tuple(sorted(other.shapes[layer]))
|
||||
shapes_theirs = tuple(sorted(self.shapes[layer]))
|
||||
if shapes_ours != shapes_theirs:
|
||||
return shapes_ours < shapes_theirs
|
||||
|
||||
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
|
||||
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
||||
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
||||
|
||||
if self_txtlayerkeys != other_txtlayerkeys:
|
||||
return self_txtlayerkeys < other_txtlayerkeys
|
||||
|
||||
for _, _, layer in self_txtlayerkeys:
|
||||
for _, _, layer in self_layerkeys:
|
||||
labels_ours = tuple(sorted(self.labels[layer]))
|
||||
labels_theirs = tuple(sorted(other.labels[layer]))
|
||||
labels_theirs = tuple(sorted(self.labels[layer]))
|
||||
if labels_ours != labels_theirs:
|
||||
return labels_ours < labels_theirs
|
||||
|
||||
|
|
@ -254,7 +252,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
return False
|
||||
|
||||
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
|
||||
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
||||
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
||||
|
||||
|
|
@ -268,7 +266,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
return False
|
||||
|
||||
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
|
||||
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
||||
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
||||
|
||||
|
|
@ -277,21 +275,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
for _, _, layer in self_layerkeys:
|
||||
shapes_ours = tuple(sorted(self.shapes[layer]))
|
||||
shapes_theirs = tuple(sorted(other.shapes[layer]))
|
||||
shapes_theirs = tuple(sorted(self.shapes[layer]))
|
||||
if shapes_ours != shapes_theirs:
|
||||
return False
|
||||
|
||||
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
|
||||
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
||||
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
||||
|
||||
if self_txtlayerkeys != other_txtlayerkeys:
|
||||
return False
|
||||
|
||||
for _, _, layer in self_txtlayerkeys:
|
||||
for _, _, layer in self_layerkeys:
|
||||
labels_ours = tuple(sorted(self.labels[layer]))
|
||||
labels_theirs = tuple(sorted(other.labels[layer]))
|
||||
labels_theirs = tuple(sorted(self.labels[layer]))
|
||||
if labels_ours != labels_theirs:
|
||||
return False
|
||||
|
||||
|
|
@ -349,16 +347,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
annotation_conflicts: set[str] = set()
|
||||
if other_pattern.annotations is not None and self.annotations is not None:
|
||||
annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys())
|
||||
if annotation_conflicts:
|
||||
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||
|
||||
port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys())
|
||||
if port_conflicts:
|
||||
raise PatternError(f'Port names overlap: {port_conflicts}')
|
||||
|
||||
for target, rseq in other_pattern.refs.items():
|
||||
self.refs[target].extend(rseq)
|
||||
for layer, sseq in other_pattern.shapes.items():
|
||||
|
|
@ -369,7 +357,14 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
if other_pattern.annotations is not None:
|
||||
if self.annotations is None:
|
||||
self.annotations = {}
|
||||
annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys())
|
||||
if annotation_conflicts:
|
||||
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||
self.annotations.update(other_pattern.annotations)
|
||||
|
||||
port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys())
|
||||
if port_conflicts:
|
||||
raise PatternError(f'Port names overlap: {port_conflicts}')
|
||||
self.ports.update(other_pattern.ports)
|
||||
|
||||
return self
|
||||
|
|
@ -504,61 +499,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
]
|
||||
return polys
|
||||
|
||||
def layer_as_polygons(
|
||||
self,
|
||||
layer: layer_t,
|
||||
flatten: bool = True,
|
||||
library: Mapping[str, 'Pattern'] | None = None,
|
||||
) -> list[Polygon]:
|
||||
"""
|
||||
Collect all geometry effectively on a given layer as a list of polygons.
|
||||
|
||||
If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`.
|
||||
`Repetition` objects are expanded, and non-polygon shapes are converted
|
||||
to `Polygon` approximations.
|
||||
|
||||
Args:
|
||||
layer: The layer to collect geometry from.
|
||||
flatten: If `True`, include geometry from referenced patterns.
|
||||
library: Required if `flatten=True` to resolve references.
|
||||
|
||||
Returns:
|
||||
A list of `Polygon` objects.
|
||||
"""
|
||||
if flatten and self.has_refs() and library is None:
|
||||
raise PatternError("Must provide a library to layer_as_polygons() when flatten=True")
|
||||
|
||||
polys: list[Polygon] = []
|
||||
|
||||
# Local shapes
|
||||
for shape in self.shapes.get(layer, []):
|
||||
for p in shape.to_polygons():
|
||||
# expand repetitions
|
||||
if p.repetition is not None:
|
||||
for offset in p.repetition.displacements:
|
||||
polys.append(p.deepcopy().translate(offset).set_repetition(None))
|
||||
else:
|
||||
polys.append(p.deepcopy())
|
||||
|
||||
if flatten and self.has_refs():
|
||||
assert library is not None
|
||||
for target, refs in self.refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
target_pat = library[target]
|
||||
for ref in refs:
|
||||
# Get polygons from target pattern on the same layer
|
||||
ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library)
|
||||
# Apply ref transformations
|
||||
for p in ref_polys:
|
||||
p_pat = ref.as_pattern(Pattern(shapes={layer: [p]}))
|
||||
# as_pattern expands repetition of the ref itself
|
||||
# but we need to pull the polygons back out
|
||||
for p_transformed in p_pat.shapes[layer]:
|
||||
polys.append(cast('Polygon', p_transformed))
|
||||
|
||||
return polys
|
||||
|
||||
def referenced_patterns(self) -> set[str | None]:
|
||||
"""
|
||||
Get all pattern namers referenced by this pattern. Non-recursive.
|
||||
|
|
@ -695,7 +635,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
"""
|
||||
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
|
||||
cast('Positionable', entry).translate(offset)
|
||||
self._log_bulk_update(f"translate({offset!r})")
|
||||
return self
|
||||
|
||||
def scale_elements(self, c: float) -> Self:
|
||||
|
|
@ -749,9 +688,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||
"""
|
||||
Extrinsic transformation: Rotate the Pattern around the a location in the
|
||||
container's coordinate system. This affects all elements' offsets and
|
||||
their repetition grids.
|
||||
Rotate the Pattern around the a location.
|
||||
|
||||
Args:
|
||||
pivot: (x, y) location to rotate around
|
||||
|
|
@ -765,14 +702,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
self.rotate_elements(rotation)
|
||||
self.rotate_element_centers(rotation)
|
||||
self.translate_elements(+pivot)
|
||||
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
|
||||
return self
|
||||
|
||||
def rotate_element_centers(self, rotation: float) -> Self:
|
||||
"""
|
||||
Extrinsic transformation part: Rotate the offsets and repetition grids of all
|
||||
shapes, labels, refs, and ports around (0, 0) in the container's
|
||||
coordinate system.
|
||||
Rotate the offsets of all shapes, labels, refs, and ports around (0, 0)
|
||||
|
||||
Args:
|
||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||
|
|
@ -783,15 +717,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||
old_offset = cast('Positionable', entry).offset
|
||||
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset)
|
||||
if isinstance(entry, Repeatable) and entry.repetition is not None:
|
||||
entry.repetition.rotate(rotation)
|
||||
return self
|
||||
|
||||
def rotate_elements(self, rotation: float) -> Self:
|
||||
"""
|
||||
Intrinsic transformation part: Rotate each shape, ref, label, and port around its
|
||||
origin (offset) in the container's coordinate system. This does NOT
|
||||
affect their repetition grids.
|
||||
Rotate each shape, ref, and port around its origin (offset)
|
||||
|
||||
Args:
|
||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||
|
|
@ -799,61 +729,54 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||
if isinstance(entry, Rotatable):
|
||||
entry.rotate(rotation)
|
||||
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
|
||||
cast('Rotatable', entry).rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror_element_centers(self, axis: int = 0) -> Self:
|
||||
def mirror_element_centers(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Extrinsic transformation part: Mirror the offsets and repetition grids of all
|
||||
shapes, labels, refs, and ports relative to the container's origin.
|
||||
Mirror the offsets of all shapes, labels, and refs across an axis
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across (0: x-axis, 1: y-axis)
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||
cast('Positionable', entry).offset[1 - axis] *= -1
|
||||
if isinstance(entry, Repeatable) and entry.repetition is not None:
|
||||
entry.repetition.mirror(axis)
|
||||
cast('Positionable', entry).offset[1 - across_axis] *= -1
|
||||
return self
|
||||
|
||||
def mirror_elements(self, axis: int = 0) -> Self:
|
||||
def mirror_elements(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Intrinsic transformation part: Mirror each shape, ref, label, and port relative
|
||||
to its offset. This does NOT affect their repetition grids.
|
||||
Mirror each shape, ref, and pattern across an axis, relative
|
||||
to its offset
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across
|
||||
0: mirror across x axis (flip y),
|
||||
1: mirror across y axis (flip x)
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||
if isinstance(entry, Mirrorable):
|
||||
entry.mirror(axis=axis)
|
||||
self._log_bulk_update(f"mirror_elements({axis})")
|
||||
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
|
||||
cast('Mirrorable', entry).mirror(across_axis)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
def mirror(self, across_axis: int = 0) -> Self:
|
||||
"""
|
||||
Extrinsic transformation: Mirror the Pattern across an axis through its origin.
|
||||
This affects all elements' offsets and their internal orientations.
|
||||
Mirror the Pattern across an axis
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||
across_axis: Axis to mirror across
|
||||
(0: mirror across x axis, 1: mirror across y axis)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.mirror_elements(axis=axis)
|
||||
self.mirror_element_centers(axis=axis)
|
||||
self._log_bulk_update(f"mirror({axis})")
|
||||
self.mirror_elements(across_axis)
|
||||
self.mirror_element_centers(across_axis)
|
||||
return self
|
||||
|
||||
def copy(self) -> Self:
|
||||
|
|
@ -864,7 +787,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
Returns:
|
||||
A deep copy of the current Pattern.
|
||||
"""
|
||||
return self.deepcopy()
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def deepcopy(self) -> Self:
|
||||
"""
|
||||
|
|
@ -1007,28 +930,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
del self.labels[layer]
|
||||
return self
|
||||
|
||||
def resolve_repeated_refs(self) -> Self:
|
||||
"""
|
||||
Expand all repeated references into multiple individual references.
|
||||
Alters the current pattern in-place.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list)
|
||||
for target, rseq in self.refs.items():
|
||||
for ref in rseq:
|
||||
if ref.repetition is None:
|
||||
new_refs[target].append(ref)
|
||||
else:
|
||||
for dd in ref.repetition.displacements:
|
||||
new_ref = ref.deepcopy()
|
||||
new_ref.offset = ref.offset + dd
|
||||
new_ref.repetition = None
|
||||
new_refs[target].append(new_ref)
|
||||
self.refs = new_refs
|
||||
return self
|
||||
|
||||
def prune_refs(self) -> Self:
|
||||
"""
|
||||
Remove empty ref lists in `self.refs`.
|
||||
|
|
@ -1080,16 +981,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
|
||||
if target_pat is None:
|
||||
raise PatternError(f'Circular reference in {name} to {target}')
|
||||
ports_only = flatten_ports and bool(target_pat.ports)
|
||||
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
|
||||
if target_pat.is_empty(): # avoid some extra allocations
|
||||
continue
|
||||
|
||||
for ref in refs:
|
||||
if flatten_ports and ref.repetition is not None and target_pat.ports:
|
||||
raise PatternError(
|
||||
f'Cannot flatten ports from repeated ref to {target!r}; '
|
||||
'flatten with flatten_ports=False or expand/rename the ports manually first.'
|
||||
)
|
||||
p = ref.as_pattern(pattern=target_pat)
|
||||
if not flatten_ports:
|
||||
p.ports.clear()
|
||||
|
|
@ -1108,8 +1003,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
line_color: str = 'k',
|
||||
fill_color: str = 'none',
|
||||
overdraw: bool = False,
|
||||
filename: str | None = None,
|
||||
ports: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Draw a picture of the Pattern and wait for the user to inspect it
|
||||
|
|
@ -1120,18 +1013,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
klayout or a different GDS viewer!
|
||||
|
||||
Args:
|
||||
library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`.
|
||||
offset: Coordinates to offset by before drawing.
|
||||
line_color: Outlines are drawn with this color.
|
||||
fill_color: Interiors are drawn with this color.
|
||||
overdraw: Whether to create a new figure or draw on a pre-existing one.
|
||||
filename: If provided, save the figure to this file instead of showing it.
|
||||
ports: If True, annotate the plot with arrows representing the ports.
|
||||
offset: Coordinates to offset by before drawing
|
||||
line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
|
||||
fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
|
||||
overdraw: Whether to create a new figure or draw on a pre-existing one
|
||||
"""
|
||||
# TODO: add text labels to visualize()
|
||||
try:
|
||||
from matplotlib import pyplot # type: ignore #noqa: PLC0415
|
||||
import matplotlib.collections # type: ignore #noqa: PLC0415
|
||||
from matplotlib import pyplot # type: ignore
|
||||
import matplotlib.collections # type: ignore
|
||||
except ImportError:
|
||||
logger.exception('Pattern.visualize() depends on matplotlib!\n'
|
||||
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
|
||||
|
|
@ -1140,155 +1030,48 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
if self.has_refs() and library is None:
|
||||
raise PatternError('Must provide a library when visualizing a pattern with refs')
|
||||
|
||||
# Cache for {Pattern object ID: List of local polygon vertex arrays}
|
||||
# Polygons are stored relative to the pattern's origin (offset included)
|
||||
poly_cache: dict[int, list[NDArray[numpy.float64]]] = {}
|
||||
offset = numpy.asarray(offset, dtype=float)
|
||||
|
||||
def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]:
|
||||
pid = id(pat)
|
||||
if pid not in poly_cache:
|
||||
polys = []
|
||||
for shape in chain.from_iterable(pat.shapes.values()):
|
||||
for ss in shape.to_polygons():
|
||||
# Shape.to_polygons() returns Polygons with their own offsets and vertices.
|
||||
# We need to expand any shape-level repetition here.
|
||||
v_base = ss.vertices + ss.offset
|
||||
if ss.repetition is not None:
|
||||
for disp in ss.repetition.displacements:
|
||||
polys.append(v_base + disp)
|
||||
else:
|
||||
polys.append(v_base)
|
||||
poly_cache[pid] = polys
|
||||
return poly_cache[pid]
|
||||
|
||||
all_polygons: list[NDArray[numpy.float64]] = []
|
||||
port_info: list[tuple[str, NDArray[numpy.float64], float]] = []
|
||||
|
||||
def collect_polys_recursive(
|
||||
pat: 'Pattern',
|
||||
c_offset: NDArray[numpy.float64],
|
||||
c_rotation: float,
|
||||
c_mirrored: bool,
|
||||
c_scale: float,
|
||||
) -> None:
|
||||
# Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale)
|
||||
|
||||
# 1. Transform and collect local polygons
|
||||
local_polys = get_local_polys(pat)
|
||||
if local_polys:
|
||||
rot_mat = rotation_matrix_2d(c_rotation)
|
||||
for v in local_polys:
|
||||
vt = v * c_scale
|
||||
if c_mirrored:
|
||||
vt = vt.copy()
|
||||
vt[:, 1] *= -1
|
||||
vt = (rot_mat @ vt.T).T + c_offset
|
||||
all_polygons.append(vt)
|
||||
|
||||
# 2. Collect ports if requested
|
||||
if ports:
|
||||
for name, p in pat.ports.items():
|
||||
pt_v = p.offset * c_scale
|
||||
if c_mirrored:
|
||||
pt_v = pt_v.copy()
|
||||
pt_v[1] *= -1
|
||||
pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset
|
||||
|
||||
if p.rotation is not None:
|
||||
pt_rot = p.rotation
|
||||
if c_mirrored:
|
||||
pt_rot = -pt_rot
|
||||
pt_rot += c_rotation
|
||||
port_info.append((name, pt_v, pt_rot))
|
||||
|
||||
# 3. Recurse into refs
|
||||
for target, refs in pat.refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
assert library is not None
|
||||
target_pat = library[target]
|
||||
for ref in refs:
|
||||
# Ref order of operations: mirror, rotate, scale, translate, repeat
|
||||
|
||||
# Combined scale and mirror
|
||||
r_scale = c_scale * ref.scale
|
||||
r_mirrored = c_mirrored ^ ref.mirrored
|
||||
|
||||
# Combined rotation: push c_mirrored and c_rotation through ref.rotation
|
||||
r_rot_relative = -ref.rotation if c_mirrored else ref.rotation
|
||||
r_rotation = c_rotation + r_rot_relative
|
||||
|
||||
# Offset composition helper
|
||||
def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]:
|
||||
o = rel_offset * c_scale
|
||||
if c_mirrored:
|
||||
o = o.copy()
|
||||
o[1] *= -1
|
||||
return rotation_matrix_2d(c_rotation) @ o + c_offset
|
||||
|
||||
if ref.repetition is not None:
|
||||
for disp in ref.repetition.displacements:
|
||||
collect_polys_recursive(
|
||||
target_pat,
|
||||
get_full_offset(ref.offset + disp),
|
||||
r_rotation,
|
||||
r_mirrored,
|
||||
r_scale
|
||||
)
|
||||
else:
|
||||
collect_polys_recursive(
|
||||
target_pat,
|
||||
get_full_offset(ref.offset),
|
||||
r_rotation,
|
||||
r_mirrored,
|
||||
r_scale
|
||||
)
|
||||
|
||||
# Start recursive collection
|
||||
collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0)
|
||||
|
||||
# Plotting
|
||||
if not overdraw:
|
||||
figure = pyplot.figure()
|
||||
pyplot.axis('equal')
|
||||
else:
|
||||
figure = pyplot.gcf()
|
||||
|
||||
axes = figure.gca()
|
||||
|
||||
if all_polygons:
|
||||
mpl_poly_collection = matplotlib.collections.PolyCollection(
|
||||
all_polygons,
|
||||
facecolors = fill_color,
|
||||
edgecolors = line_color,
|
||||
)
|
||||
axes.add_collection(mpl_poly_collection)
|
||||
polygons = []
|
||||
for shape in chain.from_iterable(self.shapes.values()):
|
||||
polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
|
||||
|
||||
if ports:
|
||||
for port_name, pt_v, pt_rot in port_info:
|
||||
p1 = pt_v
|
||||
angle = pt_rot
|
||||
size = 1.0 # arrow size
|
||||
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||
mpl_poly_collection = matplotlib.collections.PolyCollection(
|
||||
polygons,
|
||||
facecolors=fill_color,
|
||||
edgecolors=line_color,
|
||||
)
|
||||
axes.add_collection(mpl_poly_collection)
|
||||
pyplot.axis('equal')
|
||||
|
||||
axes.annotate(
|
||||
port_name,
|
||||
xy = tuple(p1),
|
||||
xytext = tuple(p2),
|
||||
arrowprops = dict(arrowstyle="->", color='g', linewidth=1),
|
||||
color = 'g',
|
||||
fontsize = 8,
|
||||
for target, refs in self.refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
if not refs:
|
||||
continue
|
||||
assert library is not None
|
||||
target_pat = library[target]
|
||||
for ref in refs:
|
||||
ref.as_pattern(target_pat).visualize(
|
||||
library=library,
|
||||
offset=offset,
|
||||
overdraw=True,
|
||||
line_color=line_color,
|
||||
fill_color=fill_color,
|
||||
)
|
||||
|
||||
axes.autoscale_view()
|
||||
axes.set_aspect('equal')
|
||||
|
||||
if not overdraw:
|
||||
axes.set_xlabel('x')
|
||||
axes.set_ylabel('y')
|
||||
if filename:
|
||||
figure.savefig(filename)
|
||||
else:
|
||||
figure.show()
|
||||
pyplot.xlabel('x')
|
||||
pyplot.ylabel('y')
|
||||
pyplot.show()
|
||||
|
||||
# @overload
|
||||
# def place(
|
||||
|
|
@ -1331,7 +1114,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
port_map: dict[str, str | None] | None = None,
|
||||
skip_port_check: bool = False,
|
||||
append: bool = False,
|
||||
skip_geometry: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Instantiate or append the pattern `other` into the current pattern, adding its
|
||||
|
|
@ -1363,10 +1145,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
skip_geometry: If `True`, the operation only updates the port list and
|
||||
skips adding any geometry (shapes, labels, or references). This
|
||||
allows the pattern assembly to proceed for port-tracking purposes
|
||||
even when layout generation is suppressed.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
|
@ -1381,26 +1159,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
port_map = {}
|
||||
|
||||
if not skip_port_check:
|
||||
port_map, overwrite_targets = self._resolve_insert_mapping(
|
||||
other.ports.keys(),
|
||||
map_in=None,
|
||||
map_out=port_map,
|
||||
allow_conflicts=skip_geometry,
|
||||
)
|
||||
for target in overwrite_targets:
|
||||
self.ports.pop(target, None)
|
||||
|
||||
if not skip_geometry:
|
||||
if append:
|
||||
if isinstance(other, Abstract):
|
||||
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
|
||||
if other.annotations is not None and self.annotations is not None:
|
||||
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
|
||||
if annotation_conflicts:
|
||||
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||
elif isinstance(other, Pattern):
|
||||
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
||||
'Use `append=True` if you intended to append the full geometry.')
|
||||
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
|
||||
|
||||
ports = {}
|
||||
for name, port in other.ports.items():
|
||||
|
|
@ -1417,12 +1176,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
pp.rotate_around(pivot, rotation)
|
||||
pp.translate(offset)
|
||||
self.ports[name] = pp
|
||||
self._log_port_update(name)
|
||||
|
||||
if skip_geometry:
|
||||
return self
|
||||
|
||||
if append:
|
||||
if isinstance(other, Abstract):
|
||||
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
|
||||
other_copy = other.deepcopy()
|
||||
other_copy.ports.clear()
|
||||
if mirrored:
|
||||
|
|
@ -1431,6 +1188,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
other_copy.translate_elements(offset)
|
||||
self.append(other_copy)
|
||||
else:
|
||||
assert not isinstance(other, Pattern)
|
||||
ref = Ref(mirrored=mirrored)
|
||||
ref.rotate_around(pivot, rotation)
|
||||
ref.translate(offset)
|
||||
|
|
@ -1476,7 +1234,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
set_rotation: bool | None = None,
|
||||
append: bool = False,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
skip_geometry: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Instantiate or append a pattern into the current pattern, connecting
|
||||
|
|
@ -1526,15 +1283,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
append: If `True`, `other` is appended instead of being referenced.
|
||||
Note that this does not flatten `other`, so its refs will still
|
||||
be refs (now inside `self`).
|
||||
ok_connections: Set of additional allowed ptype combinations.
|
||||
Ptypes accepted by the shared compatibility policy are always
|
||||
allowed. Non-allowed ptype connections will emit a warning.
|
||||
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||
skip_geometry: If `True`, only ports are updated and geometry is
|
||||
skipped. If a valid transform cannot be found (e.g. due to
|
||||
misaligned ports), a 'best-effort' dummy transform is used
|
||||
to ensure new ports are still added at approximate locations,
|
||||
allowing downstream routing to continue.
|
||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||
ptypes are always allowed to connect, as is `'unk'` with
|
||||
any other ptypte. Non-allowed ptype connections will emit a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
|
@ -1566,59 +1319,23 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
|
||||
map_out = {out_port_name: next(iter(map_in.keys()))}
|
||||
|
||||
map_out, overwrite_targets = self._resolve_insert_mapping(
|
||||
other.ports.keys(),
|
||||
self.check_ports(other.ports.keys(), map_in, map_out)
|
||||
translation, rotation, pivot = self.find_transform(
|
||||
other,
|
||||
map_in,
|
||||
map_out,
|
||||
allow_conflicts=skip_geometry,
|
||||
mirrored = mirrored,
|
||||
set_rotation = set_rotation,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
if not skip_geometry:
|
||||
if append:
|
||||
if isinstance(other, Abstract):
|
||||
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
|
||||
if other.annotations is not None and self.annotations is not None:
|
||||
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
|
||||
if annotation_conflicts:
|
||||
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||
elif isinstance(other, Pattern):
|
||||
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
||||
'Use `append=True` if you intended to append the full geometry.')
|
||||
try:
|
||||
translation, rotation, pivot = self.find_transform(
|
||||
other,
|
||||
map_in,
|
||||
mirrored = mirrored,
|
||||
set_rotation = set_rotation,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
except PortError:
|
||||
if not skip_geometry:
|
||||
raise
|
||||
logger.warning("Port transform failed for dead device. Using dummy transform.")
|
||||
if map_in:
|
||||
ki, vi = next(iter(map_in.items()))
|
||||
s_port = self.ports[ki]
|
||||
o_port = other.ports[vi].deepcopy()
|
||||
if mirrored:
|
||||
o_port.mirror()
|
||||
o_port.offset[1] *= -1
|
||||
translation = s_port.offset - o_port.offset
|
||||
rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0
|
||||
pivot = o_port.offset
|
||||
else:
|
||||
translation = numpy.zeros(2)
|
||||
rotation = 0.0
|
||||
pivot = numpy.zeros(2)
|
||||
|
||||
for target in overwrite_targets:
|
||||
self.ports.pop(target, None)
|
||||
|
||||
# get rid of plugged ports
|
||||
for ki, vi in map_in.items():
|
||||
del self.ports[ki]
|
||||
self._log_port_removal(ki)
|
||||
map_out[vi] = None
|
||||
|
||||
if isinstance(other, Pattern):
|
||||
assert append, 'Got a name (not an abstract) but was asked to reference (not append)'
|
||||
|
||||
self.place(
|
||||
other,
|
||||
offset = translation,
|
||||
|
|
@ -1628,7 +1345,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
port_map = map_out,
|
||||
skip_port_check = True,
|
||||
append = append,
|
||||
skip_geometry = skip_geometry,
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
@ -1662,7 +1378,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
current device.
|
||||
|
||||
Args:
|
||||
source: A collection of ports (e.g. Pattern, Pather, or dict)
|
||||
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
||||
from which to create the interface.
|
||||
in_prefix: Prepended to port names for newly-created ports with
|
||||
reversed directions compared to the current device.
|
||||
|
|
@ -1690,13 +1406,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
|||
else:
|
||||
raise PatternError(f'Unable to get ports from {type(source)}: {source}')
|
||||
|
||||
if port_map is not None:
|
||||
if port_map:
|
||||
if isinstance(port_map, dict):
|
||||
missing_inkeys = set(port_map.keys()) - set(orig_ports.keys())
|
||||
port_targets = list(port_map.values())
|
||||
duplicate_targets = {vv for vv in port_targets if port_targets.count(vv) > 1}
|
||||
if duplicate_targets:
|
||||
raise PortError(f'Duplicate targets in `port_map`: {duplicate_targets}')
|
||||
mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map}
|
||||
else:
|
||||
port_set = set(port_map)
|
||||
|
|
|
|||
311
masque/ports.py
311
masque/ports.py
|
|
@ -2,7 +2,6 @@ from typing import overload, Self, NoReturn, Any
|
|||
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
||||
import logging
|
||||
import functools
|
||||
import copy
|
||||
from collections import Counter
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from itertools import chain
|
||||
|
|
@ -11,17 +10,16 @@ import numpy
|
|||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
|
||||
from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d
|
||||
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
|
||||
from .utils import rotate_offsets_around, rotation_matrix_2d
|
||||
from .error import PortError, format_stacktrace
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
port_logger = logging.getLogger('masque.ports')
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
||||
"""
|
||||
A point at which a `Device` can be snapped to another `Device`.
|
||||
|
||||
|
|
@ -93,12 +91,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
|||
def copy(self) -> Self:
|
||||
return self.deepcopy()
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
return new
|
||||
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
return numpy.vstack((self.offset, self.offset))
|
||||
|
||||
|
|
@ -107,27 +99,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
|||
self.ptype = ptype
|
||||
return self
|
||||
|
||||
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
|
||||
"""
|
||||
Mirror the object across a line in the container's coordinate system.
|
||||
|
||||
Note this operation is performed relative to the pattern's origin and modifies the port's offset.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0.
|
||||
x: Vertical line x=val to mirror across.
|
||||
y: Horizontal line y=val to mirror across.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
|
||||
self.translate(-pivot)
|
||||
self.mirror(axis)
|
||||
self.offset[1 - axis] *= -1
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
if self.rotation is not None:
|
||||
self.rotation *= -1
|
||||
|
|
@ -143,34 +114,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
|||
self.rotation = rotation
|
||||
return self
|
||||
|
||||
def describe(self) -> str:
|
||||
"""
|
||||
Returns a human-readable description of the port's state including cardinal directions.
|
||||
"""
|
||||
deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None
|
||||
|
||||
cardinal = ""
|
||||
travel_dir = ""
|
||||
|
||||
if self.rotation is not None:
|
||||
dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"}
|
||||
# normalize to [0, 360)
|
||||
deg_norm = deg % 360
|
||||
|
||||
# Find closest cardinal
|
||||
closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180))
|
||||
if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3):
|
||||
cardinal = f" ({dirs[closest]})"
|
||||
|
||||
# Travel direction (rotation + 180)
|
||||
t_deg = (deg_norm + 180) % 360
|
||||
closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180))
|
||||
if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3):
|
||||
travel_dir = f" (Travel -> {dirs[closest_t]})"
|
||||
|
||||
deg_text = 'any' if deg is None else f'{deg:g}'
|
||||
return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.rotation is None:
|
||||
rot = 'any'
|
||||
|
|
@ -236,19 +179,6 @@ class PortList(metaclass=ABCMeta):
|
|||
def ports(self, value: dict[str, Port]) -> None:
|
||||
pass
|
||||
|
||||
def _log_port_update(self, name: str) -> None:
|
||||
""" Log the current state of the named port """
|
||||
port_logger.debug("Port %s: %s", name, self.ports[name].describe())
|
||||
|
||||
def _log_port_removal(self, name: str) -> None:
|
||||
""" Log that the named port has been removed """
|
||||
port_logger.debug("Port %s: removed", name)
|
||||
|
||||
def _log_bulk_update(self, label: str) -> None:
|
||||
""" Log all current ports at DEBUG level """
|
||||
for name, port in self.ports.items():
|
||||
port_logger.debug("%s: Port %s: %s", label, name, port)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: str) -> Port:
|
||||
pass
|
||||
|
|
@ -273,12 +203,6 @@ class PortList(metaclass=ABCMeta):
|
|||
else: # noqa: RET505
|
||||
return {k: self.ports[k] for k in key}
|
||||
|
||||
def measure_travel(self, src: str, dst: str) -> tuple[NDArray[numpy.float64], float | None]:
|
||||
"""
|
||||
Convenience wrapper for measuring travel between two named ports.
|
||||
"""
|
||||
return self[src].measure_travel(self[dst])
|
||||
|
||||
def __contains__(self, key: str) -> NoReturn:
|
||||
raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')
|
||||
|
||||
|
|
@ -308,7 +232,6 @@ class PortList(metaclass=ABCMeta):
|
|||
raise PortError(f'Port {name} already exists.')
|
||||
assert name not in self.ports
|
||||
self.ports[name] = value
|
||||
self._log_port_update(name)
|
||||
return self
|
||||
|
||||
def rename_ports(
|
||||
|
|
@ -330,147 +253,17 @@ class PortList(metaclass=ABCMeta):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
self._rename_ports_impl(mapping, overwrite=overwrite)
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _normalize_target_mapping(
|
||||
ordered_targets: Iterable[tuple[str, str | None]],
|
||||
explicit_map: Mapping[str, str | None] | None = None,
|
||||
) -> dict[str, str | None]:
|
||||
ordered_targets = list(ordered_targets)
|
||||
normalized = {} if explicit_map is None else copy.deepcopy(dict(explicit_map))
|
||||
winners = {
|
||||
target: source
|
||||
for source, target in ordered_targets
|
||||
if target is not None
|
||||
}
|
||||
for source, target in ordered_targets:
|
||||
if target is not None and winners[target] != source:
|
||||
normalized[source] = None
|
||||
return normalized
|
||||
|
||||
def _resolve_insert_mapping(
|
||||
self,
|
||||
other_names: Iterable[str],
|
||||
map_in: Mapping[str, str] | None = None,
|
||||
map_out: Mapping[str, str | None] | None = None,
|
||||
*,
|
||||
allow_conflicts: bool = False,
|
||||
) -> tuple[dict[str, str | None], set[str]]:
|
||||
if map_in is None:
|
||||
map_in = {}
|
||||
|
||||
normalized_map_out = {} if map_out is None else copy.deepcopy(dict(map_out))
|
||||
other_names = list(other_names)
|
||||
other = set(other_names)
|
||||
|
||||
missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
|
||||
if missing_inkeys:
|
||||
raise PortError(f'`map_in` keys not present in device: {missing_inkeys}')
|
||||
|
||||
missing_invals = set(map_in.values()) - other
|
||||
if missing_invals:
|
||||
raise PortError(f'`map_in` values not present in other device: {missing_invals}')
|
||||
|
||||
map_in_counts = Counter(map_in.values())
|
||||
conflicts_in = {kk for kk, vv in map_in_counts.items() if vv > 1}
|
||||
if conflicts_in:
|
||||
raise PortError(f'Duplicate values in `map_in`: {conflicts_in}')
|
||||
|
||||
missing_outkeys = set(normalized_map_out.keys()) - other
|
||||
if missing_outkeys:
|
||||
raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}')
|
||||
|
||||
connected_outkeys = set(normalized_map_out.keys()) & set(map_in.values())
|
||||
if connected_outkeys:
|
||||
raise PortError(f'`map_out` keys conflict with connected ports: {connected_outkeys}')
|
||||
|
||||
orig_remaining = set(self.ports.keys()) - set(map_in.keys())
|
||||
connected = set(map_in.values())
|
||||
if allow_conflicts:
|
||||
ordered_targets = [
|
||||
(name, normalized_map_out.get(name, name))
|
||||
for name in other_names
|
||||
if name not in connected
|
||||
]
|
||||
normalized_map_out = self._normalize_target_mapping(ordered_targets, normalized_map_out)
|
||||
final_targets = {
|
||||
normalized_map_out.get(name, name)
|
||||
for name in other_names
|
||||
if name not in connected and normalized_map_out.get(name, name) is not None
|
||||
}
|
||||
overwrite_targets = {target for target in final_targets if target in orig_remaining}
|
||||
return normalized_map_out, overwrite_targets
|
||||
|
||||
other_remaining = other - set(normalized_map_out.keys()) - connected
|
||||
mapped_vals = set(normalized_map_out.values())
|
||||
mapped_vals.discard(None)
|
||||
|
||||
conflicts_final = orig_remaining & (other_remaining | mapped_vals)
|
||||
if conflicts_final:
|
||||
raise PortError(f'Device ports conflict with existing ports: {conflicts_final}')
|
||||
|
||||
conflicts_partial = other_remaining & mapped_vals
|
||||
if conflicts_partial:
|
||||
raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}')
|
||||
|
||||
map_out_counts = Counter(normalized_map_out.values())
|
||||
map_out_counts[None] = 0
|
||||
conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1}
|
||||
if conflicts_out:
|
||||
raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')
|
||||
return normalized_map_out, set()
|
||||
|
||||
def _rename_ports_impl(
|
||||
self,
|
||||
mapping: Mapping[str, str | None],
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
allow_collisions: bool = False,
|
||||
) -> dict[str, str]:
|
||||
if not overwrite:
|
||||
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
||||
if duplicates:
|
||||
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
|
||||
missing = set(mapping) - set(self.ports)
|
||||
if missing:
|
||||
raise PortError(f'Ports to rename were not found: {missing}')
|
||||
renamed_targets = [vv for vv in mapping.values() if vv is not None]
|
||||
if not allow_collisions:
|
||||
duplicate_targets = {vv for vv in renamed_targets if renamed_targets.count(vv) > 1}
|
||||
if duplicate_targets:
|
||||
raise PortError(f'Renamed ports would collide: {duplicate_targets}')
|
||||
|
||||
winners = {
|
||||
target: source
|
||||
for source, target in mapping.items()
|
||||
if target is not None
|
||||
}
|
||||
overwritten = {
|
||||
target
|
||||
for target, source in winners.items()
|
||||
if target in self.ports and target not in mapping and target != source
|
||||
}
|
||||
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
|
||||
if None in renamed:
|
||||
del renamed[None]
|
||||
|
||||
for kk, vv in mapping.items():
|
||||
if vv is None or vv != kk:
|
||||
self._log_port_removal(kk)
|
||||
|
||||
source_ports = {kk: self.ports.pop(kk) for kk in mapping}
|
||||
for target in overwritten:
|
||||
self.ports.pop(target, None)
|
||||
|
||||
renamed = {
|
||||
vv: source_ports[kk]
|
||||
for kk, vv in mapping.items()
|
||||
if vv is not None and winners[vv] == kk
|
||||
}
|
||||
self.ports.update(renamed) # type: ignore
|
||||
|
||||
for vv in winners:
|
||||
self._log_port_update(vv)
|
||||
return winners
|
||||
return self
|
||||
|
||||
def add_port_pair(
|
||||
self,
|
||||
|
|
@ -492,16 +285,12 @@ class PortList(metaclass=ABCMeta):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
if names[0] == names[1]:
|
||||
raise PortError(f'Port names must be distinct: {names[0]!r}')
|
||||
new_ports = {
|
||||
names[0]: Port(offset, rotation=rotation, ptype=ptype),
|
||||
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
|
||||
}
|
||||
self.check_ports(names)
|
||||
self.ports.update(new_ports)
|
||||
self._log_port_update(names[0])
|
||||
self._log_port_update(names[1])
|
||||
return self
|
||||
|
||||
def plugged(
|
||||
|
|
@ -524,25 +313,13 @@ class PortList(metaclass=ABCMeta):
|
|||
Raises:
|
||||
`PortError` if the ports are not properly aligned.
|
||||
"""
|
||||
if not connections:
|
||||
raise PortError('Must provide at least one port connection')
|
||||
missing_a = set(connections) - set(self.ports)
|
||||
if missing_a:
|
||||
raise PortError(f'Connection source ports were not found: {missing_a}')
|
||||
missing_b = set(connections.values()) - set(self.ports)
|
||||
if missing_b:
|
||||
raise PortError(f'Connection destination ports were not found: {missing_b}')
|
||||
a_names, b_names = list(zip(*connections.items(), strict=True))
|
||||
used_names = list(chain(a_names, b_names))
|
||||
duplicate_names = {name for name in used_names if used_names.count(name) > 1}
|
||||
if duplicate_names:
|
||||
raise PortError(f'Each port may appear in at most one connection: {duplicate_names}')
|
||||
a_ports = [self.ports[pp] for pp in a_names]
|
||||
b_ports = [self.ports[pp] for pp in b_names]
|
||||
|
||||
a_types = [pp.ptype for pp in a_ports]
|
||||
b_types = [pp.ptype for pp in b_ports]
|
||||
type_conflicts = numpy.array([not ptypes_compatible(at, bt)
|
||||
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():
|
||||
|
|
@ -583,7 +360,6 @@ class PortList(metaclass=ABCMeta):
|
|||
|
||||
for pp in chain(a_names, b_names):
|
||||
del self.ports[pp]
|
||||
self._log_port_removal(pp)
|
||||
return self
|
||||
|
||||
def check_ports(
|
||||
|
|
@ -614,7 +390,45 @@ class PortList(metaclass=ABCMeta):
|
|||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
"""
|
||||
self._resolve_insert_mapping(other_names, map_in, map_out)
|
||||
if map_in is None:
|
||||
map_in = {}
|
||||
|
||||
if map_out is None:
|
||||
map_out = {}
|
||||
|
||||
other = set(other_names)
|
||||
|
||||
missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
|
||||
if missing_inkeys:
|
||||
raise PortError(f'`map_in` keys not present in device: {missing_inkeys}')
|
||||
|
||||
missing_invals = set(map_in.values()) - other
|
||||
if missing_invals:
|
||||
raise PortError(f'`map_in` values not present in other device: {missing_invals}')
|
||||
|
||||
missing_outkeys = set(map_out.keys()) - other
|
||||
if missing_outkeys:
|
||||
raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}')
|
||||
|
||||
orig_remaining = set(self.ports.keys()) - set(map_in.keys())
|
||||
other_remaining = other - set(map_out.keys()) - set(map_in.values())
|
||||
mapped_vals = set(map_out.values())
|
||||
mapped_vals.discard(None)
|
||||
|
||||
conflicts_final = orig_remaining & (other_remaining | mapped_vals)
|
||||
if conflicts_final:
|
||||
raise PortError(f'Device ports conflict with existing ports: {conflicts_final}')
|
||||
|
||||
conflicts_partial = other_remaining & mapped_vals
|
||||
if conflicts_partial:
|
||||
raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}')
|
||||
|
||||
map_out_counts = Counter(map_out.values())
|
||||
map_out_counts[None] = 0
|
||||
conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1}
|
||||
if conflicts_out:
|
||||
raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')
|
||||
|
||||
return self
|
||||
|
||||
def find_transform(
|
||||
|
|
@ -641,10 +455,11 @@ class PortList(metaclass=ABCMeta):
|
|||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `other` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
ok_connections: Set of additional allowed ptype combinations.
|
||||
Ptypes accepted by the shared compatibility policy are always
|
||||
allowed. Non-allowed ptype connections will log a warning.
|
||||
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||
ptypes are always allowed to connect, as is `'unk'` with
|
||||
any other ptypte. Non-allowed ptype connections will log a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
|
|
@ -653,8 +468,6 @@ class PortList(metaclass=ABCMeta):
|
|||
|
||||
The rotation should be performed before the translation.
|
||||
"""
|
||||
if not map_in:
|
||||
raise PortError('Must provide at least one port connection')
|
||||
s_ports = self[map_in.keys()]
|
||||
o_ports = other[map_in.values()]
|
||||
return self.find_port_transform(
|
||||
|
|
@ -693,10 +506,11 @@ class PortList(metaclass=ABCMeta):
|
|||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `o_ports` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
ok_connections: Set of additional allowed ptype combinations.
|
||||
Ptypes accepted by the shared compatibility policy are always
|
||||
allowed. Non-allowed ptype connections will log a warning.
|
||||
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
||||
ptypes are always allowed to connect, as is `'unk'` with
|
||||
any other ptypte. Non-allowed ptype connections will log a
|
||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
||||
`(b, a)`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
|
|
@ -705,8 +519,6 @@ class PortList(metaclass=ABCMeta):
|
|||
|
||||
The rotation should be performed before the translation.
|
||||
"""
|
||||
if not map_in:
|
||||
raise PortError('Must provide at least one port connection')
|
||||
s_offsets = numpy.array([p.offset for p in s_ports.values()])
|
||||
o_offsets = numpy.array([p.offset for p in o_ports.values()])
|
||||
s_types = [p.ptype for p in s_ports.values()]
|
||||
|
|
@ -723,10 +535,8 @@ class PortList(metaclass=ABCMeta):
|
|||
o_rotations *= -1
|
||||
|
||||
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
|
||||
type_conflicts = numpy.array([
|
||||
not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs
|
||||
for st, ot in zip(s_types, o_types, strict=True)
|
||||
])
|
||||
type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs)
|
||||
for st, ot in zip(s_types, o_types, strict=True)])
|
||||
if type_conflicts.any():
|
||||
msg = 'Ports have conflicting types:\n'
|
||||
for nn, (kk, vv) in enumerate(map_in.items()):
|
||||
|
|
@ -738,7 +548,7 @@ class PortList(metaclass=ABCMeta):
|
|||
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
||||
if not has_rot.any():
|
||||
if set_rotation is None:
|
||||
raise PortError('Must provide set_rotation if rotation is indeterminate')
|
||||
PortError('Must provide set_rotation if rotation is indeterminate')
|
||||
rotations[:] = set_rotation
|
||||
else:
|
||||
rotations[~has_rot] = rotations[has_rot][0]
|
||||
|
|
@ -763,3 +573,4 @@ class PortList(metaclass=ABCMeta):
|
|||
raise PortError(msg)
|
||||
|
||||
return translations[0], rotations[0], o_offsets[0]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation
|
|||
from .repetition import Repetition
|
||||
from .traits import (
|
||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
FlippableImpl,
|
||||
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -26,9 +25,8 @@ if TYPE_CHECKING:
|
|||
|
||||
@functools.total_ordering
|
||||
class Ref(
|
||||
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||
Copyable,
|
||||
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
|
||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
):
|
||||
"""
|
||||
`Ref` provides basic support for nesting Pattern objects within each other.
|
||||
|
|
@ -44,7 +42,7 @@ class Ref(
|
|||
__slots__ = (
|
||||
'_mirrored',
|
||||
# inherited
|
||||
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
|
||||
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
|
||||
)
|
||||
|
||||
_mirrored: bool
|
||||
|
|
@ -86,48 +84,24 @@ class Ref(
|
|||
self.repetition = repetition
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
offset: NDArray[numpy.float64],
|
||||
rotation: float,
|
||||
mirrored: bool,
|
||||
scale: float,
|
||||
repetition: Repetition | None,
|
||||
annotations: annotations_t | None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._offset = offset
|
||||
new._rotation = rotation % (2 * pi)
|
||||
new._scale = scale
|
||||
new._mirrored = mirrored
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __copy__(self) -> 'Ref':
|
||||
new = Ref(
|
||||
offset=self.offset.copy(),
|
||||
rotation=self.rotation,
|
||||
scale=self.scale,
|
||||
mirrored=self.mirrored,
|
||||
repetition=self.repetition,
|
||||
annotations=self.annotations,
|
||||
repetition=copy.deepcopy(self.repetition),
|
||||
annotations=copy.deepcopy(self.annotations),
|
||||
)
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new.repetition = copy.deepcopy(self.repetition, memo)
|
||||
new.annotations = copy.deepcopy(self.annotations, memo)
|
||||
#new.repetition = copy.deepcopy(self.repetition, memo)
|
||||
#new.annotations = copy.deepcopy(self.annotations, memo)
|
||||
return new
|
||||
|
||||
def copy(self) -> 'Ref':
|
||||
return self.deepcopy()
|
||||
|
||||
def __lt__(self, other: 'Ref') -> bool:
|
||||
if (self.offset != other.offset).any():
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
|
|
@ -142,8 +116,6 @@ class Ref(
|
|||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (
|
||||
numpy.array_equal(self.offset, other.offset)
|
||||
and self.mirrored == other.mirrored
|
||||
|
|
@ -188,16 +160,16 @@ class Ref(
|
|||
return pattern
|
||||
|
||||
def rotate(self, rotation: float) -> Self:
|
||||
"""
|
||||
Intrinsic transformation: Rotate the target pattern relative to this Ref's
|
||||
origin. This does NOT affect the repetition grid.
|
||||
"""
|
||||
self.rotation += rotation
|
||||
if self.repetition is not None:
|
||||
self.repetition.rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self.mirror_target(axis)
|
||||
self.rotation *= -1
|
||||
if self.repetition is not None:
|
||||
self.repetition.mirror(axis)
|
||||
return self
|
||||
|
||||
def mirror_target(self, axis: int = 0) -> Self:
|
||||
|
|
@ -215,11 +187,10 @@ class Ref(
|
|||
xys = self.offset[None, :]
|
||||
if self.repetition is not None:
|
||||
xys = xys + self.repetition.displacements
|
||||
transforms = numpy.empty((xys.shape[0], 5))
|
||||
transforms = numpy.empty((xys.shape[0], 4))
|
||||
transforms[:, :2] = xys
|
||||
transforms[:, 2] = self.rotation
|
||||
transforms[:, 3] = self.mirrored
|
||||
transforms[:, 4] = self.scale
|
||||
return transforms
|
||||
|
||||
def get_bounds_single(
|
||||
|
|
@ -256,10 +227,7 @@ class Ref(
|
|||
bounds = numpy.vstack((numpy.min(corners, axis=0),
|
||||
numpy.max(corners, axis=0))) * self.scale + [self.offset]
|
||||
return bounds
|
||||
|
||||
single_ref = self.deepcopy()
|
||||
single_ref.repetition = None
|
||||
return single_ref.as_pattern(pattern=pattern).get_bounds(library)
|
||||
return self.as_pattern(pattern=pattern).get_bounds(library)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __lt__(self, other: 'Repetition') -> bool:
|
||||
def __le__(self, other: 'Repetition') -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -64,7 +64,7 @@ class Grid(Repetition):
|
|||
_a_count: int
|
||||
""" Number of instances along the direction specified by the `a_vector` """
|
||||
|
||||
_b_vector: NDArray[numpy.float64]
|
||||
_b_vector: NDArray[numpy.float64] | None
|
||||
""" Vector `[x, y]` specifying a second lattice vector for the grid.
|
||||
Specifies center-to-center spacing between adjacent elements.
|
||||
Can be `None` for a 1D array.
|
||||
|
|
@ -113,22 +113,6 @@ class Grid(Repetition):
|
|||
self.a_count = a_count
|
||||
self.b_count = b_count
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls: type[GG],
|
||||
*,
|
||||
a_vector: NDArray[numpy.float64],
|
||||
a_count: int,
|
||||
b_vector: NDArray[numpy.float64],
|
||||
b_count: int,
|
||||
) -> GG:
|
||||
new = cls.__new__(cls)
|
||||
new._a_vector = a_vector
|
||||
new._b_vector = b_vector
|
||||
new._a_count = int(a_count)
|
||||
new._b_count = int(b_count)
|
||||
return new
|
||||
|
||||
@classmethod
|
||||
def aligned(
|
||||
cls: type[GG],
|
||||
|
|
@ -200,8 +184,6 @@ class Grid(Repetition):
|
|||
def a_count(self, val: int) -> None:
|
||||
if val != int(val):
|
||||
raise PatternError('a_count must be convertable to an int!')
|
||||
if int(val) < 1:
|
||||
raise PatternError(f'Repetition has too-small a_count: {val}')
|
||||
self._a_count = int(val)
|
||||
|
||||
# b_count property
|
||||
|
|
@ -213,12 +195,13 @@ class Grid(Repetition):
|
|||
def b_count(self, val: int) -> None:
|
||||
if val != int(val):
|
||||
raise PatternError('b_count must be convertable to an int!')
|
||||
if int(val) < 1:
|
||||
raise PatternError(f'Repetition has too-small b_count: {val}')
|
||||
self._b_count = int(val)
|
||||
|
||||
@property
|
||||
def displacements(self) -> NDArray[numpy.float64]:
|
||||
if self.b_vector is None:
|
||||
return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :]
|
||||
|
||||
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
|
||||
return (aa.flatten()[:, None] * self.a_vector[None, :]
|
||||
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
|
||||
|
|
@ -308,7 +291,7 @@ class Grid(Repetition):
|
|||
return False
|
||||
return True
|
||||
|
||||
def __lt__(self, other: Repetition) -> bool:
|
||||
def __le__(self, other: Repetition) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
other = cast('Grid', other)
|
||||
|
|
@ -318,8 +301,12 @@ class Grid(Repetition):
|
|||
return self.b_count < other.b_count
|
||||
if not numpy.array_equal(self.a_vector, other.a_vector):
|
||||
return tuple(self.a_vector) < tuple(other.a_vector)
|
||||
if self.b_vector is None:
|
||||
return other.b_vector is not None
|
||||
if other.b_vector is None:
|
||||
return False
|
||||
if not numpy.array_equal(self.b_vector, other.b_vector):
|
||||
return tuple(self.b_vector) < tuple(other.b_vector)
|
||||
return tuple(self.a_vector) < tuple(other.a_vector)
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -345,22 +332,7 @@ class Arbitrary(Repetition):
|
|||
|
||||
@displacements.setter
|
||||
def displacements(self, val: ArrayLike) -> None:
|
||||
try:
|
||||
vala = numpy.array(val, dtype=float)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PatternError('displacements must be convertible to an Nx2 ndarray') from exc
|
||||
|
||||
if vala.size == 0:
|
||||
self._displacements = numpy.empty((0, 2), dtype=float)
|
||||
return
|
||||
|
||||
if vala.ndim == 1:
|
||||
if vala.size != 2:
|
||||
raise PatternError('displacements must be convertible to an Nx2 ndarray')
|
||||
vala = vala.reshape(1, 2)
|
||||
elif vala.ndim != 2 or vala.shape[1] != 2:
|
||||
raise PatternError('displacements must be convertible to an Nx2 ndarray')
|
||||
|
||||
vala = numpy.array(val, dtype=float)
|
||||
order = numpy.lexsort(vala.T[::-1]) # sortrows
|
||||
self._displacements = vala[order]
|
||||
|
||||
|
|
@ -378,11 +350,11 @@ class Arbitrary(Repetition):
|
|||
return (f'<Arbitrary {len(self.displacements)}pts >')
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(other) is not type(self):
|
||||
if not type(other) is not type(self):
|
||||
return False
|
||||
return numpy.array_equal(self.displacements, other.displacements)
|
||||
|
||||
def __lt__(self, other: Repetition) -> bool:
|
||||
def __le__(self, other: Repetition) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
other = cast('Arbitrary', other)
|
||||
|
|
@ -419,9 +391,7 @@ class Arbitrary(Repetition):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
new_displacements = self.displacements.copy()
|
||||
new_displacements[:, 1 - axis] *= -1
|
||||
self.displacements = new_displacements
|
||||
self.displacements[1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
||||
|
|
@ -432,8 +402,6 @@ class Arbitrary(Repetition):
|
|||
Returns:
|
||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
||||
"""
|
||||
if self.displacements.size == 0:
|
||||
return None
|
||||
xy_min = numpy.min(self.displacements, axis=0)
|
||||
xy_max = numpy.max(self.displacements, axis=0)
|
||||
return numpy.array((xy_min, xy_max))
|
||||
|
|
@ -448,5 +416,6 @@ class Arbitrary(Repetition):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
self.displacements = self.displacements * c
|
||||
self.displacements *= c
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from .shape import (
|
|||
|
||||
from .polygon import Polygon as Polygon
|
||||
from .poly_collection import PolyCollection as PolyCollection
|
||||
from .rect_collection import RectCollection as RectCollection
|
||||
from .circle import Circle as Circle
|
||||
from .ellipse import Ellipse as Ellipse
|
||||
from .arc import Arc as Arc
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from typing import Any, cast
|
||||
import copy
|
||||
import functools
|
||||
from enum import Enum
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
|
@ -14,37 +13,18 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
|
|||
from ..traits import PositionableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class ArcAngleRef(Enum):
|
||||
Center = 'center'
|
||||
FocusPos = 'focus_pos'
|
||||
FocusNeg = 'focus_neg'
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
if self.__class__ is not other.__class__:
|
||||
return self.__class__.__name__ < other.__class__.__name__
|
||||
order = {
|
||||
ArcAngleRef.Center: 0,
|
||||
ArcAngleRef.FocusPos: 1,
|
||||
ArcAngleRef.FocusNeg: 2,
|
||||
}
|
||||
return order[self] < order[other]
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Arc(PositionableImpl, Shape):
|
||||
"""
|
||||
An elliptical arc, formed by cutting off an elliptical ring with two rays.
|
||||
By default the rays exit from its center, but they can optionally exit from one of the
|
||||
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
|
||||
a rotation, and a width.
|
||||
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
|
||||
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
|
||||
|
||||
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
||||
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
||||
The start and stop angle are measured counterclockwise from the first (x) radius.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
|
||||
'_radii', '_angles', '_width', '_rotation',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations',
|
||||
)
|
||||
|
|
@ -61,11 +41,6 @@ class Arc(PositionableImpl, Shape):
|
|||
_width: float
|
||||
""" Width of the arc """
|
||||
|
||||
_angle_ref: ArcAngleRef
|
||||
""" Origin used by start/stop rays """
|
||||
|
||||
AngleRef = ArcAngleRef
|
||||
|
||||
# radius properties
|
||||
@property
|
||||
def radii(self) -> NDArray[numpy.float64]:
|
||||
|
|
@ -79,8 +54,8 @@ class Arc(PositionableImpl, Shape):
|
|||
val = numpy.array(val, dtype=float).flatten()
|
||||
if not val.size == 2:
|
||||
raise PatternError('Radii must have length 2')
|
||||
if not val.min() > 0:
|
||||
raise PatternError('Radii must be positive')
|
||||
if not val.min() >= 0:
|
||||
raise PatternError('Radii must be non-negative')
|
||||
self._radii = val
|
||||
|
||||
@property
|
||||
|
|
@ -89,8 +64,8 @@ class Arc(PositionableImpl, Shape):
|
|||
|
||||
@radius_x.setter
|
||||
def radius_x(self, val: float) -> None:
|
||||
if not val > 0:
|
||||
raise PatternError('Radius must be positive')
|
||||
if not val >= 0:
|
||||
raise PatternError('Radius must be non-negative')
|
||||
self._radii[0] = val
|
||||
|
||||
@property
|
||||
|
|
@ -99,8 +74,8 @@ class Arc(PositionableImpl, Shape):
|
|||
|
||||
@radius_y.setter
|
||||
def radius_y(self, val: float) -> None:
|
||||
if not val > 0:
|
||||
raise PatternError('Radius must be positive')
|
||||
if not val >= 0:
|
||||
raise PatternError('Radius must be non-negative')
|
||||
self._radii[1] = val
|
||||
|
||||
# arc start/stop angle properties
|
||||
|
|
@ -138,18 +113,6 @@ class Arc(PositionableImpl, Shape):
|
|||
def stop_angle(self, val: float) -> None:
|
||||
self.angles = (self.angles[0], val)
|
||||
|
||||
# Angle reference property
|
||||
@property
|
||||
def angle_ref(self) -> ArcAngleRef:
|
||||
"""
|
||||
Origin used to interpret start and stop angle rays.
|
||||
"""
|
||||
return self._angle_ref
|
||||
|
||||
@angle_ref.setter
|
||||
def angle_ref(self, val: ArcAngleRef | str) -> None:
|
||||
self._angle_ref = ArcAngleRef(val)
|
||||
|
||||
# Rotation property
|
||||
@property
|
||||
def rotation(self) -> float:
|
||||
|
|
@ -196,40 +159,27 @@ class Arc(PositionableImpl, Shape):
|
|||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self.radii = radii
|
||||
self.angles = angles
|
||||
self.width = width
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.angle_ref = angle_ref
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
radii: NDArray[numpy.float64],
|
||||
angles: NDArray[numpy.float64],
|
||||
width: float,
|
||||
offset: NDArray[numpy.float64],
|
||||
rotation: float,
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||
) -> 'Arc':
|
||||
new = cls.__new__(cls)
|
||||
new._radii = radii
|
||||
new._angles = angles
|
||||
new._width = width
|
||||
new._offset = offset
|
||||
new._rotation = rotation % (2 * pi)
|
||||
new._angle_ref = ArcAngleRef(angle_ref)
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
if raw:
|
||||
assert isinstance(radii, numpy.ndarray)
|
||||
assert isinstance(angles, numpy.ndarray)
|
||||
assert isinstance(offset, numpy.ndarray)
|
||||
self._radii = radii
|
||||
self._angles = angles
|
||||
self._width = width
|
||||
self._offset = offset
|
||||
self._rotation = rotation
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.radii = radii
|
||||
self.angles = angles
|
||||
self.width = width
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
|
||||
memo = {} if memo is None else memo
|
||||
|
|
@ -237,7 +187,6 @@ class Arc(PositionableImpl, Shape):
|
|||
new._offset = self._offset.copy()
|
||||
new._radii = self._radii.copy()
|
||||
new._angles = self._angles.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -249,7 +198,6 @@ class Arc(PositionableImpl, Shape):
|
|||
and numpy.array_equal(self.angles, other.angles)
|
||||
and self.width == other.width
|
||||
and self.rotation == other.rotation
|
||||
and self.angle_ref == other.angle_ref
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
|
@ -266,8 +214,6 @@ class Arc(PositionableImpl, Shape):
|
|||
return tuple(self.radii) < tuple(other.radii)
|
||||
if not numpy.array_equal(self.angles, other.angles):
|
||||
return tuple(self.angles) < tuple(other.angles)
|
||||
if self.angle_ref != other.angle_ref:
|
||||
return self.angle_ref < other.angle_ref
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.rotation != other.rotation:
|
||||
|
|
@ -284,8 +230,6 @@ class Arc(PositionableImpl, Shape):
|
|||
if (num_vertices is None) and (max_arclen is None):
|
||||
raise PatternError('Max number of points and arclength left unspecified'
|
||||
+ ' (default was also overridden)')
|
||||
if max_arclen is not None and (numpy.isnan(max_arclen) or max_arclen <= 0):
|
||||
raise PatternError('Max arclength must be positive and not NaN')
|
||||
|
||||
r0, r1 = self.radii
|
||||
|
||||
|
|
@ -312,38 +256,29 @@ class Arc(PositionableImpl, Shape):
|
|||
return arc_lengths, tt
|
||||
|
||||
wh = self.width / 2.0
|
||||
arclen_limits: list[float] = []
|
||||
if max_arclen is not None:
|
||||
arclen_limits.append(max_arclen)
|
||||
if num_vertices is not None:
|
||||
n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int)
|
||||
perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum()
|
||||
perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum()
|
||||
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
|
||||
if not (numpy.isnan(implied_arclen) or implied_arclen <= 0):
|
||||
arclen_limits.append(implied_arclen)
|
||||
if not arclen_limits:
|
||||
raise PatternError('Arc polygonization could not determine a valid max_arclen')
|
||||
max_arclen = min(arclen_limits)
|
||||
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 = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen)))
|
||||
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]
|
||||
start = 0
|
||||
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
|
||||
start = 1
|
||||
while start < arc_lengths.size:
|
||||
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
|
||||
if not removable.any():
|
||||
next_to_keep = start + 1
|
||||
else:
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] + 1
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
|
||||
keep.append(next_to_keep)
|
||||
start = 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)
|
||||
|
||||
|
|
@ -375,59 +310,81 @@ class Arc(PositionableImpl, Shape):
|
|||
return [poly]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Equation for rotated ellipse is
|
||||
`x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)`
|
||||
`y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)`
|
||||
where `t` is our parameter.
|
||||
|
||||
Differentiating and solving for 0 slope wrt. `t`, we find
|
||||
`tan(t) = -+ b/a cot(phi)`
|
||||
where -+ is for x, y cases, so that's where the extrema are.
|
||||
|
||||
If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
|
||||
"""
|
||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
|
||||
def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
|
||||
return numpy.array((
|
||||
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
|
||||
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
|
||||
))
|
||||
|
||||
def points_in_interval(rx: float, ry: float, a0: float, a1: float) -> list[NDArray[numpy.float64]]:
|
||||
candidates = [a0, a1]
|
||||
if rx != 0 and ry != 0:
|
||||
tx = numpy.arctan2(-ry * sin_r, rx * cos_r)
|
||||
ty = numpy.arctan2(ry * cos_r, rx * sin_r)
|
||||
candidates.extend((tx, tx + pi, ty, ty + pi))
|
||||
|
||||
lo = min(a0, a1)
|
||||
hi = max(a0, a1)
|
||||
pts = []
|
||||
for base in candidates:
|
||||
k_min = int(numpy.floor((lo - base) / (2 * pi))) - 1
|
||||
k_max = int(numpy.ceil((hi - base) / (2 * pi))) + 1
|
||||
for kk in range(k_min, k_max + 1):
|
||||
tt = base + kk * 2 * pi
|
||||
if lo <= tt <= hi:
|
||||
pts.append(point(rx, ry, tt))
|
||||
return pts
|
||||
|
||||
pts = []
|
||||
mins = []
|
||||
maxs = []
|
||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||
wh = sgn * self.width / 2
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
if rx == 0 or ry == 0:
|
||||
pts.append(numpy.zeros(2))
|
||||
continue
|
||||
pts.extend(points_in_interval(rx, ry, aa[0], aa[1]))
|
||||
|
||||
all_pts = numpy.asarray(pts) + self.offset
|
||||
return numpy.vstack((numpy.min(all_pts, axis=0),
|
||||
numpy.max(all_pts, axis=0)))
|
||||
if rx == 0 or ry == 0:
|
||||
# Single point, at origin
|
||||
mins.append([0, 0])
|
||||
maxs.append([0, 0])
|
||||
continue
|
||||
|
||||
a0, a1 = aa
|
||||
a0_offset = a0 - (a0 % (2 * pi))
|
||||
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
sin_a = numpy.sin(aa)
|
||||
cos_a = numpy.cos(aa)
|
||||
|
||||
# Cutoff angles
|
||||
xpt = (-self.rotation) % (2 * pi) + a0_offset
|
||||
ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset
|
||||
xnt = (xpt - pi) % (2 * pi) + a0_offset
|
||||
ynt = (ypt - pi) % (2 * pi) + a0_offset
|
||||
|
||||
# Points along coordinate axes
|
||||
rx2_inv = 1 / (rx * rx)
|
||||
ry2_inv = 1 / (ry * ry)
|
||||
xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5
|
||||
yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5
|
||||
|
||||
# Arc endpoints
|
||||
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
|
||||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
||||
|
||||
# If our arc subtends a coordinate axis, use the extremum along that axis
|
||||
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
|
||||
xp = xr
|
||||
|
||||
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
|
||||
xn = -xr
|
||||
|
||||
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
|
||||
yp = yr
|
||||
|
||||
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
|
||||
yn = -yr
|
||||
|
||||
mins.append([xn, yn])
|
||||
maxs.append([xp, yp])
|
||||
return numpy.vstack((numpy.min(mins, axis=0) + self.offset,
|
||||
numpy.max(maxs, axis=0) + self.offset))
|
||||
|
||||
def rotate(self, theta: float) -> 'Arc':
|
||||
self.rotation += theta
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Arc':
|
||||
if self.angle_ref != ArcAngleRef.Center:
|
||||
x_major = self.radius_x > self.radius_y
|
||||
y_major = self.radius_y > self.radius_x
|
||||
if (axis == 0 and y_major) or (axis == 1 and x_major):
|
||||
self._swap_focus_ref()
|
||||
self.offset[axis - 1] *= -1
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
self.angles *= -1
|
||||
|
|
@ -439,7 +396,6 @@ class Arc(PositionableImpl, Shape):
|
|||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
angle_ref = self.angle_ref
|
||||
if self.radius_x < self.radius_y:
|
||||
radii = self.radii / self.radius_x
|
||||
scale = self.radius_x
|
||||
|
|
@ -450,26 +406,23 @@ class Arc(PositionableImpl, Shape):
|
|||
scale = self.radius_y
|
||||
rotation = self.rotation + pi / 2
|
||||
angles = self.angles - pi / 2
|
||||
angle_ref = _swapped_focus_ref(angle_ref)
|
||||
|
||||
delta_angle = angles[1] - angles[0]
|
||||
start_angle = angles[0] % (2 * pi)
|
||||
if start_angle >= pi:
|
||||
start_angle -= pi
|
||||
rotation += pi
|
||||
angle_ref = _swapped_focus_ref(angle_ref)
|
||||
|
||||
norm_angles = (start_angle, start_angle + delta_angle)
|
||||
rotation %= 2 * pi
|
||||
width = self.width
|
||||
|
||||
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
|
||||
return ((type(self), radii, norm_angles, width / norm_value),
|
||||
(self.offset, scale / norm_value, rotation, False),
|
||||
lambda: Arc(
|
||||
radii=radii * norm_value,
|
||||
angles=norm_angles,
|
||||
width=width * norm_value,
|
||||
angle_ref=angle_ref,
|
||||
))
|
||||
|
||||
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
||||
|
|
@ -480,16 +433,27 @@ class Arc(PositionableImpl, Shape):
|
|||
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
||||
```
|
||||
"""
|
||||
a_ranges = self._angles_to_parameters()
|
||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
||||
|
||||
cuts = []
|
||||
for index in range(2):
|
||||
edge = []
|
||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||
wh = sgn * self.width / 2
|
||||
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
|
||||
cuts.append(edge)
|
||||
return numpy.array(cuts) + self.offset
|
||||
mins = []
|
||||
maxs = []
|
||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||
wh = sgn * self.width / 2
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
sin_a = numpy.sin(aa)
|
||||
cos_a = numpy.cos(aa)
|
||||
|
||||
# arc endpoints
|
||||
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
|
||||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
||||
|
||||
mins.append([xn, yn])
|
||||
maxs.append([xp, yp])
|
||||
return numpy.array([mins, maxs]) + self.offset
|
||||
|
||||
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
|
|
@ -500,111 +464,22 @@ class Arc(PositionableImpl, Shape):
|
|||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||
"""
|
||||
aa = []
|
||||
d_angle = self.angles[1] - self.angles[0]
|
||||
if abs(d_angle) >= 2 * pi:
|
||||
# Full ring
|
||||
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
|
||||
|
||||
for sgn in (-1, +1):
|
||||
wh = sgn * self.width / 2.0
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
|
||||
sign = numpy.sign(d_angle)
|
||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
||||
sign = numpy.sign(self.angles[1] - self.angles[0])
|
||||
if sign != numpy.sign(a1 - a0):
|
||||
a1 += sign * 2 * pi
|
||||
|
||||
aa.append((a0, a1))
|
||||
return numpy.array(aa, dtype=float)
|
||||
|
||||
def _angle_to_parameter(self, angle: float, rx: float, ry: float) -> float:
|
||||
"""
|
||||
Convert an angle-reference ray to the ellipse parameter for one boundary edge.
|
||||
|
||||
Center-referenced arcs convert the ray angle from polar coordinates about the origin.
|
||||
Focus-referenced arcs solve the forward ray/ellipse intersection from the selected
|
||||
nominal focus and return the parameter `t` for `[rx*cos(t), ry*sin(t)]`.
|
||||
"""
|
||||
if self.angle_ref == ArcAngleRef.Center:
|
||||
return numpy.arctan2(rx * numpy.sin(angle), ry * numpy.cos(angle))
|
||||
|
||||
focus = self._focus_point()
|
||||
if rx <= 0 or ry <= 0:
|
||||
raise PatternError('Focus-referenced arc boundary radii must be positive')
|
||||
|
||||
fx, fy = focus
|
||||
origin_position = fx * fx / (rx * rx) + fy * fy / (ry * ry)
|
||||
if origin_position >= 1:
|
||||
raise PatternError('Focus-referenced arc ray origin must be inside both arc boundary ellipses')
|
||||
|
||||
dx = numpy.cos(angle)
|
||||
dy = numpy.sin(angle)
|
||||
aa = dx * dx / (rx * rx) + dy * dy / (ry * ry)
|
||||
bb = 2 * (fx * dx / (rx * rx) + fy * dy / (ry * ry))
|
||||
cc = origin_position - 1
|
||||
determinant = bb * bb - 4 * aa * cc
|
||||
if determinant < 0:
|
||||
raise PatternError('Focus-referenced arc ray does not intersect boundary ellipse')
|
||||
|
||||
roots = numpy.array((
|
||||
(-bb - numpy.sqrt(determinant)) / (2 * aa),
|
||||
(-bb + numpy.sqrt(determinant)) / (2 * aa),
|
||||
))
|
||||
positive_roots = roots[roots > 0]
|
||||
if positive_roots.size != 1:
|
||||
raise PatternError('Focus-referenced arc ray must have exactly one forward boundary intersection')
|
||||
|
||||
point = focus + positive_roots[0] * numpy.array((dx, dy))
|
||||
return numpy.arctan2(point[1] / ry, point[0] / rx)
|
||||
|
||||
def _focus_point(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Return the selected nominal focus in the arc's unrotated local coordinates.
|
||||
|
||||
`FocusPos` and `FocusNeg` select opposite directions along the major axis. Circles
|
||||
have coincident foci, so both focus modes intentionally collapse to the center.
|
||||
"""
|
||||
if self.angle_ref == ArcAngleRef.Center or self.radius_x == self.radius_y:
|
||||
return numpy.zeros(2)
|
||||
|
||||
sign = 1 if self.angle_ref == ArcAngleRef.FocusPos else -1
|
||||
if self.radius_x > self.radius_y:
|
||||
return numpy.array((sign * numpy.sqrt(self.radius_x * self.radius_x - self.radius_y * self.radius_y), 0.0))
|
||||
return numpy.array((0.0, sign * numpy.sqrt(self.radius_y * self.radius_y - self.radius_x * self.radius_x)))
|
||||
|
||||
def _point_on_edge(self, rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Return a rotated local-space point on a boundary ellipse, before applying offset.
|
||||
"""
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
return numpy.array((
|
||||
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
|
||||
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
|
||||
))
|
||||
|
||||
def _swap_focus_ref(self) -> None:
|
||||
"""
|
||||
Swap `focus_pos` and `focus_neg`, leaving center-referenced arcs unchanged.
|
||||
"""
|
||||
self.angle_ref = _swapped_focus_ref(self.angle_ref)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
angles = f' a°{numpy.rad2deg(self.angles)}'
|
||||
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
|
||||
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
|
||||
|
||||
|
||||
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
|
||||
"""
|
||||
Return the opposite focus reference, or center for center-referenced arcs.
|
||||
"""
|
||||
if angle_ref == ArcAngleRef.FocusPos:
|
||||
return ArcAngleRef.FocusNeg
|
||||
if angle_ref == ArcAngleRef.FocusNeg:
|
||||
return ArcAngleRef.FocusPos
|
||||
return angle_ref
|
||||
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
|
||||
|
||||
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
||||
|
|
|
|||
|
|
@ -50,33 +50,24 @@ class Circle(PositionableImpl, Shape):
|
|||
offset: ArrayLike = (0.0, 0.0),
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self.radius = radius
|
||||
self.offset = offset
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
radius: float,
|
||||
offset: NDArray[numpy.float64],
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> 'Circle':
|
||||
new = cls.__new__(cls)
|
||||
new._radius = radius
|
||||
new._offset = offset
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
if raw:
|
||||
assert isinstance(offset, numpy.ndarray)
|
||||
self._radius = radius
|
||||
self._offset = offset
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.radius = radius
|
||||
self.offset = offset
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -117,7 +108,7 @@ class Circle(PositionableImpl, Shape):
|
|||
n += [num_vertices]
|
||||
if max_arclen is not None:
|
||||
n += [2 * pi * self.radius / max_arclen]
|
||||
num_vertices = max(3, int(round(max(n))))
|
||||
num_vertices = int(round(max(n)))
|
||||
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
||||
xs = numpy.cos(thetas) * self.radius
|
||||
ys = numpy.sin(thetas) * self.radius
|
||||
|
|
@ -133,6 +124,7 @@ class Circle(PositionableImpl, Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
||||
self.offset[axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Circle':
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class Ellipse(PositionableImpl, Shape):
|
|||
|
||||
@radii.setter
|
||||
def radii(self, val: ArrayLike) -> None:
|
||||
val = numpy.array(val, dtype=float).flatten()
|
||||
val = numpy.array(val).flatten()
|
||||
if not val.size == 2:
|
||||
raise PatternError('Radii must have length 2')
|
||||
if not val.min() >= 0:
|
||||
|
|
@ -95,37 +95,28 @@ class Ellipse(PositionableImpl, Shape):
|
|||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self.radii = radii
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
radii: NDArray[numpy.float64],
|
||||
offset: NDArray[numpy.float64],
|
||||
rotation: float,
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._radii = radii
|
||||
new._offset = offset
|
||||
new._rotation = rotation % pi
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
if raw:
|
||||
assert isinstance(radii, numpy.ndarray)
|
||||
assert isinstance(offset, numpy.ndarray)
|
||||
self._radii = radii
|
||||
self._offset = offset
|
||||
self._rotation = rotation
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.radii = radii
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new._radii = self._radii.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -177,7 +168,7 @@ class Ellipse(PositionableImpl, Shape):
|
|||
n += [num_vertices]
|
||||
if max_arclen is not None:
|
||||
n += [perimeter / max_arclen]
|
||||
num_vertices = max(3, int(round(max(n))))
|
||||
num_vertices = int(round(max(n)))
|
||||
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
||||
|
||||
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
|
||||
|
|
@ -189,19 +180,16 @@ class Ellipse(PositionableImpl, Shape):
|
|||
return [poly]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2)
|
||||
y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2)
|
||||
extents = numpy.array((x_extent, y_extent))
|
||||
return numpy.vstack((self.offset - extents,
|
||||
self.offset + extents))
|
||||
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:
|
||||
self.rotation += theta
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self.offset[axis - 1] *= -1
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
return self
|
||||
|
|
@ -219,7 +207,7 @@ class Ellipse(PositionableImpl, Shape):
|
|||
radii = self.radii[::-1] / self.radius_y
|
||||
scale = self.radius_y
|
||||
angle = (self.rotation + pi / 2) % pi
|
||||
return ((type(self), tuple(radii.tolist())),
|
||||
return ((type(self), radii),
|
||||
(self.offset, scale / norm_value, angle, False),
|
||||
lambda: Ellipse(radii=radii * norm_value))
|
||||
|
||||
|
|
|
|||
|
|
@ -24,16 +24,7 @@ class PathCap(Enum):
|
|||
# # defined by path.cap_extensions
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
if self.__class__ is not other.__class__:
|
||||
return self.__class__.__name__ < other.__class__.__name__
|
||||
# Order: Flush, Square, Circle, SquareCustom
|
||||
order = {
|
||||
PathCap.Flush: 0,
|
||||
PathCap.Square: 1,
|
||||
PathCap.Circle: 2,
|
||||
PathCap.SquareCustom: 3,
|
||||
}
|
||||
return order[self] < order[other]
|
||||
return self.value == other.value
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
|
|
@ -88,10 +79,10 @@ class Path(Shape):
|
|||
def cap(self, val: PathCap) -> None:
|
||||
self._cap = PathCap(val)
|
||||
if self.cap != PathCap.SquareCustom:
|
||||
self._cap_extensions = None
|
||||
elif self._cap_extensions is None:
|
||||
self.cap_extensions = None
|
||||
elif self.cap_extensions is None:
|
||||
# just got set to SquareCustom
|
||||
self._cap_extensions = numpy.zeros(2)
|
||||
self.cap_extensions = numpy.zeros(2)
|
||||
|
||||
# cap_extensions property
|
||||
@property
|
||||
|
|
@ -201,50 +192,37 @@ class Path(Shape):
|
|||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self._cap_extensions = None # Since .cap setter might access it
|
||||
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self._cap = cap
|
||||
if cap == PathCap.SquareCustom and cap_extensions is None:
|
||||
self._cap_extensions = numpy.zeros(2)
|
||||
if raw:
|
||||
assert isinstance(vertices, numpy.ndarray)
|
||||
assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None
|
||||
self._vertices = vertices
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
self._width = width
|
||||
self._cap = cap
|
||||
self._cap_extensions = cap_extensions
|
||||
else:
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.width = width
|
||||
self.cap = cap
|
||||
self.cap_extensions = cap_extensions
|
||||
self.width = width
|
||||
if rotation:
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
vertices: NDArray[numpy.float64],
|
||||
width: float,
|
||||
cap: PathCap,
|
||||
cap_extensions: NDArray[numpy.float64] | None = None,
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._vertices = vertices
|
||||
new._width = width
|
||||
new._cap = cap
|
||||
new._cap_extensions = cap_extensions
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Path':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._vertices = self._vertices.copy()
|
||||
new._cap = copy.deepcopy(self._cap, memo)
|
||||
new._cap_extensions = copy.deepcopy(self._cap_extensions, memo)
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -275,14 +253,6 @@ class Path(Shape):
|
|||
if self.cap_extensions is None:
|
||||
return True
|
||||
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
|
||||
if not numpy.array_equal(self.vertices, other.vertices):
|
||||
min_len = min(self.vertices.shape[0], other.vertices.shape[0])
|
||||
eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
|
||||
eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
|
||||
eq_lt_masked = eq_lt[eq_mask]
|
||||
if eq_lt_masked.size > 0:
|
||||
return eq_lt_masked.flat[0]
|
||||
return self.vertices.shape[0] < other.vertices.shape[0]
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
|
@ -333,30 +303,9 @@ class Path(Shape):
|
|||
) -> list['Polygon']:
|
||||
extensions = self._calculate_cap_extensions()
|
||||
|
||||
v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
||||
v = remove_colinear_vertices(self.vertices, closed_path=False)
|
||||
dv = numpy.diff(v, axis=0)
|
||||
norms = numpy.sqrt((dv * dv).sum(axis=1))
|
||||
|
||||
# Filter out zero-length segments if any remained after remove_colinear_vertices
|
||||
valid = (norms > 1e-18)
|
||||
if not numpy.all(valid):
|
||||
# This shouldn't happen much if remove_colinear_vertices is working
|
||||
v = v[numpy.append(valid, True)]
|
||||
dv = numpy.diff(v, axis=0)
|
||||
norms = norms[valid]
|
||||
|
||||
if dv.shape[0] == 0:
|
||||
# All vertices were the same. It's a point.
|
||||
if self.width == 0:
|
||||
return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate
|
||||
if self.cap == PathCap.Circle:
|
||||
return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
|
||||
if self.cap == PathCap.Square:
|
||||
return [Polygon.square(side_length=self.width, offset=v[0])]
|
||||
# Flush or CustomSquare
|
||||
return [Polygon(vertices=numpy.zeros((3, 2)))]
|
||||
|
||||
dvdir = dv / norms[:, None]
|
||||
dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]
|
||||
|
||||
if self.width == 0:
|
||||
verts = numpy.vstack((v, v[::-1]))
|
||||
|
|
@ -375,21 +324,11 @@ class Path(Shape):
|
|||
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
|
||||
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
|
||||
|
||||
try:
|
||||
# Vectorized solve for all intersections
|
||||
# solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1)
|
||||
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0]
|
||||
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0]
|
||||
except numpy.linalg.LinAlgError:
|
||||
# Fallback to slower lstsq if some segments are parallel (singular matrix)
|
||||
rp = numpy.zeros(As.shape[0])
|
||||
rn = numpy.zeros(As.shape[0])
|
||||
for ii in range(As.shape[0]):
|
||||
rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0]
|
||||
rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0]
|
||||
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0]
|
||||
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0]
|
||||
|
||||
intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1]
|
||||
intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1]
|
||||
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
|
||||
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
|
||||
|
||||
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
|
||||
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
|
||||
|
|
@ -457,14 +396,12 @@ class Path(Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Path':
|
||||
self.vertices[:, 1 - axis] *= -1
|
||||
self.vertices[:, axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Path':
|
||||
self.vertices *= c
|
||||
self.width *= c
|
||||
if self.cap_extensions is not None:
|
||||
self.cap_extensions *= c
|
||||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
|
|
@ -481,22 +418,21 @@ class Path(Shape):
|
|||
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
|
||||
for v in normed_vertices])
|
||||
|
||||
# Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller
|
||||
if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat):
|
||||
reordered_vertices = rotated_vertices[::-1]
|
||||
else:
|
||||
reordered_vertices = rotated_vertices
|
||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||
x_min = rotated_vertices[:, 0].argmin()
|
||||
if not is_scalar(x_min):
|
||||
y_min = rotated_vertices[x_min, 1].argmin()
|
||||
x_min = cast('Sequence', x_min)[y_min]
|
||||
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||
|
||||
width0 = self.width / norm_value
|
||||
cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions)
|
||||
|
||||
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0),
|
||||
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
|
||||
(offset, scale / norm_value, rotation, False),
|
||||
lambda: Path(
|
||||
reordered_vertices * norm_value,
|
||||
width=width0 * norm_value,
|
||||
width=self.width * norm_value,
|
||||
cap=self.cap,
|
||||
cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0),
|
||||
))
|
||||
|
||||
def clean_vertices(self) -> 'Path':
|
||||
|
|
@ -526,7 +462,7 @@ class Path(Shape):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
|
||||
return self
|
||||
|
||||
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class PolyCollection(Shape):
|
|||
_vertex_lists: NDArray[numpy.float64]
|
||||
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
|
||||
|
||||
_vertex_offsets: NDArray[numpy.integer[Any]]
|
||||
_vertex_offsets: NDArray[numpy.intp]
|
||||
""" 1D NDArray specifying the starting offset for each polygon """
|
||||
|
||||
@property
|
||||
|
|
@ -45,7 +45,7 @@ class PolyCollection(Shape):
|
|||
return self._vertex_lists
|
||||
|
||||
@property
|
||||
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
|
||||
def vertex_offsets(self) -> NDArray[numpy.intp]:
|
||||
"""
|
||||
Starting offset (in `vertex_lists`) for each polygon
|
||||
"""
|
||||
|
|
@ -56,14 +56,12 @@ class PolyCollection(Shape):
|
|||
"""
|
||||
Iterator which provides slices which index vertex_lists
|
||||
"""
|
||||
if self._vertex_offsets.size == 0:
|
||||
return
|
||||
for ii, ff in zip(
|
||||
self._vertex_offsets,
|
||||
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
|
||||
strict=True,
|
||||
):
|
||||
yield slice(int(ii), int(ff))
|
||||
yield slice(ii, ff)
|
||||
|
||||
@property
|
||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||
|
|
@ -84,7 +82,7 @@ class PolyCollection(Shape):
|
|||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('PolyCollection offset is forced to (0, 0)')
|
||||
raise PatternError('Path offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
|
|
@ -100,38 +98,30 @@ class PolyCollection(Shape):
|
|||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
|
||||
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if raw:
|
||||
assert isinstance(vertex_lists, numpy.ndarray)
|
||||
assert isinstance(vertex_offsets, numpy.ndarray)
|
||||
self._vertex_lists = vertex_lists
|
||||
self._vertex_offsets = vertex_offsets
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
|
||||
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if rotation:
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
vertex_lists: NDArray[numpy.float64],
|
||||
vertex_offsets: NDArray[numpy.integer[Any]],
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._vertex_lists = vertex_lists
|
||||
new._vertex_offsets = vertex_offsets
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._vertex_lists = self._vertex_lists.copy()
|
||||
new._vertex_offsets = self._vertex_offsets.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -139,7 +129,7 @@ class PolyCollection(Shape):
|
|||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
|
||||
and numpy.array_equal(self.vertex_offsets, other.vertex_offsets)
|
||||
and numpy.array_equal(self._vertex_offsets, other._vertex_offsets)
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
|
@ -178,9 +168,7 @@ class PolyCollection(Shape):
|
|||
annotations = copy.deepcopy(self.annotations),
|
||||
) for vv in self.polygon_vertices]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
|
||||
if self._vertex_lists.size == 0:
|
||||
return None
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
|
||||
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
||||
numpy.max(self._vertex_lists, axis=0)))
|
||||
|
||||
|
|
@ -191,7 +179,7 @@ class PolyCollection(Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self._vertex_lists[:, 1 - axis] *= -1
|
||||
self._vertex_lists[:, axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
|
|
@ -222,11 +210,11 @@ class PolyCollection(Shape):
|
|||
|
||||
# TODO: normalize mirroring?
|
||||
|
||||
return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()),
|
||||
return ((type(self), rotated_vertices.data.tobytes() + self._vertex_offsets.tobytes()),
|
||||
(offset, scale / norm_value, rotation, False),
|
||||
lambda: PolyCollection(
|
||||
vertex_lists=rotated_vertices * norm_value,
|
||||
vertex_offsets=self.vertex_offsets.copy(),
|
||||
vertex_offsets=self._vertex_offsets,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Any, cast, TYPE_CHECKING, Self, Literal
|
||||
from typing import Any, cast, TYPE_CHECKING, Self
|
||||
import copy
|
||||
import functools
|
||||
|
||||
|
|
@ -96,11 +96,11 @@ class Polygon(Shape):
|
|||
@offset.setter
|
||||
def offset(self, val: ArrayLike) -> None:
|
||||
if numpy.any(val):
|
||||
raise PatternError('Polygon offset is forced to (0, 0)')
|
||||
raise PatternError('Path offset is forced to (0, 0)')
|
||||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('Polygon offset is forced to (0, 0)')
|
||||
raise PatternError('Path offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
|
|
@ -115,34 +115,26 @@ class Polygon(Shape):
|
|||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if raw:
|
||||
assert isinstance(vertices, numpy.ndarray)
|
||||
self._vertices = vertices
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if rotation:
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
vertices: NDArray[numpy.float64],
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._vertices = vertices
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Polygon':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._vertices = self._vertices.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -329,7 +321,7 @@ class Polygon(Shape):
|
|||
else:
|
||||
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
||||
|
||||
poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition)
|
||||
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
|
||||
return poly
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -402,7 +394,7 @@ class Polygon(Shape):
|
|||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Polygon':
|
||||
self.vertices[:, 1 - axis] *= -1
|
||||
self.vertices[:, axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Polygon':
|
||||
|
|
@ -425,15 +417,11 @@ class Polygon(Shape):
|
|||
for v in normed_vertices])
|
||||
|
||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||
x_min_val = rotated_vertices[:, 0].min()
|
||||
x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0]
|
||||
if x_min_inds.size > 1:
|
||||
y_min_val = rotated_vertices[x_min_inds, 1].min()
|
||||
tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0]
|
||||
start_ind = x_min_inds[tie_breaker]
|
||||
else:
|
||||
start_ind = x_min_inds[0]
|
||||
reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0)
|
||||
x_min = rotated_vertices[:, 0].argmin()
|
||||
if not is_scalar(x_min):
|
||||
y_min = rotated_vertices[x_min, 1].argmin()
|
||||
x_min = cast('Sequence', x_min)[y_min]
|
||||
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||
|
||||
# TODO: normalize mirroring?
|
||||
|
||||
|
|
@ -474,23 +462,3 @@ class Polygon(Shape):
|
|||
def __repr__(self) -> str:
|
||||
centroid = self.vertices.mean(axis=0)
|
||||
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
|
||||
|
||||
def boolean(
|
||||
self,
|
||||
other: Any,
|
||||
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
|
||||
scale: float = 1e6,
|
||||
) -> list['Polygon']:
|
||||
"""
|
||||
Perform a boolean operation using this polygon as the subject.
|
||||
|
||||
Args:
|
||||
other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP.
|
||||
operation: 'union', 'intersection', 'difference', 'xor'.
|
||||
scale: Scaling factor for integer conversion.
|
||||
|
||||
Returns:
|
||||
A list of resulting Polygons.
|
||||
"""
|
||||
from ..utils.boolean import boolean #noqa: PLC0415
|
||||
return boolean([self], other, operation=operation, scale=scale)
|
||||
|
|
|
|||
|
|
@ -1,249 +0,0 @@
|
|||
from typing import Any, cast, Self
|
||||
from collections.abc import Iterator
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, normalized_shape_tuple
|
||||
from .polygon import Polygon
|
||||
from ..error import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t
|
||||
|
||||
|
||||
def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]:
|
||||
arr = numpy.asarray(rects, dtype=float)
|
||||
if arr.ndim != 2 or arr.shape[1] != 4:
|
||||
raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]')
|
||||
if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]):
|
||||
raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax')
|
||||
if arr.shape[0] <= 1:
|
||||
return arr
|
||||
order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0]))
|
||||
return arr[order]
|
||||
|
||||
|
||||
def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None:
|
||||
x0 = numpy.minimum(rects[:, 0], rects[:, 2])
|
||||
x1 = numpy.maximum(rects[:, 0], rects[:, 2])
|
||||
y0 = numpy.minimum(rects[:, 1], rects[:, 3])
|
||||
y1 = numpy.maximum(rects[:, 1], rects[:, 3])
|
||||
rects[:, 0] = x0
|
||||
rects[:, 1] = y0
|
||||
rects[:, 2] = x1
|
||||
rects[:, 3] = y1
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class RectCollection(Shape):
|
||||
"""
|
||||
A collection of axis-aligned rectangles, stored as an Nx4 array of
|
||||
`[xmin, ymin, xmax, ymax]` rows.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_rects',
|
||||
'_repetition', '_annotations',
|
||||
)
|
||||
|
||||
_rects: NDArray[numpy.float64]
|
||||
|
||||
@property
|
||||
def rects(self) -> NDArray[numpy.float64]:
|
||||
return self._rects
|
||||
|
||||
@rects.setter
|
||||
def rects(self, val: ArrayLike) -> None:
|
||||
self._rects = _normalize_rects(val)
|
||||
|
||||
@property
|
||||
def offset(self) -> NDArray[numpy.float64]:
|
||||
return numpy.zeros(2)
|
||||
|
||||
@offset.setter
|
||||
def offset(self, val: ArrayLike) -> None:
|
||||
if numpy.any(val):
|
||||
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
delta = numpy.asarray(offset, dtype=float).reshape(2)
|
||||
self._rects[:, [0, 2]] += delta[0]
|
||||
self._rects[:, [1, 3]] += delta[1]
|
||||
return self
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rects: ArrayLike,
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
) -> None:
|
||||
self.rects = rects
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if rotation:
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
rects: NDArray[numpy.float64],
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._rects = rects
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
@property
|
||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||
for rect in self._rects:
|
||||
xmin, ymin, xmax, ymax = rect
|
||||
yield numpy.array([
|
||||
[xmin, ymin],
|
||||
[xmin, ymax],
|
||||
[xmax, ymax],
|
||||
[xmax, ymin],
|
||||
], dtype=float)
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._rects = self._rects.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
def _sorted_rects(self) -> NDArray[numpy.float64]:
|
||||
if self._rects.shape[0] <= 1:
|
||||
return self._rects
|
||||
order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0]))
|
||||
return self._rects[order]
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self._sorted_rects(), other._sorted_rects())
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
|
||||
other = cast('RectCollection', other)
|
||||
self_rects = self._sorted_rects()
|
||||
other_rects = other._sorted_rects()
|
||||
if not numpy.array_equal(self_rects, other_rects):
|
||||
min_len = min(self_rects.shape[0], other_rects.shape[0])
|
||||
eq_mask = self_rects[:min_len] != other_rects[:min_len]
|
||||
eq_lt = self_rects[:min_len] < other_rects[:min_len]
|
||||
eq_lt_masked = eq_lt[eq_mask]
|
||||
if eq_lt_masked.size > 0:
|
||||
return bool(eq_lt_masked.flat[0])
|
||||
return self_rects.shape[0] < other_rects.shape[0]
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = None, # unused # noqa: ARG002
|
||||
max_arclen: float | None = None, # unused # noqa: ARG002
|
||||
) -> list[Polygon]:
|
||||
return [
|
||||
Polygon(
|
||||
vertices=vertices,
|
||||
repetition=copy.deepcopy(self.repetition),
|
||||
annotations=copy.deepcopy(self.annotations),
|
||||
)
|
||||
for vertices in self.polygon_vertices
|
||||
]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||
if self._rects.size == 0:
|
||||
return None
|
||||
mins = self._rects[:, :2].min(axis=0)
|
||||
maxs = self._rects[:, 2:].max(axis=0)
|
||||
return numpy.vstack((mins, maxs))
|
||||
|
||||
def rotate(self, theta: float) -> Self:
|
||||
quarter_turns = int(numpy.rint(theta / (pi / 2)))
|
||||
if not numpy.isclose(theta, quarter_turns * (pi / 2)):
|
||||
raise PatternError('RectCollection only supports Manhattan rotations')
|
||||
turns = quarter_turns % 4
|
||||
if turns == 0 or self._rects.size == 0:
|
||||
return self
|
||||
|
||||
corners = numpy.stack((
|
||||
self._rects[:, [0, 1]],
|
||||
self._rects[:, [0, 3]],
|
||||
self._rects[:, [2, 3]],
|
||||
self._rects[:, [2, 1]],
|
||||
), axis=1)
|
||||
flat = corners.reshape(-1, 2)
|
||||
if turns == 1:
|
||||
rotated = numpy.column_stack((-flat[:, 1], flat[:, 0]))
|
||||
elif turns == 2:
|
||||
rotated = -flat
|
||||
else:
|
||||
rotated = numpy.column_stack((flat[:, 1], -flat[:, 0]))
|
||||
corners = rotated.reshape(corners.shape)
|
||||
self._rects[:, 0] = corners[:, :, 0].min(axis=1)
|
||||
self._rects[:, 1] = corners[:, :, 1].min(axis=1)
|
||||
self._rects[:, 2] = corners[:, :, 0].max(axis=1)
|
||||
self._rects[:, 3] = corners[:, :, 1].max(axis=1)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
if axis not in (0, 1):
|
||||
raise PatternError('Axis must be 0 or 1')
|
||||
if axis == 0:
|
||||
self._rects[:, [1, 3]] *= -1
|
||||
else:
|
||||
self._rects[:, [0, 2]] *= -1
|
||||
_renormalize_rects_in_place(self._rects)
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
self._rects *= c
|
||||
_renormalize_rects_in_place(self._rects)
|
||||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
rects = self._sorted_rects()
|
||||
centers = 0.5 * (rects[:, :2] + rects[:, 2:])
|
||||
offset = centers.mean(axis=0)
|
||||
zeroed = rects.copy()
|
||||
zeroed[:, [0, 2]] -= offset[0]
|
||||
zeroed[:, [1, 3]] -= offset[1]
|
||||
normed = zeroed / norm_value
|
||||
return (
|
||||
(type(self), normed.data.tobytes()),
|
||||
(offset, 1.0, 0.0, False),
|
||||
lambda: RectCollection(rects=normed * norm_value),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self._rects.size == 0:
|
||||
return '<RectCollection r0>'
|
||||
centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:])
|
||||
centroid = centers.mean(axis=0)
|
||||
return f'<RectCollection centroid {centroid} r{self._rects.shape[0]}>'
|
||||
|
|
@ -6,8 +6,8 @@ import numpy
|
|||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from ..traits import (
|
||||
Copyable, Scalable, FlippableImpl,
|
||||
PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
Rotatable, Mirrorable, Copyable, Scalable,
|
||||
Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -26,9 +26,8 @@ normalized_shape_tuple = tuple[
|
|||
DEFAULT_POLY_NUM_VERTICES = 24
|
||||
|
||||
|
||||
class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
Copyable, Scalable,
|
||||
metaclass=ABCMeta):
|
||||
class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
|
||||
"""
|
||||
Class specifying functions common to all shapes.
|
||||
"""
|
||||
|
|
@ -74,7 +73,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
|
||||
"""
|
||||
Writes the shape in a standardized notation, with offset, scale, and rotation
|
||||
information separated out from the remaining values.
|
||||
|
|
@ -121,7 +120,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
Returns:
|
||||
List of `Polygon` objects with grid-aligned edges.
|
||||
"""
|
||||
from . import Polygon #noqa: PLC0415
|
||||
from . import Polygon
|
||||
|
||||
gx = numpy.unique(grid_x)
|
||||
gy = numpy.unique(grid_y)
|
||||
|
|
@ -139,24 +138,22 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
|
||||
dv = v_next - v
|
||||
|
||||
# Find x-index bounds for the line
|
||||
# Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
|
||||
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
|
||||
gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1))
|
||||
gxi_max = int(numpy.max(gxi_range).clip(0, len(gx)))
|
||||
gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1)
|
||||
gxi_max = numpy.max(gxi_range).clip(0, len(gx))
|
||||
|
||||
if gxi_min < len(gx) - 1:
|
||||
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
||||
if err_xmin >= 0.5:
|
||||
gxi_min += 1
|
||||
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
||||
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
||||
|
||||
if gxi_max > 0 and gxi_max < len(gx):
|
||||
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
||||
if err_xmax >= 0.5:
|
||||
gxi_max += 1
|
||||
if err_xmin >= 0.5:
|
||||
gxi_min += 1
|
||||
if err_xmax >= 0.5:
|
||||
gxi_max += 1
|
||||
|
||||
if abs(dv[0]) < 1e-20:
|
||||
# Vertical line, don't calculate slope
|
||||
xi = [gxi_min, max(gxi_min, gxi_max - 1)]
|
||||
xi = [gxi_min, gxi_max - 1]
|
||||
ys = numpy.array([v[1], v_next[1]])
|
||||
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
|
||||
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
|
||||
|
|
@ -252,9 +249,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
Returns:
|
||||
List of `Polygon` objects with grid-aligned edges.
|
||||
"""
|
||||
from . import Polygon #noqa: PLC0415
|
||||
import skimage.measure #noqa: PLC0415
|
||||
import float_raster #noqa: PLC0415
|
||||
from . import Polygon
|
||||
import skimage.measure # type: ignore
|
||||
import float_raster
|
||||
|
||||
grx = numpy.unique(grid_x)
|
||||
gry = numpy.unique(grid_y)
|
||||
|
|
|
|||
|
|
@ -70,48 +70,31 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
mirrored: bool = False,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
self.offset = offset
|
||||
self.string = string
|
||||
self.height = height
|
||||
self.rotation = rotation
|
||||
self.mirrored = mirrored
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if raw:
|
||||
assert isinstance(offset, numpy.ndarray)
|
||||
self._offset = offset
|
||||
self._string = string
|
||||
self._height = height
|
||||
self._rotation = rotation
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
self.offset = offset
|
||||
self.string = string
|
||||
self.height = height
|
||||
self.rotation = rotation
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.font_path = font_path
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
string: str,
|
||||
height: float,
|
||||
font_path: str,
|
||||
offset: NDArray[numpy.float64],
|
||||
rotation: float,
|
||||
mirrored: bool,
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._offset = offset
|
||||
new._string = string
|
||||
new._height = height
|
||||
new._rotation = rotation % (2 * pi)
|
||||
new._mirrored = mirrored
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
new.font_path = font_path
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
|
|
@ -122,7 +105,6 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
and self.string == other.string
|
||||
and self.height == other.height
|
||||
and self.font_path == other.font_path
|
||||
and self.mirrored == other.mirrored
|
||||
and self.rotation == other.rotation
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
|
|
@ -142,8 +124,6 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
return self.font_path < other.font_path
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.mirrored != other.mirrored:
|
||||
return self.mirrored < other.mirrored
|
||||
if self.rotation != other.rotation:
|
||||
return self.rotation < other.rotation
|
||||
if self.repetition != other.repetition:
|
||||
|
|
@ -166,7 +146,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
if self.mirrored:
|
||||
poly.mirror()
|
||||
poly.scale_by(self.height)
|
||||
poly.translate(self.offset + [total_advance, 0])
|
||||
poly.offset = self.offset + [total_advance, 0]
|
||||
poly.rotate_around(self.offset, self.rotation)
|
||||
all_polygons += [poly]
|
||||
|
||||
|
|
@ -191,25 +171,22 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
|
||||
lambda: Text(
|
||||
string=self.string,
|
||||
height=norm_value,
|
||||
height=self.height * norm_value,
|
||||
font_path=self.font_path,
|
||||
rotation=rotation,
|
||||
).mirror2d(across_x=self.mirrored),
|
||||
)
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
|
||||
# just convert to polygons instead
|
||||
polys = self.to_polygons()
|
||||
if not polys:
|
||||
return None
|
||||
|
||||
pbounds = numpy.full((len(polys), 2, 2), nan)
|
||||
for pp, poly in enumerate(polys):
|
||||
pbounds[pp] = poly.get_bounds_nonempty()
|
||||
bounds = numpy.vstack((
|
||||
numpy.min(pbounds[:, 0, :], axis=0),
|
||||
numpy.max(pbounds[:, 1, :], axis=0),
|
||||
numpy.min(pbounds[: 0, :], axis=0),
|
||||
numpy.max(pbounds[: 1, :], axis=0),
|
||||
))
|
||||
|
||||
return bounds
|
||||
|
|
@ -225,8 +202,8 @@ def get_char_as_polygons(
|
|||
char: str,
|
||||
resolution: float = 48 * 64,
|
||||
) -> tuple[list[NDArray[numpy.float64]], float]:
|
||||
from freetype import Face # type: ignore #noqa: PLC0415
|
||||
from matplotlib.path import Path # type: ignore #noqa: PLC0415
|
||||
from freetype import Face # type: ignore
|
||||
from matplotlib.path import Path # type: ignore
|
||||
|
||||
"""
|
||||
Get a list of polygons representing a single character.
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
||||
"""
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
"""
|
||||
|
||||
Test fixtures
|
||||
|
||||
"""
|
||||
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import numpy
|
||||
|
||||
|
||||
FixtureRequest = Any
|
||||
PRNG = numpy.random.RandomState(12345)
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from masque import Pather, Port
|
||||
from masque.builder.tools import RenderStep
|
||||
|
||||
|
||||
def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Return lengths for each edge of an implicitly closed vertex loop.
|
||||
"""
|
||||
vv = numpy.asarray(vertices, dtype=float)
|
||||
return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1))
|
||||
|
||||
|
||||
def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None:
|
||||
"""
|
||||
Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`.
|
||||
"""
|
||||
assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol)
|
||||
|
||||
|
||||
def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None:
|
||||
"""
|
||||
Assert that an object's single-shape bounds match `expected`.
|
||||
"""
|
||||
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
|
||||
|
||||
|
||||
def normalized_route_data(data: Any) -> Any:
|
||||
"""
|
||||
Return a deterministic, comparison-friendly representation of route data.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0])))
|
||||
if isinstance(data, list | tuple):
|
||||
return tuple(normalized_route_data(value) for value in data)
|
||||
if isinstance(data, numpy.ndarray):
|
||||
return tuple(normalized_route_data(value) for value in data.tolist())
|
||||
if isinstance(data, numpy.generic):
|
||||
return data.item()
|
||||
try:
|
||||
hash(data)
|
||||
except TypeError:
|
||||
return repr(data)
|
||||
return data
|
||||
|
||||
|
||||
def route_step_signature(step: RenderStep) -> tuple[Any, ...]:
|
||||
"""
|
||||
Return the stable planning-relevant portion of one rendered route step.
|
||||
"""
|
||||
return (
|
||||
step.opcode,
|
||||
tuple(round(float(value), 9) for value in step.start_port.offset),
|
||||
None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9),
|
||||
step.start_port.ptype,
|
||||
tuple(round(float(value), 9) for value in step.end_port.offset),
|
||||
None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9),
|
||||
step.end_port.ptype,
|
||||
normalized_route_data(step.data),
|
||||
)
|
||||
|
||||
|
||||
def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]:
|
||||
"""
|
||||
Return a deterministic signature for a pather route.
|
||||
"""
|
||||
return tuple(route_step_signature(step) for step in pather._paths[portspec])
|
||||
|
||||
|
||||
def route_endpoint(pather: Pather, portspec: str) -> Port:
|
||||
"""
|
||||
Return the endpoint of a routed port, falling back to the live port for empty routes.
|
||||
"""
|
||||
steps = pather._paths[portspec]
|
||||
if not steps:
|
||||
return pather.pattern[portspec]
|
||||
return steps[-1].end_port
|
||||
|
||||
|
||||
def assert_route_endpoint(
|
||||
pather: Pather,
|
||||
portspec: str,
|
||||
expected: Port,
|
||||
*,
|
||||
atol: float = 1e-8,
|
||||
) -> None:
|
||||
"""
|
||||
Assert that a route endpoint matches an expected port pose and ptype.
|
||||
"""
|
||||
actual = route_endpoint(pather, portspec)
|
||||
assert_allclose(actual.offset, expected.offset, atol=atol)
|
||||
if expected.rotation is None:
|
||||
assert actual.rotation is None
|
||||
else:
|
||||
assert actual.rotation is not None
|
||||
assert numpy.isclose(actual.rotation, expected.rotation, atol=atol)
|
||||
assert actual.ptype == expected.ptype
|
||||
|
||||
|
||||
def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None:
|
||||
"""
|
||||
Assert a simple render-step bend budget for route signatures.
|
||||
"""
|
||||
bend_count = sum(1 for step in pather._paths[portspec] if step.opcode == 'L' and step.start_port.rotation != step.end_port.rotation)
|
||||
assert bend_count <= max_bends
|
||||
|
||||
|
||||
def assert_route_deterministic(
|
||||
make_pather: Callable[[], Pather],
|
||||
route: Callable[[Pather], None],
|
||||
portspec: str,
|
||||
) -> None:
|
||||
"""
|
||||
Assert that the same route operation produces the same route signature twice.
|
||||
"""
|
||||
first = make_pather()
|
||||
route(first)
|
||||
first_signature = route_signature(first, portspec)
|
||||
|
||||
second = make_pather()
|
||||
route(second)
|
||||
assert route_signature(second, portspec) == first_signature
|
||||
|
||||
|
||||
def assert_route_failure_does_not_mutate(
|
||||
pather: Pather,
|
||||
route: Callable[[], None],
|
||||
expected_exception: type[BaseException],
|
||||
) -> BaseException:
|
||||
"""
|
||||
Assert that a failing route operation leaves pending route steps untouched.
|
||||
"""
|
||||
before = deepcopy(dict(pather._paths))
|
||||
try:
|
||||
route()
|
||||
except expected_exception as err:
|
||||
assert dict(pather._paths) == before
|
||||
return err
|
||||
raise AssertionError(f'Expected {expected_exception.__name__}')
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
from numpy.testing import assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..abstract import Abstract
|
||||
from ..ports import Port
|
||||
from ..ref import Ref
|
||||
|
||||
|
||||
def test_abstract_init() -> None:
|
||||
ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)}
|
||||
abs_obj = Abstract("test", ports)
|
||||
assert abs_obj.name == "test"
|
||||
assert len(abs_obj.ports) == 2
|
||||
assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied
|
||||
|
||||
|
||||
def test_abstract_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||
# Rotate 90 deg around (0,0)
|
||||
abs_obj.rotate_around((0, 0), pi / 2)
|
||||
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
||||
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
# Mirror across x axis (axis 0): flips y-offset
|
||||
abs_obj.mirror(0)
|
||||
# (0, 10) mirrored(0) -> (0, -10)
|
||||
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
|
||||
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_ref_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||
|
||||
# Apply ref transform
|
||||
abs_obj.apply_ref_transform(ref)
|
||||
# Ref order: mirror, rotate, scale, translate
|
||||
|
||||
# 1. mirror (across x: y -> -y)
|
||||
# (10, 0) rot 0 -> (10, 0) rot 0
|
||||
|
||||
# 2. rotate pi/2 around (0,0)
|
||||
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
||||
|
||||
# 3. translate (100, 100)
|
||||
# (0, 10) -> (100, 110)
|
||||
|
||||
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_ref_transform_scales_offsets() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
||||
|
||||
abs_obj.apply_ref_transform(ref)
|
||||
|
||||
assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_undo_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||
|
||||
abs_obj.undo_ref_transform(ref)
|
||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_undo_transform_scales_offsets() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
||||
|
||||
abs_obj.undo_ref_transform(ref)
|
||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..error import PatternError
|
||||
from ..shapes import Arc
|
||||
from .helpers import assert_closed_edges_within
|
||||
|
||||
|
||||
def test_arc_init() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
||||
assert_equal(a.radii, [10, 10])
|
||||
assert_equal(a.angles, [0, pi / 2])
|
||||
assert a.width == 2
|
||||
|
||||
def test_arc_to_polygons() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
polys = a.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
|
||||
# Quarter-circle ring section with outer radius 11 and inner radius 9.
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
||||
|
||||
def test_arc_focus_to_polygons() -> None:
|
||||
a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos)
|
||||
polys = a.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
|
||||
focus = numpy.array([8.0, 0.0])
|
||||
cuts = a.get_cap_edges()
|
||||
for angle, cut in zip(a.angles, cuts, strict=True):
|
||||
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||
for point in cut:
|
||||
delta = point - focus
|
||||
assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10)
|
||||
assert numpy.dot(direction, delta) > 0
|
||||
|
||||
def test_arc_circle_focus_matches_center() -> None:
|
||||
center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||
|
||||
assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices,
|
||||
center.to_polygons(num_vertices=32)[0].vertices,
|
||||
atol=1e-10)
|
||||
|
||||
def test_arc_edge_cases() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
||||
a.to_polygons(num_vertices=64)
|
||||
bounds = a.get_bounds_single()
|
||||
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
||||
|
||||
def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
||||
arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200))
|
||||
bounds = arc.get_bounds_single()
|
||||
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||
|
||||
def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None:
|
||||
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4,
|
||||
offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos)
|
||||
bounds = arc.get_bounds_single()
|
||||
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||
|
||||
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
||||
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
|
||||
with pytest.raises(PatternError, match='valid max_arclen'):
|
||||
arc.to_polygons(num_vertices=24)
|
||||
|
||||
def test_focus_arc_rejects_focus_outside_inner_boundary() -> None:
|
||||
arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos)
|
||||
with pytest.raises(PatternError, match='inside both arc boundary ellipses'):
|
||||
arc.to_polygons(num_vertices=24)
|
||||
|
||||
def test_focus_arc_max_arclen_limits_segments() -> None:
|
||||
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg)
|
||||
assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2)
|
||||
|
||||
def test_arc_rejects_zero_radii_up_front() -> None:
|
||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||
Arc(radii=(0, 5), angles=(0, 1), width=1)
|
||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||
Arc(radii=(5, 0), angles=(0, 1), width=1)
|
||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||
Arc(radii=(0, 0), angles=(0, 1), width=1)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,242 +0,0 @@
|
|||
# ruff: noqa: PLC0415
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_allclose
|
||||
from masque.pattern import Pattern
|
||||
from masque.shapes.polygon import Polygon
|
||||
from masque.repetition import Grid
|
||||
from masque.library import Library
|
||||
from masque.error import PatternError
|
||||
|
||||
|
||||
def _poly_area(poly: Polygon) -> float:
|
||||
verts = poly.vertices
|
||||
x = verts[:, 0]
|
||||
y = verts[:, 1]
|
||||
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
|
||||
|
||||
def test_layer_as_polygons_basic() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||
assert len(polys) == 1
|
||||
assert isinstance(polys[0], Polygon)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
def test_layer_as_polygons_repetition() -> None:
|
||||
pat = Pattern()
|
||||
rep = Grid(a_vector=(2, 0), a_count=2)
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
|
||||
|
||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||
assert len(polys) == 2
|
||||
# First polygon at (0,0)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
# Second polygon at (2,0)
|
||||
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
|
||||
|
||||
def test_layer_as_polygons_flatten() -> None:
|
||||
lib = Library()
|
||||
|
||||
child = Pattern()
|
||||
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
|
||||
lib['child'] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
|
||||
|
||||
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
||||
assert len(polys) == 1
|
||||
# Child vertices are rotated by the ref and then translated by the ref offset.
|
||||
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
||||
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
||||
|
||||
def test_boolean_import_error() -> None:
|
||||
from masque import boolean
|
||||
# If pyclipper is not installed, this should raise ImportError
|
||||
try:
|
||||
import pyclipper # noqa: F401
|
||||
pytest.skip("pyclipper is installed, cannot test ImportError")
|
||||
except ImportError:
|
||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||
boolean([], [], operation='union')
|
||||
|
||||
def test_polygon_boolean_shortcut() -> None:
|
||||
poly = Polygon([[0, 0], [1, 0], [1, 1]])
|
||||
# This should also raise ImportError if pyclipper is missing
|
||||
try:
|
||||
import pyclipper # noqa: F401
|
||||
pytest.skip("pyclipper is installed")
|
||||
except ImportError:
|
||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||
poly.boolean(poly)
|
||||
|
||||
|
||||
def test_boolean_intersection_with_pyclipper() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
result = boolean(
|
||||
[Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])],
|
||||
[Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])],
|
||||
operation='intersection',
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
|
||||
|
||||
def test_polygon_boolean_shortcut_with_pyclipper() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
|
||||
poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||
result = poly.boolean(
|
||||
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
operation='intersection',
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_union_difference_and_xor_with_pyclipper() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||
rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
|
||||
|
||||
union = boolean([rect_a], [rect_b], operation='union')
|
||||
assert len(union) == 1
|
||||
assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10)
|
||||
assert_allclose(_poly_area(union[0]), 7, atol=1e-10)
|
||||
|
||||
difference = boolean([rect_a], [rect_b], operation='difference')
|
||||
assert len(difference) == 1
|
||||
assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10)
|
||||
assert_allclose(_poly_area(difference[0]), 3, atol=1e-10)
|
||||
|
||||
xor = boolean([rect_a], [rect_b], operation='xor')
|
||||
assert len(xor) == 2
|
||||
assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10)
|
||||
xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor)
|
||||
assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))]
|
||||
|
||||
|
||||
def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
raw_result = boolean(
|
||||
[numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])],
|
||||
numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
operation='intersection',
|
||||
)
|
||||
assert len(raw_result) == 1
|
||||
assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10)
|
||||
|
||||
single_shape_result = boolean(
|
||||
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
|
||||
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
operation='intersection',
|
||||
)
|
||||
assert len(single_shape_result) == 1
|
||||
assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_handles_multi_polygon_inputs() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
result = boolean(
|
||||
[
|
||||
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
|
||||
Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]),
|
||||
],
|
||||
[
|
||||
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]),
|
||||
],
|
||||
operation='intersection',
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10)
|
||||
result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result)
|
||||
assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))]
|
||||
|
||||
|
||||
def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
|
||||
result = boolean([outer], [hole], operation='difference')
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
|
||||
assert_allclose(_poly_area(result[0]), 64, atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_nested_hole_and_island_case() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
|
||||
island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]])
|
||||
|
||||
result = boolean([outer, island], [hole], operation='union')
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
|
||||
assert_allclose(_poly_area(result[0]), 100, atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_empty_inputs_follow_set_semantics() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
|
||||
|
||||
union = boolean([], [rect], operation='union')
|
||||
assert len(union) == 1
|
||||
assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
intersection = boolean([], [rect], operation='intersection')
|
||||
assert intersection == []
|
||||
|
||||
difference = boolean([], [rect], operation='difference')
|
||||
assert difference == []
|
||||
|
||||
xor = boolean([], [rect], operation='xor')
|
||||
assert len(xor) == 1
|
||||
assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
clip_empty_union = boolean([rect], [], operation='union')
|
||||
assert len(clip_empty_union) == 1
|
||||
assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
clip_empty_intersection = boolean([rect], [], operation='intersection')
|
||||
assert clip_empty_intersection == []
|
||||
|
||||
clip_empty_difference = boolean([rect], [], operation='difference')
|
||||
assert len(clip_empty_difference) == 1
|
||||
assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
clip_empty_xor = boolean([rect], [], operation='xor')
|
||||
assert len(clip_empty_xor) == 1
|
||||
assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_invalid_inputs_raise_pattern_error() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
for bad in (123, object(), [123]):
|
||||
with pytest.raises(PatternError, match='Unsupported type'):
|
||||
boolean([rect], bad, operation='intersection')
|
||||
|
|
@ -1,485 +0,0 @@
|
|||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from ..builder import Pather
|
||||
from ..error import BuildError
|
||||
from ..library import BuildLibrary, BuildReport, ILibraryView, Library, cell, dangling_mode_t
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
def _owned_by(report: BuildReport, owner: str) -> set[str]:
|
||||
return {
|
||||
name for name, prov in report.provenance.items()
|
||||
if prov.owner_declared_name == owner
|
||||
}
|
||||
|
||||
|
||||
class _MetadataSource(ILibraryView):
|
||||
def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None:
|
||||
self.mapping = mapping
|
||||
self._child_graph = child_graph
|
||||
self.loads = 0
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
self.loads += 1
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return tuple(self.mapping)
|
||||
|
||||
def child_graph(self, dangling: dangling_mode_t = 'error') -> dict[str, set[str]]: # noqa: ARG002
|
||||
return self._child_graph
|
||||
|
||||
|
||||
def test_build_library_traces_declared_dependencies_out_of_order() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.ref("child")
|
||||
assert lib.abstract("child").name == "child"
|
||||
return pat
|
||||
|
||||
builder.cells.parent = cell(make_parent)(builder)
|
||||
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
|
||||
|
||||
built, report = builder.build()
|
||||
|
||||
assert "parent" in built
|
||||
assert "child" in built
|
||||
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||
assert report.provenance["parent"].kind == "declared"
|
||||
|
||||
|
||||
def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
tree = Library({"_helper": Pattern()})
|
||||
name_a = lib << tree
|
||||
name_b = lib << tree
|
||||
top = Pattern()
|
||||
top.ref(name_a)
|
||||
top.ref(name_b)
|
||||
return top
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
_built, report = builder.build()
|
||||
|
||||
helpers = [
|
||||
(name, prov) for name, prov in report.provenance.items()
|
||||
if prov.owner_declared_name == "top" and prov.kind == "helper"
|
||||
]
|
||||
|
||||
assert "top" in _owned_by(report, "top")
|
||||
assert len(helpers) == 2
|
||||
assert any(name != prov.requested_name for name, prov in helpers)
|
||||
|
||||
|
||||
def test_build_library_authoring_tree_merge_renames_repeated_single_use_names() -> None:
|
||||
builder = BuildLibrary()
|
||||
tree = Library({"_helper": Pattern()})
|
||||
|
||||
name_a = builder << tree
|
||||
name_b = builder << tree
|
||||
built, report = builder.build()
|
||||
|
||||
assert name_a == "_helper"
|
||||
assert name_b != "_helper"
|
||||
assert name_a in built
|
||||
assert name_b in built
|
||||
assert report.provenance[name_b].requested_name == name_b
|
||||
|
||||
|
||||
def test_build_library_authoring_tree_merge_remaps_internal_refs() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["_helper"] = Pattern()
|
||||
helper = Pattern()
|
||||
top = Pattern()
|
||||
top.ref("_helper")
|
||||
|
||||
top_name = builder << Library({"_helper": helper, "top": top})
|
||||
built, _report = builder.build()
|
||||
|
||||
assert top_name == "top"
|
||||
assert "_helper" not in built[top_name].refs
|
||||
assert any(name != "_helper" for name in built[top_name].refs)
|
||||
|
||||
|
||||
def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["leaf"] = Pattern()
|
||||
|
||||
with pytest.raises(BuildError, match="validate\\(\\) or build\\(\\)"):
|
||||
_ = builder["leaf"]
|
||||
|
||||
with pytest.raises(BuildError, match="write-only"):
|
||||
_ = builder.cells.leaf
|
||||
|
||||
built, report = builder.build(output="library")
|
||||
|
||||
assert isinstance(built, Library)
|
||||
assert report.requested_roots == ("leaf",)
|
||||
|
||||
with pytest.raises(BuildError, match="frozen"):
|
||||
builder["later"] = Pattern()
|
||||
with pytest.raises(BuildError, match="frozen"):
|
||||
builder.build()
|
||||
|
||||
|
||||
def test_build_library_validate_is_retryable_after_failure() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.ref("child")
|
||||
lib.abstract("child")
|
||||
return pat
|
||||
|
||||
builder.cells.parent = cell(make_parent)(builder)
|
||||
|
||||
with pytest.raises(BuildError, match='Failed while building declared cell "parent"'):
|
||||
builder.validate()
|
||||
|
||||
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
|
||||
report = builder.validate()
|
||||
|
||||
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||
|
||||
|
||||
def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["child"] = Pattern()
|
||||
|
||||
def make_parent() -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.ref("child")
|
||||
return pat
|
||||
|
||||
builder.cells.parent = cell(make_parent)().depends_on("child")
|
||||
report = builder.validate(names=("parent",))
|
||||
|
||||
assert report.requested_roots == ("parent",)
|
||||
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||
|
||||
|
||||
def test_build_library_validate_rejects_removed_output_argument() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["leaf"] = Pattern()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
builder.validate(output="library") # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_build_library_rejects_unknown_build_output_mode() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["leaf"] = Pattern()
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown build output mode"):
|
||||
builder.build(output="bad") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_build_library_allows_helper_writes_via_pather() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)})
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
helper = Pather(library=lib, ports="leaf", name="_route")
|
||||
top = Pattern()
|
||||
top.ref("_route")
|
||||
top.ref("leaf")
|
||||
top.ports.update(helper.pattern.ports)
|
||||
return top
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
_built, report = builder.build()
|
||||
|
||||
helper_prov = report.provenance["_route"]
|
||||
assert helper_prov.kind == "helper"
|
||||
assert helper_prov.owner_declared_name == "top"
|
||||
|
||||
|
||||
def test_build_library_contains_tracks_active_session_names() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["leaf"] = Pattern()
|
||||
builder.add_source(Library({"src": Pattern()}))
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
assert "leaf" in lib
|
||||
assert "src" in lib
|
||||
assert "_helper" not in lib
|
||||
lib["_helper"] = Pattern()
|
||||
assert "_helper" in lib
|
||||
return Pattern()
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
built, _report = builder.build()
|
||||
|
||||
assert "_helper" in built
|
||||
|
||||
|
||||
def test_build_library_preserves_source_cells_and_records_source_provenance() -> None:
|
||||
source = Library({"src": Pattern()})
|
||||
builder = BuildLibrary()
|
||||
builder.add_source(source)
|
||||
builder.cells.top = cell(lambda: Pattern())()
|
||||
|
||||
built, report = builder.build()
|
||||
|
||||
assert "src" in built
|
||||
assert report.provenance["src"].kind == "source"
|
||||
|
||||
|
||||
def test_build_library_add_source_can_rename_every_source_cell() -> None:
|
||||
source = Library()
|
||||
source["child"] = Pattern()
|
||||
parent = Pattern()
|
||||
parent.ref("child")
|
||||
source["parent"] = parent
|
||||
|
||||
builder = BuildLibrary()
|
||||
rename_map = builder.add_source(
|
||||
source,
|
||||
rename_theirs=lambda _lib, name: f"mapped_{name}",
|
||||
rename_when="always",
|
||||
)
|
||||
built, report = builder.build()
|
||||
|
||||
assert rename_map == {
|
||||
"child": "mapped_child",
|
||||
"parent": "mapped_parent",
|
||||
}
|
||||
assert "mapped_child" in built["mapped_parent"].refs
|
||||
assert report.provenance["mapped_child"].requested_name == "child"
|
||||
|
||||
|
||||
def test_build_library_authoring_tree_merge_keeps_source_view_lazy() -> None:
|
||||
child = Pattern()
|
||||
top = Pattern()
|
||||
top.ref("child")
|
||||
source = _MetadataSource(
|
||||
{"child": child, "top": top},
|
||||
{"child": set(), "top": {"child"}},
|
||||
)
|
||||
|
||||
builder = BuildLibrary()
|
||||
top_name = builder << source
|
||||
built, _report = builder.build()
|
||||
|
||||
assert top_name == "top"
|
||||
assert "top" in built
|
||||
assert source.loads == 0
|
||||
|
||||
|
||||
def test_build_library_authoring_source_tree_merge_returns_renamed_top() -> None:
|
||||
existing = Pattern()
|
||||
source_top = Pattern()
|
||||
source = _MetadataSource(
|
||||
{"_helper": source_top},
|
||||
{"_helper": set()},
|
||||
)
|
||||
|
||||
builder = BuildLibrary()
|
||||
builder["_helper"] = existing
|
||||
top_name = builder << source
|
||||
built, _report = builder.build()
|
||||
|
||||
assert top_name != "_helper"
|
||||
assert top_name in built
|
||||
assert source.loads == 0
|
||||
|
||||
|
||||
def test_build_library_authoring_source_tree_merge_remaps_renamed_child_on_materialization() -> None:
|
||||
source_helper = Pattern()
|
||||
source_top = Pattern()
|
||||
source_top.ref("_helper")
|
||||
source = _MetadataSource(
|
||||
{"_helper": source_helper, "top": source_top},
|
||||
{"_helper": set(), "top": {"_helper"}},
|
||||
)
|
||||
|
||||
builder = BuildLibrary()
|
||||
builder["_helper"] = Pattern()
|
||||
top_name = builder << source
|
||||
built, _report = builder.build(output="library")
|
||||
|
||||
assert top_name == "top"
|
||||
assert "_helper" not in built[top_name].refs
|
||||
assert source.loads == 2
|
||||
|
||||
|
||||
def test_build_library_rejects_authoring_tree_le_before_mutating() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
with pytest.raises(BuildError, match="__le__"):
|
||||
_abstract = builder <= Library({"leaf": Pattern()})
|
||||
|
||||
assert list(builder) == []
|
||||
|
||||
|
||||
def test_build_library_rejects_source_cells_added_after_add_source() -> None:
|
||||
source = Library({"src": Pattern()})
|
||||
builder = BuildLibrary()
|
||||
builder.add_source(source)
|
||||
source["late"] = Pattern()
|
||||
|
||||
with pytest.raises(BuildError, match="Do not structurally mutate source libraries"):
|
||||
builder.build()
|
||||
|
||||
|
||||
def test_build_library_rejects_source_cells_removed_after_add_source() -> None:
|
||||
source = Library({"src": Pattern()})
|
||||
builder = BuildLibrary()
|
||||
builder.add_source(source)
|
||||
del source["src"]
|
||||
|
||||
with pytest.raises(BuildError, match="Do not structurally mutate source libraries"):
|
||||
builder.build()
|
||||
|
||||
|
||||
def test_build_library_rejects_add_source_during_build() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
lib.add_source(Library({"src": Pattern()}))
|
||||
return Pattern()
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
|
||||
with pytest.raises(BuildError, match="add_source"):
|
||||
builder.build()
|
||||
|
||||
|
||||
def test_build_library_rejects_renaming_imported_source_cells_during_authoring() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder.add_source(Library({"src": Pattern()}))
|
||||
|
||||
with pytest.raises(BuildError, match="add_source"):
|
||||
builder.rename("src", "renamed_src", move_references=True)
|
||||
|
||||
|
||||
def test_build_library_rejects_renaming_declared_cells_during_authoring() -> None:
|
||||
builder = BuildLibrary()
|
||||
builder["declared"] = Pattern()
|
||||
|
||||
with pytest.raises(BuildError, match='Cannot rename declared build cell "declared"'):
|
||||
builder.rename("declared", "renamed_declared")
|
||||
|
||||
|
||||
def test_build_library_helper_rename_updates_provenance_owner() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
lib["_helper"] = Pattern()
|
||||
lib.rename("_helper", "final_helper")
|
||||
top = Pattern()
|
||||
top.ref("final_helper")
|
||||
return top
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
built, report = builder.build()
|
||||
|
||||
assert "final_helper" in built
|
||||
assert "_helper" not in built
|
||||
owned = _owned_by(report, "top")
|
||||
assert "final_helper" in owned
|
||||
assert "_helper" not in owned
|
||||
prov = report.provenance["final_helper"]
|
||||
assert prov.kind == "helper"
|
||||
assert prov.requested_name == "_helper"
|
||||
|
||||
|
||||
def test_build_library_helper_delete_removes_provenance_and_ownership() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
lib["_helper"] = Pattern()
|
||||
del lib["_helper"]
|
||||
return Pattern()
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
built, report = builder.build()
|
||||
|
||||
assert "_helper" not in built
|
||||
assert "_helper" not in report.provenance
|
||||
assert _owned_by(report, "top") == {"top"}
|
||||
|
||||
|
||||
def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None:
|
||||
builder = BuildLibrary()
|
||||
|
||||
def make_top(lib: BuildLibrary) -> Pattern:
|
||||
tree = Library({"_helper": Pattern()})
|
||||
_ = lib << tree
|
||||
renamed = lib << tree
|
||||
lib.rename(renamed, "final_helper")
|
||||
top = Pattern()
|
||||
top.ref("_helper")
|
||||
top.ref("final_helper")
|
||||
return top
|
||||
|
||||
builder.cells.top = cell(make_top)(builder)
|
||||
built, report = builder.build()
|
||||
|
||||
assert "final_helper" in built
|
||||
prov = report.provenance["final_helper"]
|
||||
assert prov.requested_name == "_helper"
|
||||
|
||||
|
||||
def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None:
|
||||
declared = BuildLibrary()
|
||||
declared["leaf"] = Pattern()
|
||||
|
||||
def rename_declared(lib: BuildLibrary) -> Pattern:
|
||||
lib.rename("leaf", "renamed_leaf")
|
||||
return Pattern()
|
||||
|
||||
declared.cells.top = cell(rename_declared)(declared)
|
||||
with pytest.raises(BuildError, match='Cannot rename declared build cell "leaf"'):
|
||||
declared.build()
|
||||
|
||||
source = BuildLibrary()
|
||||
source.add_source(Library({"src": Pattern()}))
|
||||
|
||||
def rename_source(lib: BuildLibrary) -> Pattern:
|
||||
lib.rename("src", "renamed_src")
|
||||
return Pattern()
|
||||
|
||||
source.cells.top = cell(rename_source)(source)
|
||||
with pytest.raises(BuildError, match='Cannot rename imported source cell "src"'):
|
||||
source.build()
|
||||
|
||||
|
||||
def test_build_library_rejects_deleting_declared_or_source_cells_during_build() -> None:
|
||||
declared = BuildLibrary()
|
||||
declared["leaf"] = Pattern()
|
||||
|
||||
def delete_declared(lib: BuildLibrary) -> Pattern:
|
||||
del lib["leaf"]
|
||||
return Pattern()
|
||||
|
||||
declared.cells.top = cell(delete_declared)(declared)
|
||||
with pytest.raises(BuildError, match='Cannot delete declared build cell "leaf"'):
|
||||
declared.build()
|
||||
|
||||
source = BuildLibrary()
|
||||
source.add_source(Library({"src": Pattern()}))
|
||||
|
||||
def delete_source(lib: BuildLibrary) -> Pattern:
|
||||
del lib["src"]
|
||||
return Pattern()
|
||||
|
||||
source.cells.top = cell(delete_source)(source)
|
||||
with pytest.raises(BuildError, match='Cannot delete imported source cell "src"'):
|
||||
source.build()
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..builder import Pather
|
||||
from ..builder.utils import ell
|
||||
from ..error import BuildError
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
def test_builder_public_imports() -> None:
|
||||
from masque import PortPather as TopPortPather
|
||||
from masque import RenderStep as TopRenderStep
|
||||
from masque.builder import PortPather as BuilderPortPather
|
||||
from masque.builder import RenderStep as BuilderRenderStep
|
||||
|
||||
assert TopPortPather is BuilderPortPather
|
||||
assert TopRenderStep is BuilderRenderStep
|
||||
|
||||
|
||||
def test_builder_init() -> None:
|
||||
lib = Library()
|
||||
b = Pather(lib, name="mypat")
|
||||
assert b.pattern is lib["mypat"]
|
||||
assert b.library is lib
|
||||
|
||||
|
||||
def test_builder_place() -> None:
|
||||
lib = Library()
|
||||
child = Pattern()
|
||||
child.ports["A"] = Port((0, 0), 0)
|
||||
lib["child"] = child
|
||||
|
||||
b = Pather(lib)
|
||||
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
|
||||
|
||||
assert "child_A" in b.ports
|
||||
assert_equal(b.ports["child_A"].offset, [10, 20])
|
||||
assert "child" in b.pattern.refs
|
||||
|
||||
|
||||
def test_builder_plug() -> None:
|
||||
lib = Library()
|
||||
|
||||
wire = Pattern()
|
||||
wire.ports["in"] = Port((0, 0), 0)
|
||||
wire.ports["out"] = Port((10, 0), pi)
|
||||
lib["wire"] = wire
|
||||
|
||||
b = Pather(lib)
|
||||
b.ports["start"] = Port((100, 100), 0)
|
||||
|
||||
# Plug wire's "in" port into builder's "start" port
|
||||
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
|
||||
# builder start: (100, 100) rotation 0
|
||||
# wire in: (0, 0) rotation 0
|
||||
# wire out: (10, 0) rotation pi
|
||||
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
|
||||
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
|
||||
b.plug("wire", map_in={"start": "in"})
|
||||
|
||||
assert "start" in b.ports
|
||||
assert_equal(b.ports["start"].offset, [90, 100])
|
||||
assert b.ports["start"].rotation is not None
|
||||
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_builder_interface() -> None:
|
||||
lib = Library()
|
||||
source = Pattern()
|
||||
source.ports["P1"] = Port((0, 0), 0)
|
||||
lib["source"] = source
|
||||
|
||||
b = Pather.interface("source", library=lib, name="iface")
|
||||
assert "in_P1" in b.ports
|
||||
assert "P1" in b.ports
|
||||
assert b.pattern is lib["iface"]
|
||||
|
||||
|
||||
def test_builder_set_dead() -> None:
|
||||
lib = Library()
|
||||
lib["sub"] = Pattern()
|
||||
b = Pather(lib)
|
||||
b.set_dead()
|
||||
|
||||
b.place("sub")
|
||||
assert not b.pattern.has_refs()
|
||||
|
||||
|
||||
def test_builder_dead_ports() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.ports['A'] = Port((0, 0), 0)
|
||||
b = Pather(lib, pattern=pat)
|
||||
b.set_dead()
|
||||
|
||||
# Attempt to plug a device where ports don't line up
|
||||
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
|
||||
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
|
||||
|
||||
# This should NOT raise PortError because b is dead
|
||||
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
|
||||
|
||||
# Port A should be removed, and Port B (renamed from D) should be added
|
||||
assert 'A' not in b.ports
|
||||
assert 'B' in b.ports
|
||||
|
||||
# Verify geometry was not added
|
||||
assert not b.pattern.has_refs()
|
||||
assert not b.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_dead_plug_best_effort() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.ports['A'] = Port((0, 0), 0)
|
||||
b = Pather(lib, pattern=pat)
|
||||
b.set_dead()
|
||||
|
||||
# Device with multiple ports, none of which line up correctly
|
||||
other = Pattern(ports={
|
||||
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
|
||||
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
|
||||
})
|
||||
|
||||
# Try to plug. find_transform will fail.
|
||||
# It should fall back to aligning the first pair ('A' and 'P1').
|
||||
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
|
||||
|
||||
assert 'A' not in b.ports
|
||||
assert 'B' in b.ports
|
||||
|
||||
# Dummy transform aligns A (0,0) with P1 (10,10)
|
||||
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
|
||||
# P2 (20,20) rotation pi:
|
||||
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
|
||||
# 2. Rotate (10,10) by -pi: (-10,-10)
|
||||
# 3. Translate by s_port.offset (0,0): (-10,-10)
|
||||
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
|
||||
# P2 rot pi + transform rot -pi = 0
|
||||
assert b.ports['B'].rotation is not None
|
||||
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_ell_validates_spacing_length() -> None:
|
||||
ports = {
|
||||
'A': Port((0, 0), 0),
|
||||
'B': Port((0, 1), 0),
|
||||
'C': Port((0, 2), 0),
|
||||
}
|
||||
|
||||
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
|
||||
ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3])
|
||||
|
||||
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
|
||||
ell(ports, True, 'min_extension', 5, spacing=[])
|
||||
|
||||
|
||||
def test_ell_handles_array_spacing_when_ccw_none() -> None:
|
||||
ports = {
|
||||
'A': Port((0, 0), 0),
|
||||
'B': Port((0, 1), 0),
|
||||
}
|
||||
|
||||
scalar = ell(ports, None, 'min_extension', 5, spacing=0)
|
||||
array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0]))
|
||||
assert scalar == array_zero
|
||||
|
||||
with pytest.raises(BuildError, match='Spacing must be 0 or None'):
|
||||
ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0]))
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..shapes import Circle, Polygon
|
||||
|
||||
|
||||
def test_circle_init() -> None:
|
||||
c = Circle(radius=10, offset=(5, 5))
|
||||
assert c.radius == 10
|
||||
assert_equal(c.offset, [5, 5])
|
||||
|
||||
def test_circle_to_polygons() -> None:
|
||||
c = Circle(radius=10)
|
||||
polys = c.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
assert isinstance(polys[0], Polygon)
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from numpy import pi
|
||||
|
||||
from ..shapes import Arc, Circle, Ellipse
|
||||
from .helpers import assert_closed_edges_within
|
||||
|
||||
|
||||
def test_shape_arclen() -> None:
|
||||
e = Ellipse(radii=(10, 5))
|
||||
polys = e.to_polygons(max_arclen=5)
|
||||
v = polys[0].vertices
|
||||
assert_closed_edges_within(v, 5)
|
||||
assert len(v) > 10
|
||||
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
polys = a.to_polygons(max_arclen=2)
|
||||
assert_closed_edges_within(polys[0].vertices, 2)
|
||||
|
||||
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
||||
for shape in (
|
||||
Circle(radius=10),
|
||||
Ellipse(radii=(10, 20)),
|
||||
Arc(radii=(10, 20), angles=(0, 1), width=2),
|
||||
):
|
||||
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
|
||||
assert len(polys) == 1
|
||||
assert len(polys[0].vertices) >= 3
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import io
|
||||
import numpy
|
||||
import ezdxf
|
||||
from numpy.testing import assert_allclose
|
||||
from pathlib import Path
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath, Polygon
|
||||
from ..repetition import Grid
|
||||
from ..file import dxf
|
||||
|
||||
|
||||
def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
|
||||
return bool(
|
||||
numpy.allclose(actual, expected)
|
||||
or numpy.allclose(actual, expected[::-1])
|
||||
)
|
||||
|
||||
|
||||
def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
|
||||
return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()}
|
||||
|
||||
|
||||
def test_dxf_roundtrip(tmp_path: Path):
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
|
||||
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
pat.polygon("1", vertices=poly_verts)
|
||||
|
||||
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
|
||||
pat.path("2", vertices=path_verts, width=2)
|
||||
|
||||
# Two-point paths remain paths rather than being polygonized.
|
||||
path2_verts = numpy.array([[40, 0], [50, 10]])
|
||||
pat.path("3", vertices=path2_verts, width=0)
|
||||
|
||||
subpat = Pattern()
|
||||
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
lib["sub"] = subpat
|
||||
|
||||
pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3))
|
||||
|
||||
lib["top"] = pat
|
||||
|
||||
dxf_file = tmp_path / "test.dxf"
|
||||
dxf.writefile(lib, "top", dxf_file)
|
||||
|
||||
read_lib, _ = dxf.readfile(dxf_file)
|
||||
|
||||
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||
|
||||
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
|
||||
assert len(polys) >= 1
|
||||
poly_read = polys[0]
|
||||
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
|
||||
|
||||
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
|
||||
assert len(paths) >= 1
|
||||
path_read = paths[0]
|
||||
assert _matches_open_path(path_read.vertices, path_verts)
|
||||
assert path_read.width == 2
|
||||
|
||||
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
|
||||
assert len(paths2) >= 1
|
||||
path2_read = paths2[0]
|
||||
assert _matches_open_path(path2_read.vertices, path2_verts)
|
||||
assert path2_read.width == 0
|
||||
|
||||
assert "sub" in read_lib
|
||||
|
||||
found_grid = False
|
||||
for target, reflist in top_pat.refs.items():
|
||||
if target.upper() == "SUB":
|
||||
for ref in reflist:
|
||||
if isinstance(ref.repetition, Grid):
|
||||
assert ref.repetition.a_count == 2
|
||||
assert ref.repetition.b_count == 3
|
||||
assert_allclose(ref.repetition.a_vector, (10, 0))
|
||||
assert_allclose(ref.repetition.b_vector, (0, 10))
|
||||
found_grid = True
|
||||
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
|
||||
|
||||
def test_dxf_manhattan_precision(tmp_path: Path):
|
||||
lib = Library()
|
||||
sub = Pattern()
|
||||
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
lib["sub"] = sub
|
||||
|
||||
top = Pattern()
|
||||
angle = numpy.pi / 2 # 90 degrees
|
||||
top.ref("sub", offset=(0, 0), rotation=angle,
|
||||
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
|
||||
|
||||
lib["top"] = top
|
||||
|
||||
dxf_file = tmp_path / "precision.dxf"
|
||||
dxf.writefile(lib, "top", dxf_file)
|
||||
|
||||
# Near-integer rotated basis vectors round-trip as a Manhattan Grid.
|
||||
read_lib, _ = dxf.readfile(dxf_file)
|
||||
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||
|
||||
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
||||
ref = read_top.refs[target_name][0]
|
||||
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"
|
||||
|
||||
|
||||
def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path):
|
||||
lib = Library()
|
||||
sub = Pattern()
|
||||
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
lib["sub"] = sub
|
||||
|
||||
top = Pattern()
|
||||
top.ref(
|
||||
"sub",
|
||||
offset=(0, 0),
|
||||
rotation=numpy.pi / 2,
|
||||
repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2),
|
||||
)
|
||||
lib["top"] = top
|
||||
|
||||
dxf_file = tmp_path / "rotated_grid.dxf"
|
||||
dxf.writefile(lib, "top", dxf_file)
|
||||
|
||||
read_lib, _ = dxf.readfile(dxf_file)
|
||||
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||
|
||||
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
||||
ref = read_top.refs[target_name][0]
|
||||
assert isinstance(ref.repetition, Grid)
|
||||
actual = ref.repetition.displacements
|
||||
expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements
|
||||
assert_allclose(
|
||||
actual[numpy.lexsort((actual[:, 1], actual[:, 0]))],
|
||||
expected[numpy.lexsort((expected[:, 1], expected[:, 0]))],
|
||||
)
|
||||
|
||||
|
||||
def test_dxf_read_legacy_polyline() -> None:
|
||||
doc = ezdxf.new()
|
||||
msp = doc.modelspace()
|
||||
msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True)
|
||||
|
||||
stream = io.StringIO()
|
||||
doc.write(stream)
|
||||
stream.seek(0)
|
||||
|
||||
read_lib, _ = dxf.read(stream)
|
||||
top_pat = read_lib.get("Model") or list(read_lib.values())[0]
|
||||
|
||||
polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)]
|
||||
assert len(polys) == 1
|
||||
assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]]))
|
||||
|
||||
|
||||
def test_dxf_read_ignores_unreferenced_setup_blocks() -> None:
|
||||
lib = Library({"top": Pattern()})
|
||||
stream = io.StringIO()
|
||||
|
||||
dxf.write(lib, "top", stream)
|
||||
stream.seek(0)
|
||||
|
||||
read_lib, _ = dxf.read(stream)
|
||||
|
||||
assert set(read_lib) == {"Model"}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
from numpy import pi
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..shapes import Ellipse
|
||||
|
||||
|
||||
def test_ellipse_init() -> None:
|
||||
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
||||
assert_equal(e.radii, [10, 5])
|
||||
assert_equal(e.offset, [1, 2])
|
||||
assert e.rotation == pi / 4
|
||||
|
||||
def test_ellipse_to_polygons() -> None:
|
||||
e = Ellipse(radii=(10, 5))
|
||||
polys = e.to_polygons(num_vertices=64)
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
||||
|
||||
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
|
||||
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
|
||||
bounds = ellipse.get_bounds_single()
|
||||
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||
|
||||
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
||||
ellipse = Ellipse(radii=(10, 20))
|
||||
ellipse.scale_by(0.5)
|
||||
assert_allclose(ellipse.radii, [5, 10])
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# ruff: noqa
|
||||
# ruff: noqa: ARG001
|
||||
|
||||
|
||||
import dataclasses
|
||||
import pytest # type: ignore
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray
|
||||
# from numpy.testing import assert_allclose, assert_array_equal
|
||||
|
||||
from .. import Pattern, Arc, Circle
|
||||
|
||||
|
||||
def test_circle_mirror():
|
||||
cc = Circle(radius=4, offset=(10, 20))
|
||||
cc.flip_across(axis=0) # flip across y=0
|
||||
assert cc.offset[0] == 10
|
||||
assert cc.offset[1] == -20
|
||||
assert cc.radius == 4
|
||||
cc.flip_across(axis=1) # flip across x=0
|
||||
assert cc.offset[0] == -10
|
||||
assert cc.offset[1] == -20
|
||||
assert cc.radius == 4
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
from pathlib import Path
|
||||
from typing import cast
|
||||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
|
||||
from ..repetition import Grid, Arbitrary
|
||||
|
||||
def create_test_library(for_gds: bool = False) -> Library:
|
||||
lib = Library()
|
||||
|
||||
pat_poly = Pattern()
|
||||
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
|
||||
lib["polygons"] = pat_poly
|
||||
|
||||
pat_paths = Pattern()
|
||||
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
|
||||
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
|
||||
if for_gds:
|
||||
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
|
||||
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
|
||||
lib["paths"] = pat_paths
|
||||
|
||||
pat_circles = Pattern()
|
||||
if for_gds:
|
||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
|
||||
else:
|
||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
|
||||
lib["circles"] = pat_circles
|
||||
|
||||
pat_refs = Pattern()
|
||||
pat_refs.ref("polygons", offset=(0, 0))
|
||||
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
|
||||
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
|
||||
lib["refs"] = pat_refs
|
||||
|
||||
pat_rep_shapes = Pattern()
|
||||
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
|
||||
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
|
||||
lib["rep_shapes"] = pat_rep_shapes
|
||||
|
||||
if for_gds:
|
||||
lib.wrap_repeated_shapes()
|
||||
|
||||
return lib
|
||||
|
||||
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
|
||||
from ..file import gdsii
|
||||
lib = create_test_library(for_gds=True)
|
||||
gds_file = tmp_path / "full_test.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
|
||||
for name in lib:
|
||||
assert name in read_lib
|
||||
|
||||
read_paths = read_lib["paths"]
|
||||
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
|
||||
assert p_flush.cap == MPath.Cap.Flush
|
||||
|
||||
p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
|
||||
assert p_square.cap == MPath.Cap.Square
|
||||
|
||||
p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
|
||||
assert p_circle.cap == MPath.Cap.Circle
|
||||
|
||||
p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
|
||||
assert p_custom.cap == MPath.Cap.SquareCustom
|
||||
assert p_custom.cap_extensions is not None
|
||||
assert_allclose(p_custom.cap_extensions, (1, 5))
|
||||
|
||||
read_refs = read_lib["refs"]
|
||||
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
|
||||
|
||||
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
|
||||
assert len(arefs) == 1
|
||||
assert isinstance(arefs[0].repetition, Grid)
|
||||
assert arefs[0].repetition.a_count == 3
|
||||
assert arefs[0].repetition.b_count == 2
|
||||
|
||||
# GDS stores repeated shapes through refs created by wrap_repeated_shapes().
|
||||
assert len(read_lib["rep_shapes"].refs) > 0
|
||||
|
||||
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
||||
pytest.importorskip("fatamorgana")
|
||||
from ..file import oasis
|
||||
lib = create_test_library(for_gds=False)
|
||||
oas_file = tmp_path / "full_test.oas"
|
||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||
|
||||
read_lib, _ = oasis.readfile(oas_file)
|
||||
|
||||
for name in lib:
|
||||
assert name in read_lib
|
||||
|
||||
read_circles = read_lib["circles"]
|
||||
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
|
||||
assert read_circles.shapes[(3, 0)][0].radius == 5
|
||||
|
||||
read_paths = read_lib["paths"]
|
||||
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
|
||||
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
|
||||
|
||||
read_rep_shapes = read_lib["rep_shapes"]
|
||||
poly = read_rep_shapes.shapes[(4, 0)][0]
|
||||
assert poly.repetition is not None
|
||||
assert isinstance(poly.repetition, Grid)
|
||||
assert poly.repetition.a_count == 5
|
||||
|
||||
|
||||
def test_gdsii_rect_collection_roundtrip(tmp_path: Path) -> None:
|
||||
from ..file import gdsii
|
||||
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.shapes[(5, 0)].append(
|
||||
RectCollection(
|
||||
rects=[[0, 0, 10, 5], [20, -5, 30, 10]],
|
||||
annotations={'1': ['rects']},
|
||||
)
|
||||
)
|
||||
lib['rects'] = pat
|
||||
|
||||
gds_file = tmp_path / 'rect_collection.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
polys = read_lib['rects'].shapes[(5, 0)]
|
||||
|
||||
assert len(polys) == 2
|
||||
assert all(isinstance(poly, Polygon) for poly in polys)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [0, 5], [10, 5], [10, 0]])
|
||||
assert_allclose(polys[1].vertices, [[20, -5], [20, 10], [30, 10], [30, -5]])
|
||||
assert polys[0].annotations == {'1': ['rects']}
|
||||
assert polys[1].annotations == {'1': ['rects']}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
from pathlib import Path
|
||||
from typing import cast
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..error import LibraryError
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..file import gdsii
|
||||
from ..shapes import Path as MPath, Polygon
|
||||
|
||||
|
||||
def test_gdsii_roundtrip(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
|
||||
# Simple polygon cell
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["poly_cell"] = pat1
|
||||
|
||||
# Path cell
|
||||
pat2 = Pattern()
|
||||
pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10)
|
||||
lib["path_cell"] = pat2
|
||||
|
||||
# Cell with Ref
|
||||
pat3 = Pattern()
|
||||
pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2)
|
||||
lib["ref_cell"] = pat3
|
||||
|
||||
gds_file = tmp_path / "test.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, info = gdsii.readfile(gds_file)
|
||||
|
||||
assert "poly_cell" in read_lib
|
||||
assert "path_cell" in read_lib
|
||||
assert "ref_cell" in read_lib
|
||||
|
||||
# Check polygon
|
||||
read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0])
|
||||
# GDSII closes polygons, so it might have an extra vertex or different order
|
||||
assert len(read_poly.vertices) >= 4
|
||||
# Check bounds as a proxy for geometry correctness
|
||||
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
|
||||
|
||||
# Check path
|
||||
read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0])
|
||||
assert isinstance(read_path, MPath)
|
||||
assert read_path.width == 10
|
||||
assert_equal(read_path.vertices, [[0, 0], [100, 0]])
|
||||
|
||||
# Check Ref
|
||||
read_ref = read_lib["ref_cell"].refs["poly_cell"][0]
|
||||
assert_equal(read_ref.offset, [50, 50])
|
||||
assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5)
|
||||
|
||||
|
||||
def test_gdsii_annotations(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
# GDS only supports integer keys in range [1, 126] for properties
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]})
|
||||
lib["cell"] = pat
|
||||
|
||||
gds_file = tmp_path / "test_ann.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
|
||||
assert read_ann is not None
|
||||
assert read_ann["1"] == ["hello"]
|
||||
|
||||
|
||||
def test_gdsii_check_valid_names_validates_generator_lengths() -> None:
|
||||
names = (name for name in ("a" * 40,))
|
||||
|
||||
with pytest.raises(LibraryError, match="invalid names"):
|
||||
gdsii.check_valid_names(names)
|
||||
|
|
@ -1,604 +0,0 @@
|
|||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import klamath
|
||||
import numpy
|
||||
import pytest
|
||||
|
||||
pytest.importorskip('pyarrow')
|
||||
|
||||
from .. import Ref, Label, PatternError
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..repetition import Grid
|
||||
from ..shapes import Path as MPath, Polygon, PolyCollection, RectCollection
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import arrow as gdsii_arrow
|
||||
from ..file.gdsii.perf import write_fixture
|
||||
|
||||
|
||||
if not gdsii_arrow.is_available():
|
||||
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
|
||||
|
||||
|
||||
def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None:
|
||||
if not annotations:
|
||||
return None
|
||||
return tuple(sorted((key, tuple(values)) for key, values in annotations.items()))
|
||||
|
||||
|
||||
def _coord_key(values: object) -> tuple[int, ...] | tuple[tuple[int, int], ...]:
|
||||
arr = numpy.rint(numpy.asarray(values, dtype=float)).astype(int)
|
||||
if arr.ndim == 1:
|
||||
return tuple(arr.tolist())
|
||||
return tuple(tuple(row.tolist()) for row in arr)
|
||||
|
||||
|
||||
def _canonical_polygon_key(vertices: object) -> tuple[tuple[int, int], ...]:
|
||||
arr = numpy.rint(numpy.asarray(vertices, dtype=float)).astype(int)
|
||||
rows = [tuple(tuple(row.tolist()) for row in numpy.roll(arr, -shift, axis=0)) for shift in range(arr.shape[0])]
|
||||
rev = arr[::-1]
|
||||
rows.extend(tuple(tuple(row.tolist()) for row in numpy.roll(rev, -shift, axis=0)) for shift in range(rev.shape[0]))
|
||||
return min(rows)
|
||||
|
||||
|
||||
def _shape_key(shape: object, layer: tuple[int, int]) -> list[tuple[object, ...]]:
|
||||
if isinstance(shape, MPath):
|
||||
cap_extensions = None if shape.cap_extensions is None else _coord_key(shape.cap_extensions)
|
||||
return [(
|
||||
'path',
|
||||
layer,
|
||||
_coord_key(shape.vertices),
|
||||
_coord_key(shape.offset),
|
||||
int(round(float(shape.width))),
|
||||
shape.cap.name,
|
||||
cap_extensions,
|
||||
_annotations_key(shape.annotations),
|
||||
)]
|
||||
|
||||
keys = []
|
||||
for poly in shape.to_polygons():
|
||||
keys.append((
|
||||
'polygon',
|
||||
layer,
|
||||
_canonical_polygon_key(poly.vertices),
|
||||
_coord_key(poly.offset),
|
||||
_annotations_key(poly.annotations),
|
||||
))
|
||||
return keys
|
||||
|
||||
|
||||
def _ref_keys(target: str, ref: object) -> list[tuple[object, ...]]:
|
||||
keys = []
|
||||
for transform in ref.as_transforms():
|
||||
keys.append((
|
||||
target,
|
||||
_coord_key(transform[:2]),
|
||||
round(float(transform[2]), 8),
|
||||
round(float(transform[4]), 8),
|
||||
bool(int(round(float(transform[3])))),
|
||||
_annotations_key(ref.annotations),
|
||||
))
|
||||
return keys
|
||||
|
||||
|
||||
def _label_key(layer: tuple[int, int], label: object) -> tuple[object, ...]:
|
||||
return (
|
||||
layer,
|
||||
label.string,
|
||||
_coord_key(label.offset),
|
||||
_annotations_key(label.annotations),
|
||||
)
|
||||
|
||||
|
||||
def _pattern_summary(pattern: Pattern) -> dict[str, object]:
|
||||
shape_keys: list[tuple[object, ...]] = []
|
||||
for layer, shapes in pattern.shapes.items():
|
||||
for shape in shapes:
|
||||
shape_keys.extend(_shape_key(shape, layer))
|
||||
|
||||
ref_keys: list[tuple[object, ...]] = []
|
||||
for target, refs in pattern.refs.items():
|
||||
for ref in refs:
|
||||
ref_keys.extend(_ref_keys(target, ref))
|
||||
|
||||
label_keys = [
|
||||
_label_key(layer, label)
|
||||
for layer, labels in pattern.labels.items()
|
||||
for label in labels
|
||||
]
|
||||
|
||||
return {
|
||||
'shapes': sorted(shape_keys),
|
||||
'refs': sorted(ref_keys),
|
||||
'labels': sorted(label_keys),
|
||||
}
|
||||
|
||||
|
||||
def _library_summary(lib: Library) -> dict[str, dict[str, object]]:
|
||||
return {name: _pattern_summary(pattern) for name, pattern in lib.items()}
|
||||
|
||||
|
||||
def _make_arrow_test_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]], annotations={'1': ['leaf-poly']})
|
||||
leaf.polygon((2, 0), vertices=[[40, 0], [50, 0], [50, 10], [40, 10]])
|
||||
leaf.polygon((1, 0), vertices=[[20, 0], [30, 0], [30, 10], [20, 10]])
|
||||
leaf.polygon((1, 0), vertices=[[80, 0], [90, 0], [90, 10], [80, 10]])
|
||||
leaf.polygon((2, 0), vertices=[[60, 0], [70, 0], [70, 10], [60, 10]], annotations={'18': ['leaf-poly-2']})
|
||||
leaf.label((10, 0), string='LEAF', offset=(3, 4), annotations={'10': ['leaf-label']})
|
||||
lib['leaf'] = leaf
|
||||
|
||||
child = Pattern()
|
||||
child.path(
|
||||
(2, 0),
|
||||
vertices=[[0, 0], [15, 5], [30, 5]],
|
||||
width=6,
|
||||
cap=MPath.Cap.SquareCustom,
|
||||
cap_extensions=(2, 4),
|
||||
annotations={'2': ['child-path']},
|
||||
)
|
||||
child.label((11, 0), string='CHILD', offset=(7, 8), annotations={'11': ['child-label']})
|
||||
child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25, annotations={'12': ['child-ref']})
|
||||
lib['child'] = child
|
||||
|
||||
sibling = Pattern()
|
||||
sibling.polygon((3, 0), vertices=[[0, 0], [5, 0], [5, 6], [0, 6]])
|
||||
sibling.label((12, 0), string='SIB', offset=(1, 2), annotations={'13': ['sib-label']})
|
||||
sibling.ref(
|
||||
'leaf',
|
||||
offset=(-50, 60),
|
||||
repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2),
|
||||
annotations={'14': ['sib-ref']},
|
||||
)
|
||||
lib['sibling'] = sibling
|
||||
|
||||
fanout = Pattern()
|
||||
fanout.ref('leaf', offset=(0, 0))
|
||||
fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1)
|
||||
fanout.ref('leaf', offset=(20, 0))
|
||||
fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3))
|
||||
fanout.ref('child', offset=(40, 0), mirrored=True, rotation=numpy.pi / 4, scale=1.2,
|
||||
repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2))
|
||||
fanout.ref('leaf', offset=(50, 0), repetition=Grid(a_vector=(6, 0), a_count=3, b_vector=(0, 8), b_count=2))
|
||||
fanout.ref('leaf', offset=(60, 0), annotations={'19': ['fanout-sref']})
|
||||
fanout.ref('child', offset=(70, 0), repetition=Grid(a_vector=(4, 0), a_count=2, b_vector=(0, 5), b_count=2),
|
||||
annotations={'20': ['fanout-aref']})
|
||||
lib['fanout'] = fanout
|
||||
|
||||
top = Pattern()
|
||||
top.ref('child', offset=(500, 600), annotations={'15': ['top-child-ref']})
|
||||
top.ref('sibling', offset=(-100, 50), rotation=numpy.pi, annotations={'16': ['top-sibling-ref']})
|
||||
top.ref('fanout', offset=(250, -75))
|
||||
top.label((13, 0), string='TOP', offset=(0, 0), annotations={'17': ['top-label']})
|
||||
lib['top'] = top
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def _write_invalid_path_type_fixture(path: Path) -> None:
|
||||
with path.open('wb') as stream:
|
||||
header = klamath.library.FileHeader(
|
||||
name=b'test',
|
||||
user_units_per_db_unit=1.0,
|
||||
meters_per_db_unit=1e-9,
|
||||
)
|
||||
header.write(stream)
|
||||
elem = klamath.elements.Path(
|
||||
layer=(1, 0),
|
||||
path_type=3,
|
||||
width=10,
|
||||
extension=(0, 0),
|
||||
xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32),
|
||||
properties={},
|
||||
)
|
||||
klamath.library.write_struct(stream, name=b'top', elements=[elem])
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
|
||||
|
||||
def test_gdsii_arrow_matches_gdsii_readfile(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_roundtrip.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
canonical_lib, canonical_info = gdsii.readfile(gds_file)
|
||||
arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file)
|
||||
|
||||
assert canonical_info == arrow_info
|
||||
assert _library_summary(canonical_lib) == _library_summary(arrow_lib)
|
||||
|
||||
|
||||
def test_gdsii_arrow_matches_gdsii_readfile_for_gzipped_file(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_roundtrip.gds.gz'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
canonical_lib, canonical_info = gdsii.readfile(gds_file)
|
||||
arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file)
|
||||
|
||||
assert canonical_info == arrow_info
|
||||
assert _library_summary(canonical_lib) == _library_summary(arrow_lib)
|
||||
|
||||
|
||||
def test_gdsii_arrow_readfile_arrow_returns_native_payload(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'many_cells_native.gds'
|
||||
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
|
||||
|
||||
libarr, info = gdsii_arrow.readfile_arrow(gds_file)
|
||||
|
||||
assert info['name'] == manifest.library_name
|
||||
assert libarr['lib_name'].as_py() == manifest.library_name
|
||||
assert len(libarr['cells']) == manifest.cells
|
||||
assert 0 < len(libarr['layers']) <= manifest.layers
|
||||
|
||||
|
||||
def test_gdsii_arrow_readfile_arrow_reads_gzipped_file(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'native_payload.gds.gz'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr, info = gdsii_arrow.readfile_arrow(gds_file)
|
||||
|
||||
assert info['name'] == 'masque-klamath'
|
||||
assert libarr['lib_name'].as_py() == 'masque-klamath'
|
||||
assert len(libarr['cells']) == len(lib)
|
||||
assert len(libarr['layers']) > 0
|
||||
|
||||
|
||||
def test_gdsii_arrow_removed_raw_mode_arg(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'removed_raw_mode.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr, _ = gdsii_arrow.readfile_arrow(gds_file)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
gdsii_arrow.readfile(gds_file, raw_mode=False)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
gdsii_arrow.read_arrow(libarr, raw_mode=False)
|
||||
|
||||
|
||||
def test_gdsii_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid.gds'
|
||||
gds_file.write_bytes(b'not-a-gds')
|
||||
|
||||
script = textwrap.dedent(f"""
|
||||
from masque.file.gdsii import arrow as gdsii_arrow
|
||||
try:
|
||||
gdsii_arrow.readfile({str(gds_file)!r})
|
||||
except Exception as exc:
|
||||
print(type(exc).__module__)
|
||||
print(type(exc).__qualname__)
|
||||
print(exc)
|
||||
else:
|
||||
raise SystemExit('expected gdsii_arrow.readfile() to fail')
|
||||
""")
|
||||
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert 'klamath.basic' in result.stdout
|
||||
assert 'KlamathError' in result.stdout
|
||||
|
||||
|
||||
def test_gdsii_arrow_reads_small_perf_fixture(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'many_cells_smoke.gds'
|
||||
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
|
||||
|
||||
lib, info = gdsii_arrow.readfile(gds_file)
|
||||
|
||||
assert info['name'] == manifest.library_name
|
||||
assert len(lib) == manifest.cells
|
||||
assert 'TOP' in lib
|
||||
assert sum(len(refs) for refs in lib['TOP'].refs.values()) > 0
|
||||
|
||||
|
||||
def test_gdsii_arrow_degenerate_aref_decodes_as_single_transform(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
top = Pattern()
|
||||
top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1))
|
||||
lib['top'] = top
|
||||
|
||||
gds_file = tmp_path / 'degenerate_aref.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
canonical_lib, _ = gdsii.readfile(gds_file)
|
||||
arrow_lib, _ = gdsii_arrow.readfile(gds_file)
|
||||
assert _library_summary(arrow_lib) == _library_summary(canonical_lib)
|
||||
|
||||
decoded_ref = arrow_lib['top'].refs['leaf'][0]
|
||||
assert decoded_ref.repetition is None
|
||||
|
||||
|
||||
def test_gdsii_arrow_plain_srefs_decode_without_arbitrary(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'plain_srefs.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
arrow_lib, _ = gdsii_arrow.readfile(gds_file)
|
||||
fanout = arrow_lib['fanout']
|
||||
|
||||
plain_leaf_refs = [
|
||||
ref
|
||||
for ref in fanout.refs['leaf']
|
||||
if ref.annotations is None and ref.repetition is None
|
||||
]
|
||||
assert len(plain_leaf_refs) == 2
|
||||
assert all(type(ref.repetition) is not Grid for ref in plain_leaf_refs)
|
||||
|
||||
|
||||
def test_gdsii_arrow_degenerate_aref_schema_normalizes_to_sref(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
top = Pattern()
|
||||
top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1))
|
||||
lib['top'] = top
|
||||
|
||||
gds_file = tmp_path / 'degenerate_aref_schema.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top')
|
||||
|
||||
srefs = cells.field('srefs')[top_index].as_py()
|
||||
arefs = cells.field('arefs')[top_index].as_py()
|
||||
|
||||
assert len(srefs) == 1
|
||||
assert len(arefs) == 0
|
||||
assert cell_names[srefs[0]['target']] == 'leaf'
|
||||
|
||||
|
||||
def test_gdsii_arrow_boundary_batch_schema(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_batches.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
layer_table = [
|
||||
((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF)
|
||||
for layer in libarr['layers'].values.to_numpy()
|
||||
]
|
||||
|
||||
leaf_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'leaf')
|
||||
|
||||
rect_batches = cells.field('rect_batches')[leaf_index].as_py()
|
||||
boundary_batches = cells.field('boundary_batches')[leaf_index].as_py()
|
||||
boundary_props = cells.field('boundary_props')[leaf_index].as_py()
|
||||
|
||||
assert len(rect_batches) == 2
|
||||
assert len(boundary_batches) == 0
|
||||
assert len(boundary_props) == 2
|
||||
|
||||
rects_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in rect_batches}
|
||||
assert rects_by_layer[(1, 0)]['rects'] == [20, 0, 30, 10, 80, 0, 90, 10]
|
||||
assert rects_by_layer[(2, 0)]['rects'] == [40, 0, 50, 10]
|
||||
|
||||
props_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in boundary_props}
|
||||
assert sorted(props_by_layer) == [(1, 0), (2, 0)]
|
||||
assert props_by_layer[(1, 0)]['properties'][0]['value'] == 'leaf-poly'
|
||||
assert props_by_layer[(2, 0)]['properties'][0]['value'] == 'leaf-poly-2'
|
||||
|
||||
|
||||
def test_gdsii_arrow_rect_batch_schema_for_mixed_layer(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
top = Pattern()
|
||||
top.shapes[(1, 0)].append(RectCollection(rects=[[0, 0, 10, 10], [20, 0, 30, 10], [40, 0, 50, 10], [60, 0, 70, 10]]))
|
||||
top.polygon((1, 0), vertices=[[80, 0], [85, 10], [90, 0]])
|
||||
top.polygon((1, 0), vertices=[[100, 0], [105, 10], [110, 0]])
|
||||
lib['top'] = top
|
||||
|
||||
gds_file = tmp_path / 'arrow_rect_batches.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
layer_table = [
|
||||
((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF)
|
||||
for layer in libarr['layers'].values.to_numpy()
|
||||
]
|
||||
top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top')
|
||||
|
||||
rect_batches = cells.field('rect_batches')[top_index].as_py()
|
||||
boundary_batches = cells.field('boundary_batches')[top_index].as_py()
|
||||
|
||||
assert len(rect_batches) == 1
|
||||
assert tuple(layer_table[rect_batches[0]['layer']]) == (1, 0)
|
||||
assert rect_batches[0]['rects'] == [
|
||||
0, 0, 10, 10,
|
||||
20, 0, 30, 10,
|
||||
40, 0, 50, 10,
|
||||
60, 0, 70, 10,
|
||||
]
|
||||
|
||||
assert len(boundary_batches) == 1
|
||||
assert tuple(layer_table[boundary_batches[0]['layer']]) == (1, 0)
|
||||
assert boundary_batches[0]['vertex_offsets'] == [0, 3]
|
||||
|
||||
|
||||
def test_gdsii_arrow_ref_schema(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_ref_batches.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
|
||||
fanout_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'fanout')
|
||||
|
||||
srefs = cells.field('srefs')[fanout_index].as_py()
|
||||
arefs = cells.field('arefs')[fanout_index].as_py()
|
||||
sref_props = cells.field('sref_props')[fanout_index].as_py()
|
||||
aref_props = cells.field('aref_props')[fanout_index].as_py()
|
||||
|
||||
sref_target_ids = [entry['target'] for entry in srefs]
|
||||
sref_targets = [cell_names[target] for target in sref_target_ids]
|
||||
assert sorted(sref_targets) == ['child', 'leaf', 'leaf']
|
||||
assert sref_target_ids == sorted(sref_target_ids)
|
||||
sref_by_target = {}
|
||||
for entry in srefs:
|
||||
sref_by_target.setdefault(cell_names[entry['target']], []).append(entry)
|
||||
assert [entry['invert_y'] for entry in sref_by_target['child']] == [True]
|
||||
assert [entry['scale'] for entry in sref_by_target['child']] == pytest.approx([1.1])
|
||||
assert len(sref_by_target['leaf']) == 2
|
||||
|
||||
aref_target_ids = [entry['target'] for entry in arefs]
|
||||
aref_targets = [cell_names[target] for target in aref_target_ids]
|
||||
assert sorted(aref_targets) == ['child', 'leaf', 'leaf']
|
||||
assert aref_target_ids == sorted(aref_target_ids)
|
||||
aref_by_target = {}
|
||||
for entry in arefs:
|
||||
aref_by_target.setdefault(cell_names[entry['target']], []).append(entry)
|
||||
assert [entry['invert_y'] for entry in aref_by_target['child']] == [True]
|
||||
assert [entry['scale'] for entry in aref_by_target['child']] == pytest.approx([1.2])
|
||||
assert len(aref_by_target['leaf']) == 2
|
||||
|
||||
assert len(sref_props) == 1
|
||||
assert cell_names[sref_props[0]['target']] == 'leaf'
|
||||
assert sref_props[0]['properties'][0]['value'] == 'fanout-sref'
|
||||
|
||||
assert len(aref_props) == 1
|
||||
assert cell_names[aref_props[0]['target']] == 'child'
|
||||
assert aref_props[0]['properties'][0]['value'] == 'fanout-aref'
|
||||
|
||||
|
||||
def test_gdsii_arrow_invalid_path_type_matches_gdsii(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid_path_type.gds'
|
||||
_write_invalid_path_type_fixture(gds_file)
|
||||
|
||||
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
|
||||
gdsii.readfile(gds_file)
|
||||
|
||||
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
|
||||
gdsii_arrow.readfile(gds_file)
|
||||
|
||||
|
||||
def test_raw_ref_grid_label_constructors_match_public() -> None:
|
||||
raw_grid = Grid._from_raw(
|
||||
a_vector=numpy.array([20, 0]),
|
||||
a_count=3,
|
||||
b_vector=numpy.array([0, 30]),
|
||||
b_count=2,
|
||||
)
|
||||
public_grid = Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2)
|
||||
assert raw_grid == public_grid
|
||||
|
||||
raw_poly = Polygon._from_raw(
|
||||
vertices=numpy.array([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]),
|
||||
annotations={'1': ['poly']},
|
||||
)
|
||||
public_poly = Polygon(
|
||||
vertices=[[0, 0], [5, 0], [5, 5], [0, 5]],
|
||||
annotations={'1': ['poly']},
|
||||
)
|
||||
assert raw_poly == public_poly
|
||||
|
||||
raw_poly_collection = PolyCollection._from_raw(
|
||||
vertex_lists=numpy.array([
|
||||
[0.0, 0.0], [2.0, 0.0], [2.0, 2.0],
|
||||
[10.0, 10.0], [12.0, 10.0], [12.0, 12.0],
|
||||
]),
|
||||
vertex_offsets=numpy.array([0, 3], dtype=numpy.uint32),
|
||||
annotations={'2': ['pc']},
|
||||
)
|
||||
public_poly_collection = PolyCollection(
|
||||
vertex_lists=[[0, 0], [2, 0], [2, 2], [10, 10], [12, 10], [12, 12]],
|
||||
vertex_offsets=[0, 3],
|
||||
annotations={'2': ['pc']},
|
||||
)
|
||||
assert raw_poly_collection == public_poly_collection
|
||||
assert [tuple(s.indices(len(raw_poly_collection.vertex_lists))) for s in raw_poly_collection.vertex_slices] == [(0, 3, 1), (3, 6, 1)]
|
||||
|
||||
raw_rect_collection = RectCollection._from_raw(
|
||||
rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]),
|
||||
annotations={'3': ['rects']},
|
||||
)
|
||||
public_rect_collection = RectCollection(
|
||||
rects=[[0, 0, 5, 5], [10, 10, 12, 12]],
|
||||
annotations={'3': ['rects']},
|
||||
)
|
||||
assert raw_rect_collection == public_rect_collection
|
||||
|
||||
raw_ref_empty = Ref._from_raw(
|
||||
offset=numpy.array([100, 200]),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=False,
|
||||
scale=1.0,
|
||||
repetition=None,
|
||||
annotations=None,
|
||||
)
|
||||
public_ref_empty = Ref(
|
||||
offset=(100, 200),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=False,
|
||||
scale=1.0,
|
||||
repetition=None,
|
||||
annotations=None,
|
||||
)
|
||||
assert raw_ref_empty.annotations is None
|
||||
assert raw_ref_empty == public_ref_empty
|
||||
|
||||
raw_ref = Ref._from_raw(
|
||||
offset=numpy.array([100, 200]),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=True,
|
||||
scale=1.25,
|
||||
repetition=raw_grid,
|
||||
annotations={'12': ['child-ref']},
|
||||
)
|
||||
public_ref = Ref(
|
||||
offset=(100, 200),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=True,
|
||||
scale=1.25,
|
||||
repetition=public_grid,
|
||||
annotations={'12': ['child-ref']},
|
||||
)
|
||||
assert raw_ref == public_ref
|
||||
assert numpy.array_equal(raw_ref.as_transforms(), public_ref.as_transforms())
|
||||
|
||||
raw_label_empty = Label._from_raw(
|
||||
'LEAF',
|
||||
offset=numpy.array([3, 4]),
|
||||
annotations=None,
|
||||
)
|
||||
public_label_empty = Label(
|
||||
'LEAF',
|
||||
offset=(3, 4),
|
||||
annotations=None,
|
||||
)
|
||||
assert raw_label_empty.annotations is None
|
||||
assert raw_label_empty == public_label_empty
|
||||
|
||||
raw_label = Label._from_raw(
|
||||
'LEAF',
|
||||
offset=numpy.array([3, 4]),
|
||||
annotations={'10': ['leaf-label']},
|
||||
)
|
||||
public_label = Label(
|
||||
'LEAF',
|
||||
offset=(3, 4),
|
||||
annotations={'10': ['leaf-label']},
|
||||
)
|
||||
assert raw_label == public_label
|
||||
assert numpy.array_equal(raw_label.get_bounds_single(), public_label.get_bounds_single())
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import lazy as gdsii_lazy
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
from ..library import Library, OverlayLibrary
|
||||
|
||||
|
||||
def _make_lazy_port_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.label(layer=(10, 0), string='A:type1 0', offset=(5, 0))
|
||||
lib['leaf'] = leaf
|
||||
|
||||
child = Pattern()
|
||||
child.ref('leaf', offset=(10, 20), rotation=numpy.pi / 2)
|
||||
lib['child'] = child
|
||||
|
||||
top = Pattern()
|
||||
top.ref('child', offset=(100, 200))
|
||||
lib['top'] = top
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-lazy')
|
||||
|
||||
lib, info = gdsii_lazy.readfile(gds_file)
|
||||
|
||||
assert info['name'] == 'classic-lazy'
|
||||
assert lib.source_order() == ('leaf', 'child', 'top')
|
||||
assert lib.child_graph(dangling='ignore') == {
|
||||
'leaf': set(),
|
||||
'child': {'leaf'},
|
||||
'top': {'child'},
|
||||
}
|
||||
assert not lib._cache
|
||||
|
||||
child = lib['child']
|
||||
assert list(child.refs.keys()) == ['leaf']
|
||||
assert set(lib._cache) == {'child'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_ports_view_keeps_raw_source_unmodified(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_ports.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'A'}
|
||||
assert_allclose(top.ports['A'].offset, [110, 225], atol=1e-10)
|
||||
assert not raw._cache
|
||||
|
||||
raw_top = raw['top']
|
||||
assert not raw_top.ports
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_port_overrides.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_port_overrides({
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
})
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'P'}
|
||||
assert_allclose(top.ports['P'].offset, [1, 2], atol=1e-10)
|
||||
assert top.ports['P'].rotation == 0
|
||||
assert top.ports['P'].ptype == 'wire'
|
||||
assert not raw._cache
|
||||
|
||||
raw_top = raw['top']
|
||||
assert not raw_top.ports
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_ports_override_extracted.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(
|
||||
layers=[(10, 0)],
|
||||
max_depth=2,
|
||||
ports={
|
||||
'top': {
|
||||
'A': Port((1, 2), rotation=numpy.pi, ptype='manual'),
|
||||
'B': Port((3, 4), rotation=None, ptype=None),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'A', 'B'}
|
||||
assert_allclose(top.ports['A'].offset, [1, 2], atol=1e-10)
|
||||
assert top.ports['A'].rotation == numpy.pi
|
||||
assert top.ports['A'].ptype == 'manual'
|
||||
assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10)
|
||||
assert top.ports['B'].rotation is None
|
||||
assert top.ports['B'].ptype is None
|
||||
assert not raw._cache
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_ports_replace.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(
|
||||
layers=[(10, 0)],
|
||||
max_depth=2,
|
||||
ports={
|
||||
'top': {
|
||||
'B': Port((3, 4), rotation=None, ptype=None),
|
||||
},
|
||||
},
|
||||
replace=True,
|
||||
)
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'B'}
|
||||
assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10)
|
||||
assert not raw._cache
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_overlay.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(processed)
|
||||
|
||||
assert not raw._cache
|
||||
assert not processed._cache
|
||||
|
||||
abstract = overlay.abstract('top')
|
||||
assert set(abstract.ports) == {'A'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_overlay_override.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_port_overrides({
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
})
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(processed)
|
||||
|
||||
assert not raw._cache
|
||||
assert not processed._cache
|
||||
|
||||
abstract = overlay.abstract('top')
|
||||
assert set(abstract.ports) == {'P'}
|
||||
assert_allclose(abstract.ports['P'].offset, [1, 2], atol=1e-10)
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_can_rename_every_source_cell() -> None:
|
||||
src = _make_lazy_port_library()
|
||||
overlay = OverlayLibrary()
|
||||
|
||||
rename_map = overlay.add_source(
|
||||
src,
|
||||
rename_theirs=lambda _lib, name: f'mapped_{name}',
|
||||
rename_when='always',
|
||||
)
|
||||
|
||||
assert rename_map == {
|
||||
'leaf': 'mapped_leaf',
|
||||
'child': 'mapped_child',
|
||||
'top': 'mapped_top',
|
||||
}
|
||||
assert tuple(overlay.keys()) == ('mapped_leaf', 'mapped_child', 'mapped_top')
|
||||
assert 'mapped_leaf' in overlay['mapped_child'].refs
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None:
|
||||
src = _make_lazy_port_library()
|
||||
|
||||
with pytest.raises(TypeError, match='rename_theirs'):
|
||||
OverlayLibrary().add_source(src, rename_when='always')
|
||||
|
||||
with pytest.raises(ValueError, match='rename mode'):
|
||||
OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_roundtrip.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||
|
||||
out_file = tmp_path / 'lazy_roundtrip_out.gds'
|
||||
gdsii_lazy.writefile(processed, out_file)
|
||||
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_removed_closure_based_lazy_loader() -> None:
|
||||
assert not hasattr(gdsii, 'load_library')
|
||||
assert not hasattr(gdsii, 'load_libraryfile')
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import klamath
|
||||
import numpy
|
||||
import pytest
|
||||
|
||||
pytest.importorskip('pyarrow')
|
||||
|
||||
from .. import PatternError
|
||||
from ..library import Library, OverlayLibrary
|
||||
from ..pattern import Pattern
|
||||
from ..repetition import Grid
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow
|
||||
from ..file.gdsii.perf import write_fixture
|
||||
|
||||
|
||||
if not gdsii_lazy_arrow.is_available():
|
||||
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
|
||||
|
||||
|
||||
def _make_small_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 5], [0, 5]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
mid = Pattern()
|
||||
mid.ref('leaf', offset=(10, 20))
|
||||
mid.ref('leaf', offset=(40, 0), repetition=Grid(a_vector=(12, 0), a_count=2, b_vector=(0, 9), b_count=2))
|
||||
lib['mid'] = mid
|
||||
|
||||
top = Pattern()
|
||||
top.ref('mid', offset=(100, 200))
|
||||
lib['top'] = top
|
||||
return lib
|
||||
|
||||
|
||||
def _make_complex_ref_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
child = Pattern()
|
||||
child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25)
|
||||
lib['child'] = child
|
||||
|
||||
sibling = Pattern()
|
||||
sibling.ref(
|
||||
'leaf',
|
||||
offset=(-50, 60),
|
||||
repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2),
|
||||
)
|
||||
lib['sibling'] = sibling
|
||||
|
||||
fanout = Pattern()
|
||||
fanout.ref('leaf', offset=(0, 0))
|
||||
fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1)
|
||||
fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3))
|
||||
fanout.ref(
|
||||
'child',
|
||||
offset=(40, 0),
|
||||
mirrored=True,
|
||||
rotation=numpy.pi / 4,
|
||||
scale=1.2,
|
||||
repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2),
|
||||
)
|
||||
lib['fanout'] = fanout
|
||||
|
||||
top = Pattern()
|
||||
top.ref('child', offset=(500, 600))
|
||||
top.ref('sibling', offset=(-100, 50), rotation=numpy.pi)
|
||||
top.ref('fanout', offset=(250, -75))
|
||||
lib['top'] = top
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def _write_invalid_path_type_fixture(path: Path) -> None:
|
||||
with path.open('wb') as stream:
|
||||
header = klamath.library.FileHeader(
|
||||
name=b'test',
|
||||
user_units_per_db_unit=1.0,
|
||||
meters_per_db_unit=1e-9,
|
||||
)
|
||||
header.write(stream)
|
||||
elem = klamath.elements.Path(
|
||||
layer=(1, 0),
|
||||
path_type=3,
|
||||
width=10,
|
||||
extension=(0, 0),
|
||||
xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32),
|
||||
properties={},
|
||||
)
|
||||
klamath.library.write_struct(stream, name=b'top', elements=[elem])
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
|
||||
|
||||
def _transform_rows_key(values: numpy.ndarray) -> tuple[tuple[object, ...], ...]:
|
||||
arr = numpy.asarray(values, dtype=float)
|
||||
arr = numpy.atleast_2d(arr)
|
||||
rows = [
|
||||
(
|
||||
round(float(row[0]), 8),
|
||||
round(float(row[1]), 8),
|
||||
round(float(row[2]), 8),
|
||||
bool(int(round(float(row[3])))),
|
||||
round(float(row[4]), 8),
|
||||
)
|
||||
for row in arr
|
||||
]
|
||||
return tuple(sorted(rows))
|
||||
|
||||
|
||||
def _local_refs_key(refs: dict[str, list[numpy.ndarray]]) -> dict[str, tuple[tuple[object, ...], ...]]:
|
||||
return {
|
||||
parent: _transform_rows_key(numpy.concatenate(transforms))
|
||||
for parent, transforms in refs.items()
|
||||
}
|
||||
|
||||
|
||||
def _global_refs_key(refs: dict[tuple[str, ...], numpy.ndarray]) -> dict[tuple[str, ...], tuple[tuple[object, ...], ...]]:
|
||||
return {
|
||||
path: _transform_rows_key(transforms)
|
||||
for path, transforms in refs.items()
|
||||
}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_loads_perf_fixture(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'many_cells_lazy.gds'
|
||||
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
assert info['name'] == manifest.library_name
|
||||
assert len(lib) == manifest.cells
|
||||
assert lib.top() == 'TOP'
|
||||
assert 'TOP' in lib.child_graph(dangling='ignore')
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'refs.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-refs')
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
local = lib.find_refs_local('leaf')
|
||||
assert set(local) == {'mid'}
|
||||
assert sum(arr.shape[0] for arr in local['mid']) == 5
|
||||
|
||||
global_refs = lib.find_refs_global('leaf')
|
||||
assert set(global_refs) == {('top', 'mid', 'leaf')}
|
||||
assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'complex_refs.gds'
|
||||
src = _make_complex_ref_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-complex-refs')
|
||||
|
||||
eager, _ = gdsii.readfile(gds_file)
|
||||
lazy, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
for name in ('leaf', 'child'):
|
||||
assert _local_refs_key(lazy.find_refs_local(name)) == _local_refs_key(eager.find_refs_local(name))
|
||||
assert _global_refs_key(lazy.find_refs_global(name)) == _global_refs_key(eager.find_refs_global(name))
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid.gds'
|
||||
gds_file.write_bytes(b'not-a-gds')
|
||||
|
||||
script = textwrap.dedent(f"""
|
||||
from masque.file.gdsii import lazy_arrow as gdsii_lazy_arrow
|
||||
try:
|
||||
gdsii_lazy_arrow.readfile({str(gds_file)!r})
|
||||
except Exception as exc:
|
||||
print(type(exc).__module__)
|
||||
print(type(exc).__qualname__)
|
||||
print(exc)
|
||||
else:
|
||||
raise SystemExit('expected gdsii_lazy_arrow.readfile() to fail')
|
||||
""")
|
||||
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert 'klamath.basic' in result.stdout
|
||||
assert 'KlamathError' in result.stdout
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid_path_type.gds'
|
||||
_write_invalid_path_type_fixture(gds_file)
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
|
||||
lib['top']
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'copy_source.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through')
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
out_file = tmp_path / 'copy_out.gds'
|
||||
gdsii_lazy_arrow.writefile(
|
||||
lib,
|
||||
out_file,
|
||||
meters_per_unit=info['meters_per_unit'],
|
||||
logical_units_per_unit=info['logical_units_per_unit'],
|
||||
library_name=info['name'],
|
||||
)
|
||||
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_gzipped_copy_through(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'copy_source.gds.gz'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through-gz')
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
out_file = tmp_path / 'copy_out.gds.gz'
|
||||
gdsii_lazy_arrow.writefile(
|
||||
lib,
|
||||
out_file,
|
||||
meters_per_unit=info['meters_per_unit'],
|
||||
logical_units_per_unit=info['logical_units_per_unit'],
|
||||
library_name=info['name'],
|
||||
)
|
||||
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_merge_and_write(tmp_path: Path) -> None:
|
||||
base_a = Library()
|
||||
leaf_a = Pattern()
|
||||
leaf_a.polygon((1, 0), vertices=[[0, 0], [8, 0], [8, 8], [0, 8]])
|
||||
base_a['leaf'] = leaf_a
|
||||
top_a = Pattern()
|
||||
top_a.ref('leaf', offset=(0, 0))
|
||||
base_a['top_a'] = top_a
|
||||
|
||||
base_b = Library()
|
||||
leaf_b = Pattern()
|
||||
leaf_b.polygon((2, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
base_b['leaf'] = leaf_b
|
||||
top_b = Pattern()
|
||||
top_b.ref('leaf', offset=(20, 30))
|
||||
base_b['top_b'] = top_b
|
||||
|
||||
gds_a = tmp_path / 'a.gds'
|
||||
gds_b = tmp_path / 'b.gds'
|
||||
gdsii.writefile(base_a, gds_a, meters_per_unit=1e-9, library_name='overlay')
|
||||
gdsii.writefile(base_b, gds_b, meters_per_unit=1e-9, library_name='overlay')
|
||||
|
||||
lib_a, _ = gdsii_lazy_arrow.readfile(gds_a)
|
||||
lib_b, _ = gdsii_lazy_arrow.readfile(gds_b)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lib_a)
|
||||
rename_map = overlay.add_source(lib_b, rename_theirs=lambda lib, name: lib.get_name(name))
|
||||
renamed_leaf = rename_map['leaf']
|
||||
|
||||
assert rename_map == {'leaf': renamed_leaf}
|
||||
assert renamed_leaf != 'leaf'
|
||||
assert len(lib_a._cache) == 0
|
||||
assert len(lib_b._cache) == 0
|
||||
|
||||
overlay.move_references('leaf', renamed_leaf)
|
||||
|
||||
out_file = tmp_path / 'overlay_out.gds'
|
||||
gdsii_lazy_arrow.writefile(overlay, out_file)
|
||||
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert set(roundtrip.keys()) == {'leaf', renamed_leaf, 'top_a', 'top_b'}
|
||||
assert 'top_b' in roundtrip
|
||||
assert list(roundtrip['top_b'].refs.keys()) == [renamed_leaf]
|
||||
|
||||
|
||||
def test_gdsii_writer_accepts_overlay_library(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'overlay_source.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-src')
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lib)
|
||||
overlay.rename('leaf', 'leaf_copy', move_references=True)
|
||||
|
||||
out_file = tmp_path / 'overlay_via_eager_writer.gds'
|
||||
gdsii.writefile(
|
||||
overlay,
|
||||
out_file,
|
||||
meters_per_unit=info['meters_per_unit'],
|
||||
logical_units_per_unit=info['logical_units_per_unit'],
|
||||
library_name=info['name'],
|
||||
)
|
||||
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert set(roundtrip.keys()) == {'leaf_copy', 'mid', 'top'}
|
||||
assert list(roundtrip['mid'].refs.keys()) == ['leaf_copy']
|
||||
|
||||
|
||||
def test_svg_writer_uses_detached_materialized_copy(tmp_path: Path) -> None:
|
||||
pytest.importorskip('svgwrite')
|
||||
from ..file import svg
|
||||
from ..shapes import Path as MPath
|
||||
|
||||
gds_file = tmp_path / 'svg_source.gds'
|
||||
src = _make_small_library()
|
||||
src['top'].path((3, 0), vertices=[[0, 0], [0, 20]], width=4)
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='svg-src')
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
top_pat = lib['top']
|
||||
assert list(top_pat.refs.keys()) == ['mid']
|
||||
assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)])
|
||||
|
||||
svg_path = tmp_path / 'lazy.svg'
|
||||
svg.writefile(lib, 'top', str(svg_path))
|
||||
|
||||
assert svg_path.exists()
|
||||
assert list(top_pat.refs.keys()) == ['mid']
|
||||
assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)])
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
from dataclasses import asdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii.perf import fixture_manifest, write_fixture
|
||||
|
||||
|
||||
def test_gdsii_perf_fixture_smoke(tmp_path: Path) -> None:
|
||||
output = tmp_path / 'many_cells.gds'
|
||||
manifest = write_fixture(output, preset='many_cells', scale=0.002)
|
||||
expected = fixture_manifest(output, preset='many_cells', scale=0.002)
|
||||
|
||||
assert output.exists()
|
||||
assert manifest == expected
|
||||
|
||||
sidecar = json.loads(output.with_suffix('.gds.json').read_text())
|
||||
assert sidecar == asdict(manifest)
|
||||
|
||||
read_lib, info = gdsii.readfile(output)
|
||||
assert info['name'] == manifest.library_name
|
||||
assert len(read_lib) == manifest.cells
|
||||
assert 'TOP' in read_lib
|
||||
assert len(read_lib['TOP'].refs) > 0
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import copy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..label import Label
|
||||
from ..repetition import Grid
|
||||
from ..utils import annotations_eq
|
||||
|
||||
|
||||
def test_label_init() -> None:
|
||||
lbl = Label("test", offset=(10, 20))
|
||||
assert lbl.string == "test"
|
||||
assert_equal(lbl.offset, [10, 20])
|
||||
|
||||
|
||||
def test_label_transform() -> None:
|
||||
lbl = Label("test", offset=(10, 0))
|
||||
# Rotate 90 deg CCW around (0,0)
|
||||
lbl.rotate_around((0, 0), pi / 2)
|
||||
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
|
||||
|
||||
# Translate
|
||||
lbl.translate((5, 5))
|
||||
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
|
||||
|
||||
|
||||
def test_label_repetition() -> None:
|
||||
rep = Grid(a_vector=(10, 0), a_count=3)
|
||||
lbl = Label("rep", offset=(0, 0), repetition=rep)
|
||||
assert lbl.repetition is rep
|
||||
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
|
||||
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
|
||||
# seem to automatically include repetition bounds in label.py itself,
|
||||
# it's handled during pattern bounding.
|
||||
|
||||
|
||||
def test_label_copy() -> None:
|
||||
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
|
||||
l2 = copy.deepcopy(l1)
|
||||
|
||||
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
|
||||
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
|
||||
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
|
||||
|
||||
assert l1 == l2
|
||||
assert l1 is not l2
|
||||
l2.offset[0] = 100
|
||||
assert l1.offset[0] == 1
|
||||
|
||||
|
||||
def test_label_eq_unrelated_objects_is_false() -> None:
|
||||
lbl = Label("test")
|
||||
assert not (lbl == None)
|
||||
assert not (lbl == object())
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
import pytest
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from numpy.testing import assert_allclose
|
||||
from ..library import Library, LazyLibrary
|
||||
from ..pattern import Pattern
|
||||
from ..error import LibraryError, PatternError
|
||||
from ..ports import Port
|
||||
from ..repetition import Grid
|
||||
from ..shapes import Arc, Ellipse, Path, Text
|
||||
from ..file.utils import preflight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..shapes import Polygon
|
||||
|
||||
|
||||
def test_library_basic() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
lib["cell1"] = pat
|
||||
|
||||
assert "cell1" in lib
|
||||
assert lib["cell1"] is pat
|
||||
assert len(lib) == 1
|
||||
|
||||
with pytest.raises(LibraryError):
|
||||
lib["cell1"] = Pattern() # Overwriting not allowed
|
||||
|
||||
|
||||
def test_library_tops() -> None:
|
||||
lib = Library()
|
||||
lib["child"] = Pattern()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("child")
|
||||
|
||||
assert set(lib.tops()) == {"parent"}
|
||||
assert lib.top() == "parent"
|
||||
|
||||
|
||||
def test_library_dangling() -> None:
|
||||
lib = Library()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("missing")
|
||||
|
||||
assert lib.dangling_refs() == {"missing"}
|
||||
|
||||
|
||||
def test_library_dangling_graph_modes() -> None:
|
||||
lib = Library()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("missing")
|
||||
|
||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
||||
lib.child_graph()
|
||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
||||
lib.parent_graph()
|
||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
||||
lib.child_order()
|
||||
|
||||
assert lib.child_graph(dangling="ignore") == {"parent": set()}
|
||||
assert lib.parent_graph(dangling="ignore") == {"parent": set()}
|
||||
assert lib.child_order(dangling="ignore") == ["parent"]
|
||||
|
||||
assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()}
|
||||
assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}}
|
||||
assert lib.child_order(dangling="include") == ["missing", "parent"]
|
||||
|
||||
|
||||
def test_find_refs_with_dangling_modes() -> None:
|
||||
lib = Library()
|
||||
lib["target"] = Pattern()
|
||||
|
||||
mid = Pattern()
|
||||
mid.ref("target", offset=(2, 0))
|
||||
lib["mid"] = mid
|
||||
|
||||
top = Pattern()
|
||||
top.ref("mid", offset=(5, 0))
|
||||
top.ref("missing", offset=(9, 0))
|
||||
lib["top"] = top
|
||||
|
||||
assert lib.find_refs_local("missing", dangling="ignore") == {}
|
||||
assert lib.find_refs_global("missing", dangling="ignore") == {}
|
||||
|
||||
local_missing = lib.find_refs_local("missing", dangling="include")
|
||||
assert set(local_missing) == {"top"}
|
||||
assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]])
|
||||
|
||||
global_missing = lib.find_refs_global("missing", dangling="include")
|
||||
assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]])
|
||||
|
||||
with pytest.raises(LibraryError, match="missing"):
|
||||
lib.find_refs_local("missing")
|
||||
with pytest.raises(LibraryError, match="missing"):
|
||||
lib.find_refs_global("missing")
|
||||
|
||||
global_target = lib.find_refs_global("target")
|
||||
assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]])
|
||||
|
||||
|
||||
def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None:
|
||||
def make_lib() -> Library:
|
||||
lib = Library()
|
||||
lib["empty"] = Pattern()
|
||||
lib["top"] = Pattern()
|
||||
lib["top"].ref("missing")
|
||||
return lib
|
||||
|
||||
caplog.set_level("WARNING")
|
||||
warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True)
|
||||
assert "empty" not in warned
|
||||
assert any("Dangling refs found" in record.message for record in caplog.records)
|
||||
|
||||
allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True)
|
||||
assert "empty" not in allowed
|
||||
|
||||
with pytest.raises(LibraryError, match="Dangling refs found"):
|
||||
preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True)
|
||||
|
||||
|
||||
def test_library_flatten() -> None:
|
||||
lib = Library()
|
||||
child = Pattern()
|
||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(10, 10))
|
||||
lib["parent"] = parent
|
||||
|
||||
flat_lib = lib.flatten("parent")
|
||||
flat_parent = flat_lib["parent"]
|
||||
|
||||
assert not flat_parent.has_refs()
|
||||
assert len(flat_parent.shapes[(1, 0)]) == 1
|
||||
# Transformations are baked into vertices for Polygon
|
||||
assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices
|
||||
assert tuple(assert_vertices[0]) == (10.0, 10.0)
|
||||
|
||||
|
||||
def test_library_flatten_preserves_ports_only_child() -> None:
|
||||
lib = Library()
|
||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(10, 10))
|
||||
lib["parent"] = parent
|
||||
|
||||
flat_parent = lib.flatten("parent", flatten_ports=True)["parent"]
|
||||
|
||||
assert set(flat_parent.ports) == {"P1"}
|
||||
assert cast("Port", flat_parent.ports["P1"]).rotation == 0
|
||||
assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0)
|
||||
|
||||
|
||||
def test_library_flatten_repeated_ref_with_ports_raises() -> None:
|
||||
lib = Library()
|
||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||
lib["parent"] = parent
|
||||
|
||||
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
|
||||
lib.flatten("parent", flatten_ports=True)
|
||||
|
||||
|
||||
def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None:
|
||||
lib = Library()
|
||||
child = Pattern()
|
||||
child.ref("missing")
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child")
|
||||
lib["parent"] = parent
|
||||
|
||||
flat = lib.flatten("parent", dangling_ok=True)
|
||||
|
||||
assert set(flat["child"].refs) == {"missing"}
|
||||
assert flat["child"].has_refs()
|
||||
assert set(flat["parent"].refs) == {"missing"}
|
||||
assert flat["parent"].has_refs()
|
||||
|
||||
|
||||
def test_lazy_library() -> None:
|
||||
lib = LazyLibrary()
|
||||
called = 0
|
||||
|
||||
def make_pat() -> Pattern:
|
||||
nonlocal called
|
||||
called += 1
|
||||
return Pattern()
|
||||
|
||||
lib["lazy"] = make_pat
|
||||
assert called == 0
|
||||
|
||||
pat = lib["lazy"]
|
||||
assert called == 1
|
||||
assert isinstance(pat, Pattern)
|
||||
|
||||
# Second access should be cached
|
||||
pat2 = lib["lazy"]
|
||||
assert called == 1
|
||||
assert pat is pat2
|
||||
|
||||
|
||||
def test_library_rename() -> None:
|
||||
lib = Library()
|
||||
lib["old"] = Pattern()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("old")
|
||||
|
||||
lib.rename("old", "new", move_references=True)
|
||||
|
||||
assert "old" not in lib
|
||||
assert "new" in lib
|
||||
assert "new" in lib["parent"].refs
|
||||
assert "old" not in lib["parent"].refs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
|
||||
def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
|
||||
lib = library_cls()
|
||||
lib["top"] = Pattern()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("top")
|
||||
|
||||
lib.rename("top", "top", move_references=True)
|
||||
|
||||
assert set(lib.keys()) == {"top", "parent"}
|
||||
assert "top" in lib["parent"].refs
|
||||
assert len(lib["parent"].refs["top"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
|
||||
def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
|
||||
lib = library_cls()
|
||||
lib["top"] = Pattern()
|
||||
|
||||
lib.rename_top("top")
|
||||
|
||||
assert list(lib.keys()) == ["top"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
|
||||
def test_library_rename_missing_raises_library_error(library_cls: type[Library] | type[LazyLibrary]) -> None:
|
||||
lib = library_cls()
|
||||
lib["top"] = Pattern()
|
||||
|
||||
with pytest.raises(LibraryError, match="does not exist"):
|
||||
lib.rename("missing", "new")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
|
||||
def test_library_move_references_same_target_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
|
||||
lib = library_cls()
|
||||
lib["top"] = Pattern()
|
||||
lib["parent"] = Pattern()
|
||||
lib["parent"].ref("top")
|
||||
|
||||
lib.move_references("top", "top")
|
||||
|
||||
assert "top" in lib["parent"].refs
|
||||
assert len(lib["parent"].refs["top"]) == 1
|
||||
|
||||
|
||||
def test_library_dfs_can_replace_existing_patterns() -> None:
|
||||
lib = Library()
|
||||
child = Pattern()
|
||||
lib["child"] = child
|
||||
top = Pattern()
|
||||
top.ref("child")
|
||||
lib["top"] = top
|
||||
|
||||
replacement_top = Pattern(ports={"T": Port((1, 2), 0)})
|
||||
replacement_child = Pattern(ports={"C": Port((3, 4), 0)})
|
||||
|
||||
def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001
|
||||
if hierarchy[-1] == "child":
|
||||
return replacement_child
|
||||
if hierarchy[-1] == "top":
|
||||
return replacement_top
|
||||
return pattern
|
||||
|
||||
lib.dfs(lib["top"], visit_after=visit_after, hierarchy=("top",), transform=True)
|
||||
|
||||
assert lib["top"] is replacement_top
|
||||
assert lib["child"] is replacement_child
|
||||
|
||||
|
||||
def test_lazy_library_dfs_can_replace_existing_patterns() -> None:
|
||||
lib = LazyLibrary()
|
||||
lib["child"] = lambda: Pattern()
|
||||
lib["top"] = lambda: Pattern(refs={"child": []})
|
||||
|
||||
top = lib["top"]
|
||||
top.ref("child")
|
||||
|
||||
replacement_top = Pattern(ports={"T": Port((1, 2), 0)})
|
||||
replacement_child = Pattern(ports={"C": Port((3, 4), 0)})
|
||||
|
||||
def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001
|
||||
if hierarchy[-1] == "child":
|
||||
return replacement_child
|
||||
if hierarchy[-1] == "top":
|
||||
return replacement_top
|
||||
return pattern
|
||||
|
||||
lib.dfs(top, visit_after=visit_after, hierarchy=("top",), transform=True)
|
||||
|
||||
assert lib["top"] is replacement_top
|
||||
assert lib["child"] is replacement_child
|
||||
|
||||
|
||||
def test_library_add_no_duplicates_respects_mutate_other_false() -> None:
|
||||
src_pat = Pattern(ports={"A": Port((0, 0), 0)})
|
||||
lib = Library({"a": Pattern()})
|
||||
|
||||
lib.add({"b": src_pat}, mutate_other=False)
|
||||
|
||||
assert lib["b"] is not src_pat
|
||||
lib["b"].ports["A"].offset[0] = 123
|
||||
assert tuple(src_pat.ports["A"].offset) == (0.0, 0.0)
|
||||
|
||||
|
||||
def test_library_add_returns_only_renamed_entries() -> None:
|
||||
lib = Library({"a": Pattern(), "_shape": Pattern()})
|
||||
|
||||
assert lib.add({"b": Pattern(), "c": Pattern()}, mutate_other=False) == {}
|
||||
|
||||
rename_map = lib.add({"_shape": Pattern(), "keep": Pattern()}, mutate_other=False)
|
||||
|
||||
assert set(rename_map) == {"_shape"}
|
||||
assert rename_map["_shape"] != "_shape"
|
||||
assert "keep" not in rename_map
|
||||
|
||||
|
||||
def test_library_subtree() -> None:
|
||||
lib = Library()
|
||||
lib["a"] = Pattern()
|
||||
lib["b"] = Pattern()
|
||||
lib["c"] = Pattern()
|
||||
lib["a"].ref("b")
|
||||
|
||||
sub = lib.subtree("a")
|
||||
assert "a" in sub
|
||||
assert "b" in sub
|
||||
assert "c" not in sub
|
||||
|
||||
|
||||
def test_library_child_order_cycle_raises_library_error() -> None:
|
||||
lib = Library()
|
||||
lib["a"] = Pattern()
|
||||
lib["a"].ref("b")
|
||||
lib["b"] = Pattern()
|
||||
lib["b"].ref("a")
|
||||
|
||||
with pytest.raises(LibraryError, match="Cycle found while building child order"):
|
||||
lib.child_order()
|
||||
|
||||
|
||||
def test_library_find_refs_global_cycle_raises_library_error() -> None:
|
||||
lib = Library()
|
||||
lib["a"] = Pattern()
|
||||
lib["a"].ref("a")
|
||||
|
||||
with pytest.raises(LibraryError, match="Cycle found while building child order"):
|
||||
lib.find_refs_global("a")
|
||||
|
||||
|
||||
def test_library_get_name() -> None:
|
||||
lib = Library()
|
||||
lib["cell"] = Pattern()
|
||||
|
||||
name1 = lib.get_name("cell")
|
||||
assert name1 != "cell"
|
||||
assert name1.startswith("cell")
|
||||
|
||||
name2 = lib.get_name("other")
|
||||
assert name2 == "other"
|
||||
|
||||
|
||||
def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.shapes[(1, 0)] += [
|
||||
Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)),
|
||||
Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)),
|
||||
]
|
||||
lib["top"] = pat
|
||||
|
||||
lib.dedup(norm_value=1, threshold=2)
|
||||
|
||||
assert not lib["top"].refs
|
||||
assert len(lib["top"].shapes[(1, 0)]) == 2
|
||||
|
||||
|
||||
def test_library_dedup_text_preserves_scale_and_mirror_flag() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.shapes[(1, 0)] += [
|
||||
Text("A", 10, "dummy.ttf", offset=(0, 0)),
|
||||
Text("A", 10, "dummy.ttf", offset=(100, 0)),
|
||||
]
|
||||
lib["top"] = pat
|
||||
|
||||
lib.dedup(exclude_types=(), norm_value=5, threshold=2)
|
||||
|
||||
target_name = next(iter(lib["top"].refs))
|
||||
refs = lib["top"].refs[target_name]
|
||||
assert [ref.mirrored for ref in refs] == [False, False]
|
||||
assert [ref.scale for ref in refs] == [2.0, 2.0]
|
||||
assert cast("Text", lib[target_name].shapes[(1, 0)][0]).height == 5
|
||||
|
||||
flat = lib.flatten("top")["top"]
|
||||
assert [cast("Text", shape).height for shape in flat.shapes[(1, 0)]] == [10, 10]
|
||||
|
||||
|
||||
def test_library_dedup_handles_arc_and_ellipse_labels() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.shapes[(1, 0)] += [
|
||||
Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(0, 0)),
|
||||
Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(50, 0)),
|
||||
]
|
||||
pat.shapes[(2, 0)] += [
|
||||
Ellipse(radii=(10, 20), offset=(0, 0)),
|
||||
Ellipse(radii=(10, 20), offset=(50, 0)),
|
||||
]
|
||||
lib["top"] = pat
|
||||
|
||||
lib.dedup(exclude_types=(), norm_value=1, threshold=2)
|
||||
|
||||
assert len(lib["top"].refs) == 2
|
||||
assert lib["top"].shapes[(1, 0)] == []
|
||||
assert lib["top"].shapes[(2, 0)] == []
|
||||
|
||||
flat = lib.flatten("top")["top"]
|
||||
assert sum(isinstance(shape, Arc) for shape in flat.shapes[(1, 0)]) == 2
|
||||
assert sum(isinstance(shape, Ellipse) for shape in flat.shapes[(2, 0)]) == 2
|
||||
|
||||
|
||||
def test_library_dedup_handles_multiple_duplicate_groups() -> None:
|
||||
from ..shapes import Circle
|
||||
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))]
|
||||
pat.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)]
|
||||
lib["top"] = pat
|
||||
|
||||
lib.dedup(exclude_types=(), norm_value=1, threshold=2)
|
||||
|
||||
assert len(lib["top"].refs) == 2
|
||||
assert all(len(refs) == 2 for refs in lib["top"].refs.values())
|
||||
assert len(lib["top"].shapes[(1, 0)]) == 0
|
||||
assert len(lib["top"].shapes[(2, 0)]) == 0
|
||||
|
||||
|
||||
def test_library_dedup_uses_stable_target_names_per_label() -> None:
|
||||
from ..shapes import Circle
|
||||
|
||||
lib = Library()
|
||||
|
||||
p1 = Pattern()
|
||||
p1.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))]
|
||||
lib["p1"] = p1
|
||||
|
||||
p2 = Pattern()
|
||||
p2.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)]
|
||||
lib["p2"] = p2
|
||||
|
||||
lib.dedup(exclude_types=(), norm_value=1, threshold=2)
|
||||
|
||||
circle_target = next(iter(lib["p1"].refs))
|
||||
path_target = next(iter(lib["p2"].refs))
|
||||
|
||||
assert circle_target != path_target
|
||||
assert all(isinstance(shape, Circle) for shapes in lib[circle_target].shapes.values() for shape in shapes)
|
||||
assert all(isinstance(shape, Path) for shapes in lib[path_target].shapes.values() for shape in shapes)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
|
||||
from ..shapes import Polygon
|
||||
|
||||
|
||||
def test_manhattanize() -> None:
|
||||
pytest.importorskip("float_raster")
|
||||
pytest.importorskip("skimage.measure")
|
||||
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
||||
grid = numpy.arange(0, 11, 1)
|
||||
|
||||
manhattan_polys = poly.manhattanize(grid, grid)
|
||||
assert len(manhattan_polys) >= 1
|
||||
for mp in manhattan_polys:
|
||||
dv = numpy.diff(mp.vertices, axis=0)
|
||||
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import io
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
from ..error import PatternError
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath
|
||||
|
||||
|
||||
def test_oasis_roundtrip(tmp_path: Path) -> None:
|
||||
# Skip if fatamorgana is not installed
|
||||
pytest.importorskip("fatamorgana")
|
||||
from ..file import oasis
|
||||
|
||||
lib = Library()
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["cell1"] = pat1
|
||||
|
||||
oas_file = tmp_path / "test.oas"
|
||||
# OASIS needs units_per_micron
|
||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||
|
||||
read_lib, info = oasis.readfile(oas_file)
|
||||
assert "cell1" in read_lib
|
||||
|
||||
# Check bounds
|
||||
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])
|
||||
|
||||
|
||||
def test_oasis_properties_to_annotations_merges_repeated_keys() -> None:
|
||||
pytest.importorskip("fatamorgana")
|
||||
import fatamorgana.records as fatrec
|
||||
from ..file.oasis import properties_to_annotations
|
||||
|
||||
annotations = properties_to_annotations(
|
||||
[
|
||||
fatrec.Property("k", [1], is_standard=False),
|
||||
fatrec.Property("k", [2, 3], is_standard=False),
|
||||
],
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
assert annotations == {"k": [1, 2, 3]}
|
||||
|
||||
|
||||
def test_oasis_write_rejects_circle_path_caps() -> None:
|
||||
pytest.importorskip("fatamorgana")
|
||||
from ..file import oasis
|
||||
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle)
|
||||
lib["cell1"] = pat
|
||||
|
||||
with pytest.raises(PatternError, match="does not support path cap"):
|
||||
oasis.write(lib, io.BytesIO(), units_per_micron=1000)
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
def test_maxrects_bssf_simple() -> None:
|
||||
# Pack two 10x10 squares into one 20x10 container
|
||||
rects = [[10, 10], [10, 10]]
|
||||
containers = [[0, 0, 20, 10]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers)
|
||||
|
||||
assert not rejects
|
||||
# They should be at (0,0) and (10,0)
|
||||
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
|
||||
|
||||
|
||||
def test_maxrects_bssf_reject() -> None:
|
||||
# Try to pack a too-large rectangle
|
||||
rects = [[10, 10], [30, 30]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
|
||||
assert 1 in rejects # Second rect rejected
|
||||
assert 0 not in rejects
|
||||
|
||||
|
||||
def test_maxrects_bssf_exact_fill_rejects_remaining() -> None:
|
||||
rects = [[20, 20], [1, 1]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True)
|
||||
|
||||
assert tuple(locs[0]) == (0.0, 0.0)
|
||||
assert rejects == {1}
|
||||
|
||||
|
||||
def test_maxrects_bssf_presort_reject_mapping() -> None:
|
||||
rects = [[10, 12], [19, 14], [13, 11]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
_locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True)
|
||||
|
||||
assert rejects == {0, 2}
|
||||
|
||||
|
||||
def test_guillotine_bssf_sas_presort_reject_mapping() -> None:
|
||||
rects = [[2, 1], [17, 15], [16, 11]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
_locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True)
|
||||
|
||||
assert rejects == {2}
|
||||
|
||||
|
||||
def test_pack_patterns() -> None:
|
||||
lib = Library()
|
||||
p1 = Pattern()
|
||||
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["p1"] = p1
|
||||
|
||||
p2 = Pattern()
|
||||
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib["p2"] = p2
|
||||
|
||||
# Containers: one 20x20
|
||||
containers = [[0, 0, 20, 20]]
|
||||
# 2um spacing
|
||||
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
|
||||
|
||||
assert not rejects
|
||||
assert len(pat.refs) == 2
|
||||
assert "p1" in pat.refs
|
||||
assert "p2" in pat.refs
|
||||
|
||||
# Check that they don't overlap (simple check via bounds)
|
||||
# p1 size 10x10, effectively 12x12
|
||||
# p2 size 5x5, effectively 7x7
|
||||
# Both should fit in 20x20
|
||||
|
||||
|
||||
def test_pack_patterns_reject_names_match_original_patterns() -> None:
|
||||
lib = Library()
|
||||
for name, (lx, ly) in {
|
||||
"p0": (10, 12),
|
||||
"p1": (19, 14),
|
||||
"p2": (13, 11),
|
||||
}.items():
|
||||
pat = Pattern()
|
||||
pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly)
|
||||
lib[name] = pat
|
||||
|
||||
pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0))
|
||||
|
||||
assert set(rejects) == {"p0", "p2"}
|
||||
assert set(pat.refs) == {"p1"}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..shapes import Path, Path as MPath
|
||||
|
||||
|
||||
def test_path_init() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||
assert_equal(p.vertices, [[0, 0], [10, 0]])
|
||||
assert p.width == 2
|
||||
assert p.cap == Path.Cap.Flush
|
||||
|
||||
|
||||
def test_path_to_polygons_flush() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[0, -1], [10, 1]])
|
||||
|
||||
|
||||
def test_path_to_polygons_square() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||
|
||||
|
||||
def test_path_to_polygons_circle() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
||||
polys = p.to_polygons(num_vertices=32)
|
||||
assert len(polys) >= 3
|
||||
|
||||
bounds = p.get_bounds_single()
|
||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||
|
||||
|
||||
def test_path_custom_cap() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[-5, -1], [20, 1]])
|
||||
|
||||
|
||||
def test_path_bend() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[0, -1], [11, 10]])
|
||||
|
||||
|
||||
def test_path_mirror() -> None:
|
||||
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
||||
p.mirror(0)
|
||||
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
||||
|
||||
|
||||
def test_path_scale() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2)
|
||||
p.scale_by(2)
|
||||
assert_equal(p.vertices, [[0, 0], [20, 0]])
|
||||
assert p.width == 4
|
||||
|
||||
|
||||
def test_path_scale_custom_cap_extensions() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||
p.scale_by(3)
|
||||
|
||||
assert_equal(p.vertices, [[0, 0], [30, 0]])
|
||||
assert p.width == 6
|
||||
assert p.cap_extensions is not None
|
||||
assert_allclose(p.cap_extensions, [3, 6])
|
||||
assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]])
|
||||
|
||||
|
||||
def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||
|
||||
intrinsic, _extrinsic, ctor = p.normalized_form(5)
|
||||
q = ctor()
|
||||
|
||||
assert intrinsic[-1] == (0.2, 0.4)
|
||||
assert q.width == 2
|
||||
assert q.cap_extensions is not None
|
||||
assert_allclose(q.cap_extensions, [1, 2])
|
||||
|
||||
|
||||
def test_path_normalized_form_distinguishes_custom_caps() -> None:
|
||||
p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
|
||||
|
||||
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
|
||||
|
||||
|
||||
def test_path_edge_cases() -> None:
|
||||
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from masque import Pather, Library, Pattern, Port
|
||||
from masque.builder.tools import AutoTool
|
||||
|
||||
|
||||
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
|
||||
return pat
|
||||
|
||||
def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern:
|
||||
pat = Pattern()
|
||||
# Rectangular approximation of a 90 degree bend.
|
||||
if clockwise:
|
||||
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||
pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0)
|
||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype)
|
||||
else:
|
||||
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||
pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius)
|
||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype)
|
||||
return pat
|
||||
|
||||
@pytest.fixture
|
||||
def multi_bend_tool() -> tuple[AutoTool, Library]:
|
||||
lib = Library()
|
||||
|
||||
lib["b1"] = make_bend(2, ptype="wire")
|
||||
b1_abs = lib.abstract("b1")
|
||||
lib["b2"] = make_bend(5, ptype="wire")
|
||||
b2_abs = lib.abstract("b2")
|
||||
|
||||
tool = (
|
||||
AutoTool()
|
||||
.add_straight(make_straight, "wire", "A", length_range=(0, 10))
|
||||
.add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8))
|
||||
.add_bend(b1_abs, "A", "B", clockwise=True, mirror=True)
|
||||
.add_bend(b2_abs, "A", "B", clockwise=True, mirror=True)
|
||||
)
|
||||
return tool, lib
|
||||
|
||||
def test_autotool_uturn() -> None:
|
||||
from masque.builder.tools import AutoTool
|
||||
lib = Library()
|
||||
|
||||
def make_straight(length: float) -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
|
||||
pat.ports['in'] = Port((0, 0), 0)
|
||||
pat.ports['out'] = Port((length, 0), pi)
|
||||
return pat
|
||||
|
||||
bend_pat = Pattern()
|
||||
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
|
||||
bend_pat.ports['in'] = Port((0, 0), 0)
|
||||
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
||||
lib['bend'] = bend_pat
|
||||
|
||||
tool = (
|
||||
AutoTool()
|
||||
.add_straight(make_straight, 'wire', 'in')
|
||||
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
|
||||
)
|
||||
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), 0)
|
||||
|
||||
p.at('A').uturn(offset=-2000, length=1000)
|
||||
|
||||
# U-turn plan output is transformed into the port extension frame.
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||
|
||||
def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||
tool, lib = multi_bend_tool
|
||||
rp = Pather(lib, tools=tool)
|
||||
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||
|
||||
rp.jog("A", 10, length=20)
|
||||
|
||||
assert_allclose(rp.ports["A"].offset, [-20, -10])
|
||||
assert_allclose(rp.ports["A"].rotation, 0)
|
||||
|
||||
rp.render()
|
||||
assert len(rp.pattern.refs) > 0
|
||||
|
||||
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||
tool, lib = multi_bend_tool
|
||||
|
||||
p = Pather(lib, tools=tool)
|
||||
p.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||
|
||||
p.uturn("A", 10, length=5)
|
||||
|
||||
# Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames,
|
||||
# yielding a global endpoint at (-5, -10).
|
||||
assert_allclose(p.ports["A"].offset, [-5, -10])
|
||||
assert_allclose(p.ports["A"].rotation, pi)
|
||||
|
|
@ -1,601 +0,0 @@
|
|||
from collections.abc import Sequence
|
||||
from typing import Any, Literal, Never
|
||||
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import Pather, Library, Port
|
||||
from masque.builder.planner import RoutePortContext, RoutingPlanner
|
||||
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool
|
||||
from masque.error import BuildError
|
||||
from masque.library import ILibrary
|
||||
|
||||
|
||||
class PlanningOnlyTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[Any, ...]:
|
||||
_ = kind, in_ptype, out_ptype, kwargs
|
||||
return ()
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
tree, pat = Library.mktree('planning_only_tool')
|
||||
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
|
||||
return tree
|
||||
|
||||
|
||||
class FirstPortOnlyTraceTool(PlanningOnlyTool):
|
||||
def __init__(self) -> None:
|
||||
self.render_calls = 0
|
||||
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[StraightOffer | BendOffer, ...]:
|
||||
_ = out_ptype
|
||||
if in_ptype != 'wire':
|
||||
return ()
|
||||
|
||||
if kind == 'straight':
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype='wire')
|
||||
|
||||
def commit(length: float) -> dict[str, float | str]:
|
||||
return {'kind': 'straight', 'length': length}
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
if kind == 'bend':
|
||||
ccw = bool(kwargs['ccw'])
|
||||
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port(
|
||||
(length, 1 if ccw else -1),
|
||||
rotation=-pi / 2 if ccw else pi / 2,
|
||||
ptype='wire',
|
||||
)
|
||||
|
||||
def commit(length: float) -> dict[str, float | str]:
|
||||
return {'kind': 'bend', 'length': length}
|
||||
|
||||
return (BendOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
ccw=ccw,
|
||||
length_domain=(1, numpy.inf),
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
return ()
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
**kwargs: Any,
|
||||
) -> Library:
|
||||
_ = batch, port_names, kwargs
|
||||
self.render_calls += 1
|
||||
tree, pat = Library.mktree('trace')
|
||||
pat.add_port_pair(names=port_names, ptype='wire')
|
||||
return tree
|
||||
|
||||
|
||||
class CountingPathTool(PathTool):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.render_calls = 0
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
**kwargs: Any,
|
||||
) -> ILibrary:
|
||||
self.render_calls += 1
|
||||
return super().render(batch, port_names=port_names, **kwargs)
|
||||
|
||||
|
||||
def test_pather_jog_failed_two_bend_route_is_atomic() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||
p = Pather(lib, tools=tool, render='immediate')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='S-bend'):
|
||||
p.jog('A', 1.5, length=1.5)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].rotation == 0
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||
p = Pather(lib, tools=tool, render='immediate')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.jog('A', 1.5, length=5)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
|
||||
assert p.pattern.ports['A'].rotation == 0
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_immediate_render_batches_multi_step_selected_route_once() -> None:
|
||||
lib = Library()
|
||||
tool = CountingPathTool(layer='M1', width=2, ptype='wire')
|
||||
p = Pather(lib, tools=tool, render='immediate')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.jog('A', 4, length=10)
|
||||
|
||||
assert tool.render_calls == 1
|
||||
assert len(p._paths['A']) == 0
|
||||
assert p.pattern.has_shapes()
|
||||
|
||||
def test_pather_jog_length_solved_from_single_position_bound() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.jog('A', 2, x=-6)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
|
||||
q = Pather(Library(), tools=tool)
|
||||
q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
q.jog('A', 2, p=-6)
|
||||
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
|
||||
|
||||
|
||||
def test_pather_positional_bound_requires_port_rotation() -> None:
|
||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=None, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='Ports must have rotation'):
|
||||
p.trace_to('A', None, x=-5)
|
||||
|
||||
|
||||
def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.jog('A', 2)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -2))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert [step.opcode for step in p._paths['A']] == ['L', 'L', 'L']
|
||||
|
||||
with pytest.raises(BuildError, match='exactly one positional bound'):
|
||||
p.jog('A', 2, x=-6, p=-6)
|
||||
|
||||
def test_pather_trace_omitted_length_uses_minimum_offer() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.trace('A', None)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
|
||||
p.trace('A', True)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -1))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2)
|
||||
|
||||
def test_pather_trace_to_without_bound_uses_single_port_trace_minimum() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.trace_to('A', False)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1, 1))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 3 * pi / 2)
|
||||
|
||||
def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
|
||||
for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}):
|
||||
p = Pather(Library(), tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
with pytest.raises(BuildError, match='exactly one positional bound'):
|
||||
p.trace_to('A', None, **kwargs)
|
||||
|
||||
p = Pather(Library(), tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||
p.trace_to('A', None, x=-5, length=3)
|
||||
|
||||
def test_pather_trace_rejects_length_with_bundle_bound() -> None:
|
||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||
p.trace('A', None, length=5, xmin=-100)
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}]) # noqa: PT007
|
||||
def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None:
|
||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='exactly one bundle bound'):
|
||||
p.trace(['A', 'B'], None, **kwargs)
|
||||
|
||||
|
||||
def test_planner_constrained_bend_requires_jog() -> None:
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
context = RoutePortContext('A', Port((0, 0), rotation=0, ptype='wire'), tool)
|
||||
|
||||
with pytest.raises(BuildError, match='trace route requires a jog constraint'):
|
||||
RoutingPlanner().plan_leg('bend', context, length=5, constrain_jog=True)
|
||||
|
||||
|
||||
def test_pather_trace_each_plans_all_ports_before_mutation() -> None:
|
||||
tool = FirstPortOnlyTraceTool()
|
||||
p = Pather(Library(), tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((-2, 5), rotation=0, ptype='blocked')
|
||||
|
||||
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
|
||||
p.trace(['A', 'B'], None, each=5)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-2, 5))
|
||||
assert p.pattern.ports['A'].ptype == 'wire'
|
||||
assert p.pattern.ports['B'].ptype == 'blocked'
|
||||
assert len(p._paths['A']) == 0
|
||||
assert len(p._paths['B']) == 0
|
||||
|
||||
def test_pather_bundle_trace_plans_all_ports_before_mutation_or_render() -> None:
|
||||
tool = FirstPortOnlyTraceTool()
|
||||
p = Pather(Library(), tools=tool, render='immediate')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='blocked')
|
||||
|
||||
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
|
||||
p.trace(['A', 'B'], True, xmin=-10, spacing=2)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4))
|
||||
assert p.pattern.ports['A'].rotation == 0
|
||||
assert p.pattern.ports['B'].rotation == 0
|
||||
assert len(p._paths['A']) == 0
|
||||
assert len(p._paths['B']) == 0
|
||||
assert tool.render_calls == 0
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_pather_route_commit_failure_is_atomic_for_multi_port_trace() -> None:
|
||||
class CommitFailureTool(PlanningOnlyTool):
|
||||
def __init__(self) -> None:
|
||||
self.committed: list[str | None] = []
|
||||
self.render_calls = 0
|
||||
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[StraightOffer, ...]:
|
||||
_ = out_ptype, kwargs
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype=in_ptype)
|
||||
|
||||
def commit(length: float) -> dict[str, float | str | None]:
|
||||
_ = length
|
||||
self.committed.append(in_ptype)
|
||||
if in_ptype == 'bad':
|
||||
raise BuildError('selected commit failed')
|
||||
return {'ptype': in_ptype, 'length': length}
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=in_ptype,
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
**kwargs: Any,
|
||||
) -> Library:
|
||||
_ = batch, port_names, kwargs
|
||||
self.render_calls += 1
|
||||
tree, pat = Library.mktree('commit_failure_tool')
|
||||
pat.add_port_pair(names=port_names)
|
||||
return tree
|
||||
|
||||
tool = CommitFailureTool()
|
||||
p = Pather(Library(), tools=tool, render='immediate')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='bad')
|
||||
|
||||
with pytest.raises(BuildError, match='selected commit failed'):
|
||||
p.trace(['A', 'B'], None, each=5)
|
||||
|
||||
assert tool.committed == ['wire', 'bad']
|
||||
assert tool.render_calls == 0
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4))
|
||||
assert p.pattern.ports['A'].ptype == 'wire'
|
||||
assert p.pattern.ports['B'].ptype == 'bad'
|
||||
assert len(p._paths['A']) == 0
|
||||
assert len(p._paths['B']) == 0
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
def test_pather_jog_rejects_length_with_position_bound() -> None:
|
||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||
p.jog('A', 2, length=5, x=-999)
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{'x': -999}, {'xmin': -10}]) # noqa: PT007
|
||||
def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
|
||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
|
||||
p.uturn('A', 4, **kwargs)
|
||||
|
||||
def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.uturn('A', 4)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||
|
||||
def test_pather_uturn_explicit_zero_length_preserves_old_shape() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.uturn('A', 4, length=0)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||
|
||||
def test_pather_uturn_does_not_use_direct_planl_fallback() -> None:
|
||||
class PlanLOnlyTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Never:
|
||||
del kind, in_ptype, out_ptype, kwargs
|
||||
raise NotImplementedError
|
||||
|
||||
def legacy_planL(
|
||||
self,
|
||||
ccw: object,
|
||||
length: float,
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[Port, dict[str, object]]:
|
||||
del out_ptype
|
||||
if ccw is None:
|
||||
rotation = pi
|
||||
jog = 0
|
||||
elif bool(ccw):
|
||||
rotation = -pi / 2
|
||||
jog = 1
|
||||
else:
|
||||
rotation = pi / 2
|
||||
jog = -1
|
||||
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
|
||||
|
||||
p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='No legal primitive offer for omitted-length U-turn'):
|
||||
p.uturn('A', 5)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
with pytest.raises((BuildError, NotImplementedError)):
|
||||
p.uturn('A', 5, length=10)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_su_topology_rejects_out_ptype_sensitive_planl_jog() -> None:
|
||||
class OutPtypeSensitiveTool(PlanningOnlyTool):
|
||||
def legacy_planL(
|
||||
self,
|
||||
ccw: object,
|
||||
length: float,
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[Port, dict[str, object]]:
|
||||
radius = 1 if out_ptype is None else 2
|
||||
if ccw is None:
|
||||
rotation = pi
|
||||
jog = 0
|
||||
elif bool(ccw):
|
||||
rotation = -pi / 2
|
||||
jog = radius
|
||||
else:
|
||||
rotation = pi / 2
|
||||
jog = -radius
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
||||
|
||||
p = Pather(Library(), tools=OutPtypeSensitiveTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises((BuildError, NotImplementedError)):
|
||||
p.jog('A', 5, length=10, out_ptype='wide')
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_two_l_planl_only_uturn_is_not_supported() -> None:
|
||||
class PlanLOnlyTool(PlanningOnlyTool):
|
||||
def legacy_planL(
|
||||
self,
|
||||
ccw: object,
|
||||
length: float,
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[Port, dict[str, object]]:
|
||||
del out_ptype
|
||||
if ccw is None:
|
||||
rotation = pi
|
||||
jog = 0
|
||||
elif bool(ccw):
|
||||
rotation = -pi / 2
|
||||
jog = 1
|
||||
else:
|
||||
rotation = pi / 2
|
||||
jog = -1
|
||||
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
|
||||
|
||||
p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises((BuildError, NotImplementedError)):
|
||||
p.uturn('A', 5, length=10)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_two_l_planl_only_jog_is_not_supported() -> None:
|
||||
class PlanLOnlyTool(PlanningOnlyTool):
|
||||
def legacy_planL(
|
||||
self,
|
||||
ccw: object,
|
||||
length: float,
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[Port, dict[str, object]]:
|
||||
del out_ptype
|
||||
if ccw is None:
|
||||
rotation = pi
|
||||
jog = 0
|
||||
elif bool(ccw):
|
||||
rotation = -pi / 2
|
||||
jog = 1
|
||||
else:
|
||||
rotation = pi / 2
|
||||
jog = -1
|
||||
return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length}
|
||||
|
||||
p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises((BuildError, NotImplementedError)):
|
||||
p.jog('A', 5, length=10, out_ptype='unk')
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].ptype == 'wire'
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_su_topology_rejects_out_ptype_sensitive_planl_uturn() -> None:
|
||||
class OutPtypeSensitiveTool(PlanningOnlyTool):
|
||||
def legacy_planL(
|
||||
self,
|
||||
ccw: object,
|
||||
length: float,
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[Port, dict[str, object]]:
|
||||
radius = 1 if out_ptype is None else 2
|
||||
if ccw is None:
|
||||
rotation = pi
|
||||
jog = 0
|
||||
elif bool(ccw):
|
||||
rotation = -pi / 2
|
||||
jog = radius
|
||||
else:
|
||||
rotation = pi / 2
|
||||
jog = -radius
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
||||
|
||||
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises((BuildError, NotImplementedError)):
|
||||
p.uturn('A', 5, length=10, out_ptype='wide')
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_uturn_failed_two_bend_route_is_atomic() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='U-turn'):
|
||||
p.uturn('A', 1.5, length=0)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].rotation == 0
|
||||
assert len(p._paths['A']) == 0
|
||||
|
|
@ -1,538 +0,0 @@
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_allclose, assert_equal
|
||||
|
||||
from masque import Pather, Library, Pattern, Port
|
||||
from masque.builder import PathTool, PrimitiveOffer, StraightOffer
|
||||
from masque.builder.planner import RoutingPlanner
|
||||
from masque.error import BuildError, PortError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pather_setup() -> tuple[Pather, PathTool, Library]:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool)
|
||||
# Port rotation points into the device, so path extension moves in the opposite direction.
|
||||
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
return p, tool, lib
|
||||
|
||||
|
||||
def test_builder_tool_symbol_exports() -> None:
|
||||
import masque
|
||||
import masque.builder
|
||||
import masque.builder.tools as builder_tools
|
||||
|
||||
package_root_exports = (
|
||||
'RenderStep',
|
||||
'PortPather',
|
||||
)
|
||||
builder_tool_names = (
|
||||
'PrimitiveOffer',
|
||||
'StraightOffer',
|
||||
'BendOffer',
|
||||
'SOffer',
|
||||
'UOffer',
|
||||
)
|
||||
internal_names = (
|
||||
'PTypeMatch',
|
||||
'canonicalize_domain_value',
|
||||
'ptype_match',
|
||||
'ptypes_compatible',
|
||||
)
|
||||
|
||||
for name in (*package_root_exports, *builder_tool_names):
|
||||
assert hasattr(masque.builder, name)
|
||||
|
||||
for name in package_root_exports:
|
||||
assert hasattr(masque, name)
|
||||
|
||||
for name in (*builder_tool_names, *internal_names):
|
||||
assert not hasattr(masque, name)
|
||||
|
||||
for name in internal_names:
|
||||
assert not hasattr(masque.builder, name)
|
||||
|
||||
for name in builder_tool_names:
|
||||
assert hasattr(masque.builder, name)
|
||||
assert hasattr(builder_tools, name)
|
||||
|
||||
|
||||
def test_pather_pending_render_steps_are_private() -> None:
|
||||
p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1))
|
||||
|
||||
assert not hasattr(p, 'paths')
|
||||
assert hasattr(p, '_paths')
|
||||
|
||||
|
||||
def test_pather_accepts_and_reuses_planner_instance() -> None:
|
||||
class CountingPlanner(RoutingPlanner):
|
||||
def __init__(self) -> None:
|
||||
self.trace_to_calls = 0
|
||||
|
||||
def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any:
|
||||
self.trace_to_calls += 1
|
||||
return super().plan_trace_to_route(*args, **kwargs)
|
||||
|
||||
planner = CountingPlanner()
|
||||
p = Pather(
|
||||
Library(),
|
||||
tools=PathTool(layer=(1, 0), width=1),
|
||||
render='deferred',
|
||||
planner=planner,
|
||||
)
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 1)
|
||||
p.straight('A', 2)
|
||||
|
||||
assert p.planner is planner
|
||||
assert planner.trace_to_calls == 2
|
||||
|
||||
|
||||
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
p.straight("start", 10)
|
||||
|
||||
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
|
||||
assert p.ports["start"].rotation is not None
|
||||
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
p.cw("start", 10)
|
||||
|
||||
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
|
||||
assert p.ports["start"].rotation is not None
|
||||
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
|
||||
|
||||
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
p.straight("start", y=-50)
|
||||
assert_equal(p.ports["start"].offset, [0, -50])
|
||||
|
||||
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
|
||||
|
||||
p.straight(["A", "B"], ymin=-20)
|
||||
assert_equal(p.ports["A"].offset, [0, -20])
|
||||
assert_equal(p.ports["B"].offset, [10, -20])
|
||||
|
||||
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, tool, lib = pather_setup
|
||||
p.at("start").straight(10).ccw(10)
|
||||
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
|
||||
assert p.ports["start"].rotation is not None
|
||||
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
|
||||
|
||||
def test_pather_dead_ports() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=1)
|
||||
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
|
||||
p.set_dead()
|
||||
|
||||
p.straight("in", -10)
|
||||
|
||||
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
|
||||
|
||||
p.straight("in", 20)
|
||||
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
|
||||
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
def test_pather_trace_basic() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
|
||||
# Routing extends opposite the port's inward-facing rotation.
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').trace(None, 5000)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
||||
|
||||
p.at('A').trace(True, 5000) # CCW bend
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2)
|
||||
|
||||
def test_pather_trace_to() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').trace_to(None, x=-10000)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||
|
||||
p.at('A').trace_to(None, p=-20000)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0))
|
||||
|
||||
def test_pather_bundle_trace() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
p.at(['A', 'B']).straight(xmin=-10000)
|
||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
|
||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
|
||||
|
||||
p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
|
||||
# The lower port is on the inner bend, so `xmin` applies to that route.
|
||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000)
|
||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000)
|
||||
|
||||
def test_portpather_default_spacing_matches_explicit_spacing() -> None:
|
||||
lib_default = Library()
|
||||
tool_default = PathTool(layer='M1', width=1000)
|
||||
p_default = Pather(lib_default, tools=tool_default)
|
||||
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
lib_explicit = Library()
|
||||
tool_explicit = PathTool(layer='M1', width=1000)
|
||||
p_explicit = Pather(lib_explicit, tools=tool_explicit)
|
||||
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
p_default.at(['A', 'B'], spacing=2000).ccw(xmin=-20000)
|
||||
p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
|
||||
|
||||
assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset)
|
||||
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
|
||||
|
||||
def test_portpather_default_spacing_reused_and_overridden() -> None:
|
||||
p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000))
|
||||
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
|
||||
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
pp_default = p_default.at(['A', 'B'], spacing=2000)
|
||||
pp_default.ccw(xmin=-20000)
|
||||
pp_default.cw(emin=1000)
|
||||
p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000).cw(emin=1000, spacing=2000)
|
||||
|
||||
assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset)
|
||||
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
|
||||
|
||||
p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000))
|
||||
p_override.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_override.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
|
||||
p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
p_override.at(['A', 'B'], spacing=2000).ccw(xmin=-20000, spacing=3000)
|
||||
p_override_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=3000)
|
||||
|
||||
assert_allclose(p_override.pattern.ports['A'].offset, p_override_explicit.pattern.ports['A'].offset)
|
||||
assert_allclose(p_override.pattern.ports['B'].offset, p_override_explicit.pattern.ports['B'].offset)
|
||||
|
||||
def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
|
||||
p.at(['A', 'B'], spacing=2000).straight(xmin=-10000)
|
||||
|
||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
|
||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
|
||||
|
||||
def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||
p.pattern.ports['C'] = Port((0, 4000), rotation=0)
|
||||
|
||||
pp = p.at(['A', 'B', 'C'], spacing=[2000, 3000])
|
||||
pp.deselect('C')
|
||||
|
||||
with pytest.raises(BuildError, match='spacing must be scalar or have length 1'):
|
||||
pp.ccw(xmin=-20000)
|
||||
|
||||
def test_pather_each_bound() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
|
||||
|
||||
p.at(['A', 'B']).trace(None, each=5000)
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000))
|
||||
|
||||
def test_selection_management() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
||||
|
||||
pp = p.at('A')
|
||||
assert pp.ports == ['A']
|
||||
|
||||
pp.select('B')
|
||||
assert pp.ports == ['A', 'B']
|
||||
|
||||
pp.deselect('A')
|
||||
assert pp.ports == ['B']
|
||||
|
||||
pp.select(['A'])
|
||||
assert pp.ports == ['B', 'A']
|
||||
|
||||
pp.drop()
|
||||
assert 'A' not in p.pattern.ports
|
||||
assert 'B' not in p.pattern.ports
|
||||
assert pp.ports == []
|
||||
|
||||
def test_mark_fork() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib)
|
||||
p.pattern.ports['A'] = Port((100, 200), rotation=1)
|
||||
|
||||
pp = p.at('A')
|
||||
pp.mark('B')
|
||||
assert 'B' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200))
|
||||
assert p.pattern.ports['B'].rotation == 1
|
||||
assert pp.ports == ['A']
|
||||
|
||||
pp.fork('C')
|
||||
assert 'C' in p.pattern.ports
|
||||
assert pp.ports == ['C']
|
||||
|
||||
def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None:
|
||||
lib = Library()
|
||||
|
||||
p_mark = Pather(lib, pattern=Pattern(ports={
|
||||
'A': Port((0, 0), rotation=0),
|
||||
'C': Port((2, 0), rotation=0),
|
||||
}))
|
||||
with pytest.raises(PortError, match='overwrite existing ports'):
|
||||
p_mark.at('A').mark('C')
|
||||
assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0))
|
||||
|
||||
p_fork = Pather(lib, pattern=Pattern(ports={
|
||||
'A': Port((0, 0), rotation=0),
|
||||
'B': Port((1, 0), rotation=0),
|
||||
}))
|
||||
pp = p_fork.at(['A', 'B'])
|
||||
with pytest.raises(PortError, match='targets would collide'):
|
||||
pp.fork({'A': 'X', 'B': 'X'})
|
||||
assert set(p_fork.pattern.ports) == {'A', 'B'}
|
||||
assert pp.ports == ['A', 'B']
|
||||
|
||||
def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib, pattern=Pattern(ports={
|
||||
'A': Port((0, 0), rotation=0),
|
||||
'B': Port((1, 0), rotation=0),
|
||||
'C': Port((2, 0), rotation=0),
|
||||
}))
|
||||
p.set_dead()
|
||||
|
||||
p.at('A').mark('C')
|
||||
assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0))
|
||||
|
||||
pp = p.at(['A', 'B'])
|
||||
pp.fork({'A': 'X', 'B': 'X'})
|
||||
assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0))
|
||||
assert pp.ports == ['X']
|
||||
|
||||
def test_mark_fork_reject_missing_sources() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib, pattern=Pattern(ports={
|
||||
'A': Port((0, 0), rotation=0),
|
||||
'B': Port((1, 0), rotation=0),
|
||||
}))
|
||||
|
||||
with pytest.raises(PortError, match='selected ports'):
|
||||
p.at(['A', 'B']).mark({'Z': 'C'})
|
||||
|
||||
with pytest.raises(PortError, match='selected ports'):
|
||||
p.at(['A', 'B']).fork({'Z': 'C'})
|
||||
|
||||
def test_rename() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').rename('B')
|
||||
assert 'A' not in p.pattern.ports
|
||||
assert 'B' in p.pattern.ports
|
||||
|
||||
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
||||
pp = p.at(['B', 'C'])
|
||||
pp.rename({'B': 'D', 'C': 'E'})
|
||||
assert 'B' not in p.pattern.ports
|
||||
assert 'C' not in p.pattern.ports
|
||||
assert 'D' in p.pattern.ports
|
||||
assert 'E' in p.pattern.ports
|
||||
assert set(pp.ports) == {'D', 'E'}
|
||||
|
||||
def test_pather_dead_fallback_preserves_out_ptype() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.set_dead()
|
||||
|
||||
p.straight('A', -1000, out_ptype='other')
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0))
|
||||
assert p.pattern.ports['A'].ptype == 'other'
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None:
|
||||
class MismatchedEndpointTool(PathTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind, # noqa: ANN001
|
||||
*,
|
||||
in_ptype=None, # noqa: ANN001
|
||||
out_ptype=None, # noqa: ANN001,ARG002
|
||||
**kwargs, # noqa: ANN003
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kwargs
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype='wire')
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype='other',
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=lambda length: {'length': length},
|
||||
),)
|
||||
|
||||
p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.set_dead()
|
||||
|
||||
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
|
||||
p.straight('A', 1000)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].ptype == 'wire'
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
|
||||
def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None:
|
||||
class PartlyMismatchedEndpointTool(PathTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind, # noqa: ANN001
|
||||
*,
|
||||
in_ptype=None, # noqa: ANN001
|
||||
out_ptype=None, # noqa: ANN001,ARG002
|
||||
**kwargs, # noqa: ANN003
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kwargs
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def mismatched_endpoint(length: float) -> Port:
|
||||
ptype = 'wire' if numpy.isclose(length, 0) else 'other'
|
||||
return Port((length, 0), rotation=pi, ptype=ptype)
|
||||
|
||||
def valid_endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype='wire')
|
||||
|
||||
return (
|
||||
StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype='wire',
|
||||
endpoint_planner=mismatched_endpoint,
|
||||
commit_planner=lambda length: {'kind': 'mismatched', 'length': length},
|
||||
),
|
||||
StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype='wire',
|
||||
endpoint_planner=valid_endpoint,
|
||||
commit_planner=lambda length: {'kind': 'valid', 'length': length},
|
||||
),
|
||||
)
|
||||
|
||||
p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
|
||||
p.straight('A', 1000)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert p.pattern.ports['A'].ptype == 'wire'
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
|
||||
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
|
||||
lib = Library()
|
||||
p = Pather(lib, pattern=Pattern(ports={
|
||||
'A': Port((5, 5), rotation=0),
|
||||
'keep': Port((9, 9), rotation=0),
|
||||
}))
|
||||
p.set_dead()
|
||||
|
||||
other = Pattern()
|
||||
other.ports['X'] = Port((1, 0), rotation=0)
|
||||
other.ports['Y'] = Port((2, 0), rotation=pi / 2)
|
||||
|
||||
p.place(other, port_map={'X': 'A', 'Y': 'A'})
|
||||
|
||||
assert set(p.pattern.ports) == {'A', 'keep'}
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2)
|
||||
|
||||
def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
||||
p = Pather(lib, tools=tool, pattern=Pattern(ports={
|
||||
'A': Port((0, 0), rotation=0, ptype='wire'),
|
||||
'B': Port((99, 99), rotation=0, ptype='wire'),
|
||||
}))
|
||||
p.set_dead()
|
||||
|
||||
other = Pattern()
|
||||
other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire')
|
||||
other.ports['X'] = Port((10, 0), rotation=0, ptype='wire')
|
||||
other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'})
|
||||
|
||||
assert 'A' not in p.pattern.ports
|
||||
assert 'B' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0))
|
||||
assert p.pattern.ports['B'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['B'].rotation, 0)
|
||||
|
||||
def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None:
|
||||
p = Pather(Library(), pattern=Pattern(ports={
|
||||
'A': Port((0, 0), rotation=0),
|
||||
'B': Port((1, 0), rotation=0),
|
||||
'C': Port((2, 0), rotation=0),
|
||||
}))
|
||||
p.set_dead()
|
||||
|
||||
p.rename_ports({'A': 'C', 'B': 'C'})
|
||||
|
||||
assert set(p.pattern.ports) == {'C'}
|
||||
assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0))
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import Pather, Library, Pattern, Port
|
||||
from masque.builder.tools import PathTool
|
||||
from masque.error import PortError, PatternError
|
||||
|
||||
|
||||
def test_pather_place_treeview_resolves_once() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
|
||||
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
|
||||
|
||||
p.place(tree)
|
||||
|
||||
assert len(lib) == 1
|
||||
assert 'child' in lib
|
||||
assert 'child' in p.pattern.refs
|
||||
assert 'B' in p.pattern.ports
|
||||
|
||||
def test_pather_plug_treeview_resolves_once() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
|
||||
|
||||
p.plug(tree, {'A': 'B'})
|
||||
|
||||
assert len(lib) == 1
|
||||
assert 'child' in lib
|
||||
assert 'child' in p.pattern.refs
|
||||
assert 'A' not in p.pattern.ports
|
||||
|
||||
def test_pather_failed_plug_does_not_add_break_marker() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.annotations = {'k': [1]}
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').trace(None, 5000)
|
||||
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||
|
||||
other = Pattern(
|
||||
annotations={'k': [2]},
|
||||
ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)},
|
||||
)
|
||||
|
||||
with pytest.raises(PatternError, match='Annotation keys overlap'):
|
||||
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
|
||||
|
||||
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||
assert set(p.pattern.ports) == {'A'}
|
||||
|
||||
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').straight(5000)
|
||||
p.rename_ports({'A': None})
|
||||
|
||||
other = Pattern(ports={'X': Port((-5000, 0), rotation=0)})
|
||||
p.place(other, port_map={'X': 'A'}, append=True)
|
||||
p.at('A').straight(2000)
|
||||
|
||||
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
|
||||
|
||||
p.render()
|
||||
assert p.pattern.has_shapes()
|
||||
assert 'A' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
||||
|
||||
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').straight(5000)
|
||||
p.rename_ports({'A': None})
|
||||
|
||||
other = Pattern(
|
||||
ports={
|
||||
'X': Port((0, 0), rotation=pi),
|
||||
'Y': Port((-5000, 0), rotation=0),
|
||||
},
|
||||
)
|
||||
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
|
||||
p.at('A').straight(2000)
|
||||
|
||||
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
|
||||
|
||||
p.render()
|
||||
assert p.pattern.has_shapes()
|
||||
assert 'A' in p.pattern.ports
|
||||
assert 'B' not in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
||||
|
||||
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool, render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
p.at('A').straight(5000)
|
||||
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||
|
||||
with pytest.raises(PortError, match='Connection destination ports were not found'):
|
||||
p.plugged({'A': 'missing'})
|
||||
|
||||
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||
assert set(p._paths) == {'A'}
|
||||
|
|
@ -1,659 +0,0 @@
|
|||
from collections.abc import Callable
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy import pi
|
||||
|
||||
from masque import Library, Path, Port, Pather
|
||||
from masque.builder.planner import RoutingPlanner
|
||||
from masque.builder.tools import (
|
||||
BendOffer,
|
||||
PathTool,
|
||||
PrimitiveOffer,
|
||||
SOffer,
|
||||
StraightOffer,
|
||||
Tool,
|
||||
UOffer,
|
||||
)
|
||||
from masque.error import BuildError
|
||||
from masque.utils import PTypeMatch, ptype_match
|
||||
|
||||
|
||||
def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]:
|
||||
return {
|
||||
'endpoint_planner': lambda parameter: planner(parameter)[0],
|
||||
'commit_planner': lambda parameter: planner(parameter)[1],
|
||||
}
|
||||
|
||||
|
||||
class PlanningOnlyTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kind, in_ptype, out_ptype, kwargs
|
||||
return ()
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
tree, pat = Library.mktree('planning_only_tool')
|
||||
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
|
||||
return tree
|
||||
|
||||
|
||||
def test_tool_requires_primitive_offers_override() -> None:
|
||||
class RenderOnlyTool(Tool):
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
return Library()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
RenderOnlyTool()
|
||||
|
||||
|
||||
def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None:
|
||||
class BaseCallingTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
return Library()
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
BaseCallingTool().primitive_offers('straight')
|
||||
|
||||
|
||||
def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float:
|
||||
offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
|
||||
return offer.canonicalize_parameter(value)
|
||||
|
||||
|
||||
def test_offer_canonicalize_parameter_half_open_and_singleton() -> None:
|
||||
assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0
|
||||
assert canonicalize_offer_parameter(3, (0, 10)) == 3
|
||||
|
||||
with pytest.raises(BuildError, match='outside half-open domain'):
|
||||
canonicalize_offer_parameter(10, (0, 10))
|
||||
|
||||
assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5
|
||||
|
||||
with pytest.raises(BuildError, match='outside singleton domain'):
|
||||
canonicalize_offer_parameter(5.1, (5, 5))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf])
|
||||
def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None:
|
||||
with pytest.raises(BuildError, match='must be finite'):
|
||||
canonicalize_offer_parameter(value, (0, 10))
|
||||
|
||||
|
||||
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
|
||||
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
|
||||
canonicalize_offer_parameter(3, (10, 0))
|
||||
|
||||
|
||||
def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None:
|
||||
assert ptype_match('wire', 'wire') is PTypeMatch.EXACT
|
||||
assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD
|
||||
assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD
|
||||
assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH
|
||||
|
||||
|
||||
def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None:
|
||||
endpoint_calls: list[float] = []
|
||||
commit_calls: list[float] = []
|
||||
|
||||
def endpoint(length: float) -> Port:
|
||||
endpoint_calls.append(length)
|
||||
return Port((length, 2), rotation=pi, ptype='wire')
|
||||
|
||||
def commit(length: float) -> dict[str, float]:
|
||||
commit_calls.append(length)
|
||||
return {'length': length}
|
||||
|
||||
offer = StraightOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
length_domain=(5, 5),
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
)
|
||||
|
||||
assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2))
|
||||
assert offer.cost_at(5 + 1e-13) == 5 + pi
|
||||
assert commit_calls == []
|
||||
|
||||
assert offer.commit(5 + 1e-13) == {'length': 5}
|
||||
assert endpoint_calls == [5, 5]
|
||||
assert commit_calls == [5]
|
||||
|
||||
|
||||
def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None:
|
||||
def data_at(length: float) -> dict[str, float]:
|
||||
return {'length': length}
|
||||
|
||||
offer = StraightOffer.generated(
|
||||
'wire',
|
||||
data_at,
|
||||
length_domain=(2, 8),
|
||||
priority_bias=3,
|
||||
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]),
|
||||
)
|
||||
|
||||
endpoint = offer.endpoint_at(4)
|
||||
|
||||
assert offer.length_domain == (2, 8)
|
||||
assert offer.priority_bias == 3
|
||||
assert numpy.allclose(endpoint.offset, [4, 0])
|
||||
assert endpoint.rotation == pi
|
||||
assert endpoint.ptype == 'wire'
|
||||
assert offer.commit(4) == {'length': 4}
|
||||
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]])
|
||||
|
||||
|
||||
def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None:
|
||||
def endpoint_at(jog: float) -> Port:
|
||||
return Port((10, jog), rotation=pi, ptype='wire')
|
||||
|
||||
def data_at(jog: float) -> dict[str, Any]:
|
||||
return {'jog': abs(jog), 'mirrored': jog < 0}
|
||||
|
||||
offer = SOffer.generated(
|
||||
'wire',
|
||||
endpoint_at,
|
||||
data_at,
|
||||
jog_domain=(-5, 5),
|
||||
bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]),
|
||||
)
|
||||
|
||||
endpoint = offer.endpoint_at(-3)
|
||||
|
||||
assert numpy.allclose(endpoint.offset, [10, -3])
|
||||
assert offer.commit(-3) == {'jog': 3, 'mirrored': True}
|
||||
assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]])
|
||||
|
||||
|
||||
def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
|
||||
def endpoint_at(length: float) -> Port:
|
||||
return Port((length, length), rotation=-pi / 2, ptype='wire')
|
||||
|
||||
def data_at(length: float) -> dict[str, float]:
|
||||
return {'length': length}
|
||||
|
||||
offer = BendOffer.generated(
|
||||
'wire',
|
||||
endpoint_at,
|
||||
data_at,
|
||||
ccw=True,
|
||||
length_domain=(4, 4),
|
||||
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]),
|
||||
)
|
||||
|
||||
endpoint = offer.endpoint_at(4)
|
||||
|
||||
assert offer.ccw
|
||||
assert offer.length_domain == (4, 4)
|
||||
assert numpy.allclose(endpoint.offset, [4, 4])
|
||||
assert endpoint.rotation == 3 * pi / 2
|
||||
assert offer.commit(4) == {'length': 4}
|
||||
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]])
|
||||
|
||||
|
||||
def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
|
||||
def endpoint_at(jog: float) -> Port:
|
||||
return Port((12, jog), rotation=0, ptype='wire')
|
||||
|
||||
def data_at(jog: float) -> dict[str, float]:
|
||||
return {'jog': jog}
|
||||
|
||||
offer = UOffer.generated(
|
||||
'wire',
|
||||
endpoint_at,
|
||||
data_at,
|
||||
jog_domain=(-6, 6),
|
||||
bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]),
|
||||
)
|
||||
|
||||
endpoint = offer.endpoint_at(-4)
|
||||
|
||||
assert offer.jog_domain == (-6, 6)
|
||||
assert numpy.allclose(endpoint.offset, [12, -4])
|
||||
assert endpoint.rotation == 0
|
||||
assert offer.commit(-4) == {'jog': -4}
|
||||
assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]])
|
||||
|
||||
|
||||
def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None:
|
||||
data = {'kind': 'prebuilt'}
|
||||
bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731
|
||||
endpoint = Port((5, 2), rotation=pi / 2, ptype='out')
|
||||
cases = [
|
||||
(StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5),
|
||||
(BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5),
|
||||
(SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
|
||||
(UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
|
||||
]
|
||||
|
||||
for offer, parameter in cases:
|
||||
first = offer.endpoint_at(parameter)
|
||||
first.offset[:] = 99
|
||||
second = offer.endpoint_at(parameter)
|
||||
|
||||
assert numpy.allclose(second.offset, [5, 2])
|
||||
assert second.rotation == pi / 2
|
||||
assert second.ptype == 'out'
|
||||
assert offer.commit(parameter) is data
|
||||
assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]])
|
||||
|
||||
|
||||
def test_offer_rejects_negative_priority_bias() -> None:
|
||||
with pytest.raises(BuildError, match='priority_bias must be nonnegative'):
|
||||
StraightOffer(in_ptype='wire', out_ptype='wire', priority_bias=-1)
|
||||
|
||||
|
||||
def test_offer_rejects_one_sided_split_callbacks() -> None:
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='require both'):
|
||||
StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint)
|
||||
|
||||
|
||||
def test_offer_bbox_at_validates_bounds() -> None:
|
||||
offer = StraightOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]),
|
||||
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
|
||||
)
|
||||
|
||||
assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]])
|
||||
|
||||
bad = StraightOffer(
|
||||
in_ptype='wire',
|
||||
out_ptype='wire',
|
||||
bbox_planner=lambda _length: numpy.array([0, 1]),
|
||||
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
|
||||
)
|
||||
with pytest.raises(BuildError, match='shape'):
|
||||
bad.bbox_at(3)
|
||||
|
||||
|
||||
def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
|
||||
offer = tool.primitive_offers('straight', in_ptype='wire')[0]
|
||||
|
||||
bounds = offer.bbox_at(10)
|
||||
expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single()
|
||||
|
||||
assert numpy.allclose(bounds, expected)
|
||||
|
||||
|
||||
def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
|
||||
offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0]
|
||||
|
||||
bounds = offer.bbox_at(1)
|
||||
expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single()
|
||||
|
||||
assert isinstance(offer, BendOffer)
|
||||
assert offer.length_domain == (1, 1)
|
||||
assert numpy.allclose(bounds, expected)
|
||||
|
||||
|
||||
def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
|
||||
offer = tool.primitive_offers('s', in_ptype='wire', length=6)[0]
|
||||
|
||||
bounds = offer.bbox_at(3)
|
||||
expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single()
|
||||
|
||||
assert isinstance(offer, SOffer)
|
||||
assert numpy.allclose(bounds, expected)
|
||||
|
||||
|
||||
def test_pathtool_u_offers_remain_unsupported() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
|
||||
|
||||
assert tool.primitive_offers('u', in_ptype='wire') == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('kind', 'kwargs'),
|
||||
[
|
||||
('straight', {}),
|
||||
('bend', {'ccw': True}),
|
||||
('s', {'length': 6}),
|
||||
],
|
||||
)
|
||||
def test_pathtool_out_ptype_unk_is_wildcard(
|
||||
kind: Literal['straight', 'bend', 's'],
|
||||
kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
|
||||
|
||||
offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs)
|
||||
|
||||
assert offers
|
||||
assert offers[0].out_ptype == 'wire'
|
||||
|
||||
|
||||
def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
|
||||
class NoOfferTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kind, in_ptype, out_ptype, kwargs
|
||||
raise NotImplementedError
|
||||
|
||||
p = Pather(Library(), tools=NoOfferTool())
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
|
||||
p.straight('A', 5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError])
|
||||
def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None:
|
||||
class BrokenTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kind, in_ptype, out_ptype, kwargs
|
||||
raise err_type('offer query failed')
|
||||
|
||||
p = Pather(Library(), tools=BrokenTool())
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(err_type):
|
||||
p.straight('A', 5)
|
||||
|
||||
|
||||
def test_pather_selects_lowest_cost_offer() -> None:
|
||||
class MultiOfferTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kwargs
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def high(length: float) -> tuple[Port, dict[str, str | float]]:
|
||||
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'}
|
||||
|
||||
def low(length: float) -> tuple[Port, dict[str, str | float]]:
|
||||
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'}
|
||||
|
||||
return (
|
||||
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=10, **offer_callbacks(high)),
|
||||
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=0, **offer_callbacks(low)),
|
||||
)
|
||||
|
||||
p = Pather(Library(), tools=MultiOfferTool(), render='deferred')
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 7)
|
||||
|
||||
assert p._paths['A'][0].data == {'kind': 'low'}
|
||||
assert numpy.allclose(p.ports['A'].offset, (-7, 0))
|
||||
|
||||
|
||||
class StrategyTieTool(PlanningOnlyTool):
|
||||
def __init__(self) -> None:
|
||||
self.seen_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
self.seen_kwargs.append(dict(kwargs))
|
||||
endpoint_ptype = out_ptype or in_ptype
|
||||
if kind == 'straight':
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=endpoint_ptype,
|
||||
**offer_callbacks(lambda length: (
|
||||
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
|
||||
{'kind': 'straight', 'length': length},
|
||||
)),
|
||||
),)
|
||||
if kind == 's':
|
||||
return (SOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=endpoint_ptype,
|
||||
**offer_callbacks(lambda jog: (
|
||||
Port((3, jog), rotation=pi, ptype=endpoint_ptype),
|
||||
{'kind': 's', 'jog': jog},
|
||||
)),
|
||||
),)
|
||||
return ()
|
||||
|
||||
|
||||
def pather_with_strategy_tool(
|
||||
planner: RoutingPlanner | None = None,
|
||||
) -> tuple[Pather, StrategyTieTool]:
|
||||
tool = StrategyTieTool()
|
||||
pather = Pather(Library(), tools=tool, planner=planner, render='deferred')
|
||||
pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
return pather, tool
|
||||
|
||||
|
||||
def test_pather_route_strategy_defaults_to_straight_first() -> None:
|
||||
pather, _tool = pather_with_strategy_tool()
|
||||
|
||||
pather.jog('A', 4, length=10)
|
||||
|
||||
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
|
||||
assert pather._paths['A'][0].data['length'] == 7
|
||||
|
||||
|
||||
def test_pather_route_strategy_uses_planner_default() -> None:
|
||||
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
|
||||
|
||||
pather.jog('A', 4, length=10)
|
||||
|
||||
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
|
||||
assert pather._paths['A'][1].data['length'] == 7
|
||||
|
||||
|
||||
def test_pather_route_strategy_per_route_overrides_planner_default() -> None:
|
||||
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
|
||||
|
||||
pather.jog('A', 4, length=10, strategy='straight_first')
|
||||
|
||||
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
|
||||
|
||||
|
||||
def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
|
||||
pather, _tool = pather_with_strategy_tool()
|
||||
|
||||
pather.jog('A', 4, length=10, strategy='turn_first')
|
||||
|
||||
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
|
||||
|
||||
|
||||
def test_pather_route_strategy_is_not_forwarded_to_tool() -> None:
|
||||
pather, tool = pather_with_strategy_tool()
|
||||
|
||||
pather.jog('A', 4, length=10, strategy='turn_first', marker='sentinel')
|
||||
|
||||
assert tool.seen_kwargs
|
||||
assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs)
|
||||
assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs)
|
||||
|
||||
|
||||
def test_pather_route_strategy_rejects_invalid_values() -> None:
|
||||
with pytest.raises(BuildError, match='Invalid route strategy'):
|
||||
RoutingPlanner(strategy='sideways')
|
||||
|
||||
pather, _tool = pather_with_strategy_tool()
|
||||
with pytest.raises(BuildError, match='Invalid route strategy'):
|
||||
pather.jog('A', 4, length=10, strategy='sideways')
|
||||
|
||||
|
||||
def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None:
|
||||
invalid_parameters: list[float] = []
|
||||
|
||||
class RotationOfferTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kwargs
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def invalid_endpoint(length: float) -> Port:
|
||||
invalid_parameters.append(length)
|
||||
return Port((length, 0), rotation=0, ptype=out_ptype or in_ptype)
|
||||
|
||||
def valid_endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
|
||||
|
||||
return (
|
||||
StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype,
|
||||
endpoint_planner=invalid_endpoint,
|
||||
commit_planner=lambda length: {'kind': 'invalid', 'length': length},
|
||||
),
|
||||
StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype,
|
||||
endpoint_planner=valid_endpoint,
|
||||
commit_planner=lambda length: {'kind': 'valid', 'length': length},
|
||||
),
|
||||
)
|
||||
|
||||
p = Pather(Library(), tools=RotationOfferTool(), render='deferred')
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 7)
|
||||
|
||||
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7}
|
||||
assert invalid_parameters == [0.0, 0.0]
|
||||
|
||||
|
||||
def test_pather_commits_only_selected_offer() -> None:
|
||||
committed: list[str] = []
|
||||
|
||||
class RecordingTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kwargs
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def make(label: str, priority: float) -> StraightOffer:
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
|
||||
|
||||
def commit(length: float) -> dict[str, float | str]:
|
||||
committed.append(label)
|
||||
return {'kind': label, 'length': length}
|
||||
|
||||
return StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype,
|
||||
priority_bias=priority,
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
)
|
||||
|
||||
return (make('expensive', 100), make('cheap', 0))
|
||||
|
||||
p = Pather(Library(), tools=RecordingTool(), render='deferred')
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 5)
|
||||
|
||||
assert committed == ['cheap']
|
||||
assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5}
|
||||
|
||||
|
||||
def test_pather_routes_with_offer_only_s_and_u_tool() -> None:
|
||||
class OfferOnlyTool(PlanningOnlyTool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = kwargs
|
||||
endpoint_ptype = out_ptype or in_ptype
|
||||
if kind == 'straight':
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=endpoint_ptype,
|
||||
**offer_callbacks(lambda length: (
|
||||
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
|
||||
{'kind': 'straight', 'length': length},
|
||||
)),
|
||||
),)
|
||||
if kind == 's':
|
||||
return (SOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=endpoint_ptype,
|
||||
**offer_callbacks(lambda jog: (
|
||||
Port((5, jog), rotation=pi, ptype=endpoint_ptype),
|
||||
{'kind': 's', 'jog': jog},
|
||||
)),
|
||||
),)
|
||||
if kind == 'u':
|
||||
return (UOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=endpoint_ptype,
|
||||
**offer_callbacks(lambda jog: (
|
||||
Port((2, jog), rotation=0, ptype=endpoint_ptype),
|
||||
{'kind': 'u', 'jog': jog},
|
||||
)),
|
||||
),)
|
||||
return ()
|
||||
|
||||
p = Pather(Library(), tools=OfferOnlyTool(), render='deferred')
|
||||
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.jog('A', 3)
|
||||
p.uturn('A', 4)
|
||||
|
||||
assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u']
|
||||
|
|
@ -1,526 +0,0 @@
|
|||
from typing import TYPE_CHECKING, cast
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..builder import Pather
|
||||
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
|
||||
from ..error import BuildError
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..shapes import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deferred_render_setup() -> tuple[Pather, PathTool, Library]:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
rp = Pather(lib, tools=tool, render='deferred')
|
||||
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
return rp, tool, lib
|
||||
|
||||
|
||||
def add_pending_straight(pather: Pather) -> None:
|
||||
pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
pather.straight("start", 10)
|
||||
|
||||
|
||||
def route_in_context(pather: Pather) -> None:
|
||||
with pather:
|
||||
add_pending_straight(pather)
|
||||
|
||||
|
||||
def fail_in_context(pather: Pather) -> None:
|
||||
with pather:
|
||||
add_pending_straight(pather)
|
||||
raise RuntimeError('body failed')
|
||||
|
||||
|
||||
def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
rp.at("start").straight(10).straight(10)
|
||||
|
||||
assert not rp.pattern.has_shapes()
|
||||
assert len(rp._paths["start"]) == 2
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||
|
||||
# PathTool renders length steps in the port extension direction.
|
||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||
assert len(path_shape.vertices) == 3
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
||||
|
||||
def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
rp.at("start").straight(10).cw(10)
|
||||
|
||||
rp.render()
|
||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||
# The bend route is explicit straight run plus a fixed-size bend.
|
||||
assert len(path_shape.vertices) == 5
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10)
|
||||
|
||||
def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
rp.at("start").jog(4, length=10)
|
||||
|
||||
assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"]
|
||||
|
||||
rp.render()
|
||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -8], [0, -9], [1, -9], [3, -9], [4, -9], [4, -10]], atol=1e-10)
|
||||
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
|
||||
|
||||
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
rp.at("start").straight(10).cw(10)
|
||||
|
||||
rp.mirror(0)
|
||||
rp.render()
|
||||
|
||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10)
|
||||
|
||||
def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool1, lib = deferred_render_setup
|
||||
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
|
||||
|
||||
rp.at("start").straight(10)
|
||||
rp.retool(tool2, keys=["start"])
|
||||
rp.at("start").straight(10)
|
||||
|
||||
rp.render()
|
||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||
assert len(rp.pattern.shapes[(2, 0)]) == 1
|
||||
|
||||
def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
pp = rp.at("start")
|
||||
pp.straight(10)
|
||||
pp.translate((5, 0))
|
||||
pp.straight(10)
|
||||
|
||||
rp.render()
|
||||
|
||||
shapes = rp.pattern.shapes[(1, 0)]
|
||||
assert len(shapes) == 2
|
||||
assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10)
|
||||
assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10)
|
||||
assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10)
|
||||
|
||||
def test_deferred_render_dead_ports() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=1)
|
||||
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred')
|
||||
rp.set_dead()
|
||||
|
||||
rp.straight("in", -10)
|
||||
|
||||
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
|
||||
|
||||
assert len(rp._paths["in"]) == 0
|
||||
|
||||
rp.render()
|
||||
assert not rp.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_pather_default_auto_policy_renders_immediately_outside_context() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool)
|
||||
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||
|
||||
p.straight("start", 10)
|
||||
|
||||
assert p.pattern.has_shapes()
|
||||
assert not any(p._paths.values())
|
||||
|
||||
|
||||
def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
|
||||
with Pather(lib, tools=tool) as p:
|
||||
add_pending_straight(p)
|
||||
assert not p.pattern.has_shapes()
|
||||
assert len(p._paths["start"]) == 1
|
||||
|
||||
assert p.pattern.has_shapes()
|
||||
assert not any(p._paths.values())
|
||||
|
||||
|
||||
def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p:
|
||||
add_pending_straight(p)
|
||||
|
||||
records = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage()
|
||||
]
|
||||
assert len(records) == 1
|
||||
assert records[0].stack_info is None
|
||||
assert len(p._paths["start"]) == 1
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_pather_context_error_policy_rejects_pending_paths() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool, render='error')
|
||||
|
||||
with pytest.raises(BuildError, match='1 pending render step on 1 port'):
|
||||
route_in_context(p)
|
||||
|
||||
assert len(p._paths["start"]) == 1
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p:
|
||||
add_pending_straight(p)
|
||||
|
||||
assert not caplog.records
|
||||
assert len(p._paths["start"]) == 1
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_pather_context_policy_does_not_mask_body_exception() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool, render='error')
|
||||
|
||||
with pytest.raises(RuntimeError, match='body failed'):
|
||||
fail_in_context(p)
|
||||
|
||||
assert len(p._paths["start"]) == 1
|
||||
assert not p.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_pather_rejects_invalid_render_policy() -> None:
|
||||
with pytest.raises(BuildError, match='Invalid render policy'):
|
||||
Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
rp.at("start").straight(10)
|
||||
rp.rename_ports({"start": "new_start"})
|
||||
rp.at("new_start").straight(10)
|
||||
|
||||
assert "start" not in rp._paths
|
||||
assert len(rp._paths["new_start"]) == 2
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
||||
assert "new_start" in rp.ports
|
||||
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
|
||||
|
||||
def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = deferred_render_setup
|
||||
rp.at("start").straight(10).drop()
|
||||
|
||||
assert "start" not in rp.ports
|
||||
assert len(rp._paths["start"]) == 1
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
assert "start" not in rp.ports
|
||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
|
||||
|
||||
def test_pathtool_bend_offer_render_geometry_matches_ports() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
|
||||
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
|
||||
start = Port((0, 0), rotation=pi, ptype="wire")
|
||||
end = offer.endpoint_at(1)
|
||||
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),))
|
||||
pat = tree.top_pattern()
|
||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
||||
|
||||
assert offer.length_domain == (1, 1)
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10)
|
||||
|
||||
def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
|
||||
offer = tool.primitive_offers("s", in_ptype="wire")[0]
|
||||
start = Port((0, 0), rotation=pi, ptype="wire")
|
||||
end = offer.endpoint_at(4)
|
||||
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),))
|
||||
pat = tree.top_pattern()
|
||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
||||
|
||||
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
|
||||
assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10)
|
||||
|
||||
def test_deferred_render_uturn_fallback() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
rp = Pather(lib, tools=tool, render='deferred')
|
||||
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
rp.at('A').uturn(offset=10000, length=5000)
|
||||
|
||||
assert len(rp._paths['A']) == 4
|
||||
assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L']
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(rp.pattern.ports['A'].rotation, pi)
|
||||
|
||||
def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
||||
class FullTreeTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=ptype,
|
||||
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
|
||||
commit_planner=lambda length: {'length': length},
|
||||
),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data['length']
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
})
|
||||
child = Pattern(annotations={'batch': [len(batch)]})
|
||||
top.ref('_seg')
|
||||
tree['_top'] = top
|
||||
tree['_seg'] = child
|
||||
return tree
|
||||
|
||||
lib = Library()
|
||||
p = Pather(lib, tools=FullTreeTool())
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 10)
|
||||
p.render()
|
||||
p.straight('A', 10)
|
||||
p.render()
|
||||
|
||||
assert len(lib) == 2
|
||||
assert set(lib.keys()) == set(p.pattern.refs.keys())
|
||||
assert len(set(p.pattern.refs.keys())) == 2
|
||||
assert all(name.startswith('_seg') for name in lib)
|
||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
||||
|
||||
def test_custom_tool_render_preserves_segment_subtrees() -> None:
|
||||
class TraceTreeTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=ptype,
|
||||
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
|
||||
commit_planner=lambda length: self._trace(length),
|
||||
),)
|
||||
|
||||
def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
})
|
||||
child = Pattern(annotations={'length': [length]})
|
||||
top.ref('_seg')
|
||||
tree['_top'] = top
|
||||
tree['_seg'] = child
|
||||
return tree
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
assert len(batch) == 1
|
||||
assert isinstance(batch[0].data, Library)
|
||||
return batch[0].data
|
||||
|
||||
lib = Library()
|
||||
p = Pather(lib, tools=TraceTreeTool())
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 10)
|
||||
p.render()
|
||||
|
||||
assert '_seg' in lib
|
||||
assert '_seg' in p.pattern.refs
|
||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
||||
|
||||
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
|
||||
class MissingSingleUseTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=ptype,
|
||||
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
|
||||
commit_planner=lambda length: {'length': length},
|
||||
),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((1, 0), pi, ptype='wire'),
|
||||
})
|
||||
top.ref('_seg')
|
||||
tree['_top'] = top
|
||||
return tree
|
||||
|
||||
lib = Library()
|
||||
lib['_seg'] = Pattern(annotations={'stale': [1]})
|
||||
p = Pather(lib, tools=MissingSingleUseTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.straight('A', 10)
|
||||
|
||||
with pytest.raises(BuildError, match='missing single-use refs'):
|
||||
p.render()
|
||||
|
||||
assert list(lib.keys()) == ['_seg']
|
||||
assert not p.pattern.refs
|
||||
|
||||
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
||||
class SharedRefTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=ptype,
|
||||
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
|
||||
commit_planner=lambda length: {'length': length},
|
||||
),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data['length']
|
||||
tree = Library()
|
||||
top = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='wire'),
|
||||
})
|
||||
top.ref('shared')
|
||||
tree['_top'] = top
|
||||
return tree
|
||||
|
||||
lib = Library()
|
||||
lib['shared'] = Pattern(annotations={'shared': [1]})
|
||||
p = Pather(lib, tools=SharedRefTool())
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
|
||||
p.straight('A', 10)
|
||||
p.render()
|
||||
|
||||
assert 'shared' in p.pattern.refs
|
||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
||||
|
||||
@pytest.mark.parametrize('append', [True, False])
|
||||
def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None:
|
||||
class WrongEndpointTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=ptype,
|
||||
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
|
||||
commit_planner=lambda length: {'length': length},
|
||||
),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data['length']
|
||||
tree = Library()
|
||||
tree['_top'] = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length + 1, 0), pi, ptype='wire'),
|
||||
})
|
||||
return tree
|
||||
|
||||
lib = Library()
|
||||
p = Pather(lib, tools=WrongEndpointTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.straight('A', 10)
|
||||
|
||||
with pytest.raises(BuildError, match='does not match planned endpoint'):
|
||||
p.render(append=append)
|
||||
|
||||
assert not p.pattern.refs
|
||||
assert not lib
|
||||
|
||||
@pytest.mark.parametrize('append', [True, False])
|
||||
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
|
||||
class WrongPtypeTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
ptype = out_ptype or in_ptype or 'wire'
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=ptype,
|
||||
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
|
||||
commit_planner=lambda length: {'length': length},
|
||||
),)
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
length = batch[0].data['length']
|
||||
tree = Library()
|
||||
tree['_top'] = Pattern(ports={
|
||||
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
||||
port_names[1]: Port((length, 0), 0, ptype='metal'),
|
||||
})
|
||||
return tree
|
||||
|
||||
lib = Library()
|
||||
p = Pather(lib, tools=WrongPtypeTool(), render='deferred')
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.straight('A', 10)
|
||||
|
||||
with pytest.raises(BuildError, match='does not match planned endpoint'):
|
||||
p.render(append=append)
|
||||
|
||||
assert not p.pattern.refs
|
||||
assert not lib
|
||||
|
||||
def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
rp = Pather(lib, tools=tool, render='deferred')
|
||||
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
|
||||
rp.at('A').straight(5000)
|
||||
rp.rename_ports({'A': None})
|
||||
|
||||
assert 'A' not in rp.pattern.ports
|
||||
assert len(rp._paths['A']) == 1
|
||||
|
||||
rp.render()
|
||||
assert rp.pattern.has_shapes()
|
||||
assert 'A' not in rp.pattern.ports
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
from typing import Any
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
from masque import Library, PathTool, Port, Pather
|
||||
from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner
|
||||
from masque.builder.planner.planner import Candidate, RouteRequest
|
||||
from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
|
||||
from masque.error import BuildError, PortError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trace_into_setup() -> tuple[Pather, PathTool, Library]:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
p = Pather(lib, tools=tool, render='immediate', render_append=False)
|
||||
return p, tool, lib
|
||||
|
||||
|
||||
def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, _tool, _lib = trace_into_setup
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||
|
||||
p.trace_into("src", "dst")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
assert len(p.pattern.refs) == 1
|
||||
|
||||
|
||||
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, _tool, _lib = trace_into_setup
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
|
||||
|
||||
p.trace_into("src", "dst")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
assert len(p.pattern.refs) == 1
|
||||
|
||||
|
||||
def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, _tool, _lib = trace_into_setup
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
p.ports["dst"] = Port((-20, -10), pi, ptype="wire")
|
||||
|
||||
p.trace_into("src", "dst")
|
||||
|
||||
assert "src" not in p.ports
|
||||
assert "dst" not in p.ports
|
||||
|
||||
|
||||
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||
p, _tool, _lib = trace_into_setup
|
||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||
p.ports["other"] = Port((10, 10), 0)
|
||||
|
||||
p.trace_into("src", "dst", thru="other")
|
||||
|
||||
assert "src" in p.ports
|
||||
assert_equal(p.ports["src"].offset, [10, 10])
|
||||
assert "other" not in p.ports
|
||||
|
||||
|
||||
def test_pather_trace_into_shapes() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
p = Pather(lib, tools=tool)
|
||||
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
|
||||
p.at('A').trace_into('B', plug_destination=False)
|
||||
assert 'B' in p.pattern.ports
|
||||
assert 'A' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||
|
||||
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2)
|
||||
p.at('C').trace_into('D', plug_destination=False)
|
||||
assert 'D' in p.pattern.ports
|
||||
assert 'C' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000))
|
||||
|
||||
p.pattern.ports['E'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi)
|
||||
p.at('E').trace_into('F', plug_destination=False)
|
||||
assert 'F' in p.pattern.ports
|
||||
assert 'E' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000))
|
||||
|
||||
p.pattern.ports['G'] = Port((0, 0), rotation=0)
|
||||
p.pattern.ports['H'] = Port((-10000, 2000), rotation=0)
|
||||
p.at('G').trace_into('H', plug_destination=False)
|
||||
assert 'H' in p.pattern.ports
|
||||
assert 'G' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000))
|
||||
assert p.pattern.ports['G'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['G'].rotation, pi)
|
||||
|
||||
p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2)
|
||||
p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2)
|
||||
p.at('I').trace_into('J', plug_destination=False)
|
||||
assert 'J' in p.pattern.ports
|
||||
assert 'I' in p.pattern.ports
|
||||
assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000))
|
||||
assert p.pattern.ports['I'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
|
||||
|
||||
|
||||
def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
|
||||
class TransitionTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind, # noqa: ANN001
|
||||
*,
|
||||
in_ptype=None, # noqa: ANN001
|
||||
out_ptype=None, # noqa: ANN001
|
||||
**kwargs, # noqa: ANN003
|
||||
) -> tuple[PrimitiveOffer, ...]:
|
||||
_ = in_ptype
|
||||
if kind == 'straight':
|
||||
def native_endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype='m1wire')
|
||||
|
||||
native = StraightOffer(
|
||||
in_ptype='m1wire',
|
||||
out_ptype='m1wire',
|
||||
endpoint_planner=native_endpoint,
|
||||
commit_planner=lambda length: {'kind': 'straight', 'length': length},
|
||||
)
|
||||
if out_ptype in ('unk', 'm1wire'):
|
||||
return (native,)
|
||||
|
||||
def transition_endpoint(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype='m2wire')
|
||||
|
||||
transition = StraightOffer(
|
||||
in_ptype='m1wire',
|
||||
out_ptype='m2wire',
|
||||
length_domain=(2500, numpy.inf),
|
||||
endpoint_planner=transition_endpoint,
|
||||
commit_planner=lambda length: {'kind': 'transition', 'length': length},
|
||||
)
|
||||
return native, transition
|
||||
|
||||
if kind == 'bend':
|
||||
ccw = bool(kwargs['ccw'])
|
||||
|
||||
def endpoint(length: float) -> Port:
|
||||
return Port(
|
||||
(length, 500 if ccw else -500),
|
||||
rotation=-pi / 2 if ccw else pi / 2,
|
||||
ptype='m1wire',
|
||||
)
|
||||
|
||||
return (BendOffer(
|
||||
in_ptype='m1wire',
|
||||
out_ptype='m1wire',
|
||||
ccw=ccw,
|
||||
length_domain=(500, numpy.inf),
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=lambda length: {'kind': 'bend', 'length': length},
|
||||
),)
|
||||
|
||||
return ()
|
||||
|
||||
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
|
||||
tree, pat = Library.mktree('transition_tool')
|
||||
pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire')
|
||||
return tree
|
||||
|
||||
p = Pather(Library(), tools=TransitionTool(), render='deferred')
|
||||
p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire')
|
||||
p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire')
|
||||
|
||||
p.trace_into('src', 'dst')
|
||||
|
||||
assert 'src' not in p.pattern.ports
|
||||
assert 'dst' not in p.pattern.ports
|
||||
|
||||
|
||||
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire')
|
||||
p.set_dead()
|
||||
|
||||
p.trace_into('A', 'B', plug_destination=False)
|
||||
|
||||
assert set(p.pattern.ports) == {'A', 'B'}
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert len(p._paths['A']) == 0
|
||||
assert not p.pattern.has_shapes()
|
||||
assert not p.pattern.has_refs()
|
||||
|
||||
|
||||
def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire')
|
||||
|
||||
with pytest.raises(BuildError):
|
||||
p.trace_into('A', 'B', plug_destination=False, out_ptype='other')
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
|
||||
assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
|
||||
def test_pather_trace_into_rename_failure_propagates() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire')
|
||||
p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire')
|
||||
|
||||
with pytest.raises(PortError, match='overwritten'):
|
||||
p.trace_into('A', 'B', plug_destination=False, thru='other')
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('dst', 'kwargs', 'match'),
|
||||
[
|
||||
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'),
|
||||
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'),
|
||||
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'),
|
||||
],
|
||||
)
|
||||
def test_pather_trace_into_rejects_reserved_route_kwargs(
|
||||
dst: Port,
|
||||
kwargs: dict[str, Any],
|
||||
match: str,
|
||||
) -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||
p.pattern.ports['B'] = dst
|
||||
|
||||
with pytest.raises(BuildError, match=match):
|
||||
p.trace_into('A', 'B', plug_destination=False, **kwargs)
|
||||
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||
assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset)
|
||||
assert dst.rotation is not None
|
||||
assert p.pattern.ports['B'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
|
||||
assert len(p._paths['A']) == 0
|
||||
|
||||
|
||||
class TraceIntoBudgetSolver:
|
||||
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
|
||||
self.successes = successes
|
||||
self.fatal_at = set() if fatal_at is None else fatal_at
|
||||
self.attempts: list[tuple[int, int]] = []
|
||||
|
||||
def solve(
|
||||
self,
|
||||
*,
|
||||
min_bends: int = 0,
|
||||
max_bends: int | None = None,
|
||||
) -> Candidate:
|
||||
assert max_bends is not None
|
||||
band = (min_bends, max_bends)
|
||||
self.attempts.append(band)
|
||||
if band in self.fatal_at:
|
||||
raise RoutePlanningError('fatal', fatal=True)
|
||||
if band not in self.successes:
|
||||
raise BuildError('try next budget')
|
||||
return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0)
|
||||
|
||||
|
||||
class TraceIntoBudgetPlanner(RoutingPlanner):
|
||||
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
|
||||
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
|
||||
self.solver_requests = 0
|
||||
|
||||
def solver_for_request(self, request: RouteRequest) -> Any:
|
||||
_ = request
|
||||
self.solver_requests += 1
|
||||
return self.solver
|
||||
|
||||
def prepared_result_from_legs(
|
||||
self,
|
||||
legs: Any,
|
||||
*,
|
||||
renames: tuple[tuple[str, str], ...] = (),
|
||||
) -> PreparedRouteResult:
|
||||
_ = legs, renames
|
||||
return PreparedRouteResult(())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('dst', 'successes', 'attempts'),
|
||||
[
|
||||
(Port((-10, 0), rotation=pi, ptype='wire'), {(0, 2)}, [(0, 2)]),
|
||||
(Port((-10, 0), rotation=pi, ptype='wire'), {(4, 4)}, [(0, 2), (4, 4)]),
|
||||
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(1, 1)}, [(1, 1)]),
|
||||
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(3, 3)}, [(1, 1), (3, 3)]),
|
||||
],
|
||||
)
|
||||
def test_trace_into_reuses_solver_across_staged_bend_bands(
|
||||
dst: Port,
|
||||
successes: set[tuple[int, int]],
|
||||
attempts: list[tuple[int, int]],
|
||||
) -> None:
|
||||
planner = TraceIntoBudgetPlanner(successes)
|
||||
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
|
||||
|
||||
planner.plan_trace_into(context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None)
|
||||
|
||||
assert planner.solver.attempts == attempts
|
||||
assert planner.solver_requests == 1
|
||||
|
||||
|
||||
def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None:
|
||||
planner = TraceIntoBudgetPlanner({(4, 4)}, fatal_at={(0, 2)})
|
||||
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
|
||||
|
||||
with pytest.raises(RoutePlanningError, match='fatal'):
|
||||
planner.plan_trace_into(context, 'dst', Port((-10, 0), rotation=pi, ptype='wire'), out_ptype=None, plug_destination=True, thru=None)
|
||||
|
||||
assert planner.solver.attempts == [(0, 2)]
|
||||
assert planner.solver_requests == 1
|
||||
|
||||
|
||||
def test_trace_into_bend_bands_respect_max_bends() -> None:
|
||||
class OneBendPlanner(RoutingPlanner):
|
||||
TRACE_INTO_MAX_BENDS = 1
|
||||
|
||||
planner = OneBendPlanner()
|
||||
|
||||
assert planner.trace_into_bend_bands('straight') == ((0, 0),)
|
||||
assert planner.trace_into_bend_bands('s') == ((0, 0),)
|
||||
assert planner.trace_into_bend_bands('bend') == ((1, 1),)
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
import pytest
|
||||
import copy
|
||||
from typing import cast
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..error import PatternError
|
||||
from ..abstract import Abstract
|
||||
from ..pattern import Pattern
|
||||
from ..shapes import Polygon
|
||||
from ..ref import Ref
|
||||
from ..ports import Port, PortError
|
||||
from ..label import Label
|
||||
from ..repetition import Grid
|
||||
|
||||
|
||||
def test_pattern_init() -> None:
|
||||
pat = Pattern()
|
||||
assert pat.is_empty()
|
||||
assert not pat.has_shapes()
|
||||
assert not pat.has_refs()
|
||||
assert not pat.has_labels()
|
||||
assert not pat.has_ports()
|
||||
|
||||
|
||||
def test_pattern_with_elements() -> None:
|
||||
poly = Polygon.square(10)
|
||||
label = Label("test", offset=(5, 5))
|
||||
ref = Ref(offset=(100, 100))
|
||||
port = Port((0, 0), 0)
|
||||
|
||||
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
|
||||
|
||||
assert pat.has_shapes()
|
||||
assert pat.has_labels()
|
||||
assert pat.has_refs()
|
||||
assert pat.has_ports()
|
||||
assert not pat.is_empty()
|
||||
assert pat.shapes[(1, 0)] == [poly]
|
||||
assert pat.labels[(1, 2)] == [label]
|
||||
assert pat.refs["sub"] == [ref]
|
||||
assert pat.ports["P1"] == port
|
||||
|
||||
|
||||
def test_pattern_append() -> None:
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
|
||||
pat2 = Pattern()
|
||||
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
|
||||
|
||||
pat1.append(pat2)
|
||||
assert len(pat1.shapes[(1, 0)]) == 1
|
||||
assert len(pat1.shapes[(2, 0)]) == 1
|
||||
|
||||
|
||||
def test_pattern_translate() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
pat.ports["P1"] = Port((5, 5), 0)
|
||||
|
||||
pat.translate_elements((10, 20))
|
||||
|
||||
# Polygon.translate adds to vertices, and offset is always (0,0)
|
||||
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20])
|
||||
assert_equal(pat.ports["P1"].offset, [15, 25])
|
||||
|
||||
|
||||
def test_pattern_scale() -> None:
|
||||
pat = Pattern()
|
||||
# Polygon.rect sets an offset in its constructor which is immediately translated into vertices
|
||||
pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
|
||||
pat.scale_by(2)
|
||||
|
||||
# Vertices should be scaled
|
||||
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_pattern_rotate() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]])
|
||||
# Rotate 90 degrees CCW around (0,0)
|
||||
pat.rotate_around((0, 0), pi / 2)
|
||||
|
||||
# [10, 0] rotated 90 deg around (0,0) is [0, 10]
|
||||
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10)
|
||||
|
||||
|
||||
def test_pattern_mirror() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]])
|
||||
# Mirror across X axis (y -> -y)
|
||||
pat.mirror(0)
|
||||
|
||||
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5])
|
||||
|
||||
|
||||
def test_pattern_get_bounds() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
|
||||
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
|
||||
|
||||
bounds = pat.get_bounds()
|
||||
assert_equal(bounds, [[-5, -5], [10, 10]])
|
||||
|
||||
|
||||
def test_pattern_flatten_preserves_ports_only_child() -> None:
|
||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(10, 10))
|
||||
|
||||
parent.flatten({"child": child}, flatten_ports=True)
|
||||
|
||||
assert set(parent.ports) == {"P1"}
|
||||
assert parent.ports["P1"].rotation == 0
|
||||
assert tuple(parent.ports["P1"].offset) == (11.0, 12.0)
|
||||
|
||||
|
||||
def test_pattern_flatten_repeated_ref_with_ports_raises() -> None:
|
||||
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||
|
||||
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
|
||||
parent.flatten({"child": child}, flatten_ports=True)
|
||||
|
||||
|
||||
def test_pattern_place_requires_abstract_for_reference() -> None:
|
||||
parent = Pattern()
|
||||
child = Pattern()
|
||||
|
||||
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
|
||||
parent.place(child)
|
||||
|
||||
assert not parent.ports
|
||||
|
||||
|
||||
def test_pattern_place_append_requires_pattern_atomically() -> None:
|
||||
parent = Pattern()
|
||||
child = Abstract("child", {"A": Port((1, 2), 0)})
|
||||
|
||||
with pytest.raises(PatternError, match='Must provide a full `Pattern`'):
|
||||
parent.place(child, append=True)
|
||||
|
||||
assert not parent.ports
|
||||
|
||||
|
||||
def test_pattern_place_append_annotation_conflict_is_atomic() -> None:
|
||||
parent = Pattern(annotations={"k": [1]})
|
||||
child = Pattern(annotations={"k": [2]}, ports={"A": Port((1, 2), 0)})
|
||||
|
||||
with pytest.raises(PatternError, match="Annotation keys overlap"):
|
||||
parent.place(child, append=True)
|
||||
|
||||
assert not parent.ports
|
||||
assert parent.annotations == {"k": [1]}
|
||||
|
||||
|
||||
def test_pattern_place_skip_geometry_overwrites_colliding_ports_last_wins() -> None:
|
||||
parent = Pattern(ports={
|
||||
"A": Port((5, 5), 0),
|
||||
"keep": Port((9, 9), 0),
|
||||
})
|
||||
child = Pattern(ports={
|
||||
"X": Port((1, 0), 0),
|
||||
"Y": Port((2, 0), pi / 2),
|
||||
})
|
||||
|
||||
parent.place(child, port_map={"X": "A", "Y": "A"}, skip_geometry=True, append=True)
|
||||
|
||||
assert set(parent.ports) == {"A", "keep"}
|
||||
assert_allclose(parent.ports["A"].offset, (2, 0))
|
||||
assert parent.ports["A"].rotation is not None
|
||||
assert_allclose(parent.ports["A"].rotation, pi / 2)
|
||||
|
||||
|
||||
def test_pattern_interface() -> None:
|
||||
source = Pattern()
|
||||
source.ports["A"] = Port((10, 20), 0, ptype="test")
|
||||
|
||||
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
|
||||
|
||||
assert "in_A" in iface.ports
|
||||
assert "out_A" in iface.ports
|
||||
assert iface.ports["in_A"].rotation is not None
|
||||
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
|
||||
assert iface.ports["out_A"].rotation is not None
|
||||
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
|
||||
assert iface.ports["in_A"].ptype == "test"
|
||||
assert iface.ports["out_A"].ptype == "test"
|
||||
|
||||
|
||||
def test_pattern_interface_duplicate_port_map_targets_raise() -> None:
|
||||
source = Pattern()
|
||||
source.ports["A"] = Port((10, 20), 0)
|
||||
source.ports["B"] = Port((30, 40), pi)
|
||||
|
||||
with pytest.raises(PortError, match='Duplicate targets in `port_map`'):
|
||||
Pattern.interface(source, port_map={"A": "X", "B": "X"})
|
||||
|
||||
|
||||
def test_pattern_interface_empty_port_map_copies_no_ports() -> None:
|
||||
source = Pattern()
|
||||
source.ports["A"] = Port((10, 20), 0)
|
||||
source.ports["B"] = Port((30, 40), pi)
|
||||
|
||||
assert not Pattern.interface(source, port_map={}).ports
|
||||
assert not Pattern.interface(source, port_map=[]).ports
|
||||
|
||||
|
||||
def test_pattern_plug_requires_abstract_for_reference_atomically() -> None:
|
||||
parent = Pattern(ports={"X": Port((0, 0), 0)})
|
||||
child = Pattern(ports={"A": Port((0, 0), pi)})
|
||||
|
||||
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
|
||||
parent.plug(child, {"X": "A"})
|
||||
|
||||
assert set(parent.ports) == {"X"}
|
||||
|
||||
|
||||
def test_pattern_plug_append_annotation_conflict_is_atomic() -> None:
|
||||
parent = Pattern(
|
||||
annotations={"k": [1]},
|
||||
ports={"X": Port((0, 0), 0), "Q": Port((9, 9), 0)},
|
||||
)
|
||||
child = Pattern(
|
||||
annotations={"k": [2]},
|
||||
ports={"A": Port((0, 0), pi), "B": Port((5, 0), 0)},
|
||||
)
|
||||
|
||||
with pytest.raises(PatternError, match="Annotation keys overlap"):
|
||||
parent.plug(child, {"X": "A"}, map_out={"B": "Y"}, append=True)
|
||||
|
||||
assert set(parent.ports) == {"X", "Q"}
|
||||
assert_allclose(parent.ports["X"].offset, (0, 0))
|
||||
assert_allclose(parent.ports["Q"].offset, (9, 9))
|
||||
assert parent.annotations == {"k": [1]}
|
||||
|
||||
|
||||
def test_pattern_plug_skip_geometry_overwrites_colliding_ports_last_wins() -> None:
|
||||
parent = Pattern(ports={
|
||||
"A": Port((0, 0), 0, ptype="wire"),
|
||||
"B": Port((99, 99), 0, ptype="wire"),
|
||||
})
|
||||
child = Pattern(ports={
|
||||
"in": Port((0, 0), pi, ptype="wire"),
|
||||
"X": Port((10, 0), 0, ptype="wire"),
|
||||
"Y": Port((20, 0), 0, ptype="wire"),
|
||||
})
|
||||
|
||||
parent.plug(child, {"A": "in"}, map_out={"X": "B", "Y": "B"}, skip_geometry=True, append=True)
|
||||
|
||||
assert "A" not in parent.ports
|
||||
assert "B" in parent.ports
|
||||
assert_allclose(parent.ports["B"].offset, (20, 0))
|
||||
assert parent.ports["B"].rotation is not None
|
||||
assert_allclose(parent.ports["B"].rotation, 0)
|
||||
|
||||
|
||||
def test_pattern_append_port_conflict_is_atomic() -> None:
|
||||
pat1 = Pattern()
|
||||
pat1.ports["A"] = Port((0, 0), 0)
|
||||
|
||||
pat2 = Pattern()
|
||||
pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||
pat2.ports["A"] = Port((1, 0), 0)
|
||||
|
||||
with pytest.raises(PatternError, match="Port names overlap"):
|
||||
pat1.append(pat2)
|
||||
|
||||
assert not pat1.shapes
|
||||
assert set(pat1.ports) == {"A"}
|
||||
|
||||
|
||||
def test_pattern_append_annotation_conflict_is_atomic() -> None:
|
||||
pat1 = Pattern(annotations={"k": [1]})
|
||||
pat2 = Pattern(annotations={"k": [2]})
|
||||
pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||
|
||||
with pytest.raises(PatternError, match="Annotation keys overlap"):
|
||||
pat1.append(pat2)
|
||||
|
||||
assert not pat1.shapes
|
||||
assert pat1.annotations == {"k": [1]}
|
||||
|
||||
|
||||
def test_pattern_deepcopy_does_not_share_shape_repetitions() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||
|
||||
pat2 = copy.deepcopy(pat)
|
||||
pat2.scale_by(2)
|
||||
|
||||
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).repetition.a_vector, [10, 0])
|
||||
assert_allclose(cast("Polygon", pat2.shapes[(1, 0)][0]).repetition.a_vector, [20, 0])
|
||||
|
||||
|
||||
def test_pattern_flatten_does_not_mutate_child_repetitions() -> None:
|
||||
child = Pattern()
|
||||
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", scale=2)
|
||||
|
||||
parent.flatten({"child": child})
|
||||
|
||||
assert_allclose(cast("Polygon", child.shapes[(1, 0)][0]).repetition.a_vector, [10, 0])
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import pytest
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
from ..error import PatternError
|
||||
from ..shapes import Circle, Ellipse, Polygon, PolyCollection
|
||||
|
||||
|
||||
def test_poly_collection_init() -> None:
|
||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||
offsets = [0, 4]
|
||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||
assert len(list(pc.polygon_vertices)) == 2
|
||||
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
|
||||
|
||||
def test_poly_collection_to_polygons() -> None:
|
||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||
offsets = [0, 4]
|
||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||
polys = pc.to_polygons()
|
||||
assert len(polys) == 2
|
||||
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
|
||||
|
||||
def test_poly_collection_holes() -> None:
|
||||
# PolyCollection represents separate polygon boundaries, including nested boundaries.
|
||||
verts = [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 10],
|
||||
[0, 10], # Poly 1
|
||||
[2, 2],
|
||||
[2, 8],
|
||||
[8, 8],
|
||||
[8, 2], # Poly 2
|
||||
]
|
||||
offsets = [0, 4]
|
||||
pc = PolyCollection(verts, offsets)
|
||||
polys = pc.to_polygons()
|
||||
assert len(polys) == 2
|
||||
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
|
||||
|
||||
def test_poly_collection_constituent_empty() -> None:
|
||||
# Duplicate offsets create an empty constituent slice between valid polygons.
|
||||
verts = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1], # Tri
|
||||
[10, 10],
|
||||
[11, 10],
|
||||
[11, 11],
|
||||
[10, 11], # Square
|
||||
]
|
||||
offsets = [0, 3, 3]
|
||||
pc = PolyCollection(verts, offsets)
|
||||
with pytest.raises(PatternError):
|
||||
pc.to_polygons()
|
||||
|
||||
def test_poly_collection_valid() -> None:
|
||||
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||
offsets = [0, 3]
|
||||
pc = PolyCollection(verts, offsets)
|
||||
assert len(pc.to_polygons()) == 2
|
||||
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
|
||||
sorted_shapes = sorted(shapes)
|
||||
assert len(sorted_shapes) == 4
|
||||
assert sorted(sorted_shapes) == sorted_shapes
|
||||
|
||||
def test_poly_collection_normalized_form_reconstruction_is_independent() -> None:
|
||||
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
||||
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
||||
|
||||
clone = rebuild()
|
||||
clone.vertex_offsets[:] = [5]
|
||||
|
||||
assert_equal(pc.vertex_offsets, [0])
|
||||
assert_equal(clone.vertex_offsets, [5])
|
||||
|
||||
def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None:
|
||||
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
||||
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
||||
|
||||
first = rebuild()
|
||||
second = rebuild()
|
||||
first.vertex_offsets[:] = [7]
|
||||
|
||||
assert_equal(first.vertex_offsets, [7])
|
||||
assert_equal(second.vertex_offsets, [0])
|
||||
assert_equal(pc.vertex_offsets, [0])
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
|
||||
from ..shapes import Polygon
|
||||
from ..utils import R90
|
||||
from ..error import PatternError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def polygon() -> Polygon:
|
||||
return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
|
||||
def test_vertices(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
|
||||
def test_xs(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.xs, [0, 1, 1, 0])
|
||||
|
||||
|
||||
def test_ys(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.ys, [0, 0, 1, 1])
|
||||
|
||||
|
||||
def test_offset(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.offset, [0, 0])
|
||||
|
||||
|
||||
def test_square() -> None:
|
||||
square = Polygon.square(1)
|
||||
assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]])
|
||||
|
||||
|
||||
def test_rectangle() -> None:
|
||||
rectangle = Polygon.rectangle(1, 2)
|
||||
assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]])
|
||||
|
||||
|
||||
def test_rect() -> None:
|
||||
rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1)
|
||||
assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
|
||||
|
||||
rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2)
|
||||
assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]])
|
||||
|
||||
rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2)
|
||||
assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]])
|
||||
|
||||
rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0)
|
||||
assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]])
|
||||
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(xctr=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(xmin=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(xmax=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(lx=0, yctr=-2, ymax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(yctr=0, xctr=-2, xmax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(ymin=0, xctr=-2, xmax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(ymax=0, xctr=-2, xmax=0)
|
||||
with pytest.raises(PatternError):
|
||||
Polygon.rect(ly=0, xctr=-2, xmax=0)
|
||||
|
||||
|
||||
def test_octagon() -> None:
|
||||
octagon = Polygon.octagon(side_length=1) # regular=True
|
||||
assert_equal(octagon.vertices.shape, (8, 2))
|
||||
diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0)
|
||||
side_len = numpy.sqrt((diff * diff).sum(axis=1))
|
||||
assert numpy.allclose(side_len, 1)
|
||||
|
||||
|
||||
def test_to_polygons(polygon: Polygon) -> None:
|
||||
assert polygon.to_polygons() == [polygon]
|
||||
|
||||
|
||||
def test_get_bounds_single(polygon: Polygon) -> None:
|
||||
assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]])
|
||||
|
||||
|
||||
def test_rotate(polygon: Polygon) -> None:
|
||||
rotated_polygon = polygon.rotate(R90)
|
||||
assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]])
|
||||
|
||||
|
||||
def test_mirror(polygon: Polygon) -> None:
|
||||
mirrored_by_y = polygon.deepcopy().mirror(1)
|
||||
assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]])
|
||||
print(polygon.vertices)
|
||||
mirrored_by_x = polygon.deepcopy().mirror(0)
|
||||
assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]])
|
||||
|
||||
|
||||
def test_scale_by(polygon: Polygon) -> None:
|
||||
scaled_polygon = polygon.scale_by(2)
|
||||
assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||
|
||||
|
||||
def test_clean_vertices(polygon: Polygon) -> None:
|
||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices()
|
||||
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_remove_duplicate_vertices() -> None:
|
||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices()
|
||||
assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_remove_colinear_vertices() -> None:
|
||||
polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices()
|
||||
assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]])
|
||||
|
||||
|
||||
def test_vertices_dtype() -> None:
|
||||
polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32))
|
||||
polygon.scale_by(0.5)
|
||||
assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]])
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
import pytest
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..ports import Port, PortList
|
||||
from ..error import PortError
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
def test_port_init() -> None:
|
||||
p = Port(offset=(10, 20), rotation=pi / 2, ptype="test")
|
||||
assert_equal(p.offset, [10, 20])
|
||||
assert p.rotation == pi / 2
|
||||
assert p.ptype == "test"
|
||||
|
||||
|
||||
def test_port_transform() -> None:
|
||||
p = Port(offset=(10, 0), rotation=0)
|
||||
p.rotate_around((0, 0), pi / 2)
|
||||
assert_allclose(p.offset, [0, 10], atol=1e-10)
|
||||
assert p.rotation is not None
|
||||
assert_allclose(p.rotation, pi / 2, atol=1e-10)
|
||||
|
||||
p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
||||
assert_allclose(p.offset, [0, 10], atol=1e-10)
|
||||
# rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2
|
||||
assert p.rotation is not None
|
||||
assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_port_flip_across() -> None:
|
||||
p = Port(offset=(10, 0), rotation=0)
|
||||
p.flip_across(axis=1) # Mirror across x=0: flips x-offset
|
||||
assert_equal(p.offset, [-10, 0])
|
||||
# rotation was 0, mirrored(1) -> pi
|
||||
assert p.rotation is not None
|
||||
assert_allclose(p.rotation, pi, atol=1e-10)
|
||||
|
||||
|
||||
def test_port_measure_travel() -> None:
|
||||
p1 = Port((0, 0), 0)
|
||||
p2 = Port((10, 5), pi) # Facing each other
|
||||
|
||||
(travel, jog), rotation = p1.measure_travel(p2)
|
||||
assert travel == 10
|
||||
assert jog == 5
|
||||
assert rotation == pi
|
||||
|
||||
|
||||
def test_port_list_measure_travel() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {
|
||||
"A": Port((0, 0), 0),
|
||||
"B": Port((10, 5), pi),
|
||||
}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
(travel, jog), rotation = pl.measure_travel("A", "B")
|
||||
assert travel == 10
|
||||
assert jog == 5
|
||||
assert rotation == pi
|
||||
|
||||
|
||||
def test_port_describe_any_rotation() -> None:
|
||||
p = Port((0, 0), None)
|
||||
assert p.describe() == "pos=(0, 0), rot=any"
|
||||
|
||||
|
||||
def test_port_list_rename() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((0, 0), 0)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
pl.rename_ports({"A": "B"})
|
||||
assert "A" not in pl.ports
|
||||
assert "B" in pl.ports
|
||||
|
||||
|
||||
def test_port_list_rename_missing_port_raises() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((0, 0), 0)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Ports to rename were not found"):
|
||||
pl.rename_ports({"missing": "B"})
|
||||
assert set(pl.ports) == {"A"}
|
||||
|
||||
|
||||
def test_port_list_rename_colliding_targets_raises() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((0, 0), 0), "B": Port((1, 0), 0)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Renamed ports would collide"):
|
||||
pl.rename_ports({"A": "C", "B": "C"})
|
||||
assert set(pl.ports) == {"A", "B"}
|
||||
|
||||
|
||||
def test_port_list_add_port_pair_requires_distinct_names() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports: dict[str, Port] = {}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Port names must be distinct"):
|
||||
pl.add_port_pair(names=("A", "A"))
|
||||
assert not pl.ports
|
||||
|
||||
|
||||
def test_port_list_plugged() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
pl.plugged({"A": "B"})
|
||||
assert not pl.ports # Both should be removed
|
||||
|
||||
|
||||
def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
caplog.set_level("WARNING", logger="masque.ports")
|
||||
|
||||
compatible_cases = [
|
||||
("wire", "wire"),
|
||||
("unk", "wire"),
|
||||
(None, "wire"),
|
||||
]
|
||||
for left, right in compatible_cases:
|
||||
caplog.clear()
|
||||
pl = Pattern(ports={
|
||||
"A": Port((10, 10), 0, ptype=left), # type: ignore[arg-type]
|
||||
"B": Port((10, 10), pi, ptype=right), # type: ignore[arg-type]
|
||||
})
|
||||
|
||||
pl.plugged({"A": "B"})
|
||||
|
||||
assert not any("conflicting types" in record.message for record in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
pl = Pattern(ports={
|
||||
"A": Port((10, 10), 0, ptype="wire"),
|
||||
"B": Port((10, 10), pi, ptype="metal"),
|
||||
})
|
||||
|
||||
pl.plugged({"A": "B"})
|
||||
|
||||
assert any("conflicting types" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_port_list_plugged_empty_raises() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Must provide at least one port connection"):
|
||||
pl.plugged({})
|
||||
assert set(pl.ports) == {"A", "B"}
|
||||
|
||||
|
||||
def test_port_list_plugged_missing_port_raises() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Connection source ports were not found"):
|
||||
pl.plugged({"missing": "B"})
|
||||
assert set(pl.ports) == {"A", "B"}
|
||||
|
||||
|
||||
def test_port_list_plugged_reused_port_raises_atomically() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((0, 0), None), "B": Port((0, 0), None), "C": Port((0, 0), None)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
for connections in ({"A": "A"}, {"A": "B", "C": "B"}):
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Each port may appear in at most one connection"):
|
||||
pl.plugged(connections)
|
||||
assert set(pl.ports) == {"A", "B", "C"}
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Connection destination ports were not found"):
|
||||
pl.plugged({"A": "missing"})
|
||||
assert set(pl.ports) == {"A", "B", "C"}
|
||||
|
||||
|
||||
def test_port_list_plugged_mismatch() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {
|
||||
"A": Port((10, 10), 0),
|
||||
"B": Port((11, 10), pi), # Offset mismatch
|
||||
}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError):
|
||||
pl.plugged({"A": "B"})
|
||||
|
||||
|
||||
def test_port_list_check_ports_duplicate_map_in_values_raise() -> None:
|
||||
class MyPorts(PortList):
|
||||
def __init__(self) -> None:
|
||||
self._ports = {"A": Port((0, 0), 0), "B": Port((0, 0), 0)}
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, val: dict[str, Port]) -> None:
|
||||
self._ports = val
|
||||
|
||||
pl = MyPorts()
|
||||
with pytest.raises(PortError, match="Duplicate values in `map_in`"):
|
||||
pl.check_ports({"X", "Y"}, map_in={"A": "X", "B": "X"})
|
||||
assert set(pl.ports) == {"A", "B"}
|
||||
|
||||
|
||||
def test_pattern_plug_rejects_map_out_on_connected_ports_atomically() -> None:
|
||||
host = Pattern(ports={"A": Port((0, 0), 0)})
|
||||
other = Pattern(ports={"X": Port((0, 0), pi), "Y": Port((5, 0), 0)})
|
||||
|
||||
with pytest.raises(PortError, match="`map_out` keys conflict with connected ports"):
|
||||
host.plug(other, {"A": "X"}, map_out={"X": "renamed", "Y": "out"}, append=True)
|
||||
|
||||
assert set(host.ports) == {"A"}
|
||||
|
||||
|
||||
def test_find_transform_requires_connection_map() -> None:
|
||||
host = Pattern(ports={"A": Port((0, 0), 0)})
|
||||
other = Pattern(ports={"X": Port((0, 0), pi)})
|
||||
|
||||
with pytest.raises(PortError, match="at least one port connection"):
|
||||
host.find_transform(other, {})
|
||||
|
||||
with pytest.raises(PortError, match="at least one port connection"):
|
||||
Pattern.find_port_transform({}, {}, {})
|
||||
|
||||
|
||||
def test_find_transform_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
|
||||
caplog.set_level("WARNING", logger="masque.ports")
|
||||
|
||||
compatible_cases = [
|
||||
("wire", "wire"),
|
||||
("wire", "unk"),
|
||||
("wire", None),
|
||||
]
|
||||
for left, right in compatible_cases:
|
||||
caplog.clear()
|
||||
host = Pattern(ports={"A": Port((0, 0), 0, ptype=left)}) # type: ignore[arg-type]
|
||||
other = Pattern(ports={"X": Port((0, 0), pi, ptype=right)}) # type: ignore[arg-type]
|
||||
|
||||
host.find_transform(other, {"A": "X"})
|
||||
|
||||
assert not any("conflicting types" in record.message for record in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
host = Pattern(ports={"A": Port((0, 0), 0, ptype="wire")})
|
||||
other = Pattern(ports={"X": Port((0, 0), pi, ptype="metal")})
|
||||
|
||||
host.find_transform(other, {"A": "X"})
|
||||
|
||||
assert any("conflicting types" in record.message for record in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
host.find_transform(other, {"A": "X"}, ok_connections={("wire", "metal")})
|
||||
|
||||
assert not any("conflicting types" in record.message for record in caplog.records)
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..utils.ports2data import ports_to_data, data_to_ports
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
from ..library import Library
|
||||
from ..error import PortError
|
||||
from ..repetition import Grid
|
||||
|
||||
|
||||
def test_ports2data_roundtrip() -> None:
|
||||
pat = Pattern()
|
||||
pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test")
|
||||
|
||||
layer = (10, 0)
|
||||
ports_to_data(pat, layer)
|
||||
|
||||
assert len(pat.labels[layer]) == 1
|
||||
assert pat.labels[layer][0].string == "P1:test 90"
|
||||
assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0)
|
||||
|
||||
# New pattern, read ports back
|
||||
pat2 = Pattern()
|
||||
pat2.labels[layer] = pat.labels[layer]
|
||||
data_to_ports([layer], {}, pat2)
|
||||
|
||||
assert "P1" in pat2.ports
|
||||
assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10)
|
||||
assert pat2.ports["P1"].rotation is not None
|
||||
assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10)
|
||||
assert pat2.ports["P1"].ptype == "test"
|
||||
|
||||
|
||||
def test_data_to_ports_hierarchical() -> None:
|
||||
lib = Library()
|
||||
|
||||
# Child has port data in labels
|
||||
child = Pattern()
|
||||
layer = (10, 0)
|
||||
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
|
||||
lib["child"] = child
|
||||
|
||||
# Parent references child
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2)
|
||||
|
||||
# Read ports hierarchically (max_depth > 0)
|
||||
data_to_ports([layer], lib, parent, max_depth=1)
|
||||
|
||||
# child port A (5,0) rot 0
|
||||
# transformed by parent ref: rot pi/2, trans (100, 100)
|
||||
# (5,0) rot pi/2 -> (0, 5)
|
||||
# (0, 5) + (100, 100) = (100, 105)
|
||||
# rot 0 + pi/2 = pi/2
|
||||
assert "A" in parent.ports
|
||||
assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10)
|
||||
assert parent.ports["A"].rotation is not None
|
||||
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_data_to_ports_hierarchical_scaled_ref() -> None:
|
||||
lib = Library()
|
||||
|
||||
child = Pattern()
|
||||
layer = (10, 0)
|
||||
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2)
|
||||
|
||||
data_to_ports([layer], lib, parent, max_depth=1)
|
||||
|
||||
assert "A" in parent.ports
|
||||
assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10)
|
||||
assert parent.ports["A"].rotation is not None
|
||||
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_data_to_ports_hierarchical_repeated_ref_warns_and_keeps_best_effort(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
lib = Library()
|
||||
|
||||
child = Pattern()
|
||||
layer = (10, 0)
|
||||
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", repetition=Grid(a_vector=(100, 0), a_count=3))
|
||||
|
||||
caplog.set_level("WARNING")
|
||||
data_to_ports([layer], lib, parent, max_depth=1)
|
||||
|
||||
assert "A" in parent.ports
|
||||
assert_allclose(parent.ports["A"].offset, [5, 0], atol=1e-10)
|
||||
assert any("importing only the base instance ports" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_data_to_ports_hierarchical_collision_is_atomic() -> None:
|
||||
lib = Library()
|
||||
|
||||
child = Pattern()
|
||||
layer = (10, 0)
|
||||
child.label(layer=layer, string="A:type1 0", offset=(5, 0))
|
||||
lib["child"] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref("child", offset=(0, 0))
|
||||
parent.ref("child", offset=(10, 0))
|
||||
|
||||
with pytest.raises(PortError, match="Device ports conflict with existing ports"):
|
||||
data_to_ports([layer], lib, parent, max_depth=1)
|
||||
|
||||
assert not parent.ports
|
||||
|
||||
|
||||
def test_data_to_ports_flat_bad_angle_warns_and_skips(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
layer = (10, 0)
|
||||
pat = Pattern()
|
||||
pat.label(layer=layer, string="A:type1 nope", offset=(5, 0))
|
||||
|
||||
caplog.set_level("WARNING")
|
||||
data_to_ports([layer], {}, pat)
|
||||
|
||||
assert not pat.ports
|
||||
assert any('bad angle' in record.message for record in caplog.records)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue