Compare commits
255 commits
master
...
tool_rewor
| Author | SHA1 | Date | |
|---|---|---|---|
| fe4e2f760b | |||
| cf3f72b828 | |||
| 0230fb49a6 | |||
| 7083edc7ca | |||
| 520f37aa29 | |||
| 088c37e9d2 | |||
| 02a3708b30 | |||
| 4c64352152 | |||
| 9a39a436b2 | |||
| 84664303f1 | |||
| f864ebbeab | |||
| 54c4cd9a4a | |||
| b6c222cdc2 | |||
| 583bd5bd77 | |||
| 22e645e527 | |||
| 0f39ce8816 | |||
| 5e0cc0e20f | |||
| 1b83034b7e | |||
| 1723212424 | |||
| c420ac8085 | |||
| 08bbb10827 | |||
| 3dea61b05e | |||
| 0fba187d8c | |||
| 6d494142fe | |||
| e108199bcd | |||
| 151a7f846f | |||
| f3a60da30b | |||
| fd01e369e1 | |||
| c83846afb3 | |||
| e009417eed | |||
| 85aed4388e | |||
| f6ad86cdc2 | |||
| a279ccec21 | |||
| b6ee0d4929 | |||
| 4aca8acd3d | |||
| 46a15e2f0f | |||
| 8b7dd947fb | |||
| 262f2dee3a | |||
| 624d707123 | |||
| 29bd0f63e9 | |||
| d36a1a53e6 | |||
| 833f5dd159 | |||
| f4df8e0553 | |||
| 4d57936da8 | |||
| 51c7fa9add | |||
| e4a52b2c90 | |||
| e33a0f5ae1 | |||
| 950d144ead | |||
| d95ddbb6b9 | |||
| bdc4dfdd06 | |||
| 6cf9600193 | |||
| e6f5136357 | |||
| 778b3d9be7 | |||
| 02f0833fb3 | |||
| 47f150f579 | |||
| 84106dc355 | |||
| 429e687666 | |||
| c501a8ff99 | |||
| fd2698c503 | |||
| 8100d8095a | |||
| 2c5243237e | |||
| cf0a245143 | |||
| bbe3586ba9 | |||
| e071bd89b0 | |||
| 06ed2ce54a | |||
| 0f2b4d713b | |||
| 524503031c | |||
| ce7bf5ce70 | |||
| f0a4b08a31 | |||
| b3a1489258 | |||
| d366db5a62 | |||
| 20f37ea0f7 | |||
| 6fd73b9d46 | |||
| 32744512e0 | |||
| 75a9114709 | |||
| df578d7764 | |||
| 786716fc62 | |||
| a82365ec8c | |||
| 28be89f047 | |||
| afc49f945d | |||
| ce46cc18dc | |||
| 7c50f95fde | |||
| ae314cce93 | |||
| 09a95a6608 | |||
| fbe138d443 | |||
| 4b416745da | |||
| 0830dce50c | |||
| ac87179da2 | |||
| f0eea0382b | |||
| 0c9b435e94 | |||
| f461222852 | |||
| 9767ee4e62 | |||
| 395ad4df9d | |||
| 35b42c397b | |||
| 6a7b3b2259 | |||
| 8d50f497f1 | |||
| 2176d56b4c | |||
| f1e25debec | |||
| 4b07bb9e25 | |||
| 2952e6ef8f | |||
| c303a0c114 | |||
| f34b9b2f5c | |||
| 89cdd23f00 | |||
| 620b001af5 | |||
| 46a3559391 | |||
| 08421d6a54 | |||
| 462a05a665 | |||
| 2b29e46b93 | |||
| 2e0b64bdab | |||
| 20c845a881 | |||
| 707a16fe64 | |||
| 932565d531 | |||
| e7f847d4c7 | |||
| 3beadd2bf0 | |||
| 1bcf5901d6 | |||
| 56e401196a | |||
| 83ec64158a | |||
| aa7007881f | |||
| d03fafcaf6 | |||
| d3be6aeba3 | |||
| ffbe15c465 | |||
| b44c962e07 | |||
| 20bd0640e1 | |||
| 4ae8115139 | |||
| c2ef3e4217 | |||
| c32168dc64 | |||
| b843ffb4d3 | |||
| 9adfcac437 | |||
| 26cc0290b9 | |||
| 548b51df47 | |||
| 06f8611a90 | |||
| 9ede16df5d | |||
| add82e955d | |||
| cfec9e8c76 | |||
| 2275bf415a | |||
| fa3dfa1e74 | |||
| 75dc391540 | |||
| feb5d87cf4 | |||
| 5f91bd9c6c | |||
| db22237369 | |||
| a6ea5c08e6 | |||
| 3792248cd1 | |||
| e8083cc24c | |||
| d307589995 | |||
| ea93a7ef37 | |||
| 495babf837 | |||
| 5d20a061fd | |||
| 25b8fe8448 | |||
| f154303bef | |||
| 5596e2b1af | |||
| 6c42049b23 | |||
| da20922224 | |||
| b8ee4bb05d | |||
| 169f66cc85 | |||
| a38c5bb085 | |||
| 0ad89d6d95 | |||
| 6c96968341 | |||
| b7143e3287 | |||
| 0cce5e0586 | |||
| 36cb86a15d | |||
| 5e0936e15f | |||
| a467a0baca | |||
| 564ff10db3 | |||
| e261585894 | |||
| f42114bf43 | |||
| 5eb460ecb7 | |||
| fb822829ec | |||
| 838c742651 | |||
| 9a76ce5b66 | |||
| 2019fc0d74 | |||
| e3f8d28529 | |||
| 9296011d4b | |||
| 92d0140093 | |||
| c4dc9f9573 | |||
| 0b8e11e8bf | |||
| 5989e45906 | |||
| 7eec2b7acf | |||
| 2a6458b1ac | |||
| 9ee3c7ff89 | |||
| 3bedab2301 | |||
| 4eb1d8d486 | |||
| 3ceeba23b8 | |||
| 963103b859 | |||
| e5a6aab940 | |||
| 042941c838 | |||
| 0f63acbad0 | |||
| a0d7d0ed26 | |||
| d32a5ee762 | |||
| 19dafad157 | |||
| 5cb608734d | |||
| d0b48e6bfc | |||
| ef5c8c715e | |||
| 049864ddc7 | |||
| 3bf7efc404 | |||
| 74fa377450 | |||
| c3581243c8 | |||
| 338c123fb1 | |||
| a89f07c441 | |||
| bb7f4906af | |||
| 2513c7f8fd | |||
| ad4e9af59d | |||
| 46555dbd4d | |||
| 26e6a44559 | |||
| 32681edb47 | |||
| 84f37195ad | |||
| 0189756df4 | |||
| 1070815730 | |||
| 8a45c6d8d6 | |||
| 9d6fb985d8 | |||
| 69ac25078c | |||
| babbe78daa | |||
| 16875e9cd6 | |||
| 4332cf14c0 | |||
| ff8ca92963 | |||
| ed021e3d81 | |||
| 07a25ec290 | |||
| 504f89796c | |||
| 0f49924aa6 | |||
| ebfe1b559c | |||
| 7ad59d6b89 | |||
| 5d040061f4 | |||
| f42e720c68 | |||
| cf822c7dcf | |||
| 59e996e680 | |||
| abf236a046 | |||
| d40bdb1cb2 | |||
| 5e08579498 | |||
| c18e5b8d3e | |||
| 48f7569c1f | |||
| 8a56679884 | |||
| 1cce6c1f70 | |||
| d9adb4e1b9 | |||
| 1de76bff47 | |||
| 9bb0d5190d | |||
| ad49276345 | |||
| fe70d0574b | |||
| 36fed84249 | |||
| 278f0783da | |||
| 72f462d077 | |||
| 66d6fae2bd | |||
| 2b7ad00204 | |||
| 2d63e72802 | |||
| 51ced2fe83 | |||
| 19fac463e4 | |||
| 44986bac67 | |||
| accad3db9f | |||
| 05098c0c13 | |||
| f64b080b15 | |||
| 54f3b273bc | |||
| add0600bac | |||
| 737d41d592 | |||
| 395244ee83 | |||
| 43ccd8de2f | |||
| dfa0259997 | |||
| 37418d2137 |
132 changed files with 23003 additions and 4481 deletions
426
MIGRATION.md
Normal file
426
MIGRATION.md
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
# 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.
|
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
|
* One way to provide this data is through an `Abstract`, generated via
|
||||||
`Library.abstract()` or through a `Library.abstract_view()`.
|
`Library.abstract()` or through a `Library.abstract_view()`.
|
||||||
* Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates
|
* Another way is use `Pather.place()` or `Pather.plug()`, which automatically creates
|
||||||
an `Abstract` from its internally-referenced `Library`.
|
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
|
# In practice, you may do lots of
|
||||||
my_pattern.ref(lib << make_tree(...), ...)
|
my_pattern.ref(lib << make_tree(...), ...)
|
||||||
|
|
||||||
# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit:
|
# With a `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit:
|
||||||
my_builder = Builder(library=lib, ...)
|
my_builder = Pather(library=lib, ...)
|
||||||
...
|
...
|
||||||
my_builder.place(make_tree(...))
|
my_builder.place(make_tree(...))
|
||||||
```
|
```
|
||||||
|
|
@ -277,12 +277,6 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath)
|
|
||||||
* PolyCollection & arrow-based read/write
|
* PolyCollection & arrow-based read/write
|
||||||
* pather and renderpather examples, including .at() (PortPather)
|
|
||||||
* Bus-to-bus connections?
|
* 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
|
* tuple / string layer auto-translation
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from masque.file import gdsii
|
||||||
from masque import Arc, Pattern
|
from masque import Arc, Pattern
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
layer = (0, 0)
|
layer = (0, 0)
|
||||||
pat.shapes[layer].extend([
|
pat.shapes[layer].extend([
|
||||||
|
|
|
||||||
5
examples/generate_gds_perf.py
Normal file
5
examples/generate_gds_perf.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from masque.file.gdsii.perf import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import numpy
|
|
||||||
from pyclipper import (
|
from pyclipper import (
|
||||||
Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO,
|
Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO,
|
||||||
scale_to_clipper, scale_from_clipper,
|
|
||||||
)
|
)
|
||||||
p = Pyclipper()
|
p = Pyclipper()
|
||||||
p.AddPaths([
|
p.AddPaths([
|
||||||
|
|
@ -12,8 +10,8 @@ p.AddPaths([
|
||||||
], PT_SUBJECT, closed=True)
|
], PT_SUBJECT, closed=True)
|
||||||
#p.Execute2?
|
#p.Execute2?
|
||||||
#p.Execute?
|
#p.Execute?
|
||||||
p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO)
|
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||||
p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO)
|
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||||
|
|
||||||
p = Pyclipper()
|
p = Pyclipper()
|
||||||
|
|
|
||||||
131
examples/profile_gdsii_readers.py
Normal file
131
examples/profile_gdsii_readers.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from masque import LibraryError
|
||||||
|
|
||||||
|
|
||||||
|
READERS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||||
|
'gdsii': ('masque.file.gdsii', ('readfile',)),
|
||||||
|
'gdsii_arrow': ('masque.file.gdsii.arrow', ('readfile', 'arrow_import', 'arrow_convert')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_library(path: Path, elapsed_s: float, info: dict[str, object], lib: object) -> dict[str, object]:
|
||||||
|
assert hasattr(lib, '__len__')
|
||||||
|
assert hasattr(lib, 'tops')
|
||||||
|
tops = lib.tops() # type: ignore[no-any-return, attr-defined]
|
||||||
|
try:
|
||||||
|
unique_top = lib.top() # type: ignore[no-any-return, attr-defined]
|
||||||
|
except LibraryError:
|
||||||
|
unique_top = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'path': str(path),
|
||||||
|
'elapsed_s': elapsed_s,
|
||||||
|
'library_name': info['name'],
|
||||||
|
'cell_count': len(lib), # type: ignore[arg-type]
|
||||||
|
'topcells': tops,
|
||||||
|
'topcell': unique_top,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_arrow_import(path: Path, elapsed_s: float, arrow_arr: Any) -> dict[str, object]:
|
||||||
|
libarr = arrow_arr[0]
|
||||||
|
return {
|
||||||
|
'path': str(path),
|
||||||
|
'elapsed_s': elapsed_s,
|
||||||
|
'arrow_rows': len(arrow_arr),
|
||||||
|
'library_name': libarr['lib_name'].as_py(),
|
||||||
|
'cell_count': len(libarr['cells']),
|
||||||
|
'layer_count': len(libarr['layers']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_stage(module: Any, stage: str, path: Path) -> dict[str, object]:
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
if stage == 'readfile':
|
||||||
|
lib, info = module.readfile(path)
|
||||||
|
elapsed_s = time.perf_counter() - start
|
||||||
|
return _summarize_library(path, elapsed_s, info, lib)
|
||||||
|
|
||||||
|
if stage == 'arrow_import':
|
||||||
|
if hasattr(module, 'readfile_arrow'):
|
||||||
|
libarr, _info = module.readfile_arrow(path)
|
||||||
|
elapsed_s = time.perf_counter() - start
|
||||||
|
return {
|
||||||
|
'path': str(path),
|
||||||
|
'elapsed_s': elapsed_s,
|
||||||
|
'arrow_rows': 1,
|
||||||
|
'library_name': libarr['lib_name'].as_py(),
|
||||||
|
'cell_count': len(libarr['cells']),
|
||||||
|
'layer_count': len(libarr['layers']),
|
||||||
|
}
|
||||||
|
|
||||||
|
arrow_arr = module._read_to_arrow(path)
|
||||||
|
elapsed_s = time.perf_counter() - start
|
||||||
|
return _summarize_arrow_import(path, elapsed_s, arrow_arr)
|
||||||
|
|
||||||
|
if stage == 'arrow_convert':
|
||||||
|
arrow_arr = module._read_to_arrow(path)
|
||||||
|
libarr = arrow_arr[0]
|
||||||
|
start = time.perf_counter()
|
||||||
|
lib, info = module.read_arrow(libarr)
|
||||||
|
elapsed_s = time.perf_counter() - start
|
||||||
|
return _summarize_library(path, elapsed_s, info, lib)
|
||||||
|
|
||||||
|
raise ValueError(f'Unsupported stage {stage!r}')
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description='Profile GDS readers with a stable end-to-end workload.')
|
||||||
|
parser.add_argument('--reader', choices=sorted(READERS), required=True)
|
||||||
|
parser.add_argument('--stage', default='readfile')
|
||||||
|
parser.add_argument('--path', type=Path, required=True)
|
||||||
|
parser.add_argument('--warmup', type=int, default=1)
|
||||||
|
parser.add_argument('--repeat', type=int, default=1)
|
||||||
|
parser.add_argument('--output-json', type=Path)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_arg_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
module_name, stages = READERS[args.reader]
|
||||||
|
if args.stage not in stages:
|
||||||
|
parser.error(f'reader {args.reader!r} only supports stages: {", ".join(stages)}')
|
||||||
|
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
path = args.path.expanduser().resolve()
|
||||||
|
|
||||||
|
for _ in range(args.warmup):
|
||||||
|
_profile_stage(module, args.stage, path)
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
for _ in range(args.repeat):
|
||||||
|
runs.append(_profile_stage(module, args.stage, path))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'reader': args.reader,
|
||||||
|
'stage': args.stage,
|
||||||
|
'warmup': args.warmup,
|
||||||
|
'repeat': args.repeat,
|
||||||
|
'runs': runs,
|
||||||
|
}
|
||||||
|
rendered = json.dumps(payload, indent=2, sort_keys=True)
|
||||||
|
if args.output_json is not None:
|
||||||
|
args.output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output_json.write_text(rendered + '\n')
|
||||||
|
print(rendered)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
||||||
cell_name = 'ellip_grating'
|
cell_name = 'ellip_grating'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
masque Tutorial
|
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
|
Contents
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
@ -8,24 +14,30 @@ Contents
|
||||||
* Draw basic geometry
|
* Draw basic geometry
|
||||||
* Export to GDS
|
* Export to GDS
|
||||||
- [devices](devices.py)
|
- [devices](devices.py)
|
||||||
|
* Build hierarchical photonic-crystal example devices
|
||||||
* Reference other patterns
|
* Reference other patterns
|
||||||
* Add ports to a pattern
|
* Add ports to a pattern
|
||||||
* Snap ports together to build a circuit
|
* Use `Pather` to snap ports together into a circuit
|
||||||
* Check for dangling references
|
* Check for dangling references
|
||||||
- [library](library.py)
|
- [library](library.py)
|
||||||
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
|
* Continue from `devices.py` by declaring a mixed library with `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
|
||||||
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
* 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)
|
- [pather](pather.py)
|
||||||
* Use `Pather` to route individual wires and wire bundles
|
* Use `Pather` to route individual wires and wire bundles
|
||||||
* Use `BasicTool` to generate paths
|
* Define a custom `Tool` that exposes primitive routing offers
|
||||||
* Use `BasicTool` to automatically transition between path types
|
* Use primitive offers to automatically transition between path types
|
||||||
- [renderpather](rendpather.py)
|
- [renderpather](renderpather.py)
|
||||||
* Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py),
|
* Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py),
|
||||||
but using `Path` shapes instead of `Polygon`s.
|
but using `Path` shapes instead of `Polygon`s.
|
||||||
|
- [port_pather](port_pather.py)
|
||||||
|
* Use `PortPather` and the `.at()` syntax for more concise routing
|
||||||
|
* Advanced port manipulation and connections
|
||||||
|
|
||||||
|
|
||||||
Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices.
|
Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating
|
||||||
|
photonic-crystal lattices; it is support code rather than a step-by-step tutorial.
|
||||||
|
|
||||||
|
|
||||||
Running
|
Running
|
||||||
|
|
@ -37,3 +49,6 @@ cd examples/tutorial
|
||||||
python3 basic_shapes.py
|
python3 basic_shapes.py
|
||||||
klayout -e basic_shapes.gds
|
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,12 +1,9 @@
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from masque import (
|
from masque import layer_t, Pattern, Circle, Arc, Ref
|
||||||
layer_t, Pattern, Label, Port,
|
from masque.repetition import Grid
|
||||||
Circle, Arc, Polygon,
|
|
||||||
)
|
|
||||||
import masque.file.gdsii
|
import masque.file.gdsii
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,6 +36,45 @@ def hole(
|
||||||
return pat
|
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(
|
def triangle(
|
||||||
radius: float,
|
radius: float,
|
||||||
layer: layer_t = (1, 0),
|
layer: layer_t = (1, 0),
|
||||||
|
|
@ -60,9 +96,7 @@ def triangle(
|
||||||
]) * radius
|
]) * radius
|
||||||
|
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
pat.shapes[layer].extend([
|
pat.polygon(layer, vertices=vertices)
|
||||||
Polygon(offset=(0, 0), vertices=vertices),
|
|
||||||
])
|
|
||||||
return pat
|
return pat
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -111,9 +145,13 @@ def main() -> None:
|
||||||
lib['smile'] = smile(1000)
|
lib['smile'] = smile(1000)
|
||||||
lib['triangle'] = triangle(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)
|
masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS)
|
||||||
|
|
||||||
lib['triangle'].visualize()
|
lib['triangle'].visualize()
|
||||||
|
lib['grid'].visualize(lib)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
|
"""
|
||||||
|
Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Pather`.
|
||||||
|
|
||||||
|
This file uses photonic-crystal components as the concrete example, so some of
|
||||||
|
the geometry-generation code is domain-specific. The tutorial value is in the
|
||||||
|
Masque patterns around it: creating reusable cells, annotating ports, composing
|
||||||
|
hierarchy with references, and snapping ports together to build a larger circuit.
|
||||||
|
"""
|
||||||
from collections.abc import Sequence, Mapping
|
from collections.abc import Sequence, Mapping
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
||||||
from masque import (
|
from masque import (
|
||||||
layer_t, Pattern, Ref, Label, Builder, Port, Polygon,
|
layer_t, Pattern, Ref, Pather, Port, Polygon,
|
||||||
Library, ILibraryView,
|
Library,
|
||||||
)
|
)
|
||||||
from masque.utils import ports2data
|
from masque.utils import ports2data
|
||||||
from masque.file.gdsii import writefile, check_valid_names
|
from masque.file.gdsii import writefile, check_valid_names
|
||||||
|
|
@ -64,9 +72,9 @@ def perturbed_l3(
|
||||||
Provided sequence should have same length as `shifts_a`.
|
Provided sequence should have same length as `shifts_a`.
|
||||||
xy_size: `(x, y)` number of mirror periods in each direction; total size is
|
xy_size: `(x, y)` number of mirror periods in each direction; total size is
|
||||||
`2 * n + 1` holes in each direction. Default (10, 10).
|
`2 * n + 1` holes in each direction. Default (10, 10).
|
||||||
perturbed_radius: radius of holes perturbed to form an upwards-driected beam
|
perturbed_radius: radius of holes perturbed to form an upwards-directed beam
|
||||||
(multiplicative factor). Default 1.1.
|
(multiplicative factor). Default 1.1.
|
||||||
trench width: Width of the undercut trenches. Default 1200.
|
trench_width: Width of the undercut trenches. Default 1200.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
`Pattern` object representing the L3 design.
|
`Pattern` object representing the L3 design.
|
||||||
|
|
@ -79,14 +87,15 @@ def perturbed_l3(
|
||||||
shifts_a=shifts_a,
|
shifts_a=shifts_a,
|
||||||
shifts_r=shifts_r)
|
shifts_r=shifts_r)
|
||||||
|
|
||||||
# Build L3 cavity, using references to the provided hole pattern
|
# Build the cavity by instancing the supplied `hole` pattern many times.
|
||||||
|
# Using references keeps the pattern compact even though it contains many holes.
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
pat.refs[hole] += [
|
pat.refs[hole] += [
|
||||||
Ref(scale=r, offset=(lattice_constant * x,
|
Ref(scale=r, offset=(lattice_constant * x,
|
||||||
lattice_constant * y))
|
lattice_constant * y))
|
||||||
for x, y, r in xyr]
|
for x, y, r in xyr]
|
||||||
|
|
||||||
# Add rectangular undercut aids
|
# Add rectangular undercut aids based on the referenced hole extents.
|
||||||
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
|
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
|
||||||
trench_dx = max_xy[0] - min_xy[0]
|
trench_dx = max_xy[0] - min_xy[0]
|
||||||
|
|
||||||
|
|
@ -95,7 +104,7 @@ def perturbed_l3(
|
||||||
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
|
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ports are at outer extents of the device (with y=0)
|
# Define the interface in Masque terms: two ports at the left/right extents.
|
||||||
extent = lattice_constant * xy_size[0]
|
extent = lattice_constant * xy_size[0]
|
||||||
pat.ports = dict(
|
pat.ports = dict(
|
||||||
input=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
input=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||||
|
|
@ -125,17 +134,17 @@ def waveguide(
|
||||||
Returns:
|
Returns:
|
||||||
`Pattern` object representing the waveguide.
|
`Pattern` object representing the waveguide.
|
||||||
"""
|
"""
|
||||||
# Generate hole locations
|
# Generate the normalized lattice locations for the line defect.
|
||||||
xy = pcgen.waveguide(length=length, num_mirror=mirror_periods)
|
xy = pcgen.waveguide(length=length, num_mirror=mirror_periods)
|
||||||
|
|
||||||
# Build the pattern
|
# Build the pattern by placing repeated references to the same hole cell.
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
pat.refs[hole] += [
|
pat.refs[hole] += [
|
||||||
Ref(offset=(lattice_constant * x,
|
Ref(offset=(lattice_constant * x,
|
||||||
lattice_constant * y))
|
lattice_constant * y))
|
||||||
for x, y in xy]
|
for x, y in xy]
|
||||||
|
|
||||||
# Ports are at outer edges, with y=0
|
# Publish the device interface as two ports at the outer edges.
|
||||||
extent = lattice_constant * length / 2
|
extent = lattice_constant * length / 2
|
||||||
pat.ports = dict(
|
pat.ports = dict(
|
||||||
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||||
|
|
@ -164,17 +173,17 @@ def bend(
|
||||||
`Pattern` object representing the waveguide bend.
|
`Pattern` object representing the waveguide bend.
|
||||||
Ports are named 'left' (input) and 'right' (output).
|
Ports are named 'left' (input) and 'right' (output).
|
||||||
"""
|
"""
|
||||||
# Generate hole locations
|
# Generate the normalized lattice locations for the bend.
|
||||||
xy = pcgen.wgbend(num_mirror=mirror_periods)
|
xy = pcgen.wgbend(num_mirror=mirror_periods)
|
||||||
|
|
||||||
# Build the pattern
|
# Build the pattern by instancing the shared hole cell.
|
||||||
pat= Pattern()
|
pat = Pattern()
|
||||||
pat.refs[hole] += [
|
pat.refs[hole] += [
|
||||||
Ref(offset=(lattice_constant * x,
|
Ref(offset=(lattice_constant * x,
|
||||||
lattice_constant * y))
|
lattice_constant * y))
|
||||||
for x, y in xy]
|
for x, y in xy]
|
||||||
|
|
||||||
# Figure out port locations.
|
# Publish the bend interface as two ports.
|
||||||
extent = lattice_constant * mirror_periods
|
extent = lattice_constant * mirror_periods
|
||||||
pat.ports = dict(
|
pat.ports = dict(
|
||||||
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||||
|
|
@ -203,17 +212,17 @@ def y_splitter(
|
||||||
`Pattern` object representing the y-splitter.
|
`Pattern` object representing the y-splitter.
|
||||||
Ports are named 'in', 'top', and 'bottom'.
|
Ports are named 'in', 'top', and 'bottom'.
|
||||||
"""
|
"""
|
||||||
# Generate hole locations
|
# Generate the normalized lattice locations for the splitter.
|
||||||
xy = pcgen.y_splitter(num_mirror=mirror_periods)
|
xy = pcgen.y_splitter(num_mirror=mirror_periods)
|
||||||
|
|
||||||
# Build pattern
|
# Build the pattern by instancing the shared hole cell.
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
pat.refs[hole] += [
|
pat.refs[hole] += [
|
||||||
Ref(offset=(lattice_constant * x,
|
Ref(offset=(lattice_constant * x,
|
||||||
lattice_constant * y))
|
lattice_constant * y))
|
||||||
for x, y in xy]
|
for x, y in xy]
|
||||||
|
|
||||||
# Determine port locations
|
# Publish the splitter interface as one input and two outputs.
|
||||||
extent = lattice_constant * mirror_periods
|
extent = lattice_constant * mirror_periods
|
||||||
pat.ports = {
|
pat.ports = {
|
||||||
'in': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
'in': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||||
|
|
@ -227,13 +236,13 @@ def y_splitter(
|
||||||
|
|
||||||
|
|
||||||
def main(interactive: bool = True) -> None:
|
def main(interactive: bool = True) -> None:
|
||||||
# Generate some basic hole patterns
|
# First make a couple of reusable primitive cells.
|
||||||
shape_lib = {
|
shape_lib = {
|
||||||
'smile': basic_shapes.smile(RADIUS),
|
'smile': basic_shapes.smile(RADIUS),
|
||||||
'hole': basic_shapes.hole(RADIUS),
|
'hole': basic_shapes.hole(RADIUS),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build some devices
|
# Then build a small library of higher-level devices from those primitives.
|
||||||
a = LATTICE_CONSTANT
|
a = LATTICE_CONSTANT
|
||||||
|
|
||||||
devices = {}
|
devices = {}
|
||||||
|
|
@ -245,22 +254,23 @@ def main(interactive: bool = True) -> None:
|
||||||
devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5)
|
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 :)
|
devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :)
|
||||||
|
|
||||||
# Turn our dict of devices into a Library.
|
# Turn the device mapping into a `Library`.
|
||||||
# This provides some convenience functions in the future!
|
# That gives us convenience helpers for hierarchy inspection and abstract views.
|
||||||
lib = Library(devices)
|
lib = Library(devices)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a circuit
|
# Build a circuit
|
||||||
#
|
#
|
||||||
# Create a `Builder`, and add the circuit to our library as "my_circuit".
|
# Create a `Pather`, and register the resulting top cell as "my_circuit".
|
||||||
circ = Builder(library=lib, name='my_circuit')
|
circ = Pather(library=lib, name='my_circuit')
|
||||||
|
|
||||||
# Start by placing a waveguide. Call its ports "in" and "signal".
|
# Start by placing a waveguide and renaming its ports to match the circuit-level
|
||||||
|
# names we want to use while assembling the design.
|
||||||
circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
|
circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
|
||||||
|
|
||||||
# Extend the signal path by attaching the "left" port of a waveguide.
|
# Extend the signal path by attaching another waveguide.
|
||||||
# Since there is only one other port ("right") on the waveguide we
|
# Because `wg10` only has one unattached port left after the plug, Masque can
|
||||||
# are attaching (wg10), it automatically inherits the name "signal".
|
# infer that it should keep the name `signal`.
|
||||||
circ.plug('wg10', {'signal': 'left'})
|
circ.plug('wg10', {'signal': 'left'})
|
||||||
|
|
||||||
# We could have done the following instead:
|
# We could have done the following instead:
|
||||||
|
|
@ -268,8 +278,8 @@ def main(interactive: bool = True) -> None:
|
||||||
# lib['my_circuit'] = circ_pat
|
# lib['my_circuit'] = circ_pat
|
||||||
# circ_pat.place(lib.abstract('wg10'), ...)
|
# circ_pat.place(lib.abstract('wg10'), ...)
|
||||||
# circ_pat.plug(lib.abstract('wg10'), ...)
|
# circ_pat.plug(lib.abstract('wg10'), ...)
|
||||||
# but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar
|
# but `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps
|
||||||
# syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality.
|
# the assembly code focused on port-level intent.
|
||||||
|
|
||||||
# Attach a y-splitter to the signal path.
|
# Attach a y-splitter to the signal path.
|
||||||
# Since the y-splitter has 3 ports total, we can't auto-inherit the
|
# Since the y-splitter has 3 ports total, we can't auto-inherit the
|
||||||
|
|
@ -281,13 +291,10 @@ def main(interactive: bool = True) -> None:
|
||||||
circ.plug('wg05', {'signal1': 'left'})
|
circ.plug('wg05', {'signal1': 'left'})
|
||||||
circ.plug('wg05', {'signal2': 'left'})
|
circ.plug('wg05', {'signal2': 'left'})
|
||||||
|
|
||||||
# Add a bend to both ports.
|
# Add a bend to both branches.
|
||||||
# Our bend's ports "left" and "right" refer to the original counterclockwise
|
# Our bend primitive is defined with a specific orientation, so choosing which
|
||||||
# orientation. We want the bends to turn in opposite directions, so we attach
|
# port to plug determines whether the path turns clockwise or counterclockwise.
|
||||||
# the "right" port to "signal1" to bend clockwise, and the "left" port
|
# We could also mirror one instance instead of using opposite ports.
|
||||||
# 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', {'signal1': 'right'})
|
||||||
circ.plug('bend0', {'signal2': 'left'})
|
circ.plug('bend0', {'signal2': 'left'})
|
||||||
|
|
||||||
|
|
@ -296,29 +303,26 @@ def main(interactive: bool = True) -> None:
|
||||||
circ.plug('l3cav', {'signal1': 'input'})
|
circ.plug('l3cav', {'signal1': 'input'})
|
||||||
circ.plug('wg10', {'signal1': 'left'})
|
circ.plug('wg10', {'signal1': 'left'})
|
||||||
|
|
||||||
# "signal2" just gets a single of equivalent length
|
# `signal2` gets a single waveguide of equivalent overall length.
|
||||||
circ.plug('wg28', {'signal2': 'left'})
|
circ.plug('wg28', {'signal2': 'left'})
|
||||||
|
|
||||||
# Now we bend both waveguides back towards each other
|
# Now bend both branches back towards each other.
|
||||||
circ.plug('bend0', {'signal1': 'right'})
|
circ.plug('bend0', {'signal1': 'right'})
|
||||||
circ.plug('bend0', {'signal2': 'left'})
|
circ.plug('bend0', {'signal2': 'left'})
|
||||||
circ.plug('wg05', {'signal1': 'left'})
|
circ.plug('wg05', {'signal1': 'left'})
|
||||||
circ.plug('wg05', {'signal2': 'left'})
|
circ.plug('wg05', {'signal2': 'left'})
|
||||||
|
|
||||||
# To join the waveguides, we attach a second y-junction.
|
# To join the branches, attach a second y-junction.
|
||||||
# We plug "signal1" into the "bot" port, and "signal2" into the "top" port.
|
# This succeeds only if both chosen ports agree on the same translation and
|
||||||
# The remaining port gets named "signal_out".
|
# rotation for the inserted device; otherwise Masque raises an exception.
|
||||||
# This operation would raise an exception if the ports did not line up
|
|
||||||
# correctly (i.e. they required different rotations or translations of the
|
|
||||||
# y-junction device).
|
|
||||||
circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
|
circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
|
||||||
|
|
||||||
# Finally, add some more waveguide to "signal_out".
|
# Finally, add some more waveguide to "signal_out".
|
||||||
circ.plug('wg10', {'signal_out': 'left'})
|
circ.plug('wg10', {'signal_out': 'left'})
|
||||||
|
|
||||||
# We can also add text labels for our circuit's ports.
|
# Bake the top-level port metadata into labels so it survives GDS export.
|
||||||
# They will appear at the uppermost hierarchy level, while the individual
|
# These labels appear on the circuit cell; individual child devices keep their
|
||||||
# device ports will appear further down, in their respective cells.
|
# own port labels in their own cells.
|
||||||
ports_to_data(circ.pattern)
|
ports_to_data(circ.pattern)
|
||||||
|
|
||||||
# Check if we forgot to include any patterns... ooops!
|
# Check if we forgot to include any patterns... ooops!
|
||||||
|
|
@ -330,12 +334,12 @@ def main(interactive: bool = True) -> None:
|
||||||
lib.add(shape_lib)
|
lib.add(shape_lib)
|
||||||
assert not lib.dangling_refs()
|
assert not lib.dangling_refs()
|
||||||
|
|
||||||
# We can visualize the design. Usually it's easier to just view the GDS.
|
# We can visualize the design directly, though opening the written GDS is often easier.
|
||||||
if interactive:
|
if interactive:
|
||||||
print('Visualizing... this step may be slow')
|
print('Visualizing... this step may be slow')
|
||||||
circ.pattern.visualize(lib)
|
circ.pattern.visualize(lib)
|
||||||
|
|
||||||
#Write out to GDS, only keeping patterns referenced by our circuit (including itself)
|
# Write out only the subtree reachable from our top cell.
|
||||||
subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use
|
subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use
|
||||||
check_valid_names(subtree.keys())
|
check_valid_names(subtree.keys())
|
||||||
writefile(subtree, 'circuit.gds', **GDS_OPTS)
|
writefile(subtree, 'circuit.gds', **GDS_OPTS)
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,115 @@
|
||||||
|
"""
|
||||||
|
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 typing import Any
|
||||||
from collections.abc import Sequence, Callable
|
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from masque import Pattern, Builder, LazyLibrary
|
from masque import BuildLibrary, Pather, Pattern, cell
|
||||||
from masque.file.gdsii import writefile, load_libraryfile
|
from masque.file.gdsii import writefile
|
||||||
|
from masque.file.gdsii.lazy import readfile
|
||||||
|
|
||||||
import pcgen
|
|
||||||
import basic_shapes
|
import basic_shapes
|
||||||
import devices
|
import devices
|
||||||
from devices import ports_to_data, data_to_ports
|
|
||||||
from basic_shapes import GDS_OPTS
|
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:
|
def main() -> None:
|
||||||
# Define a `LazyLibrary`, which provides lazy evaluation for generating
|
builder = BuildLibrary()
|
||||||
# patterns and lazy-loading of GDS contents.
|
cells = builder.cells
|
||||||
lib = LazyLibrary()
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Load some devices from a GDS file
|
# Load some devices from a GDS file
|
||||||
#
|
#
|
||||||
|
|
||||||
# Scan circuit.gds and prepare to lazy-load its contents
|
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
|
||||||
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
|
# 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))
|
||||||
|
|
||||||
# Add it into the device library by providing a way to read port info
|
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
|
||||||
# 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())))
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Add some new devices to the library, this time from python code rather than GDS
|
# Register some new devices, this time from python code rather than GDS.
|
||||||
#
|
#
|
||||||
|
|
||||||
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
|
cells.triangle = basic_shapes.triangle(devices.RADIUS)
|
||||||
opts: dict[str, Any] = dict(
|
opts: dict[str, Any] = dict(
|
||||||
lattice_constant = devices.LATTICE_CONSTANT,
|
lattice_constant=devices.LATTICE_CONSTANT,
|
||||||
hole = 'triangle',
|
hole='triangle',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Triangle-based variants. These are defined here, but they won't run until they're
|
cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts)
|
||||||
# retrieved from the library.
|
cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts)
|
||||||
lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts)
|
cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts)
|
||||||
lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts)
|
cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts)
|
||||||
lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts)
|
cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts)
|
||||||
lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts)
|
cells.tri_l3cav = cell(devices.perturbed_l3)(xy_size=(4, 10), **opts, hole_lib=builder)
|
||||||
lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts)
|
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder)
|
||||||
lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib)
|
|
||||||
|
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a mixed waveguide with an L3 cavity in the middle
|
# Build the declaration set into a normal library.
|
||||||
#
|
#
|
||||||
|
|
||||||
# Immediately start building from an instance of the L3 cavity
|
built, report = builder.build()
|
||||||
circ2 = Builder(library=lib, ports='tri_l3cav')
|
print('Built library contains:\n' + pformat(list(built.keys())))
|
||||||
|
print('Build dependency graph:\n' + pformat(report.dependency_graph))
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a device that could plug into our mixed_wg_cav and joins the two ports
|
# Continue designing against the built library.
|
||||||
#
|
#
|
||||||
|
|
||||||
# We'll be designing against an existing device's interface...
|
# The built result behaves like a normal mutable library, so downstream code
|
||||||
circ3 = Builder.interface(source=circ2)
|
# can use Pather, abstract views, and writing without going back through the
|
||||||
|
# builder interface.
|
||||||
# ... that lets us continue from where we left off.
|
circ = Pather.interface(source='mixed_wg_cav', library=built)
|
||||||
circ3.plug('tri_bend0', {'input': 'right'})
|
circ.plug('tri_bend0', {'input': 'right'})
|
||||||
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
||||||
circ3.plug('tri_bend0', {'input': 'right'})
|
circ.plug('tri_bend0', {'input': 'right'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('tri_wg10', {'input': 'right'})
|
circ.plug('tri_wg10', {'input': 'right'})
|
||||||
circ3.plug('tri_wg28', {'input': 'right'})
|
circ.plug('tri_wg28', {'input': 'right'})
|
||||||
circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
circ.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
||||||
|
built['loop_segment'] = circ.pattern
|
||||||
lib['loop_segment'] = circ3.pattern
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Write all devices into a GDS file
|
# Write all devices into a GDS file.
|
||||||
#
|
#
|
||||||
print('Writing library to file...')
|
print('Writing library to file...')
|
||||||
writefile(lib, 'library.gds', **GDS_OPTS)
|
writefile(built, 'library.gds', **GDS_OPTS)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
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,11 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Manual wire routing tutorial: Pather and BasicTool
|
Manual wire routing tutorial: Pather and primitive offers
|
||||||
"""
|
"""
|
||||||
from collections.abc import Callable
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers
|
from masque import Pather, Library, Pattern, Port, layer_t
|
||||||
from masque.builder.tools import BasicTool, PathTool
|
from masque.abstract import Abstract
|
||||||
|
from masque.builder import BendOffer, RenderStep, StraightOffer, Tool
|
||||||
|
from masque.error import BuildError
|
||||||
from masque.file.gdsii import writefile
|
from masque.file.gdsii import writefile
|
||||||
|
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||||
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
||||||
|
|
@ -107,87 +114,327 @@ def map_layer(layer: layer_t) -> layer_t:
|
||||||
'M2': (20, 0),
|
'M2': (20, 0),
|
||||||
'V1': (30, 0),
|
'V1': (30, 0),
|
||||||
}
|
}
|
||||||
|
if isinstance(layer, str):
|
||||||
return layer_mapping.get(layer, layer)
|
return layer_mapping.get(layer, layer)
|
||||||
|
return layer
|
||||||
|
|
||||||
|
|
||||||
#
|
@dataclass(frozen=True, slots=True)
|
||||||
# Now we can start building up our library (collection of static cells) and pathing tools.
|
class WireStraightData:
|
||||||
#
|
length: float
|
||||||
# If any of the operations below are confusing, you can cross-reference against the `RenderPather`
|
out_transition: 'WireTransitionSpec | None' = None
|
||||||
# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others
|
|
||||||
# (e.g. geometry definition).
|
|
||||||
#
|
@dataclass(frozen=True, slots=True)
|
||||||
def main() -> None:
|
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
|
||||||
|
"""
|
||||||
# Build some patterns (static cells) using the above functions and store them in a library
|
# Build some patterns (static cells) using the above functions and store them in a library
|
||||||
library = Library()
|
library = Library()
|
||||||
library['pad'] = make_pad()
|
library['pad'] = make_pad()
|
||||||
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
|
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['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
|
||||||
library['v1_via'] = make_via(
|
library['v1_via'] = make_via(
|
||||||
layer_top='M2',
|
layer_top = 'M2',
|
||||||
layer_via='V1',
|
layer_via = 'V1',
|
||||||
layer_bot='M1',
|
layer_bot = 'M1',
|
||||||
width_top=M2_WIDTH,
|
width_top = M2_WIDTH,
|
||||||
width_via=V1_WIDTH,
|
width_via = V1_WIDTH,
|
||||||
width_bot=M1_WIDTH,
|
width_bot = M1_WIDTH,
|
||||||
ptype_bot='m1wire',
|
ptype_bot = 'm1wire',
|
||||||
ptype_top='m2wire',
|
ptype_top = 'm2wire',
|
||||||
)
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Now, define two tools.
|
# Now, define two tools.
|
||||||
# M1_tool will route on M1, using wires with M1_WIDTH
|
# M1_tool will route on M1, using wires with M1_WIDTH.
|
||||||
# M2_tool will route on M2, using wires with M2_WIDTH
|
# M2_tool will route on M2, using wires with M2_WIDTH.
|
||||||
# Both tools are able to automatically transition from the other wire type (with a via)
|
|
||||||
#
|
#
|
||||||
# Note that while we use BasicTool for this tutorial, you can define your own `Tool`
|
# Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers
|
||||||
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
|
# directly: it tells `Pather` about native straight/bend primitives and about
|
||||||
# transmission line geometry, or other features.
|
# via adapters that can transition between M1 and M2 port types.
|
||||||
#
|
#
|
||||||
M1_tool = BasicTool(
|
via = library.abstract('v1_via')
|
||||||
straight = (
|
via_transitions = (
|
||||||
# First, we need a function which takes in a length and spits out an M1 wire
|
WireTransitionSpec(via, 'top', 'bottom'),
|
||||||
lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
|
WireTransitionSpec(via, 'bottom', 'top'),
|
||||||
'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
|
|
||||||
'output', # and use the port named 'output' as the output
|
|
||||||
),
|
|
||||||
bend = (
|
|
||||||
library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
|
|
||||||
'input', # To orient it clockwise, use the port named 'input' as the input
|
|
||||||
'output', # and 'output' as the output
|
|
||||||
),
|
|
||||||
transitions = { # We can automate transitions for different (normally incompatible) port types
|
|
||||||
'm2wire': ( # For example, when we're attaching to a port with type 'm2wire'
|
|
||||||
library.abstract('v1_via'), # we can place a V1 via
|
|
||||||
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
|
|
||||||
'bottom', # and using the port named 'bottom' as the output
|
|
||||||
),
|
|
||||||
},
|
|
||||||
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
M2_tool = BasicTool(
|
M1_tool = PrimitiveWireTool(
|
||||||
straight = (
|
layer = 'M1',
|
||||||
# Again, we use make_straight_wire, but this time we set parameters for M2
|
width = M1_WIDTH,
|
||||||
lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
|
ptype = 'm1wire',
|
||||||
'input',
|
bend = library.abstract('m1_bend'),
|
||||||
'output',
|
transitions = via_transitions,
|
||||||
),
|
|
||||||
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.
|
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
|
||||||
# Then, place some pads and start routing wires!
|
# Then, place some pads and start routing wires!
|
||||||
|
|
@ -203,27 +450,25 @@ def main() -> None:
|
||||||
|
|
||||||
# Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False)
|
# 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
|
# The total distance forward (including the bend's forward component) must be 6um
|
||||||
pather.path('VCC', ccw=False, length=6_000)
|
pather.cw('VCC', 6_000)
|
||||||
|
|
||||||
# Now path VCC to x=0. This time, don't include any bend (ccw=None).
|
# Now path VCC to x=0. This time, don't include any bend.
|
||||||
# Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction.
|
# Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction.
|
||||||
pather.path_to('VCC', ccw=None, x=0)
|
pather.straight('VCC', x=0)
|
||||||
|
|
||||||
# Path GND forward by 5um, turning clockwise 90 degrees.
|
# Path GND forward by 5um, turning clockwise 90 degrees.
|
||||||
# This time we use shorthand (bool(0) == False) and omit the parameter labels
|
pather.cw('GND', 5_000)
|
||||||
# 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.
|
# This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend.
|
||||||
pather.path_to('GND', None, x=pather['VCC'].offset[0])
|
pather.straight('GND', x=pather['VCC'].offset[0])
|
||||||
|
|
||||||
# Now, start using M1_tool for GND.
|
# Now, start using M1_tool for GND.
|
||||||
# Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves.
|
# Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves.
|
||||||
# If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here
|
# 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.
|
# 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
|
# Note that even though we have changed the tool used for GND, the via doesn't get placed until
|
||||||
# the next time we draw a path on GND (the pather.mpath() statement below).
|
# the next time we route GND (the `pather.ccw()` call below).
|
||||||
pather.retool(M1_tool, keys=['GND'])
|
pather.retool(M1_tool, keys='GND')
|
||||||
|
|
||||||
# Bundle together GND and VCC, and path the bundle forward and counterclockwise.
|
# 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.
|
# Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000.
|
||||||
|
|
@ -231,7 +476,7 @@ def main() -> None:
|
||||||
#
|
#
|
||||||
# Since we recently retooled GND, its path starts with a via down to M1 (included in the distance
|
# 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.
|
# calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2.
|
||||||
pather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000)
|
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||||
|
|
||||||
# Now use M1_tool as the default tool for all ports/signals.
|
# 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.
|
# Since VCC does not have an explicitly assigned tool, it will now transition down to M1.
|
||||||
|
|
@ -241,38 +486,37 @@ def main() -> None:
|
||||||
# The total extension (travel distance along the forward direction) for the longest segment (in
|
# 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.
|
# this case the segment being added to GND) should be exactly 50um.
|
||||||
# After turning, the wire pitch should be reduced only 1.2um.
|
# After turning, the wire pitch should be reduced only 1.2um.
|
||||||
pather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200)
|
pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
||||||
|
|
||||||
# Make a U-turn with the bundle and expand back out to 4.5um wire pitch.
|
# Make a U-turn with the bundle and expand back out to 4.5um wire pitch.
|
||||||
# Here, emin specifies the travel distance for the shortest segment. For the first mpath() call
|
# Here, emin specifies the travel distance for the shortest segment. For the first call
|
||||||
# that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the
|
# that applies to VCC, and for the second call, that applies to GND; the relative lengths of the
|
||||||
# segments depend on their starting positions and their ordering within the bundle.
|
# segments depend on their starting positions and their ordering within the bundle.
|
||||||
pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200)
|
pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
||||||
pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500)
|
pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
||||||
|
|
||||||
# Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been
|
# Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been
|
||||||
# explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default.
|
# explicitly assigned a tool.
|
||||||
pather.retool(M2_tool)
|
pather.retool(M2_tool)
|
||||||
|
|
||||||
# Now path both ports to x=-28_000.
|
# Now path both ports to x=-28_000.
|
||||||
# When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate,
|
# With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
|
||||||
# However, with ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
|
|
||||||
# equivalent.
|
# equivalent.
|
||||||
pather.mpath(['GND', 'VCC'], None, xmin=-28_000)
|
pather.straight(['GND', 'VCC'], xmin=-28_000)
|
||||||
|
|
||||||
# Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1.
|
# 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
|
# 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().
|
# when using pather.retool().
|
||||||
pather.path_to('VCC', None, -50_000, out_ptype='m1wire')
|
pather.straight('VCC', x=-50_000, out_ptype='m1wire')
|
||||||
|
|
||||||
# Now extend GND out to x=-50_000, using M2 for a portion of the path.
|
# 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.
|
# We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice.
|
||||||
with pather.toolctx(M2_tool, keys=['GND']):
|
with pather.toolctx(M2_tool, keys='GND'):
|
||||||
pather.path_to('GND', None, -40_000)
|
pather.straight('GND', x=-40_000)
|
||||||
pather.path_to('GND', None, -50_000)
|
pather.straight('GND', x=-50_000)
|
||||||
|
|
||||||
# Save the pather's pattern into our library
|
# Save the pather's pattern into our library
|
||||||
library['Pather_and_BasicTool'] = pather.pattern
|
library['Pather_and_PrimitiveOffers'] = pather.pattern
|
||||||
|
|
||||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||||
library.map_layers(map_layer)
|
library.map_layers(map_layer)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Routines for creating normalized 2D lattices and common photonic crystal
|
Routines for creating normalized 2D lattices and common photonic crystal
|
||||||
cavity designs.
|
cavity designs.
|
||||||
"""
|
"""
|
||||||
from collection.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
@ -50,7 +50,7 @@ def triangular_lattice(
|
||||||
elif origin == 'corner':
|
elif origin == 'corner':
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise Exception(f'Invalid value for `origin`: {origin}')
|
raise ValueError(f'Invalid value for `origin`: {origin}')
|
||||||
|
|
||||||
return xy[xy[:, 0].argsort(), :]
|
return xy[xy[:, 0].argsort(), :]
|
||||||
|
|
||||||
|
|
@ -197,12 +197,12 @@ def ln_defect(
|
||||||
`[[x0, y0], [x1, y1], ...]` for all the holes
|
`[[x0, y0], [x1, y1], ...]` for all the holes
|
||||||
"""
|
"""
|
||||||
if defect_length % 2 != 1:
|
if defect_length % 2 != 1:
|
||||||
raise Exception('defect_length must be odd!')
|
raise ValueError('defect_length must be odd!')
|
||||||
p = triangular_lattice([2 * d + 1 for d in mirror_dims])
|
pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims])
|
||||||
half_length = numpy.floor(defect_length / 2)
|
half_length = numpy.floor(defect_length / 2)
|
||||||
hole_nums = numpy.arange(-half_length, half_length + 1)
|
hole_nums = numpy.arange(-half_length, half_length + 1)
|
||||||
holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True)
|
holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True)
|
||||||
return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ]
|
return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :]
|
||||||
|
|
||||||
|
|
||||||
def ln_shift_defect(
|
def ln_shift_defect(
|
||||||
|
|
@ -248,7 +248,7 @@ def ln_shift_defect(
|
||||||
for sign in (-1, 1):
|
for sign in (-1, 1):
|
||||||
x_val = sign * (x_removed + ind + 1)
|
x_val = sign * (x_removed + ind + 1)
|
||||||
which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0)
|
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
|
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])
|
# 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)))
|
perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2)))
|
||||||
for row in xyr:
|
for xy in perturbed_holes:
|
||||||
if numpy.fabs(row) in perturbed_holes:
|
which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1)
|
||||||
row[2] = perturbed_radius
|
xyr[which, 2] = perturbed_radius
|
||||||
return xyr
|
return xyr
|
||||||
|
|
|
||||||
170
examples/tutorial/port_pather.py
Normal file
170
examples/tutorial/port_pather.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""
|
||||||
|
PortPather tutorial: Using .at() syntax
|
||||||
|
"""
|
||||||
|
from masque import Pather, Pattern, Port, R90
|
||||||
|
from masque.file.gdsii import writefile
|
||||||
|
|
||||||
|
from basic_shapes import GDS_OPTS
|
||||||
|
from pather import map_layer, prepare_tools
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Reuse the same patterns (pads, bends, vias) and tools as in pather.py
|
||||||
|
library, M1_tool, M2_tool = prepare_tools()
|
||||||
|
|
||||||
|
# Create a deferred Pather and place some initial pads (same as Pather tutorial)
|
||||||
|
rpather = Pather(library, tools=M2_tool, render='deferred')
|
||||||
|
|
||||||
|
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||||
|
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
||||||
|
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
||||||
|
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||||
|
|
||||||
|
#
|
||||||
|
# Routing with .at() chaining
|
||||||
|
#
|
||||||
|
# The .at(port_name) method returns a PortPather object which wraps the Pather
|
||||||
|
# and remembers the selected port(s). This allows method chaining.
|
||||||
|
|
||||||
|
# Route VCC: 6um South, then West to x=0.
|
||||||
|
# (Note: since the port points North into the pad, trace() moves South by default)
|
||||||
|
(rpather.at('VCC')
|
||||||
|
.trace(False, length=6_000) # Move South, turn West (Clockwise)
|
||||||
|
.trace_to(None, x=0) # Continue West to x=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Route GND: 5um South, then West to match VCC's x-coordinate.
|
||||||
|
rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Tool management and manual plugging
|
||||||
|
#
|
||||||
|
# We can use .retool() to change the tool for specific ports.
|
||||||
|
# We can also use .plug() directly on a PortPather.
|
||||||
|
|
||||||
|
# Manually add a via to GND and switch to M1_tool for subsequent segments
|
||||||
|
(rpather.at('GND')
|
||||||
|
.plug('v1_via', 'top')
|
||||||
|
.retool(M1_tool) # this only retools the 'GND' port
|
||||||
|
)
|
||||||
|
|
||||||
|
# We can also pass multiple ports to .at(), and then route them together.
|
||||||
|
# Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via).
|
||||||
|
(rpather.at(['GND', 'VCC'])
|
||||||
|
.trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
|
||||||
|
.retool(M1_tool) # Retools both GND and VCC
|
||||||
|
.set_spacing(1_200) # Default bundle spacing for later bends
|
||||||
|
.trace(True, emax=50_000) # Turn East, moves 50um extension
|
||||||
|
.trace(False, emin=1_000) # U-turn back South
|
||||||
|
.trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retool VCC back to M2 and move both to x=-28k
|
||||||
|
rpather.at('VCC').retool(M2_tool)
|
||||||
|
rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000)
|
||||||
|
|
||||||
|
# Final segments to -50k
|
||||||
|
rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire')
|
||||||
|
with rpather.at('GND').toolctx(M2_tool):
|
||||||
|
rpather.at('GND').trace_to(None, x=-40_000)
|
||||||
|
rpather.at('GND').trace_to(None, x=-50_000)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Branching with mark and fork
|
||||||
|
#
|
||||||
|
# .mark(new_name) creates a port copy and keeps the original selected.
|
||||||
|
# .fork(new_name) creates a port copy and selects the new one.
|
||||||
|
|
||||||
|
# Create a tap on GND
|
||||||
|
(rpather.at('GND')
|
||||||
|
.trace(None, length=5_000) # Move GND further West
|
||||||
|
.mark('GND_TAP') # Mark this location for a later branch
|
||||||
|
.jog(offset=-10_000, length=10_000) # Continue GND with an S-bend
|
||||||
|
)
|
||||||
|
|
||||||
|
# Branch VCC and follow the new branch
|
||||||
|
(rpather.at('VCC')
|
||||||
|
.trace(None, length=5_000)
|
||||||
|
.fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH'
|
||||||
|
.trace(True, length=5_000) # VCC_BRANCH turns South
|
||||||
|
)
|
||||||
|
# The original 'VCC' port remains at x=-55k, y=VCC.y
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Port set management: add, drop, rename, delete
|
||||||
|
#
|
||||||
|
|
||||||
|
# Route the GND_TAP we saved earlier.
|
||||||
|
(rpather.at('GND_TAP')
|
||||||
|
.retool(M1_tool)
|
||||||
|
.trace(True, length=10_000) # Turn South
|
||||||
|
.rename('GND_FEED') # Give it a more descriptive name
|
||||||
|
.retool(M1_tool) # Re-apply tool to the new name
|
||||||
|
)
|
||||||
|
|
||||||
|
# We can manage the active set of ports in a PortPather
|
||||||
|
pp = rpather.at(['VCC_BRANCH', 'GND_FEED'])
|
||||||
|
pp.select('GND') # Now tracking 3 ports
|
||||||
|
pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND
|
||||||
|
pp.trace(None, each=5_000) # Move both 5um forward (length > transition size)
|
||||||
|
|
||||||
|
# We can also delete ports from the pather entirely
|
||||||
|
rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Advanced Connections: trace_into
|
||||||
|
#
|
||||||
|
# trace_into routes FROM the selected port TO a target port.
|
||||||
|
|
||||||
|
# Create a destination component
|
||||||
|
dest_ports = {
|
||||||
|
'in_A': Port((0, 0), rotation=R90, ptype='m2wire'),
|
||||||
|
'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire')
|
||||||
|
}
|
||||||
|
library['dest'] = Pattern(ports=dest_ports)
|
||||||
|
# Place dest so that its ports are to the West and South of our current wires.
|
||||||
|
# Rotating by pi/2 makes the ports face West (pointing East).
|
||||||
|
rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'})
|
||||||
|
|
||||||
|
# Connect GND_FEED to DEST_A
|
||||||
|
# Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice.
|
||||||
|
rpather.at('GND_FEED').trace_into('DEST_A')
|
||||||
|
|
||||||
|
# Connect VCC_BRANCH to DEST_B
|
||||||
|
rpather.at('VCC_BRANCH').trace_into('DEST_B')
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Direct Port Transformations and Metadata
|
||||||
|
#
|
||||||
|
(rpather.at('GND')
|
||||||
|
.set_ptype('m1wire') # Change metadata
|
||||||
|
.translate((1000, 0)) # Shift the port 1um East
|
||||||
|
.rotate(R90 / 2) # Rotate it 45 degrees
|
||||||
|
.set_rotation(R90) # Force it to face West
|
||||||
|
)
|
||||||
|
|
||||||
|
# Demonstrate .plugged() to acknowledge a manual connection
|
||||||
|
# (Normally used when you place components so their ports perfectly overlap)
|
||||||
|
rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2'))
|
||||||
|
rpather.at('TMP1').plugged('TMP2') # Removes both ports
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Rendering and Saving
|
||||||
|
#
|
||||||
|
# Since routing is deferred, we must call .render() to generate the geometry.
|
||||||
|
rpather.render()
|
||||||
|
|
||||||
|
library['PortPather_Tutorial'] = rpather.pattern
|
||||||
|
library.map_layers(map_layer)
|
||||||
|
writefile(library, 'port_pather.gds', **GDS_OPTS)
|
||||||
|
print("Tutorial complete. Output written to port_pather.gds")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Manual wire routing tutorial: RenderPather an PathTool
|
Manual wire routing tutorial: deferred Pather and PathTool
|
||||||
"""
|
"""
|
||||||
from collections.abc import Callable
|
from masque import Pather, Library
|
||||||
from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers
|
from masque.builder import PathTool
|
||||||
from masque.builder.tools import PathTool
|
|
||||||
from masque.file.gdsii import writefile
|
from masque.file.gdsii import writefile
|
||||||
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
@ -12,9 +11,9 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
#
|
#
|
||||||
# To illustrate the advantages of using `RenderPather`, we use `PathTool` instead
|
# To illustrate deferred routing with `Pather`, we use `PathTool` instead
|
||||||
# of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
||||||
# but when used with `RenderPather`, it can consolidate multiple routing steps into
|
# but when used with `Pather(render='deferred')`, it can consolidate multiple routing steps into
|
||||||
# a single `Path` shape.
|
# a single `Path` shape.
|
||||||
#
|
#
|
||||||
# We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py`
|
# We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py`
|
||||||
|
|
@ -25,66 +24,68 @@ def main() -> None:
|
||||||
library = Library()
|
library = Library()
|
||||||
library['pad'] = make_pad()
|
library['pad'] = make_pad()
|
||||||
library['v1_via'] = make_via(
|
library['v1_via'] = make_via(
|
||||||
layer_top='M2',
|
layer_top = 'M2',
|
||||||
layer_via='V1',
|
layer_via = 'V1',
|
||||||
layer_bot='M1',
|
layer_bot = 'M1',
|
||||||
width_top=M2_WIDTH,
|
width_top = M2_WIDTH,
|
||||||
width_via=V1_WIDTH,
|
width_via = V1_WIDTH,
|
||||||
width_bot=M1_WIDTH,
|
width_bot = M1_WIDTH,
|
||||||
ptype_bot='m1wire',
|
ptype_bot = 'm1wire',
|
||||||
ptype_top='m2wire',
|
ptype_top = 'm2wire',
|
||||||
)
|
)
|
||||||
|
|
||||||
# `PathTool` is more limited than `BasicTool`. It only generates one type of shape
|
# `PathTool` is more limited than `AutoTool`. It only generates one type of shape
|
||||||
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
|
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
|
||||||
# and what port type to present.
|
# and what port type to present.
|
||||||
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
|
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
|
||||||
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
|
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
|
||||||
rpather = RenderPather(tools=M2_ptool, library=library)
|
rpather = Pather(tools=M2_ptool, library=library, render='deferred')
|
||||||
|
|
||||||
# As in the pather tutorial, we make soem pads and labels...
|
# As in the pather tutorial, we make some pads and labels...
|
||||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
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='VCC', offset=(18e3, 30e3))
|
||||||
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||||
|
|
||||||
# ...and start routing the signals.
|
# ...and start routing the signals.
|
||||||
rpather.path('VCC', ccw=False, length=6_000)
|
rpather.cw('VCC', 6_000)
|
||||||
rpather.path_to('VCC', ccw=None, x=0)
|
rpather.straight('VCC', x=0)
|
||||||
rpather.path('GND', 0, 5_000)
|
rpather.cw('GND', 5_000)
|
||||||
rpather.path_to('GND', None, x=rpather['VCC'].offset[0])
|
rpather.straight('GND', x=rpather.pattern['VCC'].x)
|
||||||
|
|
||||||
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
|
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
|
||||||
# `plug` the via into the GND wire ourselves.
|
# `plug` the via into the GND wire ourselves.
|
||||||
rpather.plug('v1_via', {'GND': 'top'})
|
rpather.plug('v1_via', {'GND': 'top'})
|
||||||
rpather.retool(M1_ptool, keys=['GND'])
|
rpather.retool(M1_ptool, keys='GND')
|
||||||
rpather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000)
|
rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||||
|
|
||||||
# Same thing on the VCC wire when it goes down to M1.
|
# Same thing on the VCC wire when it goes down to M1.
|
||||||
rpather.plug('v1_via', {'VCC': 'top'})
|
rpather.plug('v1_via', {'VCC': 'top'})
|
||||||
rpather.retool(M1_ptool)
|
rpather.retool(M1_ptool)
|
||||||
rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200)
|
rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
||||||
rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200)
|
rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
||||||
rpather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500)
|
rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
||||||
|
|
||||||
# And again when VCC goes back up to M2.
|
# And again when VCC goes back up to M2.
|
||||||
rpather.plug('v1_via', {'VCC': 'bottom'})
|
rpather.plug('v1_via', {'VCC': 'bottom'})
|
||||||
rpather.retool(M2_ptool)
|
rpather.retool(M2_ptool)
|
||||||
rpather.mpath(['GND', 'VCC'], None, xmin=-28_000)
|
rpather.straight(['GND', 'VCC'], xmin=-28_000)
|
||||||
|
|
||||||
# Finally, since PathTool has no conception of transitions, we can't
|
# 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.
|
# 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
|
# Instead, we have to calculate the via size ourselves, and adjust the final position
|
||||||
# to account for it.
|
# to account for it.
|
||||||
via_size = abs(
|
v1pat = library['v1_via']
|
||||||
library['v1_via'].ports['top'].offset[0]
|
via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x)
|
||||||
- library['v1_via'].ports['bottom'].offset[0]
|
|
||||||
)
|
# alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0]
|
||||||
rpather.path_to('VCC', None, -50_000 + via_size)
|
# would take into account the port orientations if we didn't already know they're along x
|
||||||
|
rpather.straight('VCC', x=-50_000 + via_size)
|
||||||
rpather.plug('v1_via', {'VCC': 'top'})
|
rpather.plug('v1_via', {'VCC': 'top'})
|
||||||
|
|
||||||
|
# Render the path we defined
|
||||||
rpather.render()
|
rpather.render()
|
||||||
library['RenderPather_and_PathTool'] = rpather.pattern
|
library['Deferred_Pather_and_PathTool'] = rpather.pattern
|
||||||
|
|
||||||
|
|
||||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from .error import (
|
||||||
from .shapes import (
|
from .shapes import (
|
||||||
Shape as Shape,
|
Shape as Shape,
|
||||||
Polygon as Polygon,
|
Polygon as Polygon,
|
||||||
|
RectCollection as RectCollection,
|
||||||
Path as Path,
|
Path as Path,
|
||||||
Circle as Circle,
|
Circle as Circle,
|
||||||
Arc as Arc,
|
Arc as Arc,
|
||||||
|
|
@ -55,16 +56,23 @@ from .pattern import (
|
||||||
map_targets as map_targets,
|
map_targets as map_targets,
|
||||||
chain_elements as chain_elements,
|
chain_elements as chain_elements,
|
||||||
)
|
)
|
||||||
|
from .utils.boolean import boolean as boolean
|
||||||
|
|
||||||
from .library import (
|
from .library import (
|
||||||
ILibraryView as ILibraryView,
|
ILibraryView as ILibraryView,
|
||||||
ILibrary as ILibrary,
|
ILibrary as ILibrary,
|
||||||
LibraryView as LibraryView,
|
LibraryView as LibraryView,
|
||||||
Library as Library,
|
Library as Library,
|
||||||
|
OverlayLibrary as OverlayLibrary,
|
||||||
|
PortsLibraryView as PortsLibraryView,
|
||||||
|
BuildLibrary as BuildLibrary,
|
||||||
|
BuildReport as BuildReport,
|
||||||
|
CellProvenance as CellProvenance,
|
||||||
LazyLibrary as LazyLibrary,
|
LazyLibrary as LazyLibrary,
|
||||||
AbstractView as AbstractView,
|
AbstractView as AbstractView,
|
||||||
TreeView as TreeView,
|
TreeView as TreeView,
|
||||||
Tree as Tree,
|
Tree as Tree,
|
||||||
|
cell as cell,
|
||||||
)
|
)
|
||||||
from .ports import (
|
from .ports import (
|
||||||
Port as Port,
|
Port as Port,
|
||||||
|
|
@ -72,12 +80,9 @@ from .ports import (
|
||||||
)
|
)
|
||||||
from .abstract import Abstract as Abstract
|
from .abstract import Abstract as Abstract
|
||||||
from .builder import (
|
from .builder import (
|
||||||
Builder as Builder,
|
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
Pather as Pather,
|
Pather as Pather,
|
||||||
RenderPather as RenderPather,
|
|
||||||
RenderStep as RenderStep,
|
RenderStep as RenderStep,
|
||||||
SimpleTool as SimpleTool,
|
|
||||||
AutoTool as AutoTool,
|
AutoTool as AutoTool,
|
||||||
PathTool as PathTool,
|
PathTool as PathTool,
|
||||||
PortPather as PortPather,
|
PortPather as PortPather,
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,13 @@ from numpy.typing import ArrayLike
|
||||||
from .ref import Ref
|
from .ref import Ref
|
||||||
from .ports import PortList, Port
|
from .ports import PortList, Port
|
||||||
from .utils import rotation_matrix_2d
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Abstract(PortList):
|
class Abstract(PortList, Mirrorable):
|
||||||
"""
|
"""
|
||||||
An `Abstract` is a container for a name and associated ports.
|
An `Abstract` is a container for a name and associated ports.
|
||||||
|
|
||||||
|
|
@ -131,50 +128,18 @@ class Abstract(PortList):
|
||||||
port.rotate(rotation)
|
port.rotate(rotation)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_port_offsets(self, across_axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Mirror the offsets of all shapes, labels, and refs across an axis
|
Mirror the Abstract across an axis through its origin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
across_axis: Axis to mirror across
|
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||||
(0: mirror across x axis, 1: mirror across y axis)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for port in self.ports.values():
|
for port in self.ports.values():
|
||||||
port.offset[across_axis - 1] *= -1
|
port.flip_across(axis=axis)
|
||||||
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
|
return self
|
||||||
|
|
||||||
def apply_ref_transform(self, ref: Ref) -> Self:
|
def apply_ref_transform(self, ref: Ref) -> Self:
|
||||||
|
|
@ -192,6 +157,8 @@ class Abstract(PortList):
|
||||||
self.mirror()
|
self.mirror()
|
||||||
self.rotate_ports(ref.rotation)
|
self.rotate_ports(ref.rotation)
|
||||||
self.rotate_port_offsets(ref.rotation)
|
self.rotate_port_offsets(ref.rotation)
|
||||||
|
if ref.scale != 1:
|
||||||
|
self.scale_by(ref.scale)
|
||||||
self.translate_ports(ref.offset)
|
self.translate_ports(ref.offset)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -209,6 +176,8 @@ class Abstract(PortList):
|
||||||
# TODO test undo_ref_transform
|
# TODO test undo_ref_transform
|
||||||
"""
|
"""
|
||||||
self.translate_ports(-ref.offset)
|
self.translate_ports(-ref.offset)
|
||||||
|
if ref.scale != 1:
|
||||||
|
self.scale_by(1 / ref.scale)
|
||||||
self.rotate_port_offsets(-ref.rotation)
|
self.rotate_port_offsets(-ref.rotation)
|
||||||
self.rotate_ports(-ref.rotation)
|
self.rotate_ports(-ref.rotation)
|
||||||
if ref.mirrored:
|
if ref.mirrored:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,58 @@
|
||||||
from .builder import Builder as Builder
|
"""
|
||||||
from .pather import Pather as Pather
|
Builder helpers for port-based assembly and primitive-offer routing.
|
||||||
from .renderpather import RenderPather as RenderPather
|
|
||||||
from .pather_mixin import PortPather as PortPather
|
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 .utils import ell as ell
|
from .utils import ell as ell
|
||||||
from .tools import (
|
from .tools import (
|
||||||
Tool as Tool,
|
Tool as Tool,
|
||||||
RenderStep as RenderStep,
|
|
||||||
SimpleTool as SimpleTool,
|
|
||||||
AutoTool as AutoTool,
|
AutoTool as AutoTool,
|
||||||
PathTool as PathTool,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,448 +0,0 @@
|
||||||
"""
|
|
||||||
Simplified Pattern assembly (`Builder`)
|
|
||||||
"""
|
|
||||||
from typing import Self
|
|
||||||
from collections.abc import Iterable, Sequence, Mapping
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from numpy.typing import ArrayLike
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import ILibrary, TreeView
|
|
||||||
from ..error import BuildError
|
|
||||||
from ..ports import PortList, Port
|
|
||||||
from ..abstract import Abstract
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Builder(PortList):
|
|
||||||
"""
|
|
||||||
A `Builder` is a helper object used for snapping together multiple
|
|
||||||
lower-level patterns at their `Port`s.
|
|
||||||
|
|
||||||
The `Builder` mostly just holds context, in the form of a `Library`,
|
|
||||||
in addition to its underlying pattern. This simplifies some calls
|
|
||||||
to `plug` and `place`, by making the library implicit.
|
|
||||||
|
|
||||||
`Builder` can also be `set_dead()`, at which point further calls to `plug()`
|
|
||||||
and `place()` are ignored (intended for debugging).
|
|
||||||
|
|
||||||
|
|
||||||
Examples: Creating a Builder
|
|
||||||
===========================
|
|
||||||
- `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes
|
|
||||||
an empty pattern, adds the given ports, and places it into `library`
|
|
||||||
under the name `'mypat'`.
|
|
||||||
|
|
||||||
- `Builder(library)` makes an empty pattern with no ports. The pattern
|
|
||||||
is not added into `library` and must later be added with e.g.
|
|
||||||
`library['mypat'] = builder.pattern`
|
|
||||||
|
|
||||||
- `Builder(library, pattern=pattern, name='mypat')` uses an existing
|
|
||||||
pattern (including its ports) and sets `library['mypat'] = pattern`.
|
|
||||||
|
|
||||||
- `Builder.interface(other_pat, port_map=['A', 'B'], library=library)`
|
|
||||||
makes a new (empty) pattern, copies over ports 'A' and 'B' from
|
|
||||||
`other_pat`, and creates additional ports 'in_A' and 'in_B' facing
|
|
||||||
in the opposite directions. This can be used to build a device which
|
|
||||||
can plug into `other_pat` (using the 'in_*' ports) but which does not
|
|
||||||
itself include `other_pat` as a subcomponent.
|
|
||||||
|
|
||||||
- `Builder.interface(other_builder, ...)` does the same thing as
|
|
||||||
`Builder.interface(other_builder.pattern, ...)` but also uses
|
|
||||||
`other_builder.library` as its library by default.
|
|
||||||
|
|
||||||
|
|
||||||
Examples: Adding to a pattern
|
|
||||||
=============================
|
|
||||||
- `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
|
|
||||||
instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B'
|
|
||||||
of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports
|
|
||||||
are removed and any unconnected ports from `subdevice` are added to
|
|
||||||
`my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.
|
|
||||||
|
|
||||||
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
|
|
||||||
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
|
|
||||||
argument is provided, and the `thru` argument is not explicitly
|
|
||||||
set to `False`, the unconnected port of `wire` is automatically renamed to
|
|
||||||
'myport'. This allows easy extension of existing ports without changing
|
|
||||||
their names or having to provide `map_out` each time `plug` is called.
|
|
||||||
|
|
||||||
- `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
|
|
||||||
instantiates `pad` at the specified (x, y) offset and with the specified
|
|
||||||
rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is
|
|
||||||
renamed to 'gnd' so that further routing can use this signal or net name
|
|
||||||
rather than the port name on the original `pad` device.
|
|
||||||
"""
|
|
||||||
__slots__ = ('pattern', 'library', '_dead')
|
|
||||||
|
|
||||||
pattern: Pattern
|
|
||||||
""" Layout of this device """
|
|
||||||
|
|
||||||
library: ILibrary
|
|
||||||
"""
|
|
||||||
Library from which patterns should be referenced
|
|
||||||
"""
|
|
||||||
|
|
||||||
_dead: bool
|
|
||||||
""" If True, plug()/place() are skipped (for debugging)"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self.pattern.ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, value: dict[str, Port]) -> None:
|
|
||||||
self.pattern.ports = value
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
library: ILibrary,
|
|
||||||
*,
|
|
||||||
pattern: Pattern | None = None,
|
|
||||||
ports: str | Mapping[str, Port] | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
library: The library from which referenced patterns will be taken
|
|
||||||
pattern: The pattern which will be modified by subsequent operations.
|
|
||||||
If `None` (default), a new pattern is created.
|
|
||||||
ports: Allows specifying the initial set of ports, if `pattern` does
|
|
||||||
not already have any ports (or is not provided). May be a string,
|
|
||||||
in which case it is interpreted as a name in `library`.
|
|
||||||
Default `None` (no ports).
|
|
||||||
name: If specified, `library[name]` is set to `self.pattern`.
|
|
||||||
"""
|
|
||||||
self._dead = False
|
|
||||||
self.library = library
|
|
||||||
if pattern is not None:
|
|
||||||
self.pattern = pattern
|
|
||||||
else:
|
|
||||||
self.pattern = Pattern()
|
|
||||||
|
|
||||||
if ports is not None:
|
|
||||||
if self.pattern.ports:
|
|
||||||
raise BuildError('Ports supplied for pattern with pre-existing ports!')
|
|
||||||
if isinstance(ports, str):
|
|
||||||
ports = library.abstract(ports).ports
|
|
||||||
|
|
||||||
self.pattern.ports.update(copy.deepcopy(dict(ports)))
|
|
||||||
|
|
||||||
if name is not None:
|
|
||||||
library[name] = self.pattern
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def interface(
|
|
||||||
cls: type['Builder'],
|
|
||||||
source: PortList | Mapping[str, Port] | str,
|
|
||||||
*,
|
|
||||||
library: ILibrary | None = None,
|
|
||||||
in_prefix: str = 'in_',
|
|
||||||
out_prefix: str = '',
|
|
||||||
port_map: dict[str, str] | Sequence[str] | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
) -> 'Builder':
|
|
||||||
"""
|
|
||||||
Wrapper for `Pattern.interface()`, which returns a Builder instead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
|
||||||
from which to create the interface. May be a pattern name if
|
|
||||||
`library` is provided.
|
|
||||||
library: Library from which existing patterns should be referenced,
|
|
||||||
and to which the new one should be added (if named). If not provided,
|
|
||||||
`source.library` must exist and will be used.
|
|
||||||
in_prefix: Prepended to port names for newly-created ports with
|
|
||||||
reversed directions compared to the current device.
|
|
||||||
out_prefix: Prepended to port names for ports which are directly
|
|
||||||
copied from the current device.
|
|
||||||
port_map: Specification for ports to copy into the new device:
|
|
||||||
- If `None`, all ports are copied.
|
|
||||||
- If a sequence, only the listed ports are copied
|
|
||||||
- If a mapping, the listed ports (keys) are copied and
|
|
||||||
renamed (to the values).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The new builder, with an empty pattern and 2x as many ports as
|
|
||||||
listed in port_map.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
`PortError` if `port_map` contains port names not present in the
|
|
||||||
current device.
|
|
||||||
`PortError` if applying the prefixes results in duplicate port
|
|
||||||
names.
|
|
||||||
"""
|
|
||||||
if library is None:
|
|
||||||
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
|
|
||||||
library = source.library
|
|
||||||
else:
|
|
||||||
raise BuildError('No library was given, and `source.library` does not have one either.')
|
|
||||||
|
|
||||||
if isinstance(source, str):
|
|
||||||
source = library.abstract(source).ports
|
|
||||||
|
|
||||||
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
|
|
||||||
new = Builder(library=library, pattern=pat, name=name)
|
|
||||||
return new
|
|
||||||
|
|
||||||
@wraps(Pattern.label)
|
|
||||||
def label(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.label(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.ref)
|
|
||||||
def ref(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.ref(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.polygon)
|
|
||||||
def polygon(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.polygon(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.rect)
|
|
||||||
def rect(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.rect(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
# Note: We're a superclass of `Pather`, where path() means something different,
|
|
||||||
# so we shouldn't wrap Pattern.path()
|
|
||||||
#@wraps(Pattern.path)
|
|
||||||
#def path(self, *args, **kwargs) -> Self:
|
|
||||||
# self.pattern.path(*args, **kwargs)
|
|
||||||
# return self
|
|
||||||
|
|
||||||
def plug(
|
|
||||||
self,
|
|
||||||
other: Abstract | str | Pattern | TreeView,
|
|
||||||
map_in: dict[str, str],
|
|
||||||
map_out: dict[str, str | None] | None = None,
|
|
||||||
*,
|
|
||||||
mirrored: bool = False,
|
|
||||||
thru: bool | str = True,
|
|
||||||
set_rotation: bool | None = None,
|
|
||||||
append: bool = False,
|
|
||||||
ok_connections: Iterable[tuple[str, str]] = (),
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Wrapper around `Pattern.plug` which allows a string for `other`.
|
|
||||||
|
|
||||||
The `Builder`'s library is used to dereference the string (or `Abstract`, if
|
|
||||||
one is passed with `append=True`). If a `TreeView` is passed, it is first
|
|
||||||
added into `self.library`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
|
|
||||||
device to be instatiated. If it is a `TreeView`, it is first
|
|
||||||
added into `self.library`, after which the topcell is plugged;
|
|
||||||
an equivalent statement is `self.plug(self.library << other, ...)`.
|
|
||||||
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
|
||||||
port connections between the two devices.
|
|
||||||
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
|
|
||||||
new names for ports in `other`.
|
|
||||||
mirrored: Enables mirroring `other` across the x axis prior to
|
|
||||||
connecting any ports.
|
|
||||||
thru: If map_in specifies only a single port, `thru` provides a mechainsm
|
|
||||||
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
|
|
||||||
- If True (default), and `other` has only two ports total, and map_out
|
|
||||||
doesn't specify a name for the other port, its name is set to the key
|
|
||||||
in `map_in`, i.e. 'myport'.
|
|
||||||
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
|
|
||||||
An error is raised if that entry already exists.
|
|
||||||
|
|
||||||
This makes it easy to extend a pattern with simple 2-port devices
|
|
||||||
(e.g. wires) without providing `map_out` each time `plug` is
|
|
||||||
called. See "Examples" above for more info. Default `True`.
|
|
||||||
set_rotation: If the necessary rotation cannot be determined from
|
|
||||||
the ports being connected (i.e. all pairs have at least one
|
|
||||||
port with `rotation=None`), `set_rotation` must be provided
|
|
||||||
to indicate how much `other` should be rotated. Otherwise,
|
|
||||||
`set_rotation` must remain `None`.
|
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
|
||||||
Note that this does not flatten `other`, so its refs will still
|
|
||||||
be refs (now inside `self`).
|
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
|
||||||
any other ptypte. Non-allowed ptype connections will emit a
|
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
|
||||||
`(b, a)`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
|
||||||
exist in `self.ports` or `other_names`.
|
|
||||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
|
||||||
are applied.
|
|
||||||
`PortError` if the specified port mapping is not achieveable (the ports
|
|
||||||
do not line up)
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping plug() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
if not isinstance(other, str | Abstract | Pattern):
|
|
||||||
# We got a Tree; add it into self.library and grab an Abstract for it
|
|
||||||
other = self.library << other
|
|
||||||
|
|
||||||
if isinstance(other, str):
|
|
||||||
other = self.library.abstract(other)
|
|
||||||
if append and isinstance(other, Abstract):
|
|
||||||
other = self.library[other.name]
|
|
||||||
|
|
||||||
self.pattern.plug(
|
|
||||||
other = other,
|
|
||||||
map_in = map_in,
|
|
||||||
map_out = map_out,
|
|
||||||
mirrored = mirrored,
|
|
||||||
thru = thru,
|
|
||||||
set_rotation = set_rotation,
|
|
||||||
append = append,
|
|
||||||
ok_connections = ok_connections,
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def place(
|
|
||||||
self,
|
|
||||||
other: Abstract | str | Pattern | TreeView,
|
|
||||||
*,
|
|
||||||
offset: ArrayLike = (0, 0),
|
|
||||||
rotation: float = 0,
|
|
||||||
pivot: ArrayLike = (0, 0),
|
|
||||||
mirrored: bool = False,
|
|
||||||
port_map: dict[str, str | None] | None = None,
|
|
||||||
skip_port_check: bool = False,
|
|
||||||
append: bool = False,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`.
|
|
||||||
|
|
||||||
The `Builder`'s library is used to dereference the string (or `Abstract`, if
|
|
||||||
one is passed with `append=True`). If a `TreeView` is passed, it is first
|
|
||||||
added into `self.library`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other: An `Abstract`, string, `Pattern`, or `TreeView` describing the
|
|
||||||
device to be instatiated. If it is a `TreeView`, it is first
|
|
||||||
added into `self.library`, after which the topcell is plugged;
|
|
||||||
an equivalent statement is `self.plug(self.library << other, ...)`.
|
|
||||||
offset: Offset at which to place the instance. Default (0, 0).
|
|
||||||
rotation: Rotation applied to the instance before placement. Default 0.
|
|
||||||
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
|
||||||
Rotation is applied prior to translation (`offset`).
|
|
||||||
mirrored: Whether theinstance should be mirrored across the x axis.
|
|
||||||
Mirroring is applied before translation and rotation.
|
|
||||||
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
|
|
||||||
new names for ports in the instantiated device. New names can be
|
|
||||||
`None`, which will delete those ports.
|
|
||||||
skip_port_check: Can be used to skip the internal call to `check_ports`,
|
|
||||||
in case it has already been performed elsewhere.
|
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
|
||||||
Note that this does not flatten `other`, so its refs will still
|
|
||||||
be refs (now inside `self`).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
|
||||||
exist in `self.ports` or `other.ports`.
|
|
||||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
|
||||||
are applied.
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping place() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
if not isinstance(other, str | Abstract | Pattern):
|
|
||||||
# We got a Tree; add it into self.library and grab an Abstract for it
|
|
||||||
other = self.library << other
|
|
||||||
|
|
||||||
if isinstance(other, str):
|
|
||||||
other = self.library.abstract(other)
|
|
||||||
if append and isinstance(other, Abstract):
|
|
||||||
other = self.library[other.name]
|
|
||||||
|
|
||||||
self.pattern.place(
|
|
||||||
other = other,
|
|
||||||
offset = offset,
|
|
||||||
rotation = rotation,
|
|
||||||
pivot = pivot,
|
|
||||||
mirrored = mirrored,
|
|
||||||
port_map = port_map,
|
|
||||||
skip_port_check = skip_port_check,
|
|
||||||
append = append,
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def translate(self, offset: ArrayLike) -> Self:
|
|
||||||
"""
|
|
||||||
Translate the pattern and all ports.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
offset: (x, y) distance to translate by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.translate_elements(offset)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
|
|
||||||
"""
|
|
||||||
Rotate the pattern and all ports.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
angle: angle (radians, counterclockwise) to rotate by
|
|
||||||
pivot: location to rotate around
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.rotate_around(pivot, angle)
|
|
||||||
for port in self.ports.values():
|
|
||||||
port.rotate_around(pivot, angle)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
|
||||||
"""
|
|
||||||
Mirror the pattern and all ports across the specified axis.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
axis: Axis to mirror across (x=0, y=1)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.mirror(axis)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_dead(self) -> Self:
|
|
||||||
"""
|
|
||||||
Disallows further changes through `plug()` or `place()`.
|
|
||||||
This is meant for debugging:
|
|
||||||
```
|
|
||||||
dev.plug(a, ...)
|
|
||||||
dev.set_dead() # added for debug purposes
|
|
||||||
dev.plug(b, ...) # usually raises an error, but now skipped
|
|
||||||
dev.plug(c, ...) # also skipped
|
|
||||||
dev.pattern.visualize() # shows the device as of the set_dead() call
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self._dead = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
s = f'<Builder {self.pattern} L({len(self.library)})>'
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
80
masque/builder/logging.py
Normal file
80
masque/builder/logging.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Logging helpers for Pather."""
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from collections.abc import Iterator, Sequence
|
||||||
|
import logging
|
||||||
|
import numpy
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .pather import Pather
|
||||||
|
|
||||||
|
|
||||||
|
def _format_log_args(**kwargs) -> str:
|
||||||
|
arg_strs = []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if isinstance(v, str | int | float | bool | None):
|
||||||
|
arg_strs.append(f"{k}={v}")
|
||||||
|
elif isinstance(v, numpy.ndarray):
|
||||||
|
arg_strs.append(f"{k}={v.tolist()}")
|
||||||
|
elif isinstance(v, list | tuple) and len(v) <= 10:
|
||||||
|
arg_strs.append(f"{k}={v}")
|
||||||
|
else:
|
||||||
|
arg_strs.append(f"{k}=...")
|
||||||
|
return ", ".join(arg_strs)
|
||||||
|
|
||||||
|
|
||||||
|
class PatherLogger:
|
||||||
|
"""
|
||||||
|
Encapsulates state for Pather diagnostic logging.
|
||||||
|
"""
|
||||||
|
debug: bool
|
||||||
|
indent: int
|
||||||
|
depth: int
|
||||||
|
|
||||||
|
def __init__(self, debug: bool = False) -> None:
|
||||||
|
self.debug = debug
|
||||||
|
self.indent = 0
|
||||||
|
self.depth = 0
|
||||||
|
|
||||||
|
def _log(self, module_name: str, msg: str) -> None:
|
||||||
|
if self.debug and self.depth <= 1:
|
||||||
|
log_obj = logging.getLogger(module_name)
|
||||||
|
log_obj.info(' ' * self.indent + msg)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def log_operation(
|
||||||
|
self,
|
||||||
|
pather: 'Pather',
|
||||||
|
op: str,
|
||||||
|
portspec: str | Sequence[str] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
if not self.debug or self.depth > 0:
|
||||||
|
self.depth += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.depth -= 1
|
||||||
|
return
|
||||||
|
|
||||||
|
target = f"({portspec})" if portspec else ""
|
||||||
|
module_name = pather.__class__.__module__
|
||||||
|
self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}")
|
||||||
|
|
||||||
|
before_ports = {name: port.copy() for name, port in pather.ports.items()}
|
||||||
|
self.depth += 1
|
||||||
|
self.indent += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
after_ports = pather.ports
|
||||||
|
for name in sorted(after_ports.keys()):
|
||||||
|
if name not in before_ports or after_ports[name] != before_ports[name]:
|
||||||
|
self._log(module_name, f"Port {name}: {pather.ports[name].describe()}")
|
||||||
|
for name in sorted(before_ports.keys()):
|
||||||
|
if name not in after_ports:
|
||||||
|
self._log(module_name, f"Port {name}: removed")
|
||||||
|
|
||||||
|
self.indent -= 1
|
||||||
|
self.depth -= 1
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,677 +0,0 @@
|
||||||
from typing import Self, overload
|
|
||||||
from collections.abc import Sequence, Iterator, Iterable
|
|
||||||
import logging
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from abc import abstractmethod, ABCMeta
|
|
||||||
|
|
||||||
import numpy
|
|
||||||
from numpy import pi
|
|
||||||
from numpy.typing import ArrayLike
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import ILibrary, TreeView
|
|
||||||
from ..error import PortError, BuildError
|
|
||||||
from ..utils import SupportsBool
|
|
||||||
from ..abstract import Abstract
|
|
||||||
from .tools import Tool
|
|
||||||
from .utils import ell
|
|
||||||
from ..ports import PortList
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PatherMixin(PortList, metaclass=ABCMeta):
|
|
||||||
pattern: Pattern
|
|
||||||
""" Layout of this device """
|
|
||||||
|
|
||||||
library: ILibrary
|
|
||||||
""" Library from which patterns should be referenced """
|
|
||||||
|
|
||||||
_dead: bool
|
|
||||||
""" If True, plug()/place() are skipped (for debugging) """
|
|
||||||
|
|
||||||
tools: dict[str | None, Tool]
|
|
||||||
"""
|
|
||||||
Tool objects are used to dynamically generate new single-use Devices
|
|
||||||
(e.g wires or waveguides) to be plugged into this device.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def path(
|
|
||||||
self,
|
|
||||||
portspec: str,
|
|
||||||
ccw: SupportsBool | None,
|
|
||||||
length: float,
|
|
||||||
*,
|
|
||||||
plug_into: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def pathS(
|
|
||||||
self,
|
|
||||||
portspec: str,
|
|
||||||
length: float,
|
|
||||||
jog: float,
|
|
||||||
*,
|
|
||||||
plug_into: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def plug(
|
|
||||||
self,
|
|
||||||
other: Abstract | str | Pattern | TreeView,
|
|
||||||
map_in: dict[str, str],
|
|
||||||
map_out: dict[str, str | None] | None = None,
|
|
||||||
*,
|
|
||||||
mirrored: bool = False,
|
|
||||||
thru: bool | str = True,
|
|
||||||
set_rotation: bool | None = None,
|
|
||||||
append: bool = False,
|
|
||||||
ok_connections: Iterable[tuple[str, str]] = (),
|
|
||||||
) -> Self:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def retool(
|
|
||||||
self,
|
|
||||||
tool: Tool,
|
|
||||||
keys: str | Sequence[str | None] | None = None,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Update the `Tool` which will be used when generating `Pattern`s for the ports
|
|
||||||
given by `keys`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool: The new `Tool` to use for the given ports.
|
|
||||||
keys: Which ports the tool should apply to. `None` indicates the default tool,
|
|
||||||
used when there is no matching entry in `self.tools` for the port in question.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
if keys is None or isinstance(keys, str):
|
|
||||||
self.tools[keys] = tool
|
|
||||||
else:
|
|
||||||
for key in keys:
|
|
||||||
self.tools[key] = tool
|
|
||||||
return self
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def toolctx(
|
|
||||||
self,
|
|
||||||
tool: Tool,
|
|
||||||
keys: str | Sequence[str | None] | None = None,
|
|
||||||
) -> Iterator[Self]:
|
|
||||||
"""
|
|
||||||
Context manager for temporarily `retool`-ing and reverting the `retool`
|
|
||||||
upon exiting the context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool: The new `Tool` to use for the given ports.
|
|
||||||
keys: Which ports the tool should apply to. `None` indicates the default tool,
|
|
||||||
used when there is no matching entry in `self.tools` for the port in question.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
if keys is None or isinstance(keys, str):
|
|
||||||
keys = [keys]
|
|
||||||
saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None`
|
|
||||||
try:
|
|
||||||
yield self.retool(tool=tool, keys=keys)
|
|
||||||
finally:
|
|
||||||
for kk, tt in saved_tools.items():
|
|
||||||
if tt is None:
|
|
||||||
# delete if present
|
|
||||||
self.tools.pop(kk, None)
|
|
||||||
else:
|
|
||||||
self.tools[kk] = tt
|
|
||||||
|
|
||||||
def path_to(
|
|
||||||
self,
|
|
||||||
portspec: str,
|
|
||||||
ccw: SupportsBool | None,
|
|
||||||
position: float | None = None,
|
|
||||||
*,
|
|
||||||
x: float | None = None,
|
|
||||||
y: float | None = None,
|
|
||||||
plug_into: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Build a "wire"/"waveguide" extending from the port `portspec`, with the aim
|
|
||||||
of ending exactly at a target position.
|
|
||||||
|
|
||||||
The wire will travel so that the output port will be placed at exactly the target
|
|
||||||
position along the input port's axis. There can be an unspecified (tool-dependent)
|
|
||||||
offset in the perpendicular direction. The output port will be rotated (or not)
|
|
||||||
based on the `ccw` parameter.
|
|
||||||
|
|
||||||
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
portspec: The name of the port into which the wire will be plugged.
|
|
||||||
ccw: If `None`, the output should be along the same axis as the input.
|
|
||||||
Otherwise, cast to bool and turn counterclockwise if True
|
|
||||||
and clockwise otherwise.
|
|
||||||
position: The final port position, along the input's axis only.
|
|
||||||
(There may be a tool-dependent offset along the other axis.)
|
|
||||||
Only one of `position`, `x`, and `y` may be specified.
|
|
||||||
x: The final port position along the x axis.
|
|
||||||
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
|
|
||||||
BuildError will be raised.
|
|
||||||
y: The final port position along the y axis.
|
|
||||||
`portspec` must refer to a vertical port if `y` is passed, otherwise a
|
|
||||||
BuildError will be raised.
|
|
||||||
plug_into: If not None, attempts to plug the wire's output port into the provided
|
|
||||||
port on `self`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend
|
|
||||||
is present).
|
|
||||||
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
|
|
||||||
BuildError if more than one of `x`, `y`, and `position` is specified.
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping path_to() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
pos_count = sum(vv is not None for vv in (position, x, y))
|
|
||||||
if pos_count > 1:
|
|
||||||
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
|
|
||||||
if pos_count < 1:
|
|
||||||
raise BuildError('One of `position`, `x`, and `y` must be specified')
|
|
||||||
|
|
||||||
port = self.pattern[portspec]
|
|
||||||
if port.rotation is None:
|
|
||||||
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
|
|
||||||
|
|
||||||
if not numpy.isclose(port.rotation % (pi / 2), 0):
|
|
||||||
raise BuildError('path_to was asked to route from non-manhattan port')
|
|
||||||
|
|
||||||
is_horizontal = numpy.isclose(port.rotation % pi, 0)
|
|
||||||
if is_horizontal:
|
|
||||||
if y is not None:
|
|
||||||
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
|
|
||||||
if position is None:
|
|
||||||
position = x
|
|
||||||
else:
|
|
||||||
if x is not None:
|
|
||||||
raise BuildError('Asked to path to x-coordinate, but port is vertical')
|
|
||||||
if position is None:
|
|
||||||
position = y
|
|
||||||
|
|
||||||
x0, y0 = port.offset
|
|
||||||
if is_horizontal:
|
|
||||||
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
|
|
||||||
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
|
|
||||||
length = numpy.abs(position - x0)
|
|
||||||
else:
|
|
||||||
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
|
|
||||||
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
|
|
||||||
length = numpy.abs(position - y0)
|
|
||||||
|
|
||||||
return self.path(
|
|
||||||
portspec,
|
|
||||||
ccw,
|
|
||||||
length,
|
|
||||||
plug_into = plug_into,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
def path_into(
|
|
||||||
self,
|
|
||||||
portspec_src: str,
|
|
||||||
portspec_dst: str,
|
|
||||||
*,
|
|
||||||
out_ptype: str | None = None,
|
|
||||||
plug_destination: bool = True,
|
|
||||||
thru: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Create a "wire"/"waveguide" traveling between the ports `portspec_src` and
|
|
||||||
`portspec_dst`, and `plug` it into both (or just the source port).
|
|
||||||
|
|
||||||
Only unambiguous scenarios are allowed:
|
|
||||||
- Straight connector between facing ports
|
|
||||||
- Single 90 degree bend
|
|
||||||
- Jog between facing ports
|
|
||||||
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
|
|
||||||
|
|
||||||
By default, the destination's `pytpe` will be used as the `out_ptype` for the
|
|
||||||
wire, and the `portspec_dst` will be plugged (i.e. removed).
|
|
||||||
|
|
||||||
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
portspec_src: The name of the starting port into which the wire will be plugged.
|
|
||||||
portspec_dst: The name of the destination port.
|
|
||||||
out_ptype: Passed to the pathing tool in order to specify the desired port type
|
|
||||||
to be generated at the destination end. If `None` (default), the destination
|
|
||||||
port's `ptype` will be used.
|
|
||||||
thru: If not `None`, the port by this name will be rename to `portspec_src`.
|
|
||||||
This can be used when routing a signal through a pre-placed 2-port device.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
PortError if either port does not have a specified rotation.
|
|
||||||
BuildError if and invalid port config is encountered:
|
|
||||||
- Non-manhattan ports
|
|
||||||
- U-bend
|
|
||||||
- Destination too close to (or behind) source
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping path_into() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
port_src = self.pattern[portspec_src]
|
|
||||||
port_dst = self.pattern[portspec_dst]
|
|
||||||
|
|
||||||
if out_ptype is None:
|
|
||||||
out_ptype = port_dst.ptype
|
|
||||||
|
|
||||||
if port_src.rotation is None:
|
|
||||||
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
|
|
||||||
if port_dst.rotation is None:
|
|
||||||
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
|
|
||||||
|
|
||||||
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
|
|
||||||
raise BuildError('path_into was asked to route from non-manhattan port')
|
|
||||||
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
|
|
||||||
raise BuildError('path_into was asked to route to non-manhattan port')
|
|
||||||
|
|
||||||
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
|
|
||||||
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
|
|
||||||
xs, ys = port_src.offset
|
|
||||||
xd, yd = port_dst.offset
|
|
||||||
|
|
||||||
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
|
|
||||||
|
|
||||||
dst_extra_args = {'out_ptype': out_ptype}
|
|
||||||
if plug_destination:
|
|
||||||
dst_extra_args['plug_into'] = portspec_dst
|
|
||||||
|
|
||||||
src_args = {**kwargs}
|
|
||||||
dst_args = {**src_args, **dst_extra_args}
|
|
||||||
if src_is_horizontal and not dst_is_horizontal:
|
|
||||||
# single bend should suffice
|
|
||||||
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
|
|
||||||
self.path_to(portspec_src, None, y=yd, **dst_args)
|
|
||||||
elif dst_is_horizontal and not src_is_horizontal:
|
|
||||||
# single bend should suffice
|
|
||||||
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
|
|
||||||
self.path_to(portspec_src, None, x=xd, **dst_args)
|
|
||||||
elif numpy.isclose(angle, pi):
|
|
||||||
if src_is_horizontal and ys == yd:
|
|
||||||
# straight connector
|
|
||||||
self.path_to(portspec_src, None, x=xd, **dst_args)
|
|
||||||
elif not src_is_horizontal and xs == xd:
|
|
||||||
# straight connector
|
|
||||||
self.path_to(portspec_src, None, y=yd, **dst_args)
|
|
||||||
else:
|
|
||||||
# S-bend, delegate to implementations
|
|
||||||
(travel, jog), _ = port_src.measure_travel(port_dst)
|
|
||||||
self.pathS(portspec_src, -travel, -jog, **dst_args)
|
|
||||||
elif numpy.isclose(angle, 0):
|
|
||||||
raise BuildError('Don\'t know how to route a U-bend yet (TODO)!')
|
|
||||||
else:
|
|
||||||
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
|
|
||||||
|
|
||||||
if thru is not None:
|
|
||||||
self.rename_ports({thru: portspec_src})
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def mpath(
|
|
||||||
self,
|
|
||||||
portspec: str | Sequence[str],
|
|
||||||
ccw: SupportsBool | None,
|
|
||||||
*,
|
|
||||||
spacing: float | ArrayLike | None = None,
|
|
||||||
set_rotation: float | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
|
|
||||||
of "wires or "waveguides".
|
|
||||||
|
|
||||||
The wires will travel so that the output ports will be placed at well-defined
|
|
||||||
locations along the axis of their input ports, but may have arbitrary (tool-
|
|
||||||
dependent) offsets in the perpendicular direction.
|
|
||||||
|
|
||||||
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
|
|
||||||
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
|
|
||||||
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
|
|
||||||
which is required when `ccw` is not `None`. The final position of bundle as a
|
|
||||||
whole can be set in a number of ways:
|
|
||||||
|
|
||||||
=A>---------------------------V turn direction: `ccw=False`
|
|
||||||
=B>-------------V |
|
|
||||||
=C>-----------------------V |
|
|
||||||
=D=>----------------V |
|
|
||||||
|
|
|
||||||
|
|
||||||
x---x---x---x `spacing` (can be scalar or array)
|
|
||||||
|
|
||||||
<--------------> `emin=`
|
|
||||||
<------> `bound_type='min_past_furthest', bound=`
|
|
||||||
<--------------------------------> `emax=`
|
|
||||||
x `pmin=`
|
|
||||||
x `pmax=`
|
|
||||||
|
|
||||||
- `emin=`, equivalent to `bound_type='min_extension', bound=`
|
|
||||||
The total extension value for the furthest-out port (B in the diagram).
|
|
||||||
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
|
|
||||||
The total extension value for the closest-in port (C in the diagram).
|
|
||||||
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
|
|
||||||
The coordinate of the innermost bend (D's bend).
|
|
||||||
The x/y versions throw an error if they do not match the port axis (for debug)
|
|
||||||
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
|
|
||||||
The coordinate of the outermost bend (A's bend).
|
|
||||||
The x/y versions throw an error if they do not match the port axis (for debug)
|
|
||||||
- `bound_type='min_past_furthest', bound=`:
|
|
||||||
The distance between furthest out-port (B) and the innermost bend (D's bend).
|
|
||||||
|
|
||||||
If `ccw=None`, final output positions (along the input axis) of all wires will be
|
|
||||||
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
|
|
||||||
required. In this case, `emin=` and `emax=` are equivalent to each other, and
|
|
||||||
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
|
|
||||||
|
|
||||||
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
portspec: The names of the ports which are to be routed.
|
|
||||||
ccw: If `None`, the outputs should be along the same axis as the inputs.
|
|
||||||
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
|
|
||||||
and clockwise otherwise.
|
|
||||||
spacing: Center-to-center distance between output ports along the input port's axis.
|
|
||||||
Must be provided if (and only if) `ccw` is not `None`.
|
|
||||||
set_rotation: If the provided ports have `rotation=None`, this can be used
|
|
||||||
to set a rotation for them.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BuildError if the implied length for any wire is too close to fit the bend
|
|
||||||
(if a bend is requested).
|
|
||||||
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
|
|
||||||
match the axis of `portspec`.
|
|
||||||
BuildError if an incorrect bound type or spacing is specified.
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping mpath() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
bound_types = set()
|
|
||||||
if 'bound_type' in kwargs:
|
|
||||||
bound_types.add(kwargs.pop('bound_type'))
|
|
||||||
bound = kwargs.pop('bound')
|
|
||||||
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
|
|
||||||
if bt in kwargs:
|
|
||||||
bound_types.add(bt)
|
|
||||||
bound = kwargs.pop(bt)
|
|
||||||
|
|
||||||
if not bound_types:
|
|
||||||
raise BuildError('No bound type specified for mpath')
|
|
||||||
if len(bound_types) > 1:
|
|
||||||
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
|
|
||||||
bound_type = tuple(bound_types)[0]
|
|
||||||
|
|
||||||
if isinstance(portspec, str):
|
|
||||||
portspec = [portspec]
|
|
||||||
ports = self.pattern[tuple(portspec)]
|
|
||||||
|
|
||||||
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
|
|
||||||
|
|
||||||
#if container:
|
|
||||||
# assert not getattr(self, 'render'), 'Containers not implemented for RenderPather'
|
|
||||||
# bld = self.interface(source=ports, library=self.library, tools=self.tools)
|
|
||||||
# for port_name, length in extensions.items():
|
|
||||||
# bld.path(port_name, ccw, length, **kwargs)
|
|
||||||
# self.library[container] = bld.pattern
|
|
||||||
# self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
|
|
||||||
#else:
|
|
||||||
for port_name, length in extensions.items():
|
|
||||||
self.path(port_name, ccw, length, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
# TODO def bus_join()?
|
|
||||||
|
|
||||||
def flatten(self) -> Self:
|
|
||||||
"""
|
|
||||||
Flatten the contained pattern, using the contained library to resolve references.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.flatten(self.library)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def at(self, portspec: str | Iterable[str]) -> 'PortPather':
|
|
||||||
return PortPather(portspec, self)
|
|
||||||
|
|
||||||
|
|
||||||
class PortPather:
|
|
||||||
"""
|
|
||||||
Port state manager
|
|
||||||
|
|
||||||
This class provides a convenient way to perform multiple pathing operations on a
|
|
||||||
set of ports without needing to repeatedly pass their names.
|
|
||||||
"""
|
|
||||||
ports: list[str]
|
|
||||||
pather: PatherMixin
|
|
||||||
|
|
||||||
def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None:
|
|
||||||
self.ports = [ports] if isinstance(ports, str) else list(ports)
|
|
||||||
self.pather = pather
|
|
||||||
|
|
||||||
#
|
|
||||||
# Delegate to pather
|
|
||||||
#
|
|
||||||
def retool(self, tool: Tool) -> Self:
|
|
||||||
self.pather.retool(tool, keys=self.ports)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def toolctx(self, tool: Tool) -> Iterator[Self]:
|
|
||||||
with self.pather.toolctx(tool, keys=self.ports):
|
|
||||||
yield self
|
|
||||||
|
|
||||||
def path(self, *args, **kwargs) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
logger.warning('Use path_each() when pathing multiple ports independently')
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather.path(port, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def path_each(self, *args, **kwargs) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather.path(port, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def pathS(self, *args, **kwargs) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
logger.warning('Use pathS_each() when pathing multiple ports independently')
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather.pathS(port, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def pathS_each(self, *args, **kwargs) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather.pathS(port, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def path_to(self, *args, **kwargs) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
logger.warning('Use path_each_to() when pathing multiple ports independently')
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather.path_to(port, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def path_each_to(self, *args, **kwargs) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather.path_to(port, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def mpath(self, *args, **kwargs) -> Self:
|
|
||||||
self.pather.mpath(self.ports, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def path_into(self, *args, **kwargs) -> Self:
|
|
||||||
""" Path_into, using the current port as the source """
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.')
|
|
||||||
self.pather.path_into(self.ports[0], *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def path_from(self, *args, **kwargs) -> Self:
|
|
||||||
""" Path_into, using the current port as the destination """
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.')
|
|
||||||
thru = kwargs.pop('thru', None)
|
|
||||||
self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs)
|
|
||||||
if thru is not None:
|
|
||||||
self.rename_from(thru)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def plug(
|
|
||||||
self,
|
|
||||||
other: Abstract | str,
|
|
||||||
other_port: str,
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.'
|
|
||||||
'Use the pather or pattern directly to plug multiple ports.')
|
|
||||||
self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def plugged(self, other_port: str) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.')
|
|
||||||
self.pather.plugged({self.ports[0]: other_port})
|
|
||||||
return self
|
|
||||||
|
|
||||||
#
|
|
||||||
# Delegate to port
|
|
||||||
#
|
|
||||||
def set_ptype(self, ptype: str) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather[port].set_ptype(ptype)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def translate(self, *args, **kwargs) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather[port].translate(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def mirror(self, *args, **kwargs) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather[port].mirror(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def rotate(self, rotation: float) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather[port].rotate(rotation)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_rotation(self, rotation: float | None) -> Self:
|
|
||||||
for port in self.ports:
|
|
||||||
self.pather[port].set_rotation(rotation)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def rename_to(self, new_name: str) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
BuildError('Use rename_ports() for >1 port')
|
|
||||||
self.pather.rename_ports({self.ports[0]: new_name})
|
|
||||||
self.ports[0] = new_name
|
|
||||||
return self
|
|
||||||
|
|
||||||
def rename_from(self, old_name: str) -> Self:
|
|
||||||
if len(self.ports) > 1:
|
|
||||||
BuildError('Use rename_ports() for >1 port')
|
|
||||||
self.pather.rename_ports({old_name: self.ports[0]})
|
|
||||||
return self
|
|
||||||
|
|
||||||
def rename_ports(self, name_map: dict[str, str | None]) -> Self:
|
|
||||||
self.pather.rename_ports(name_map)
|
|
||||||
self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None]
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_ports(self, ports: Iterable[str]) -> Self:
|
|
||||||
ports = list(ports)
|
|
||||||
conflicts = set(ports) & set(self.ports)
|
|
||||||
if conflicts:
|
|
||||||
raise BuildError(f'ports {conflicts} already selected')
|
|
||||||
self.ports += ports
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_port(self, port: str, index: int | None = None) -> Self:
|
|
||||||
if port in self.ports:
|
|
||||||
raise BuildError(f'{port=} already selected')
|
|
||||||
if index is not None:
|
|
||||||
self.ports.insert(index, port)
|
|
||||||
else:
|
|
||||||
self.ports.append(port)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def drop_port(self, port: str) -> Self:
|
|
||||||
if port not in self.ports:
|
|
||||||
raise BuildError(f'{port=} already not selected')
|
|
||||||
self.ports = [pp for pp in self.ports if pp != port]
|
|
||||||
return self
|
|
||||||
|
|
||||||
def into_copy(self, new_name: str, src: str | None = None) -> Self:
|
|
||||||
""" Copy a port and replace it with the copy """
|
|
||||||
if not self.ports:
|
|
||||||
raise BuildError('Have no ports to copy')
|
|
||||||
if len(self.ports) == 1:
|
|
||||||
src = self.ports[0]
|
|
||||||
elif src is None:
|
|
||||||
raise BuildError('Must specify src when >1 port is available')
|
|
||||||
if src not in self.ports:
|
|
||||||
raise BuildError(f'{src=} not available')
|
|
||||||
self.pather.ports[new_name] = self.pather[src].copy()
|
|
||||||
self.ports = [(new_name if pp == src else pp) for pp in self.ports]
|
|
||||||
return self
|
|
||||||
|
|
||||||
def save_copy(self, new_name: str, src: str | None = None) -> Self:
|
|
||||||
""" Copy a port and but keep using the original """
|
|
||||||
if not self.ports:
|
|
||||||
raise BuildError('Have no ports to copy')
|
|
||||||
if len(self.ports) == 1:
|
|
||||||
src = self.ports[0]
|
|
||||||
elif src is None:
|
|
||||||
raise BuildError('Must specify src when >1 port is available')
|
|
||||||
if src not in self.ports:
|
|
||||||
raise BuildError(f'{src=} not available')
|
|
||||||
self.pather.ports[new_name] = self.pather[src].copy()
|
|
||||||
return self
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def delete(self, name: None) -> None: ...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def delete(self, name: str) -> Self: ...
|
|
||||||
|
|
||||||
def delete(self, name: str | None = None) -> Self | None:
|
|
||||||
if name is None:
|
|
||||||
for pp in self.ports:
|
|
||||||
del self.pather.ports[pp]
|
|
||||||
return None
|
|
||||||
del self.pather.ports[name]
|
|
||||||
self.ports = [pp for pp in self.ports if pp != name]
|
|
||||||
return self
|
|
||||||
|
|
||||||
16
masque/builder/planner/__init__.py
Normal file
16
masque/builder/planner/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
257
masque/builder/planner/bounds.py
Normal file
257
masque/builder/planner/bounds.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""
|
||||||
|
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)
|
||||||
83
masque/builder/planner/interface.py
Normal file
83
masque/builder/planner/interface.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""
|
||||||
|
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."""
|
||||||
1399
masque/builder/planner/planner.py
Normal file
1399
masque/builder/planner/planner.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,646 +0,0 @@
|
||||||
"""
|
|
||||||
Pather with batched (multi-step) rendering
|
|
||||||
"""
|
|
||||||
from typing import Self
|
|
||||||
from collections.abc import Sequence, Mapping, MutableMapping, Iterable
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
from functools import wraps
|
|
||||||
from pprint import pformat
|
|
||||||
|
|
||||||
from numpy import pi
|
|
||||||
from numpy.typing import ArrayLike
|
|
||||||
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..library import ILibrary, TreeView
|
|
||||||
from ..error import BuildError
|
|
||||||
from ..ports import PortList, Port
|
|
||||||
from ..abstract import Abstract
|
|
||||||
from ..utils import SupportsBool
|
|
||||||
from .tools import Tool, RenderStep
|
|
||||||
from .pather_mixin import PatherMixin
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RenderPather(PatherMixin):
|
|
||||||
"""
|
|
||||||
`RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath`
|
|
||||||
functions to plan out wire paths without incrementally generating the layout. Instead,
|
|
||||||
it waits until `render` is called, at which point it draws all the planned segments
|
|
||||||
simultaneously. This allows it to e.g. draw each wire using a single `Path` or
|
|
||||||
`Polygon` shape instead of multiple rectangles.
|
|
||||||
|
|
||||||
`RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific
|
|
||||||
dimensions and build the final geometry for each wire. `Tool.planL` provides the
|
|
||||||
output port data (relative to the input) for each segment. The tool, input and output
|
|
||||||
ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for
|
|
||||||
each port. When `render` is called, it bundles `RenderStep`s into batches which use
|
|
||||||
the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build
|
|
||||||
the geometry.
|
|
||||||
|
|
||||||
See `Pather` for routing examples. After routing is complete, `render` must be called
|
|
||||||
to generate the final geometry.
|
|
||||||
"""
|
|
||||||
__slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', )
|
|
||||||
|
|
||||||
pattern: Pattern
|
|
||||||
""" Layout of this device """
|
|
||||||
|
|
||||||
library: ILibrary
|
|
||||||
""" Library from which patterns should be referenced """
|
|
||||||
|
|
||||||
_dead: bool
|
|
||||||
""" If True, plug()/place() are skipped (for debugging) """
|
|
||||||
|
|
||||||
paths: defaultdict[str, list[RenderStep]]
|
|
||||||
""" Per-port list of operations, to be used by `render` """
|
|
||||||
|
|
||||||
tools: dict[str | None, Tool]
|
|
||||||
"""
|
|
||||||
Tool objects are used to dynamically generate new single-use Devices
|
|
||||||
(e.g wires or waveguides) to be plugged into this device.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ports(self) -> dict[str, Port]:
|
|
||||||
return self.pattern.ports
|
|
||||||
|
|
||||||
@ports.setter
|
|
||||||
def ports(self, value: dict[str, Port]) -> None:
|
|
||||||
self.pattern.ports = value
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
library: ILibrary,
|
|
||||||
*,
|
|
||||||
pattern: Pattern | None = None,
|
|
||||||
ports: str | Mapping[str, Port] | None = None,
|
|
||||||
tools: Tool | MutableMapping[str | None, Tool] | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
library: The library from which referenced patterns will be taken,
|
|
||||||
and where new patterns (e.g. generated by the `tools`) will be placed.
|
|
||||||
pattern: The pattern which will be modified by subsequent operations.
|
|
||||||
If `None` (default), a new pattern is created.
|
|
||||||
ports: Allows specifying the initial set of ports, if `pattern` does
|
|
||||||
not already have any ports (or is not provided). May be a string,
|
|
||||||
in which case it is interpreted as a name in `library`.
|
|
||||||
Default `None` (no ports).
|
|
||||||
tools: A mapping of {port: tool} which specifies what `Tool` should be used
|
|
||||||
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
|
|
||||||
are called. Relies on `Tool.planL` and `Tool.render` implementations.
|
|
||||||
name: If specified, `library[name]` is set to `self.pattern`.
|
|
||||||
"""
|
|
||||||
self._dead = False
|
|
||||||
self.paths = defaultdict(list)
|
|
||||||
self.library = library
|
|
||||||
if pattern is not None:
|
|
||||||
self.pattern = pattern
|
|
||||||
else:
|
|
||||||
self.pattern = Pattern()
|
|
||||||
|
|
||||||
if ports is not None:
|
|
||||||
if self.pattern.ports:
|
|
||||||
raise BuildError('Ports supplied for pattern with pre-existing ports!')
|
|
||||||
if isinstance(ports, str):
|
|
||||||
ports = library.abstract(ports).ports
|
|
||||||
|
|
||||||
self.pattern.ports.update(copy.deepcopy(dict(ports)))
|
|
||||||
|
|
||||||
if name is not None:
|
|
||||||
library[name] = self.pattern
|
|
||||||
|
|
||||||
if tools is None:
|
|
||||||
self.tools = {}
|
|
||||||
elif isinstance(tools, Tool):
|
|
||||||
self.tools = {None: tools}
|
|
||||||
else:
|
|
||||||
self.tools = dict(tools)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def interface(
|
|
||||||
cls: type['RenderPather'],
|
|
||||||
source: PortList | Mapping[str, Port] | str,
|
|
||||||
*,
|
|
||||||
library: ILibrary | None = None,
|
|
||||||
tools: Tool | MutableMapping[str | None, Tool] | None = None,
|
|
||||||
in_prefix: str = 'in_',
|
|
||||||
out_prefix: str = '',
|
|
||||||
port_map: dict[str, str] | Sequence[str] | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
) -> 'RenderPather':
|
|
||||||
"""
|
|
||||||
Wrapper for `Pattern.interface()`, which returns a RenderPather instead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
|
||||||
from which to create the interface. May be a pattern name if
|
|
||||||
`library` is provided.
|
|
||||||
library: Library from which existing patterns should be referenced,
|
|
||||||
and to which the new one should be added (if named). If not provided,
|
|
||||||
`source.library` must exist and will be used.
|
|
||||||
tools: `Tool`s which will be used by the pather for generating new wires
|
|
||||||
or waveguides (via `path`/`path_to`/`mpath`).
|
|
||||||
in_prefix: Prepended to port names for newly-created ports with
|
|
||||||
reversed directions compared to the current device.
|
|
||||||
out_prefix: Prepended to port names for ports which are directly
|
|
||||||
copied from the current device.
|
|
||||||
port_map: Specification for ports to copy into the new device:
|
|
||||||
- If `None`, all ports are copied.
|
|
||||||
- If a sequence, only the listed ports are copied
|
|
||||||
- If a mapping, the listed ports (keys) are copied and
|
|
||||||
renamed (to the values).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The new `RenderPather`, with an empty pattern and 2x as many ports as
|
|
||||||
listed in port_map.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
`PortError` if `port_map` contains port names not present in the
|
|
||||||
current device.
|
|
||||||
`PortError` if applying the prefixes results in duplicate port
|
|
||||||
names.
|
|
||||||
"""
|
|
||||||
if library is None:
|
|
||||||
if hasattr(source, 'library') and isinstance(source.library, ILibrary):
|
|
||||||
library = source.library
|
|
||||||
else:
|
|
||||||
raise BuildError('No library provided (and not present in `source.library`')
|
|
||||||
|
|
||||||
if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict):
|
|
||||||
tools = source.tools
|
|
||||||
|
|
||||||
if isinstance(source, str):
|
|
||||||
source = library.abstract(source).ports
|
|
||||||
|
|
||||||
pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map)
|
|
||||||
new = RenderPather(library=library, pattern=pat, name=name, tools=tools)
|
|
||||||
return new
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
s = f'<RenderPather {self.pattern} L({len(self.library)}) {pformat(self.tools)}>'
|
|
||||||
return s
|
|
||||||
|
|
||||||
def plug(
|
|
||||||
self,
|
|
||||||
other: Abstract | str | Pattern | TreeView,
|
|
||||||
map_in: dict[str, str],
|
|
||||||
map_out: dict[str, str | None] | None = None,
|
|
||||||
*,
|
|
||||||
mirrored: bool = False,
|
|
||||||
thru: bool | str = True,
|
|
||||||
set_rotation: bool | None = None,
|
|
||||||
append: bool = False,
|
|
||||||
ok_connections: Iterable[tuple[str, str]] = (),
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P'
|
|
||||||
for any affected ports. This separates any future `RenderStep`s on the
|
|
||||||
same port into a new batch, since the plugged device interferes with drawing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other: An `Abstract`, string, or `Pattern` describing the device to be instatiated.
|
|
||||||
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
|
||||||
port connections between the two devices.
|
|
||||||
map_out: dict of `{'old_name': 'new_name'}` mappings, specifying
|
|
||||||
new names for ports in `other`.
|
|
||||||
mirrored: Enables mirroring `other` across the x axis prior to
|
|
||||||
connecting any ports.
|
|
||||||
thru: If map_in specifies only a single port, `thru` provides a mechainsm
|
|
||||||
to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`,
|
|
||||||
- If True (default), and `other` has only two ports total, and map_out
|
|
||||||
doesn't specify a name for the other port, its name is set to the key
|
|
||||||
in `map_in`, i.e. 'myport'.
|
|
||||||
- If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport').
|
|
||||||
An error is raised if that entry already exists.
|
|
||||||
|
|
||||||
This makes it easy to extend a pattern with simple 2-port devices
|
|
||||||
(e.g. wires) without providing `map_out` each time `plug` is
|
|
||||||
called. See "Examples" above for more info. Default `True`.
|
|
||||||
set_rotation: If the necessary rotation cannot be determined from
|
|
||||||
the ports being connected (i.e. all pairs have at least one
|
|
||||||
port with `rotation=None`), `set_rotation` must be provided
|
|
||||||
to indicate how much `other` should be rotated. Otherwise,
|
|
||||||
`set_rotation` must remain `None`.
|
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
|
||||||
Note that this does not flatten `other`, so its refs will still
|
|
||||||
be refs (now inside `self`).
|
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
|
||||||
any other ptypte. Non-allowed ptype connections will emit a
|
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
|
||||||
`(b, a)`.
|
|
||||||
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
|
||||||
exist in `self.ports` or `other_names`.
|
|
||||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
|
||||||
are applied.
|
|
||||||
`PortError` if the specified port mapping is not achieveable (the ports
|
|
||||||
do not line up)
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping plug() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
other_tgt: Pattern | Abstract
|
|
||||||
if isinstance(other, str):
|
|
||||||
other_tgt = self.library.abstract(other)
|
|
||||||
if append and isinstance(other, Abstract):
|
|
||||||
other_tgt = self.library[other.name]
|
|
||||||
|
|
||||||
# get rid of plugged ports
|
|
||||||
for kk in map_in:
|
|
||||||
if kk in self.paths:
|
|
||||||
self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None))
|
|
||||||
|
|
||||||
plugged = map_in.values()
|
|
||||||
for name, port in other_tgt.ports.items():
|
|
||||||
if name in plugged:
|
|
||||||
continue
|
|
||||||
new_name = map_out.get(name, name) if map_out is not None else name
|
|
||||||
if new_name is not None and new_name in self.paths:
|
|
||||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
|
||||||
|
|
||||||
self.pattern.plug(
|
|
||||||
other = other_tgt,
|
|
||||||
map_in = map_in,
|
|
||||||
map_out = map_out,
|
|
||||||
mirrored = mirrored,
|
|
||||||
thru = thru,
|
|
||||||
set_rotation = set_rotation,
|
|
||||||
append = append,
|
|
||||||
ok_connections = ok_connections,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def place(
|
|
||||||
self,
|
|
||||||
other: Abstract | str,
|
|
||||||
*,
|
|
||||||
offset: ArrayLike = (0, 0),
|
|
||||||
rotation: float = 0,
|
|
||||||
pivot: ArrayLike = (0, 0),
|
|
||||||
mirrored: bool = False,
|
|
||||||
port_map: dict[str, str | None] | None = None,
|
|
||||||
skip_port_check: bool = False,
|
|
||||||
append: bool = False,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P'
|
|
||||||
for any affected ports. This separates any future `RenderStep`s on the
|
|
||||||
same port into a new batch, since the placed device interferes with drawing.
|
|
||||||
|
|
||||||
Note that mirroring is applied before rotation; translation (`offset`) is applied last.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other: An `Abstract` or `Pattern` describing the device to be instatiated.
|
|
||||||
offset: Offset at which to place the instance. Default (0, 0).
|
|
||||||
rotation: Rotation applied to the instance before placement. Default 0.
|
|
||||||
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
|
||||||
Rotation is applied prior to translation (`offset`).
|
|
||||||
mirrored: Whether theinstance should be mirrored across the x axis.
|
|
||||||
Mirroring is applied before translation and rotation.
|
|
||||||
port_map: dict of `{'old_name': 'new_name'}` mappings, specifying
|
|
||||||
new names for ports in the instantiated pattern. New names can be
|
|
||||||
`None`, which will delete those ports.
|
|
||||||
skip_port_check: Can be used to skip the internal call to `check_ports`,
|
|
||||||
in case it has already been performed elsewhere.
|
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
|
||||||
Note that this does not flatten `other`, so its refs will still
|
|
||||||
be refs (now inside `self`).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
`PortError` if any ports specified in `map_in` or `map_out` do not
|
|
||||||
exist in `self.ports` or `other.ports`.
|
|
||||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
|
||||||
are applied.
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping place() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
other_tgt: Pattern | Abstract
|
|
||||||
if isinstance(other, str):
|
|
||||||
other_tgt = self.library.abstract(other)
|
|
||||||
if append and isinstance(other, Abstract):
|
|
||||||
other_tgt = self.library[other.name]
|
|
||||||
|
|
||||||
for name, port in other_tgt.ports.items():
|
|
||||||
new_name = port_map.get(name, name) if port_map is not None else name
|
|
||||||
if new_name is not None and new_name in self.paths:
|
|
||||||
self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None))
|
|
||||||
|
|
||||||
self.pattern.place(
|
|
||||||
other = other_tgt,
|
|
||||||
offset = offset,
|
|
||||||
rotation = rotation,
|
|
||||||
pivot = pivot,
|
|
||||||
mirrored = mirrored,
|
|
||||||
port_map = port_map,
|
|
||||||
skip_port_check = skip_port_check,
|
|
||||||
append = append,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def plugged(
|
|
||||||
self,
|
|
||||||
connections: dict[str, str],
|
|
||||||
) -> Self:
|
|
||||||
for aa, bb in connections.items():
|
|
||||||
porta = self.ports[aa]
|
|
||||||
portb = self.ports[bb]
|
|
||||||
self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None))
|
|
||||||
self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None))
|
|
||||||
PortList.plugged(self, connections)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def path(
|
|
||||||
self,
|
|
||||||
portspec: str,
|
|
||||||
ccw: SupportsBool | None,
|
|
||||||
length: float,
|
|
||||||
*,
|
|
||||||
plug_into: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim
|
|
||||||
of traveling exactly `length` distance.
|
|
||||||
|
|
||||||
The wire will travel `length` distance along the port's axis, an an unspecified
|
|
||||||
(tool-dependent) distance in the perpendicular direction. The output port will
|
|
||||||
be rotated (or not) based on the `ccw` parameter.
|
|
||||||
|
|
||||||
`RenderPather.render` must be called after all paths have been fully planned.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
portspec: The name of the port into which the wire will be plugged.
|
|
||||||
ccw: If `None`, the output should be along the same axis as the input.
|
|
||||||
Otherwise, cast to bool and turn counterclockwise if True
|
|
||||||
and clockwise otherwise.
|
|
||||||
length: The total distance from input to output, along the input's axis only.
|
|
||||||
(There may be a tool-dependent offset along the other axis.)
|
|
||||||
plug_into: If not None, attempts to plug the wire's output port into the provided
|
|
||||||
port on `self`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BuildError if `distance` is too small to fit the bend (if a bend is present).
|
|
||||||
LibraryError if no valid name could be picked for the pattern.
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping path() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
port = self.pattern[portspec]
|
|
||||||
in_ptype = port.ptype
|
|
||||||
port_rot = port.rotation
|
|
||||||
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
|
|
||||||
|
|
||||||
tool = self.tools.get(portspec, self.tools[None])
|
|
||||||
# ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
|
|
||||||
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs)
|
|
||||||
|
|
||||||
# Update port
|
|
||||||
out_port.rotate_around((0, 0), pi + port_rot)
|
|
||||||
out_port.translate(port.offset)
|
|
||||||
|
|
||||||
step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
|
|
||||||
self.paths[portspec].append(step)
|
|
||||||
|
|
||||||
self.pattern.ports[portspec] = out_port.copy()
|
|
||||||
|
|
||||||
if plug_into is not None:
|
|
||||||
self.plugged({portspec: plug_into})
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def pathS(
|
|
||||||
self,
|
|
||||||
portspec: str,
|
|
||||||
length: float,
|
|
||||||
jog: float,
|
|
||||||
*,
|
|
||||||
plug_into: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
|
|
||||||
of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
|
|
||||||
left of direction of travel).
|
|
||||||
|
|
||||||
The output port will have the same orientation as the source port (`portspec`).
|
|
||||||
|
|
||||||
`RenderPather.render` must be called after all paths have been fully planned.
|
|
||||||
|
|
||||||
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
|
|
||||||
raises a NotImplementedError.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
portspec: The name of the port into which the wire will be plugged.
|
|
||||||
jog: Total manhattan distance perpendicular to the direction of travel.
|
|
||||||
Positive values are to the left of the direction of travel.
|
|
||||||
length: The total manhattan distance from input to output, along the input's axis only.
|
|
||||||
(There may be a tool-dependent offset along the other axis.)
|
|
||||||
plug_into: If not None, attempts to plug the wire's output port into the provided
|
|
||||||
port on `self`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
|
|
||||||
LibraryError if no valid name could be picked for the pattern.
|
|
||||||
"""
|
|
||||||
if self._dead:
|
|
||||||
logger.error('Skipping pathS() since device is dead')
|
|
||||||
return self
|
|
||||||
|
|
||||||
port = self.pattern[portspec]
|
|
||||||
in_ptype = port.ptype
|
|
||||||
port_rot = port.rotation
|
|
||||||
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
|
|
||||||
|
|
||||||
tool = self.tools.get(portspec, self.tools[None])
|
|
||||||
|
|
||||||
# check feasibility, get output port and data
|
|
||||||
try:
|
|
||||||
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
|
|
||||||
except NotImplementedError:
|
|
||||||
# Fall back to drawing two L-bends
|
|
||||||
ccw0 = jog > 0
|
|
||||||
kwargs_no_out = (kwargs | {'out_ptype': None})
|
|
||||||
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes
|
|
||||||
jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1]
|
|
||||||
t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
|
|
||||||
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
|
|
||||||
|
|
||||||
kwargs_plug = kwargs | {'plug_into': plug_into}
|
|
||||||
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
|
|
||||||
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
|
|
||||||
return self
|
|
||||||
|
|
||||||
out_port.rotate_around((0, 0), pi + port_rot)
|
|
||||||
out_port.translate(port.offset)
|
|
||||||
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
|
|
||||||
self.paths[portspec].append(step)
|
|
||||||
self.pattern.ports[portspec] = out_port.copy()
|
|
||||||
|
|
||||||
if plug_into is not None:
|
|
||||||
self.plugged({portspec: plug_into})
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
def render(
|
|
||||||
self,
|
|
||||||
append: bool = True,
|
|
||||||
) -> Self:
|
|
||||||
"""
|
|
||||||
Generate the geometry which has been planned out with `path`/`path_to`/etc.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
append: If `True`, the rendered geometry will be directly appended to
|
|
||||||
`self.pattern`. Note that it will not be flattened, so if only one
|
|
||||||
layer of hierarchy is eliminated.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
lib = self.library
|
|
||||||
tool_port_names = ('A', 'B')
|
|
||||||
pat = Pattern()
|
|
||||||
|
|
||||||
def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
|
|
||||||
assert batch[0].tool is not None
|
|
||||||
name = lib << batch[0].tool.render(batch, port_names=tool_port_names)
|
|
||||||
pat.ports[portspec] = batch[0].start_port.copy()
|
|
||||||
if append:
|
|
||||||
pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append)
|
|
||||||
del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened
|
|
||||||
else:
|
|
||||||
pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append)
|
|
||||||
|
|
||||||
for portspec, steps in self.paths.items():
|
|
||||||
batch: list[RenderStep] = []
|
|
||||||
for step in steps:
|
|
||||||
appendable_op = step.opcode in ('L', 'S', 'U')
|
|
||||||
same_tool = batch and step.tool == batch[0].tool
|
|
||||||
|
|
||||||
# If we can't continue a batch, render it
|
|
||||||
if batch and (not appendable_op or not same_tool):
|
|
||||||
render_batch(portspec, batch, append)
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
# batch is emptied already if we couldn't continue it
|
|
||||||
if appendable_op:
|
|
||||||
batch.append(step)
|
|
||||||
|
|
||||||
# Opcodes which break the batch go below this line
|
|
||||||
if not appendable_op and portspec in pat.ports:
|
|
||||||
del pat.ports[portspec]
|
|
||||||
|
|
||||||
#If the last batch didn't end yet
|
|
||||||
if batch:
|
|
||||||
render_batch(portspec, batch, append)
|
|
||||||
|
|
||||||
self.paths.clear()
|
|
||||||
pat.ports.clear()
|
|
||||||
self.pattern.append(pat)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def translate(self, offset: ArrayLike) -> Self:
|
|
||||||
"""
|
|
||||||
Translate the pattern and all ports.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
offset: (x, y) distance to translate by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.translate_elements(offset)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
|
|
||||||
"""
|
|
||||||
Rotate the pattern and all ports.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
angle: angle (radians, counterclockwise) to rotate by
|
|
||||||
pivot: location to rotate around
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.rotate_around(pivot, angle)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def mirror(self, axis: int) -> Self:
|
|
||||||
"""
|
|
||||||
Mirror the pattern and all ports across the specified axis.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
axis: Axis to mirror across (x=0, y=1)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self.pattern.mirror(axis)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def set_dead(self) -> Self:
|
|
||||||
"""
|
|
||||||
Disallows further changes through `plug()` or `place()`.
|
|
||||||
This is meant for debugging:
|
|
||||||
```
|
|
||||||
dev.plug(a, ...)
|
|
||||||
dev.set_dead() # added for debug purposes
|
|
||||||
dev.plug(b, ...) # usually raises an error, but now skipped
|
|
||||||
dev.plug(c, ...) # also skipped
|
|
||||||
dev.pattern.visualize() # shows the device as of the set_dead() call
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
self
|
|
||||||
"""
|
|
||||||
self._dead = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.label)
|
|
||||||
def label(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.label(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.ref)
|
|
||||||
def ref(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.ref(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.polygon)
|
|
||||||
def polygon(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.polygon(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@wraps(Pattern.rect)
|
|
||||||
def rect(self, *args, **kwargs) -> Self:
|
|
||||||
self.pattern.rect(*args, **kwargs)
|
|
||||||
return self
|
|
||||||
|
|
||||||
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,
|
ccw: Turn direction. `True` means counterclockwise, `False` means clockwise,
|
||||||
and `None` means no bend. If `None`, spacing must remain `None` or `0` (default),
|
and `None` means no bend. If `None`, spacing must remain `None` or `0` (default),
|
||||||
Otherwise, spacing must be set to a non-`None` value.
|
Otherwise, spacing must be set to a non-`None` value.
|
||||||
bound_method: Method used for determining the travel distance; see diagram above.
|
bound_type: Method used for determining the travel distance; see diagram above.
|
||||||
Valid values are:
|
Valid values are:
|
||||||
- 'min_extension' or 'emin':
|
- 'min_extension' or 'emin':
|
||||||
The total extension value for the furthest-out port (B in the diagram).
|
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 x- and y- axes. If specifying a position, it is projected onto
|
||||||
the extension direction.
|
the extension direction.
|
||||||
|
|
||||||
bound_value: Value associated with `bound_type`, see above.
|
bound: Value associated with `bound_type`, see above.
|
||||||
spacing: Distance between adjacent channels. Can be scalar, resulting in evenly
|
spacing: Distance between adjacent channels. Can be scalar, resulting in evenly
|
||||||
spaced channels, or a vector with length one less than `ports`, allowing
|
spaced channels, or a vector with length one less than `ports`, allowing
|
||||||
non-uniform spacing.
|
non-uniform spacing.
|
||||||
|
|
@ -84,7 +84,7 @@ def ell(
|
||||||
raise BuildError('Empty port list passed to `ell()`')
|
raise BuildError('Empty port list passed to `ell()`')
|
||||||
|
|
||||||
if ccw is None:
|
if ccw is None:
|
||||||
if spacing is not None and not numpy.isclose(spacing, 0):
|
if spacing is not None and not numpy.allclose(spacing, 0):
|
||||||
raise BuildError('Spacing must be 0 or None when ccw=None')
|
raise BuildError('Spacing must be 0 or None when ccw=None')
|
||||||
spacing = 0
|
spacing = 0
|
||||||
elif spacing is None:
|
elif spacing is None:
|
||||||
|
|
@ -106,7 +106,7 @@ def ell(
|
||||||
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
|
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
|
||||||
+ pformat(port_rotations))
|
+ pformat(port_rotations))
|
||||||
else:
|
else:
|
||||||
if set_rotation is not None:
|
if set_rotation is None:
|
||||||
raise BuildError('set_rotation must be specified if no ports have rotations!')
|
raise BuildError('set_rotation must be specified if no ports have rotations!')
|
||||||
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
||||||
|
|
||||||
|
|
@ -132,8 +132,17 @@ def ell(
|
||||||
if spacing is None:
|
if spacing is None:
|
||||||
ch_offsets = numpy.zeros_like(y_order)
|
ch_offsets = numpy.zeros_like(y_order)
|
||||||
else:
|
else:
|
||||||
|
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
|
||||||
steps = numpy.zeros_like(y_order)
|
steps = numpy.zeros_like(y_order)
|
||||||
steps[1:] = spacing
|
if spacing_arr.size == 1:
|
||||||
|
steps[1:] = spacing_arr[0]
|
||||||
|
elif spacing_arr.size == len(ports) - 1:
|
||||||
|
steps[1:] = spacing_arr
|
||||||
|
else:
|
||||||
|
raise BuildError(
|
||||||
|
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
|
||||||
|
f'got length {spacing_arr.size}'
|
||||||
|
)
|
||||||
ch_offsets = numpy.cumsum(steps)[y_ind]
|
ch_offsets = numpy.cumsum(steps)[y_ind]
|
||||||
|
|
||||||
x_start = rot_offsets[:, 0]
|
x_start = rot_offsets[:, 0]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import gzip
|
||||||
import numpy
|
import numpy
|
||||||
import ezdxf
|
import ezdxf
|
||||||
from ezdxf.enums import TextEntityAlignment
|
from ezdxf.enums import TextEntityAlignment
|
||||||
from ezdxf.entities import LWPolyline, Polyline, Text, Insert
|
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace
|
||||||
|
|
||||||
from .utils import is_gzipped, tmpfile
|
from .utils import is_gzipped, tmpfile
|
||||||
from .. import Pattern, Ref, PatternError, Label
|
from .. import Pattern, Ref, PatternError, Label
|
||||||
|
|
@ -55,8 +55,7 @@ def write(
|
||||||
tuple: (1, 2) -> '1.2'
|
tuple: (1, 2) -> '1.2'
|
||||||
str: '1.2' -> '1.2' (no change)
|
str: '1.2' -> '1.2' (no change)
|
||||||
|
|
||||||
DXF does not support shape repetition (only block repeptition). Please call
|
Shape repetitions are expanded into individual DXF entities.
|
||||||
library.wrap_repeated_shapes() before writing to file.
|
|
||||||
|
|
||||||
Other functions you may want to call:
|
Other functions you may want to call:
|
||||||
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
||||||
|
|
@ -193,8 +192,37 @@ def read(
|
||||||
|
|
||||||
top_name, top_pat = _read_block(msp)
|
top_name, top_pat = _read_block(msp)
|
||||||
mlib = Library({top_name: top_pat})
|
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:
|
for bb in lib.blocks:
|
||||||
if bb.name == '*Model_Space':
|
if bb.is_any_layout:
|
||||||
|
continue
|
||||||
|
if bb.name.startswith('_') and bb.name not in referenced:
|
||||||
continue
|
continue
|
||||||
name, pat = _read_block(bb)
|
name, pat = _read_block(bb)
|
||||||
mlib[name] = pat
|
mlib[name] = pat
|
||||||
|
|
@ -213,32 +241,60 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
||||||
if isinstance(element, LWPolyline | Polyline):
|
if isinstance(element, LWPolyline | Polyline):
|
||||||
if isinstance(element, LWPolyline):
|
if isinstance(element, LWPolyline):
|
||||||
points = numpy.asarray(element.get_points())
|
points = numpy.asarray(element.get_points())
|
||||||
elif isinstance(element, Polyline):
|
is_closed = element.closed
|
||||||
|
else:
|
||||||
points = numpy.asarray([pp.xyz for pp in element.points()])
|
points = numpy.asarray([pp.xyz for pp in element.points()])
|
||||||
|
is_closed = element.is_closed
|
||||||
attr = element.dxfattribs()
|
attr = element.dxfattribs()
|
||||||
layer = attr.get('layer', DEFAULT_LAYER)
|
layer = attr.get('layer', DEFAULT_LAYER)
|
||||||
|
|
||||||
if points.shape[1] == 2:
|
width = 0
|
||||||
raise PatternError('Invalid or unimplemented polygon?')
|
if isinstance(element, LWPolyline):
|
||||||
|
# ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge)
|
||||||
if points.shape[1] > 2:
|
if points.shape[1] >= 5:
|
||||||
if (points[0, 2] != points[:, 2]).any():
|
if (points[:, 4] != 0).any():
|
||||||
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
|
raise PatternError('LWPolyline has bulge (not yet representable in masque!)')
|
||||||
if points.shape[1] == 4 and (points[:, 3] != 0).any():
|
if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any():
|
||||||
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
|
raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)')
|
||||||
|
|
||||||
width = points[0, 2]
|
width = points[0, 2]
|
||||||
|
elif points.shape[1] == 3:
|
||||||
|
# width used to be in column 2
|
||||||
|
width = points[0, 2]
|
||||||
|
|
||||||
if width == 0:
|
if width == 0:
|
||||||
width = attr.get('const_width', 0)
|
width = attr.get('const_width', 0)
|
||||||
|
|
||||||
|
verts = points[:, :2]
|
||||||
|
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
|
||||||
|
verts = numpy.vstack((verts, verts[0]))
|
||||||
|
|
||||||
shape: Path | Polygon
|
shape: Path | Polygon
|
||||||
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
|
if width == 0 and is_closed:
|
||||||
shape = Polygon(vertices=points[:-1, :2])
|
# Use Polygon if it has at least 3 unique vertices
|
||||||
|
shape_verts = verts[:-1] if len(verts) > 1 else verts
|
||||||
|
if len(shape_verts) >= 3:
|
||||||
|
shape = Polygon(vertices=shape_verts)
|
||||||
else:
|
else:
|
||||||
shape = Path(width=width, vertices=points[:, :2])
|
shape = Path(width=width, vertices=verts)
|
||||||
|
else:
|
||||||
|
shape = Path(width=width, vertices=verts)
|
||||||
|
|
||||||
pat.shapes[layer].append(shape)
|
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):
|
elif isinstance(element, Text):
|
||||||
args = dict(
|
args = dict(
|
||||||
offset=numpy.asarray(element.get_placement()[1])[:2],
|
offset=numpy.asarray(element.get_placement()[1])[:2],
|
||||||
|
|
@ -273,12 +329,57 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'column_count' in attr:
|
if 'column_count' in attr:
|
||||||
args['repetition'] = Grid(
|
col_spacing = attr['column_spacing']
|
||||||
a_vector=(attr['column_spacing'], 0),
|
row_spacing = attr['row_spacing']
|
||||||
b_vector=(0, attr['row_spacing']),
|
col_count = attr['column_count']
|
||||||
a_count=attr['column_count'],
|
row_count = attr['row_count']
|
||||||
b_count=attr['row_count'],
|
local_x = numpy.array((col_spacing, 0.0))
|
||||||
|
local_y = numpy.array((0.0, row_spacing))
|
||||||
|
inv_rot = rotation_matrix_2d(-rotation)
|
||||||
|
|
||||||
|
candidates = (
|
||||||
|
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
|
||||||
|
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
|
||||||
)
|
)
|
||||||
|
repetition = None
|
||||||
|
for a_vector, b_vector, a_count, b_count in candidates:
|
||||||
|
rotated_a = rotation_matrix_2d(rotation) @ a_vector
|
||||||
|
rotated_b = rotation_matrix_2d(rotation) @ b_vector
|
||||||
|
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
|
||||||
|
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
|
||||||
|
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
|
||||||
|
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
|
||||||
|
and a_count == col_count
|
||||||
|
and b_count == row_count):
|
||||||
|
repetition = Grid(
|
||||||
|
a_vector=a_vector,
|
||||||
|
b_vector=b_vector,
|
||||||
|
a_count=a_count,
|
||||||
|
b_count=b_count,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
|
||||||
|
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
|
||||||
|
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
|
||||||
|
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
|
||||||
|
and b_count == col_count
|
||||||
|
and a_count == row_count):
|
||||||
|
repetition = Grid(
|
||||||
|
a_vector=a_vector,
|
||||||
|
b_vector=b_vector,
|
||||||
|
a_count=a_count,
|
||||||
|
b_count=b_count,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if repetition is None:
|
||||||
|
repetition = Grid(
|
||||||
|
a_vector=inv_rot @ local_x,
|
||||||
|
b_vector=inv_rot @ local_y,
|
||||||
|
a_count=col_count,
|
||||||
|
b_count=row_count,
|
||||||
|
)
|
||||||
|
args['repetition'] = repetition
|
||||||
pat.ref(**args)
|
pat.ref(**args)
|
||||||
else:
|
else:
|
||||||
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
|
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
|
||||||
|
|
@ -303,15 +404,23 @@ def _mrefs_to_drefs(
|
||||||
elif isinstance(rep, Grid):
|
elif isinstance(rep, Grid):
|
||||||
a = rep.a_vector
|
a = rep.a_vector
|
||||||
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||||
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
|
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
|
||||||
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
|
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
|
||||||
if rotated_a[1] == 0 and rotated_b[0] == 0:
|
# which ARE then rotated by the block reference's rotation.
|
||||||
|
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
|
||||||
|
# AND the grid is already manhattan.
|
||||||
|
|
||||||
|
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
|
||||||
|
rotated_a = rotation_matrix_2d(ref.rotation) @ a
|
||||||
|
rotated_b = rotation_matrix_2d(ref.rotation) @ b
|
||||||
|
|
||||||
|
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
|
||||||
attribs['column_count'] = rep.a_count
|
attribs['column_count'] = rep.a_count
|
||||||
attribs['row_count'] = rep.b_count
|
attribs['row_count'] = rep.b_count
|
||||||
attribs['column_spacing'] = rotated_a[0]
|
attribs['column_spacing'] = rotated_a[0]
|
||||||
attribs['row_spacing'] = rotated_b[1]
|
attribs['row_spacing'] = rotated_b[1]
|
||||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
||||||
elif rotated_a[0] == 0 and rotated_b[1] == 0:
|
elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8):
|
||||||
attribs['column_count'] = rep.b_count
|
attribs['column_count'] = rep.b_count
|
||||||
attribs['row_count'] = rep.a_count
|
attribs['row_count'] = rep.a_count
|
||||||
attribs['column_spacing'] = rotated_b[0]
|
attribs['column_spacing'] = rotated_b[0]
|
||||||
|
|
@ -344,16 +453,23 @@ def _shapes_to_elements(
|
||||||
for layer, sseq in shapes.items():
|
for layer, sseq in shapes.items():
|
||||||
attribs = dict(layer=_mlayer2dxf(layer))
|
attribs = dict(layer=_mlayer2dxf(layer))
|
||||||
for shape in sseq:
|
for shape in sseq:
|
||||||
|
displacements = [numpy.zeros(2)]
|
||||||
if shape.repetition is not None:
|
if shape.repetition is not None:
|
||||||
raise PatternError(
|
displacements = shape.repetition.displacements
|
||||||
'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():
|
for polygon in shape.to_polygons():
|
||||||
xy_open = polygon.vertices
|
xy_open = polygon.vertices + dd
|
||||||
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
|
block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs)
|
||||||
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
|
|
||||||
|
|
||||||
|
|
||||||
def _labels_to_texts(
|
def _labels_to_texts(
|
||||||
|
|
@ -363,11 +479,17 @@ def _labels_to_texts(
|
||||||
for layer, lseq in labels.items():
|
for layer, lseq in labels.items():
|
||||||
attribs = dict(layer=_mlayer2dxf(layer))
|
attribs = dict(layer=_mlayer2dxf(layer))
|
||||||
for label in lseq:
|
for label in lseq:
|
||||||
xy = label.offset
|
if label.repetition is None:
|
||||||
block.add_text(
|
block.add_text(
|
||||||
label.string,
|
label.string,
|
||||||
dxfattribs=attribs
|
dxfattribs=attribs
|
||||||
).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT)
|
).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||||
|
else:
|
||||||
|
for dd in label.repetition.displacements:
|
||||||
|
block.add_text(
|
||||||
|
label.string,
|
||||||
|
dxfattribs=attribs
|
||||||
|
).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||||
|
|
||||||
|
|
||||||
def _mlayer2dxf(layer: layer_t) -> str:
|
def _mlayer2dxf(layer: layer_t) -> str:
|
||||||
|
|
|
||||||
10
masque/file/gdsii/__init__.py
Normal file
10
masque/file/gdsii/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
878
masque/file/gdsii/arrow.py
Normal file
878
masque/file/gdsii/arrow.py
Normal file
|
|
@ -0,0 +1,878 @@
|
||||||
|
# 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')
|
||||||
|
|
@ -22,8 +22,6 @@ Notes:
|
||||||
from typing import IO, cast, Any
|
from typing import IO, cast, Any
|
||||||
from collections.abc import Iterable, Mapping, Callable
|
from collections.abc import Iterable, Mapping, Callable
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
import io
|
|
||||||
import mmap
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import gzip
|
import gzip
|
||||||
|
|
@ -35,12 +33,12 @@ from numpy.typing import ArrayLike, NDArray
|
||||||
import klamath
|
import klamath
|
||||||
from klamath import records
|
from klamath import records
|
||||||
|
|
||||||
from .utils import is_gzipped, tmpfile
|
from ..utils import is_gzipped, tmpfile
|
||||||
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||||
from ..shapes import Polygon, Path
|
from ...shapes import Polygon, Path, RectCollection
|
||||||
from ..repetition import Grid
|
from ...repetition import Grid
|
||||||
from ..utils import layer_t, annotations_t
|
from ...utils import layer_t, annotations_t
|
||||||
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
from ...library import Library, ILibrary
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -82,7 +80,7 @@ def write(
|
||||||
datatype is chosen to be `shape.layer[1]` if available,
|
datatype is chosen to be `shape.layer[1]` if available,
|
||||||
otherwise `0`
|
otherwise `0`
|
||||||
|
|
||||||
GDS does not support shape repetition (only cell repeptition). Please call
|
GDS does not support shape repetition (only cell repetition). Please call
|
||||||
`library.wrap_repeated_shapes()` before writing to file.
|
`library.wrap_repeated_shapes()` before writing to file.
|
||||||
|
|
||||||
Other functions you may want to call:
|
Other functions you may want to call:
|
||||||
|
|
@ -323,26 +321,40 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_
|
||||||
else:
|
else:
|
||||||
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
||||||
|
|
||||||
mpath = Path(
|
vertices = gpath.xy.astype(float)
|
||||||
vertices=gpath.xy.astype(float),
|
annotations = _properties_to_annotations(gpath.properties)
|
||||||
|
cap_extensions = None
|
||||||
|
if cap == Path.Cap.SquareCustom:
|
||||||
|
cap_extensions = numpy.asarray(gpath.extension, dtype=float)
|
||||||
|
|
||||||
|
if raw_mode:
|
||||||
|
mpath = Path._from_raw(
|
||||||
|
vertices=vertices,
|
||||||
width=gpath.width,
|
width=gpath.width,
|
||||||
cap=cap,
|
cap=cap,
|
||||||
offset=numpy.zeros(2),
|
cap_extensions=cap_extensions,
|
||||||
annotations=_properties_to_annotations(gpath.properties),
|
annotations=annotations,
|
||||||
raw=raw_mode,
|
)
|
||||||
|
else:
|
||||||
|
mpath = Path(
|
||||||
|
vertices=vertices,
|
||||||
|
width=gpath.width,
|
||||||
|
cap=cap,
|
||||||
|
cap_extensions=cap_extensions,
|
||||||
|
offset=numpy.zeros(2),
|
||||||
|
annotations=annotations,
|
||||||
)
|
)
|
||||||
if cap == Path.Cap.SquareCustom:
|
|
||||||
mpath.cap_extensions = gpath.extension
|
|
||||||
return gpath.layer, mpath
|
return gpath.layer, mpath
|
||||||
|
|
||||||
|
|
||||||
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
|
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
|
||||||
return boundary.layer, Polygon(
|
vertices = boundary.xy[:-1].astype(float)
|
||||||
vertices=boundary.xy[:-1].astype(float),
|
annotations = _properties_to_annotations(boundary.properties)
|
||||||
offset=numpy.zeros(2),
|
if raw_mode:
|
||||||
annotations=_properties_to_annotations(boundary.properties),
|
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
|
||||||
raw=raw_mode,
|
else:
|
||||||
)
|
poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations)
|
||||||
|
return boundary.layer, poly
|
||||||
|
|
||||||
|
|
||||||
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
|
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
|
||||||
|
|
@ -453,7 +465,7 @@ def _shapes_to_elements(
|
||||||
|
|
||||||
extension: tuple[int, int]
|
extension: tuple[int, int]
|
||||||
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
||||||
extension = tuple(shape.cap_extensions) # type: ignore
|
extension = tuple(rint_cast(shape.cap_extensions))
|
||||||
else:
|
else:
|
||||||
extension = (0, 0)
|
extension = (0, 0)
|
||||||
|
|
||||||
|
|
@ -466,6 +478,20 @@ def _shapes_to_elements(
|
||||||
properties=properties,
|
properties=properties,
|
||||||
)
|
)
|
||||||
elements.append(path)
|
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):
|
elif isinstance(shape, Polygon):
|
||||||
polygon = shape
|
polygon = shape
|
||||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||||
|
|
@ -514,112 +540,6 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
|
||||||
return texts
|
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(
|
def check_valid_names(
|
||||||
names: Iterable[str],
|
names: Iterable[str],
|
||||||
max_length: int = 32,
|
max_length: int = 32,
|
||||||
|
|
@ -632,6 +552,7 @@ def check_valid_names(
|
||||||
max_length: Max allowed length
|
max_length: Max allowed length
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
names = tuple(names)
|
||||||
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
|
allowed_chars = set(string.ascii_letters + string.digits + '_?$')
|
||||||
|
|
||||||
bad_chars = [
|
bad_chars = [
|
||||||
|
|
@ -648,7 +569,7 @@ def check_valid_names(
|
||||||
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
logger.error('Names contain invalid characters:\n' + pformat(bad_chars))
|
||||||
|
|
||||||
if bad_lengths:
|
if bad_lengths:
|
||||||
logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars))
|
logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths))
|
||||||
|
|
||||||
if bad_chars or bad_lengths:
|
if bad_chars or bad_lengths:
|
||||||
raise LibraryError('Library contains invalid names, see log above')
|
raise LibraryError('Library contains invalid names, see log above')
|
||||||
414
masque/file/gdsii/lazy.py
Normal file
414
masque/file/gdsii/lazy.py
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
541
masque/file/gdsii/lazy_arrow.py
Normal file
541
masque/file/gdsii/lazy_arrow.py
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
"""
|
||||||
|
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)
|
||||||
170
masque/file/gdsii/lazy_write.py
Normal file
170
masque/file/gdsii/lazy_write.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
626
masque/file/gdsii/perf.py
Normal file
626
masque/file/gdsii/perf.py
Normal file
|
|
@ -0,0 +1,626 @@
|
||||||
|
"""
|
||||||
|
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)
|
layer, data_type = _mlayer2oas(layer_num)
|
||||||
lib.layers += [
|
lib.layers += [
|
||||||
fatrec.LayerName(
|
fatrec.LayerName(
|
||||||
nstring=name,
|
nstring = name,
|
||||||
layer_interval=(layer, layer),
|
layer_interval = (layer, layer),
|
||||||
type_interval=(data_type, data_type),
|
type_interval = (data_type, data_type),
|
||||||
is_textlayer=tt,
|
is_textlayer = tt,
|
||||||
)
|
)
|
||||||
for tt in (True, False)]
|
for tt in (True, False)]
|
||||||
|
|
||||||
|
|
@ -182,8 +182,8 @@ def writefile(
|
||||||
Args:
|
Args:
|
||||||
library: A {name: Pattern} mapping of patterns to write.
|
library: A {name: Pattern} mapping of patterns to write.
|
||||||
filename: Filename to save to.
|
filename: Filename to save to.
|
||||||
*args: passed to `oasis.write`
|
*args: passed to `oasis.build()`
|
||||||
**kwargs: passed to `oasis.write`
|
**kwargs: passed to `oasis.build()`
|
||||||
"""
|
"""
|
||||||
path = pathlib.Path(filename)
|
path = pathlib.Path(filename)
|
||||||
|
|
||||||
|
|
@ -213,9 +213,9 @@ def readfile(
|
||||||
Will automatically decompress gzipped files.
|
Will automatically decompress gzipped files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Filename to save to.
|
filename: Filename to load from.
|
||||||
*args: passed to `oasis.read`
|
*args: passed to `oasis.read()`
|
||||||
**kwargs: passed to `oasis.read`
|
**kwargs: passed to `oasis.read()`
|
||||||
"""
|
"""
|
||||||
path = pathlib.Path(filename)
|
path = pathlib.Path(filename)
|
||||||
if is_gzipped(path):
|
if is_gzipped(path):
|
||||||
|
|
@ -286,11 +286,11 @@ def read(
|
||||||
|
|
||||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||||
pat.polygon(
|
pat.polygon(
|
||||||
vertices=vertices,
|
vertices = vertices,
|
||||||
layer=element.get_layer_tuple(),
|
layer = element.get_layer_tuple(),
|
||||||
offset=element.get_xy(),
|
offset = element.get_xy(),
|
||||||
annotations=annotations,
|
annotations = annotations,
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
)
|
)
|
||||||
elif isinstance(element, fatrec.Path):
|
elif isinstance(element, fatrec.Path):
|
||||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
|
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)
|
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||||
pat.path(
|
pat.path(
|
||||||
vertices=vertices,
|
vertices = vertices,
|
||||||
layer=element.get_layer_tuple(),
|
layer = element.get_layer_tuple(),
|
||||||
offset=element.get_xy(),
|
offset = element.get_xy(),
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
annotations=annotations,
|
annotations = annotations,
|
||||||
width=element.get_half_width() * 2,
|
width = element.get_half_width() * 2,
|
||||||
cap=cap,
|
cap = cap,
|
||||||
**path_args,
|
**path_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -325,11 +325,11 @@ def read(
|
||||||
height = element.get_height()
|
height = element.get_height()
|
||||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||||
pat.polygon(
|
pat.polygon(
|
||||||
layer=element.get_layer_tuple(),
|
layer = element.get_layer_tuple(),
|
||||||
offset=element.get_xy(),
|
offset = element.get_xy(),
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||||
annotations=annotations,
|
annotations = annotations,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(element, fatrec.Trapezoid):
|
elif isinstance(element, fatrec.Trapezoid):
|
||||||
|
|
@ -440,11 +440,11 @@ def read(
|
||||||
else:
|
else:
|
||||||
string = str_or_ref.string
|
string = str_or_ref.string
|
||||||
pat.label(
|
pat.label(
|
||||||
layer=element.get_layer_tuple(),
|
layer = element.get_layer_tuple(),
|
||||||
offset=element.get_xy(),
|
offset = element.get_xy(),
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
annotations=annotations,
|
annotations = annotations,
|
||||||
string=string,
|
string = string,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
@ -549,33 +549,35 @@ def _shapes_to_elements(
|
||||||
offset = rint_cast(shape.offset + rep_offset)
|
offset = rint_cast(shape.offset + rep_offset)
|
||||||
radius = rint_cast(shape.radius)
|
radius = rint_cast(shape.radius)
|
||||||
circle = fatrec.Circle(
|
circle = fatrec.Circle(
|
||||||
layer=layer,
|
layer = layer,
|
||||||
datatype=datatype,
|
datatype = datatype,
|
||||||
radius=cast('int', radius),
|
radius = cast('int', radius),
|
||||||
x=offset[0],
|
x = offset[0],
|
||||||
y=offset[1],
|
y = offset[1],
|
||||||
properties=properties,
|
properties = properties,
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
)
|
)
|
||||||
elements.append(circle)
|
elements.append(circle)
|
||||||
elif isinstance(shape, Path):
|
elif isinstance(shape, Path):
|
||||||
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
|
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
|
||||||
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
|
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
|
||||||
half_width = rint_cast(shape.width / 2)
|
half_width = rint_cast(shape.width / 2)
|
||||||
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
|
path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup
|
||||||
|
if path_type is None:
|
||||||
|
raise PatternError(f'OASIS writer does not support path cap {shape.cap}')
|
||||||
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
|
extension_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)
|
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
|
||||||
path = fatrec.Path(
|
path = fatrec.Path(
|
||||||
layer=layer,
|
layer = layer,
|
||||||
datatype=datatype,
|
datatype = datatype,
|
||||||
point_list=cast('Sequence[Sequence[int]]', deltas),
|
point_list = cast('Sequence[Sequence[int]]', deltas),
|
||||||
half_width=cast('int', half_width),
|
half_width = cast('int', half_width),
|
||||||
x=xy[0],
|
x = xy[0],
|
||||||
y=xy[1],
|
y = xy[1],
|
||||||
extension_start=extension_start, # TODO implement multiple cap types?
|
extension_start = extension_start, # TODO implement multiple cap types?
|
||||||
extension_end=extension_end,
|
extension_end = extension_end,
|
||||||
properties=properties,
|
properties = properties,
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
)
|
)
|
||||||
elements.append(path)
|
elements.append(path)
|
||||||
else:
|
else:
|
||||||
|
|
@ -583,13 +585,13 @@ def _shapes_to_elements(
|
||||||
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
||||||
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
||||||
elements.append(fatrec.Polygon(
|
elements.append(fatrec.Polygon(
|
||||||
layer=layer,
|
layer = layer,
|
||||||
datatype=datatype,
|
datatype = datatype,
|
||||||
x=xy[0],
|
x = xy[0],
|
||||||
y=xy[1],
|
y = xy[1],
|
||||||
point_list=cast('list[list[int]]', points),
|
point_list = cast('list[list[int]]', points),
|
||||||
properties=properties,
|
properties = properties,
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
))
|
))
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
|
|
@ -606,13 +608,13 @@ def _labels_to_texts(
|
||||||
xy = rint_cast(label.offset + rep_offset)
|
xy = rint_cast(label.offset + rep_offset)
|
||||||
properties = annotations_to_properties(label.annotations)
|
properties = annotations_to_properties(label.annotations)
|
||||||
texts.append(fatrec.Text(
|
texts.append(fatrec.Text(
|
||||||
layer=layer,
|
layer = layer,
|
||||||
datatype=datatype,
|
datatype = datatype,
|
||||||
x=xy[0],
|
x = xy[0],
|
||||||
y=xy[1],
|
y = xy[1],
|
||||||
string=label.string,
|
string = label.string,
|
||||||
properties=properties,
|
properties = properties,
|
||||||
repetition=repetition,
|
repetition = repetition,
|
||||||
))
|
))
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
|
|
@ -622,10 +624,12 @@ def repetition_fata2masq(
|
||||||
) -> Repetition | None:
|
) -> Repetition | None:
|
||||||
mrep: Repetition | None
|
mrep: Repetition | None
|
||||||
if isinstance(rep, fatamorgana.GridRepetition):
|
if isinstance(rep, fatamorgana.GridRepetition):
|
||||||
mrep = Grid(a_vector=rep.a_vector,
|
mrep = Grid(
|
||||||
b_vector=rep.b_vector,
|
a_vector = rep.a_vector,
|
||||||
a_count=rep.a_count,
|
b_vector = rep.b_vector,
|
||||||
b_count=rep.b_count)
|
a_count = rep.a_count,
|
||||||
|
b_count = rep.b_count,
|
||||||
|
)
|
||||||
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
||||||
displacements = numpy.cumsum(numpy.column_stack((
|
displacements = numpy.cumsum(numpy.column_stack((
|
||||||
rep.x_displacements,
|
rep.x_displacements,
|
||||||
|
|
@ -647,14 +651,19 @@ def repetition_masq2fata(
|
||||||
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
||||||
if isinstance(rep, Grid):
|
if isinstance(rep, Grid):
|
||||||
a_vector = rint_cast(rep.a_vector)
|
a_vector = rint_cast(rep.a_vector)
|
||||||
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
|
a_count = int(rep.a_count)
|
||||||
a_count = rint_cast(rep.a_count)
|
if rep.b_count > 1:
|
||||||
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
|
b_vector = rint_cast(rep.b_vector)
|
||||||
|
b_count = int(rep.b_count)
|
||||||
|
else:
|
||||||
|
b_vector = None
|
||||||
|
b_count = None
|
||||||
|
|
||||||
frep = fatamorgana.GridRepetition(
|
frep = fatamorgana.GridRepetition(
|
||||||
a_vector=cast('list[int]', a_vector),
|
a_vector = a_vector,
|
||||||
b_vector=cast('list[int] | None', b_vector),
|
b_vector = b_vector,
|
||||||
a_count=cast('int', a_count),
|
a_count = a_count,
|
||||||
b_count=cast('int | None', b_count),
|
b_count = b_count,
|
||||||
)
|
)
|
||||||
offset = (0, 0)
|
offset = (0, 0)
|
||||||
elif isinstance(rep, Arbitrary):
|
elif isinstance(rep, Arbitrary):
|
||||||
|
|
@ -707,13 +716,9 @@ def properties_to_annotations(
|
||||||
string = repr(value)
|
string = repr(value)
|
||||||
logger.warning(f'Converting property value for key ({key}) to string ({string})')
|
logger.warning(f'Converting property value for key ({key}) to string ({string})')
|
||||||
values.append(string)
|
values.append(string)
|
||||||
annotations[key] = values
|
annotations.setdefault(key, []).extend(values)
|
||||||
return annotations
|
return annotations
|
||||||
|
|
||||||
properties = [fatrec.Property(key, vals, is_standard=False)
|
|
||||||
for key, vals in annotations.items()]
|
|
||||||
return properties
|
|
||||||
|
|
||||||
|
|
||||||
def check_valid_names(
|
def check_valid_names(
|
||||||
names: Iterable[str],
|
names: Iterable[str],
|
||||||
|
|
|
||||||
|
|
@ -10,25 +10,59 @@ import svgwrite # type: ignore
|
||||||
|
|
||||||
from .utils import mangle_name
|
from .utils import mangle_name
|
||||||
from .. import Pattern
|
from .. import Pattern
|
||||||
|
from ..utils import rotation_matrix_2d
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def writefile(
|
||||||
library: Mapping[str, Pattern],
|
library: Mapping[str, Pattern],
|
||||||
top: str,
|
top: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
custom_attributes: bool = False,
|
custom_attributes: bool = False,
|
||||||
|
annotate_ports: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Write a Pattern to an SVG file, by first calling .polygonize() on it
|
Write a Pattern to an SVG file, by first calling .polygonize() on a detached
|
||||||
|
materialized copy
|
||||||
to change the shapes into polygons, and then writing patterns as SVG
|
to change the shapes into polygons, and then writing patterns as SVG
|
||||||
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
|
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
|
||||||
as <use> elements.
|
as <use> elements.
|
||||||
|
|
||||||
Note that this function modifies the Pattern.
|
|
||||||
|
|
||||||
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
|
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
|
||||||
is written to the relevant elements.
|
is written to the relevant elements.
|
||||||
|
|
||||||
|
|
@ -40,17 +74,21 @@ def writefile(
|
||||||
prior to calling this function.
|
prior to calling this function.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: Pattern to write to file. Modified by this function.
|
library: Mapping of pattern names to patterns.
|
||||||
|
top: Name of the top-level pattern to render.
|
||||||
filename: Filename to write to.
|
filename: Filename to write to.
|
||||||
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
||||||
SVG elements.
|
SVG elements.
|
||||||
|
annotate_ports: If True, draw an arrow for each port (similar to
|
||||||
|
`Pattern.visualize(..., ports=True)`).
|
||||||
"""
|
"""
|
||||||
pattern = library[top]
|
detached = _detached_library(library)
|
||||||
|
pattern = detached[top]
|
||||||
|
|
||||||
# Polygonize pattern
|
# Polygonize pattern
|
||||||
pattern.polygonize()
|
pattern.polygonize()
|
||||||
|
|
||||||
bounds = pattern.get_bounds(library=library)
|
bounds = pattern.get_bounds(library=detached)
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||||
|
|
@ -63,10 +101,11 @@ def writefile(
|
||||||
# Create file
|
# Create file
|
||||||
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
||||||
debug=(not custom_attributes))
|
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
|
# Now create a group for each pattern and add in any Boundary and Use elements
|
||||||
for name, pat in library.items():
|
for name, pat in detached.items():
|
||||||
svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red')
|
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
|
||||||
|
|
||||||
for layer, shapes in pat.shapes.items():
|
for layer, shapes in pat.shapes.items():
|
||||||
for shape in shapes:
|
for shape in shapes:
|
||||||
|
|
@ -79,16 +118,37 @@ def writefile(
|
||||||
|
|
||||||
svg_group.add(path)
|
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():
|
for target, refs in pat.refs.items():
|
||||||
if target is None:
|
if target is None:
|
||||||
continue
|
continue
|
||||||
for ref in refs:
|
for ref in refs:
|
||||||
transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})'
|
transform = _ref_to_svg_transform(ref)
|
||||||
use = svg.use(href='#' + mangle_name(target), transform=transform)
|
use = svg.use(href='#' + svg_ids[target], transform=transform)
|
||||||
svg_group.add(use)
|
svg_group.add(use)
|
||||||
|
|
||||||
svg.defs.add(svg_group)
|
svg.defs.add(svg_group)
|
||||||
svg.add(svg.use(href='#' + mangle_name(top)))
|
svg.add(svg.use(href='#' + svg_ids[top]))
|
||||||
svg.save()
|
svg.save()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -103,21 +163,21 @@ def writefile_inverted(
|
||||||
box and drawing the polygons with reverse vertex order inside it, all within
|
box and drawing the polygons with reverse vertex order inside it, all within
|
||||||
one `<path>` element.
|
one `<path>` element.
|
||||||
|
|
||||||
Note that this function modifies the Pattern.
|
|
||||||
|
|
||||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||||
prior to calling this function.
|
prior to calling this function.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: Pattern to write to file. Modified by this function.
|
library: Mapping of pattern names to patterns.
|
||||||
|
top: Name of the top-level pattern to render.
|
||||||
filename: Filename to write to.
|
filename: Filename to write to.
|
||||||
"""
|
"""
|
||||||
pattern = library[top]
|
detached = _detached_library(library)
|
||||||
|
pattern = detached[top]
|
||||||
|
|
||||||
# Polygonize and flatten pattern
|
# Polygonize and flatten pattern
|
||||||
pattern.polygonize().flatten(library)
|
pattern.polygonize().flatten(detached)
|
||||||
|
|
||||||
bounds = pattern.get_bounds(library=library)
|
bounds = pattern.get_bounds(library=detached)
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ def preflight(
|
||||||
Run a standard set of useful operations and checks, usually done immediately prior
|
Run a standard set of useful operations and checks, usually done immediately prior
|
||||||
to writing to a file (or immediately after reading).
|
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:
|
Args:
|
||||||
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
|
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
|
||||||
Default True. Useful for reproducible builds.
|
Default True. Useful for reproducible builds.
|
||||||
|
|
@ -75,7 +81,8 @@ def preflight(
|
||||||
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
|
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
|
||||||
|
|
||||||
if prune_empty_patterns:
|
if prune_empty_patterns:
|
||||||
pruned = lib.prune_empty()
|
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
|
||||||
|
pruned = lib.prune_empty(dangling=prune_dangling)
|
||||||
if pruned:
|
if pruned:
|
||||||
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
||||||
logger.debug('Pruned: ' + pformat(pruned))
|
logger.debug('Pruned: ' + pformat(pruned))
|
||||||
|
|
@ -144,7 +151,11 @@ def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]:
|
||||||
path = pathlib.Path(path)
|
path = pathlib.Path(path)
|
||||||
suffixes = ''.join(path.suffixes)
|
suffixes = ''.join(path.suffixes)
|
||||||
with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream:
|
with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream:
|
||||||
|
try:
|
||||||
yield tmp_stream
|
yield tmp_stream
|
||||||
|
except Exception:
|
||||||
|
pathlib.Path(tmp_stream.name).unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.move(tmp_stream.name, path)
|
shutil.move(tmp_stream.name, path)
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .repetition import Repetition
|
from .repetition import Repetition
|
||||||
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
||||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded
|
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable
|
||||||
from .traits import AnnotatableImpl
|
from .traits import AnnotatableImpl
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable):
|
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
|
||||||
"""
|
"""
|
||||||
A text annotation with a position (but no size; it is not drawn)
|
A text annotation with a position (but no size; it is not drawn)
|
||||||
"""
|
"""
|
||||||
|
|
@ -53,17 +53,36 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations if annotations is not None else {}
|
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:
|
def __copy__(self) -> Self:
|
||||||
return type(self)(
|
return type(self)(
|
||||||
string=self.string,
|
string=self.string,
|
||||||
offset=self.offset.copy(),
|
offset=self.offset.copy(),
|
||||||
repetition=self.repetition,
|
repetition=self.repetition,
|
||||||
|
annotations=copy.copy(self.annotations),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
new._offset = self._offset.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
|
new._annotations = copy.deepcopy(self._annotations, memo)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def __lt__(self, other: 'Label') -> bool:
|
def __lt__(self, other: 'Label') -> bool:
|
||||||
|
|
@ -76,6 +95,8 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
||||||
return annotations_lt(self.annotations, other.annotations)
|
return annotations_lt(self.annotations, other.annotations)
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
if type(self) is not type(other):
|
||||||
|
return False
|
||||||
return (
|
return (
|
||||||
self.string == other.string
|
self.string == other.string
|
||||||
and numpy.array_equal(self.offset, other.offset)
|
and numpy.array_equal(self.offset, other.offset)
|
||||||
|
|
@ -96,10 +117,34 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
||||||
"""
|
"""
|
||||||
pivot = numpy.asarray(pivot, dtype=float)
|
pivot = numpy.asarray(pivot, dtype=float)
|
||||||
self.translate(-pivot)
|
self.translate(-pivot)
|
||||||
|
if self.repetition is not None:
|
||||||
|
self.repetition.rotate(rotation)
|
||||||
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
||||||
self.translate(+pivot)
|
self.translate(+pivot)
|
||||||
return self
|
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]:
|
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
Return the bounds of the label.
|
Return the bounds of the label.
|
||||||
|
|
|
||||||
29
masque/library/__init__.py
Normal file
29
masque/library/__init__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""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,98 +1,36 @@
|
||||||
"""
|
"""Core library interfaces."""
|
||||||
Library classes for managing unique name->pattern mappings and deferred loading or execution.
|
from __future__ import annotations
|
||||||
|
|
||||||
Classes include:
|
from abc import ABCMeta, abstractmethod
|
||||||
- `ILibraryView`: Defines a general interface for read-only name->pattern mappings.
|
from collections import defaultdict
|
||||||
- `LibraryView`: An implementation of `ILibraryView` backed by an arbitrary `Mapping`.
|
from collections.abc import Callable, Mapping, MutableMapping, Sequence
|
||||||
Can be used to wrap any arbitrary `Mapping` to give it all the functionality in `ILibraryView`
|
from graphlib import CycleError, TopologicalSorter
|
||||||
- `ILibrary`: Defines a general interface for mutable name->pattern mappings.
|
from pprint import pformat
|
||||||
- `Library`: An implementation of `ILibrary` backed by an arbitrary `MutableMapping`.
|
from typing import TYPE_CHECKING, Self, cast
|
||||||
Can be used to wrap any arbitrary `MutableMapping` to give it all the functionality in `ILibrary`.
|
import copy
|
||||||
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 logging
|
||||||
import re
|
import re
|
||||||
import copy
|
|
||||||
from pprint import pformat
|
|
||||||
from collections import defaultdict
|
|
||||||
from abc import ABCMeta, abstractmethod
|
|
||||||
from graphlib import TopologicalSorter
|
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
|
||||||
|
|
||||||
from .error import LibraryError, PatternError
|
from ..abstract import Abstract
|
||||||
from .utils import layer_t, apply_transforms
|
from ..error import LibraryError, PatternError
|
||||||
from .shapes import Shape, Polygon
|
from ..pattern import Pattern, map_layers
|
||||||
from .label import Label
|
from ..shapes import Polygon, Shape
|
||||||
from .abstract import Abstract
|
from ..utils import apply_transforms, layer_t
|
||||||
from .pattern import map_layers
|
from .utils import SINGLE_USE_PREFIX, TreeView, b64suffix, dangling_mode_t, _rename_patterns, visitor_function_t
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .pattern import Pattern
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
|
from ..label import Label
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
Interface for a read-only library.
|
Interface for a read-only library.
|
||||||
|
|
@ -109,7 +47,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return '<ILibraryView with keys\n' + pformat(list(self.keys())) + '>'
|
return '<ILibraryView with keys\n' + pformat(list(self.keys())) + '>'
|
||||||
|
|
||||||
def abstract_view(self) -> 'AbstractView':
|
def abstract_view(self) -> AbstractView:
|
||||||
"""
|
"""
|
||||||
Returns:
|
Returns:
|
||||||
An AbstractView into this library
|
An AbstractView into this library
|
||||||
|
|
@ -128,6 +66,15 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
return Abstract(name=name, ports=self[name].ports)
|
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(
|
def dangling_refs(
|
||||||
self,
|
self,
|
||||||
tops: str | Sequence[str] | None = None,
|
tops: str | Sequence[str] | None = None,
|
||||||
|
|
@ -177,6 +124,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
|
|
||||||
if isinstance(tops, str):
|
if isinstance(tops, str):
|
||||||
tops = (tops,)
|
tops = (tops,)
|
||||||
|
tops = set(tops)
|
||||||
|
skip |= tops # don't re-visit tops
|
||||||
|
|
||||||
# Get referenced patterns for all tops
|
# Get referenced patterns for all tops
|
||||||
targets = set()
|
targets = set()
|
||||||
|
|
@ -186,16 +135,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
# Perform recursive lookups, but only once for each name
|
# Perform recursive lookups, but only once for each name
|
||||||
for target in targets - skip:
|
for target in targets - skip:
|
||||||
assert target is not None
|
assert target is not None
|
||||||
|
skip.add(target)
|
||||||
if target in self:
|
if target in self:
|
||||||
targets |= self.referenced_patterns(target, skip=skip)
|
targets |= self.referenced_patterns(target, skip=skip)
|
||||||
skip.add(target)
|
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
def subtree(
|
def subtree(
|
||||||
self,
|
self,
|
||||||
tops: str | Sequence[str],
|
tops: str | Sequence[str],
|
||||||
) -> 'ILibraryView':
|
) -> ILibraryView:
|
||||||
"""
|
"""
|
||||||
Return a new `ILibraryView`, containing only the specified patterns and the patterns they
|
Return a new `ILibraryView`, containing only the specified patterns and the patterns they
|
||||||
reference (recursively).
|
reference (recursively).
|
||||||
|
|
@ -213,6 +162,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||||
keep |= set(tops)
|
keep |= set(tops)
|
||||||
|
|
||||||
|
from .mapping import LibraryView # noqa: PLC0415
|
||||||
|
|
||||||
filtered = {kk: vv for kk, vv in self.items() if kk in keep}
|
filtered = {kk: vv for kk, vv in self.items() if kk in keep}
|
||||||
new = LibraryView(filtered)
|
new = LibraryView(filtered)
|
||||||
return new
|
return new
|
||||||
|
|
@ -264,7 +215,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
tops: str | Sequence[str],
|
tops: str | Sequence[str],
|
||||||
flatten_ports: bool = False,
|
flatten_ports: bool = False,
|
||||||
dangling_ok: bool = False,
|
dangling_ok: bool = False,
|
||||||
) -> dict[str, 'Pattern']:
|
) -> dict[str, Pattern]:
|
||||||
"""
|
"""
|
||||||
Returns copies of all `tops` patterns with all refs
|
Returns copies of all `tops` patterns with all refs
|
||||||
removed and replaced with equivalent shapes.
|
removed and replaced with equivalent shapes.
|
||||||
|
|
@ -291,8 +242,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def flatten_single(name: str) -> None:
|
def flatten_single(name: str) -> None:
|
||||||
flattened[name] = None
|
flattened[name] = None
|
||||||
pat = self[name].deepcopy()
|
pat = self[name].deepcopy()
|
||||||
|
refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items())
|
||||||
|
|
||||||
for target in pat.refs:
|
for target, refs in refs_by_target:
|
||||||
if target is None:
|
if target is None:
|
||||||
continue
|
continue
|
||||||
if dangling_ok and target not in self:
|
if dangling_ok and target not in self:
|
||||||
|
|
@ -303,10 +255,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
target_pat = flattened[target]
|
target_pat = flattened[target]
|
||||||
if target_pat is None:
|
if target_pat is None:
|
||||||
raise PatternError(f'Circular reference in {name} to {target}')
|
raise PatternError(f'Circular reference in {name} to {target}')
|
||||||
if target_pat.is_empty(): # avoid some extra allocations
|
ports_only = flatten_ports and bool(target_pat.ports)
|
||||||
|
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for ref in pat.refs[target]:
|
for ref in refs:
|
||||||
|
if flatten_ports and ref.repetition is not None and target_pat.ports:
|
||||||
|
raise PatternError(
|
||||||
|
f'Cannot flatten ports from repeated ref to {target!r}; '
|
||||||
|
'flatten with flatten_ports=False or expand/rename the ports manually first.'
|
||||||
|
)
|
||||||
p = ref.as_pattern(pattern=target_pat)
|
p = ref.as_pattern(pattern=target_pat)
|
||||||
if not flatten_ports:
|
if not flatten_ports:
|
||||||
p.ports.clear()
|
p.ports.clear()
|
||||||
|
|
@ -403,7 +361,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}')
|
raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}')
|
||||||
return tops[0]
|
return tops[0]
|
||||||
|
|
||||||
def top_pattern(self) -> 'Pattern':
|
def top_pattern(self) -> Pattern:
|
||||||
"""
|
"""
|
||||||
Shorthand for self[self.top()]
|
Shorthand for self[self.top()]
|
||||||
|
|
||||||
|
|
@ -412,9 +370,24 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
return self[self.top()]
|
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(
|
def dfs(
|
||||||
self,
|
self,
|
||||||
pattern: 'Pattern',
|
pattern: Pattern,
|
||||||
visit_before: visitor_function_t | None = None,
|
visit_before: visitor_function_t | None = None,
|
||||||
visit_after: visitor_function_t | None = None,
|
visit_after: visitor_function_t | None = None,
|
||||||
*,
|
*,
|
||||||
|
|
@ -466,9 +439,11 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
memo = {}
|
memo = {}
|
||||||
|
|
||||||
if transform is None or transform is True:
|
if transform is None or transform is True:
|
||||||
transform = numpy.zeros(4)
|
transform = numpy.array([0, 0, 0, 0, 1], dtype=float)
|
||||||
elif transform is not False:
|
elif transform is not False:
|
||||||
transform = numpy.asarray(transform, dtype=float)
|
transform = numpy.asarray(transform, dtype=float)
|
||||||
|
if transform.size == 4:
|
||||||
|
transform = numpy.append(transform, 1.0)
|
||||||
|
|
||||||
original_pattern = pattern
|
original_pattern = pattern
|
||||||
|
|
||||||
|
|
@ -490,12 +465,12 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
|
|
||||||
for ref_transform in ref_transforms:
|
for ref_transform in ref_transforms:
|
||||||
self.dfs(
|
self.dfs(
|
||||||
pattern=self[target],
|
pattern = self[target],
|
||||||
visit_before=visit_before,
|
visit_before = visit_before,
|
||||||
visit_after=visit_after,
|
visit_after = visit_after,
|
||||||
hierarchy=hierarchy + (target,),
|
hierarchy = hierarchy + (target,),
|
||||||
transform=ref_transform,
|
transform = ref_transform,
|
||||||
memo=memo,
|
memo = memo,
|
||||||
)
|
)
|
||||||
|
|
||||||
if visit_after is not None:
|
if visit_after is not None:
|
||||||
|
|
@ -511,50 +486,99 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
raise LibraryError('visit_* functions returned a new `Pattern` object'
|
raise LibraryError('visit_* functions returned a new `Pattern` object'
|
||||||
' but no top-level name was provided in `hierarchy`')
|
' but no top-level name was provided in `hierarchy`')
|
||||||
|
|
||||||
|
del cast('ILibrary', self)[name]
|
||||||
cast('ILibrary', self)[name] = pattern
|
cast('ILibrary', self)[name] = pattern
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def child_graph(self) -> dict[str, set[str | None]]:
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
Return a mapping from pattern name to a set of all child patterns
|
Return a mapping from pattern name to a set of all child patterns
|
||||||
(patterns it references).
|
(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:
|
Returns:
|
||||||
Mapping from pattern name to a set of all pattern names it references.
|
Mapping from pattern name to a set of all pattern names it references.
|
||||||
"""
|
"""
|
||||||
graph = {name: set(pat.refs.keys()) for name, pat in self.items()}
|
graph, dangling_refs = self._raw_child_graph()
|
||||||
|
if dangling == 'error':
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(dangling_refs, 'building child graph')
|
||||||
|
return graph
|
||||||
|
if dangling == 'ignore':
|
||||||
|
existing = set(graph)
|
||||||
|
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||||
|
|
||||||
|
for target in dangling_refs:
|
||||||
|
graph.setdefault(target, set())
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
def parent_graph(self) -> dict[str, set[str]]:
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
Return a mapping from pattern name to a set of all parent patterns
|
Return a mapping from pattern name to a set of all parent patterns
|
||||||
(patterns which reference it).
|
(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:
|
Returns:
|
||||||
Mapping from pattern name to a set of all patterns which reference it.
|
Mapping from pattern name to a set of all patterns which reference it.
|
||||||
"""
|
"""
|
||||||
igraph: dict[str, set[str]] = {name: set() for name in self}
|
child_graph, dangling_refs = self._raw_child_graph()
|
||||||
for name, pat in self.items():
|
if dangling == 'error' and dangling_refs:
|
||||||
for child, reflist in pat.refs.items():
|
raise self._dangling_refs_error(dangling_refs, 'building parent graph')
|
||||||
if reflist and child is not None:
|
|
||||||
igraph[child].add(name)
|
existing = set(child_graph)
|
||||||
|
igraph: dict[str, set[str]] = {name: set() for name in existing}
|
||||||
|
for parent, children in child_graph.items():
|
||||||
|
for child in children:
|
||||||
|
if child in existing:
|
||||||
|
igraph[child].add(parent)
|
||||||
|
elif dangling == 'include':
|
||||||
|
igraph.setdefault(child, set()).add(parent)
|
||||||
return igraph
|
return igraph
|
||||||
|
|
||||||
def child_order(self) -> list[str]:
|
def child_order(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return a topologically sorted list of all contained pattern names.
|
Return a topologically sorted list of graph node names.
|
||||||
Child (referenced) patterns will appear before their parents.
|
Child (referenced) patterns will appear before their parents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dangling: Passed to `child_graph()`.
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
Topologically sorted list of pattern names.
|
Topologically sorted list of pattern names.
|
||||||
"""
|
"""
|
||||||
return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order()))
|
try:
|
||||||
|
return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order()))
|
||||||
|
except CycleError as exc:
|
||||||
|
cycle = exc.args[1] if len(exc.args) > 1 else None
|
||||||
|
if cycle is None:
|
||||||
|
raise LibraryError('Cycle found while building child order') from exc
|
||||||
|
raise LibraryError(f'Cycle found while building child order: {cycle}') from exc
|
||||||
|
|
||||||
def find_refs_local(
|
def find_refs_local(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
parent_graph: dict[str, set[str]] | None = None,
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||||
"""
|
"""
|
||||||
Find the location and orientation of all refs pointing to `name`.
|
Find the location and orientation of all refs pointing to `name`.
|
||||||
|
|
@ -567,6 +591,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
The provided graph may be for a superset of `self` (i.e. it may
|
The provided graph may be for a superset of `self` (i.e. it may
|
||||||
contain additional patterns which are not present in self; they
|
contain additional patterns which are not present in self; they
|
||||||
will be ignored).
|
will be ignored).
|
||||||
|
dangling: How refs to missing targets are handled if `parent_graph`
|
||||||
|
is not provided. `'include'` also allows querying missing names.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Mapping of {parent_name: transform_list}, where transform_list
|
Mapping of {parent_name: transform_list}, where transform_list
|
||||||
|
|
@ -575,8 +601,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
instances = defaultdict(list)
|
instances = defaultdict(list)
|
||||||
if parent_graph is None:
|
if parent_graph is None:
|
||||||
parent_graph = self.parent_graph()
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
for parent in parent_graph[name]:
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return instances
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return instances
|
||||||
|
|
||||||
|
for parent in parent_graph.get(name, set()):
|
||||||
if parent not in self: # parent_graph may be a for a superset of self
|
if parent not in self: # parent_graph may be a for a superset of self
|
||||||
continue
|
continue
|
||||||
for ref in self[parent].refs[name]:
|
for ref in self[parent].refs[name]:
|
||||||
|
|
@ -589,6 +625,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
name: str,
|
name: str,
|
||||||
order: list[str] | None = None,
|
order: list[str] | None = None,
|
||||||
parent_graph: dict[str, set[str]] | None = None,
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
"""
|
"""
|
||||||
Find the absolute (top-level) location and orientation of all refs (including
|
Find the absolute (top-level) location and orientation of all refs (including
|
||||||
|
|
@ -605,18 +642,28 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
The provided graph may be for a superset of `self` (i.e. it may
|
The provided graph may be for a superset of `self` (i.e. it may
|
||||||
contain additional patterns which are not present in self; they
|
contain additional patterns which are not present in self; they
|
||||||
will be ignored).
|
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:
|
Returns:
|
||||||
Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form
|
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
|
`(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray
|
||||||
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
|
with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`.
|
||||||
"""
|
"""
|
||||||
if name not in self:
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
return {}
|
|
||||||
if order is None:
|
if order is None:
|
||||||
order = self.child_order()
|
order = self.child_order(dangling=graph_mode)
|
||||||
if parent_graph is None:
|
if parent_graph is None:
|
||||||
parent_graph = self.parent_graph()
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return {}
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {}
|
||||||
|
|
||||||
self_keys = set(self.keys())
|
self_keys = set(self.keys())
|
||||||
|
|
||||||
|
|
@ -625,16 +672,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
NDArray[numpy.float64]
|
NDArray[numpy.float64]
|
||||||
]]]
|
]]]
|
||||||
transforms = defaultdict(list)
|
transforms = defaultdict(list)
|
||||||
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items():
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||||
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
for next_name in order:
|
for next_name in order:
|
||||||
if next_name not in transforms:
|
if next_name not in transforms:
|
||||||
continue
|
continue
|
||||||
if not parent_graph[next_name] & self_keys:
|
if not parent_graph.get(next_name, set()) & self_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph)
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||||
inners = transforms.pop(next_name)
|
inners = transforms.pop(next_name)
|
||||||
for parent, outer in outers.items():
|
for parent, outer in outers.items():
|
||||||
for path, inner in inners:
|
for path, inner in inners:
|
||||||
|
|
@ -670,7 +717,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def __setitem__(
|
def __setitem__(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
value: 'Pattern | Callable[[], Pattern]',
|
value: Pattern | Callable[[], Pattern],
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -679,9 +726,36 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
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(
|
def rename(
|
||||||
self,
|
self,
|
||||||
old_name: str,
|
old_name: str,
|
||||||
|
|
@ -700,6 +774,11 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
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]
|
self[new_name] = self[old_name]
|
||||||
del self[old_name]
|
del self[old_name]
|
||||||
if move_references:
|
if move_references:
|
||||||
|
|
@ -724,6 +803,9 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
|
if old_target == new_target:
|
||||||
|
return self
|
||||||
|
|
||||||
for pattern in self.values():
|
for pattern in self.values():
|
||||||
if old_target in pattern.refs:
|
if old_target in pattern.refs:
|
||||||
pattern.refs[new_target].extend(pattern.refs[old_target])
|
pattern.refs[new_target].extend(pattern.refs[old_target])
|
||||||
|
|
@ -752,7 +834,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
pattern.labels = map_layers(pattern.labels, map_layer)
|
pattern.labels = map_layers(pattern.labels, map_layer)
|
||||||
return self
|
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,
|
Convenience method to create an empty pattern, add it to the library,
|
||||||
and return both the pattern and name.
|
and return both the pattern and name.
|
||||||
|
|
@ -763,15 +845,15 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
(name, pattern) tuple
|
(name, pattern) tuple
|
||||||
"""
|
"""
|
||||||
from .pattern import Pattern
|
from ..pattern import Pattern # noqa: PLC0415
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
self[name] = pat
|
self[name] = pat
|
||||||
return name, pat
|
return name, pat
|
||||||
|
|
||||||
def add(
|
def add(
|
||||||
self,
|
self,
|
||||||
other: Mapping[str, 'Pattern'],
|
other: Mapping[str, Pattern],
|
||||||
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
|
rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns,
|
||||||
mutate_other: bool = False,
|
mutate_other: bool = False,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -797,18 +879,25 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
(default).
|
(default).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged
|
A mapping of `{old_name: new_name}` for all names in `other` which were
|
||||||
names map to themselves.
|
renamed while being added. Unchanged names are omitted.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
|
`LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`.
|
||||||
"""
|
"""
|
||||||
from .pattern import map_targets
|
from ..pattern import map_targets # noqa: PLC0415
|
||||||
|
from .mapping import Library # noqa: PLC0415
|
||||||
|
|
||||||
duplicates = set(self.keys()) & set(other.keys())
|
duplicates = set(self.keys()) & set(other.keys())
|
||||||
|
|
||||||
if not duplicates:
|
if not duplicates:
|
||||||
for key in other:
|
if mutate_other:
|
||||||
self._merge(key, other, key)
|
temp = other
|
||||||
|
else:
|
||||||
|
temp = Library(copy.deepcopy(dict(other)))
|
||||||
|
|
||||||
|
for key in temp:
|
||||||
|
self._merge(key, temp, key)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if mutate_other:
|
if mutate_other:
|
||||||
|
|
@ -847,6 +936,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Raises:
|
Raises:
|
||||||
LibraryError if there is more than one topcell in `other`.
|
LibraryError if there is more than one topcell in `other`.
|
||||||
"""
|
"""
|
||||||
|
from .mapping import LibraryView # noqa: PLC0415
|
||||||
|
|
||||||
if len(other) == 1:
|
if len(other) == 1:
|
||||||
name = next(iter(other))
|
name = next(iter(other))
|
||||||
else:
|
else:
|
||||||
|
|
@ -863,7 +954,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
new_name = rename_map.get(name, name)
|
new_name = rename_map.get(name, name)
|
||||||
return new_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
|
Perform the same operation as `__lshift__` / `<<`, but return an `Abstract` instead
|
||||||
of just the pattern's name.
|
of just the pattern's name.
|
||||||
|
|
@ -909,7 +1000,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
# This currently simplifies globally (same shape in different patterns is
|
# This currently simplifies globally (same shape in different patterns is
|
||||||
# merged into the same ref target).
|
# merged into the same ref target).
|
||||||
|
|
||||||
from .pattern import Pattern
|
from ..pattern import Pattern # noqa: PLC0415
|
||||||
|
|
||||||
if exclude_types is None:
|
if exclude_types is None:
|
||||||
exclude_types = ()
|
exclude_types = ()
|
||||||
|
|
@ -918,6 +1009,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def label2name(label: tuple) -> str: # noqa: ARG001
|
def label2name(label: tuple) -> str: # noqa: ARG001
|
||||||
return self.get_name(SINGLE_USE_PREFIX + 'shape')
|
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_counts: MutableMapping[tuple, int] = defaultdict(int)
|
||||||
shape_funcs = {}
|
shape_funcs = {}
|
||||||
|
|
||||||
|
|
@ -934,6 +1037,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
shape_counts[label] += 1
|
shape_counts[label] += 1
|
||||||
|
|
||||||
shape_pats = {}
|
shape_pats = {}
|
||||||
|
target_names = {}
|
||||||
for label, count in shape_counts.items():
|
for label, count in shape_counts.items():
|
||||||
if count < threshold:
|
if count < threshold:
|
||||||
continue
|
continue
|
||||||
|
|
@ -942,6 +1046,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
shape_pat = Pattern()
|
shape_pat = Pattern()
|
||||||
shape_pat.shapes[label[-1]] += [shape_func()]
|
shape_pat.shapes[label[-1]] += [shape_func()]
|
||||||
shape_pats[label] = shape_pat
|
shape_pats[label] = shape_pat
|
||||||
|
target_names[label] = reserve_target_name(label)
|
||||||
|
|
||||||
# ## Second pass ##
|
# ## Second pass ##
|
||||||
for pat in tuple(self.values()):
|
for pat in tuple(self.values()):
|
||||||
|
|
@ -966,14 +1071,14 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
# For repeated shapes, create a `Pattern` holding a normalized shape object,
|
# 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
|
# 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.
|
# we should delete the `pat.shapes` entries for which we made `Ref`s.
|
||||||
shapes_to_remove = []
|
|
||||||
for label, shape_entries in shape_table.items():
|
for label, shape_entries in shape_table.items():
|
||||||
layer = label[-1]
|
layer = label[-1]
|
||||||
target = label2name(label)
|
target = target_names[label]
|
||||||
|
shapes_to_remove = []
|
||||||
for ii, values in shape_entries:
|
for ii, values in shape_entries:
|
||||||
offset, scale, rotation, mirror_x = values
|
offset, scale, rotation, mirror_x = values
|
||||||
pat.ref(target=target, offset=offset, scale=scale,
|
pat.ref(target=target, offset=offset, scale=scale,
|
||||||
rotation=rotation, mirrored=(mirror_x, False))
|
rotation=rotation, mirrored=mirror_x)
|
||||||
shapes_to_remove.append(ii)
|
shapes_to_remove.append(ii)
|
||||||
|
|
||||||
# Remove any shapes for which we have created refs.
|
# Remove any shapes for which we have created refs.
|
||||||
|
|
@ -981,13 +1086,13 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
del pat.shapes[layer][ii]
|
del pat.shapes[layer][ii]
|
||||||
|
|
||||||
for ll, pp in shape_pats.items():
|
for ll, pp in shape_pats.items():
|
||||||
self[label2name(ll)] = pp
|
self[target_names[ll]] = pp
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def wrap_repeated_shapes(
|
def wrap_repeated_shapes(
|
||||||
self,
|
self,
|
||||||
name_func: Callable[['Pattern', Shape | Label], str] | None = None,
|
name_func: Callable[[Pattern, Shape | Label], str] | None = None,
|
||||||
) -> Self:
|
) -> Self:
|
||||||
"""
|
"""
|
||||||
Wraps all shapes and labels with a non-`None` `repetition` attribute
|
Wraps all shapes and labels with a non-`None` `repetition` attribute
|
||||||
|
|
@ -1002,7 +1107,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
from .pattern import Pattern
|
from ..pattern import Pattern # noqa: PLC0415
|
||||||
|
|
||||||
if name_func is None:
|
if name_func is None:
|
||||||
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
|
def name_func(_pat: Pattern, _shape: Shape | Label) -> str:
|
||||||
|
|
@ -1036,6 +1141,25 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
|
|
||||||
return self
|
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(
|
def subtree(
|
||||||
self,
|
self,
|
||||||
tops: str | Sequence[str],
|
tops: str | Sequence[str],
|
||||||
|
|
@ -1065,17 +1189,19 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
def prune_empty(
|
def prune_empty(
|
||||||
self,
|
self,
|
||||||
repeat: bool = True,
|
repeat: bool = True,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
|
Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
|
repeat: Also recursively delete any patterns which only contain(ed) empty patterns.
|
||||||
|
dangling: Passed to `parent_graph()`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A set containing the names of all deleted patterns
|
A set containing the names of all deleted patterns
|
||||||
"""
|
"""
|
||||||
parent_graph = self.parent_graph()
|
parent_graph = self.parent_graph(dangling=dangling)
|
||||||
empty = {name for name, pat in self.items() if pat.is_empty()}
|
empty = {name for name, pat in self.items() if pat.is_empty()}
|
||||||
trimmed = set()
|
trimmed = set()
|
||||||
while empty:
|
while empty:
|
||||||
|
|
@ -1112,246 +1238,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
del pat.refs[key]
|
del pat.refs[key]
|
||||||
return self
|
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]):
|
class AbstractView(Mapping[str, Abstract]):
|
||||||
"""
|
"""
|
||||||
A read-only mapping from names to `Abstract` objects.
|
A read-only mapping from names to `Abstract` objects.
|
||||||
|
|
@ -1371,20 +1257,3 @@ class AbstractView(Mapping[str, Abstract]):
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.library.__len__()
|
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)
|
|
||||||
742
masque/library/build.py
Normal file
742
masque/library/build.py
Normal file
|
|
@ -0,0 +1,742 @@
|
||||||
|
"""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)
|
||||||
169
masque/library/lazy.py
Normal file
169
masque/library/lazy.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""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)')
|
||||||
112
masque/library/mapping.py
Normal file
112
masque/library/mapping.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""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
|
||||||
531
masque/library/overlay.py
Normal file
531
masque/library/overlay.py
Normal file
|
|
@ -0,0 +1,531 @@
|
||||||
|
"""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)
|
||||||
124
masque/library/utils.py
Normal file
124
masque/library/utils.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""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,6 +26,7 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab
|
||||||
from .ports import Port, PortList
|
from .ports import Port, PortList
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,8 +38,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
or provide equivalent functions.
|
or provide equivalent functions.
|
||||||
|
|
||||||
`Pattern` also stores a dict of `Port`s, which can be used to "snap" together points.
|
`Pattern` also stores a dict of `Port`s, which can be used to "snap" together points.
|
||||||
See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes
|
See `Pattern.plug()` and `Pattern.place()`, as well as `builder.Pather`
|
||||||
`builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`.
|
and `ports.PortsList`.
|
||||||
|
|
||||||
For convenience, ports can be read out using square brackets:
|
For convenience, ports can be read out using square brackets:
|
||||||
- `pattern['A'] == Port((0, 0), 0)`
|
- `pattern['A'] == Port((0, 0), 0)`
|
||||||
|
|
@ -171,7 +172,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def __copy__(self) -> 'Pattern':
|
def __copy__(self) -> 'Pattern':
|
||||||
logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!')
|
logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! '
|
||||||
|
'Consider using .deepcopy() if this was not intended.')
|
||||||
new = Pattern(
|
new = Pattern(
|
||||||
annotations=copy.deepcopy(self.annotations),
|
annotations=copy.deepcopy(self.annotations),
|
||||||
ports=copy.deepcopy(self.ports),
|
ports=copy.deepcopy(self.ports),
|
||||||
|
|
@ -198,7 +200,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
def __lt__(self, other: 'Pattern') -> bool:
|
def __lt__(self, other: 'Pattern') -> bool:
|
||||||
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||||
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
|
||||||
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
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))
|
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
||||||
|
|
||||||
|
|
@ -212,7 +214,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return refs_ours < refs_theirs
|
return refs_ours < refs_theirs
|
||||||
|
|
||||||
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||||
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
|
||||||
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
||||||
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
||||||
|
|
||||||
|
|
@ -221,21 +223,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
for _, _, layer in self_layerkeys:
|
for _, _, layer in self_layerkeys:
|
||||||
shapes_ours = tuple(sorted(self.shapes[layer]))
|
shapes_ours = tuple(sorted(self.shapes[layer]))
|
||||||
shapes_theirs = tuple(sorted(self.shapes[layer]))
|
shapes_theirs = tuple(sorted(other.shapes[layer]))
|
||||||
if shapes_ours != shapes_theirs:
|
if shapes_ours != shapes_theirs:
|
||||||
return shapes_ours < shapes_theirs
|
return shapes_ours < shapes_theirs
|
||||||
|
|
||||||
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||||
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
|
||||||
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
||||||
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
||||||
|
|
||||||
if self_txtlayerkeys != other_txtlayerkeys:
|
if self_txtlayerkeys != other_txtlayerkeys:
|
||||||
return self_txtlayerkeys < other_txtlayerkeys
|
return self_txtlayerkeys < other_txtlayerkeys
|
||||||
|
|
||||||
for _, _, layer in self_layerkeys:
|
for _, _, layer in self_txtlayerkeys:
|
||||||
labels_ours = tuple(sorted(self.labels[layer]))
|
labels_ours = tuple(sorted(self.labels[layer]))
|
||||||
labels_theirs = tuple(sorted(self.labels[layer]))
|
labels_theirs = tuple(sorted(other.labels[layer]))
|
||||||
if labels_ours != labels_theirs:
|
if labels_ours != labels_theirs:
|
||||||
return labels_ours < labels_theirs
|
return labels_ours < labels_theirs
|
||||||
|
|
||||||
|
|
@ -252,7 +254,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
||||||
other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist]
|
other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist]
|
||||||
self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets))
|
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))
|
other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets))
|
||||||
|
|
||||||
|
|
@ -266,7 +268,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
||||||
other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems]
|
other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems]
|
||||||
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers))
|
||||||
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers))
|
||||||
|
|
||||||
|
|
@ -275,21 +277,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
for _, _, layer in self_layerkeys:
|
for _, _, layer in self_layerkeys:
|
||||||
shapes_ours = tuple(sorted(self.shapes[layer]))
|
shapes_ours = tuple(sorted(self.shapes[layer]))
|
||||||
shapes_theirs = tuple(sorted(self.shapes[layer]))
|
shapes_theirs = tuple(sorted(other.shapes[layer]))
|
||||||
if shapes_ours != shapes_theirs:
|
if shapes_ours != shapes_theirs:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
||||||
other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems]
|
other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems]
|
||||||
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers))
|
||||||
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers))
|
||||||
|
|
||||||
if self_txtlayerkeys != other_txtlayerkeys:
|
if self_txtlayerkeys != other_txtlayerkeys:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for _, _, layer in self_layerkeys:
|
for _, _, layer in self_txtlayerkeys:
|
||||||
labels_ours = tuple(sorted(self.labels[layer]))
|
labels_ours = tuple(sorted(self.labels[layer]))
|
||||||
labels_theirs = tuple(sorted(self.labels[layer]))
|
labels_theirs = tuple(sorted(other.labels[layer]))
|
||||||
if labels_ours != labels_theirs:
|
if labels_ours != labels_theirs:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -347,6 +349,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
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():
|
for target, rseq in other_pattern.refs.items():
|
||||||
self.refs[target].extend(rseq)
|
self.refs[target].extend(rseq)
|
||||||
for layer, sseq in other_pattern.shapes.items():
|
for layer, sseq in other_pattern.shapes.items():
|
||||||
|
|
@ -357,14 +369,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
if other_pattern.annotations is not None:
|
if other_pattern.annotations is not None:
|
||||||
if self.annotations is None:
|
if self.annotations is None:
|
||||||
self.annotations = {}
|
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)
|
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)
|
self.ports.update(other_pattern.ports)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
@ -499,6 +504,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
]
|
]
|
||||||
return polys
|
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]:
|
def referenced_patterns(self) -> set[str | None]:
|
||||||
"""
|
"""
|
||||||
Get all pattern namers referenced by this pattern. Non-recursive.
|
Get all pattern namers referenced by this pattern. Non-recursive.
|
||||||
|
|
@ -635,6 +695,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
|
||||||
cast('Positionable', entry).translate(offset)
|
cast('Positionable', entry).translate(offset)
|
||||||
|
self._log_bulk_update(f"translate({offset!r})")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_elements(self, c: float) -> Self:
|
def scale_elements(self, c: float) -> Self:
|
||||||
|
|
@ -688,7 +749,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Rotate the Pattern around the a location.
|
Extrinsic transformation: Rotate the Pattern around the a location in the
|
||||||
|
container's coordinate system. This affects all elements' offsets and
|
||||||
|
their repetition grids.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pivot: (x, y) location to rotate around
|
pivot: (x, y) location to rotate around
|
||||||
|
|
@ -702,11 +765,14 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
self.rotate_elements(rotation)
|
self.rotate_elements(rotation)
|
||||||
self.rotate_element_centers(rotation)
|
self.rotate_element_centers(rotation)
|
||||||
self.translate_elements(+pivot)
|
self.translate_elements(+pivot)
|
||||||
|
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def rotate_element_centers(self, rotation: float) -> Self:
|
def rotate_element_centers(self, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Rotate the offsets of all shapes, labels, refs, and ports around (0, 0)
|
Extrinsic transformation part: Rotate the offsets and repetition grids of all
|
||||||
|
shapes, labels, refs, and ports around (0, 0) in the container's
|
||||||
|
coordinate system.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||||
|
|
@ -717,11 +783,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||||
old_offset = cast('Positionable', entry).offset
|
old_offset = cast('Positionable', entry).offset
|
||||||
cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_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
|
return self
|
||||||
|
|
||||||
def rotate_elements(self, rotation: float) -> Self:
|
def rotate_elements(self, rotation: float) -> Self:
|
||||||
"""
|
"""
|
||||||
Rotate each shape, ref, and port around its origin (offset)
|
Intrinsic transformation part: Rotate each shape, ref, label, and port around its
|
||||||
|
origin (offset) in the container's coordinate system. This does NOT
|
||||||
|
affect their repetition grids.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||||
|
|
@ -729,54 +799,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||||
cast('Rotatable', entry).rotate(rotation)
|
if isinstance(entry, Rotatable):
|
||||||
|
entry.rotate(rotation)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_element_centers(self, across_axis: int = 0) -> Self:
|
def mirror_element_centers(self, axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Mirror the offsets of all shapes, labels, and refs across an axis
|
Extrinsic transformation part: Mirror the offsets and repetition grids of all
|
||||||
|
shapes, labels, refs, and ports relative to the container's origin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
across_axis: Axis to mirror across
|
axis: Axis to mirror across (0: x-axis, 1: y-axis)
|
||||||
(0: mirror across x axis, 1: mirror across y axis)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||||
cast('Positionable', entry).offset[1 - across_axis] *= -1
|
cast('Positionable', entry).offset[1 - axis] *= -1
|
||||||
|
if isinstance(entry, Repeatable) and entry.repetition is not None:
|
||||||
|
entry.repetition.mirror(axis)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_elements(self, across_axis: int = 0) -> Self:
|
def mirror_elements(self, axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Mirror each shape, ref, and pattern across an axis, relative
|
Intrinsic transformation part: Mirror each shape, ref, label, and port relative
|
||||||
to its offset
|
to its offset. This does NOT affect their repetition grids.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
across_axis: Axis to mirror across
|
axis: Axis to mirror across
|
||||||
(0: mirror across x axis, 1: mirror across y axis)
|
0: mirror across x axis (flip y),
|
||||||
|
1: mirror across y axis (flip x)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()):
|
for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
|
||||||
cast('Mirrorable', entry).mirror(across_axis)
|
if isinstance(entry, Mirrorable):
|
||||||
|
entry.mirror(axis=axis)
|
||||||
|
self._log_bulk_update(f"mirror_elements({axis})")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, across_axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
"""
|
"""
|
||||||
Mirror the Pattern across an axis
|
Extrinsic transformation: Mirror the Pattern across an axis through its origin.
|
||||||
|
This affects all elements' offsets and their internal orientations.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
across_axis: Axis to mirror across
|
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||||
(0: mirror across x axis, 1: mirror across y axis)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.mirror_elements(across_axis)
|
self.mirror_elements(axis=axis)
|
||||||
self.mirror_element_centers(across_axis)
|
self.mirror_element_centers(axis=axis)
|
||||||
|
self._log_bulk_update(f"mirror({axis})")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def copy(self) -> Self:
|
def copy(self) -> Self:
|
||||||
|
|
@ -787,7 +864,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
Returns:
|
Returns:
|
||||||
A deep copy of the current Pattern.
|
A deep copy of the current Pattern.
|
||||||
"""
|
"""
|
||||||
return copy.deepcopy(self)
|
return self.deepcopy()
|
||||||
|
|
||||||
def deepcopy(self) -> Self:
|
def deepcopy(self) -> Self:
|
||||||
"""
|
"""
|
||||||
|
|
@ -930,6 +1007,28 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
del self.labels[layer]
|
del self.labels[layer]
|
||||||
return self
|
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:
|
def prune_refs(self) -> Self:
|
||||||
"""
|
"""
|
||||||
Remove empty ref lists in `self.refs`.
|
Remove empty ref lists in `self.refs`.
|
||||||
|
|
@ -981,10 +1080,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
|
|
||||||
if target_pat is None:
|
if target_pat is None:
|
||||||
raise PatternError(f'Circular reference in {name} to {target}')
|
raise PatternError(f'Circular reference in {name} to {target}')
|
||||||
if target_pat.is_empty(): # avoid some extra allocations
|
ports_only = flatten_ports and bool(target_pat.ports)
|
||||||
|
if target_pat.is_empty() and not ports_only: # avoid some extra allocations
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for ref in refs:
|
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)
|
p = ref.as_pattern(pattern=target_pat)
|
||||||
if not flatten_ports:
|
if not flatten_ports:
|
||||||
p.ports.clear()
|
p.ports.clear()
|
||||||
|
|
@ -1003,6 +1108,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
line_color: str = 'k',
|
line_color: str = 'k',
|
||||||
fill_color: str = 'none',
|
fill_color: str = 'none',
|
||||||
overdraw: bool = False,
|
overdraw: bool = False,
|
||||||
|
filename: str | None = None,
|
||||||
|
ports: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Draw a picture of the Pattern and wait for the user to inspect it
|
Draw a picture of the Pattern and wait for the user to inspect it
|
||||||
|
|
@ -1013,15 +1120,18 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
klayout or a different GDS viewer!
|
klayout or a different GDS viewer!
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
offset: Coordinates to offset by before drawing
|
library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`.
|
||||||
line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
|
offset: Coordinates to offset by before drawing.
|
||||||
fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`)
|
line_color: Outlines are drawn with this color.
|
||||||
overdraw: Whether to create a new figure or draw on a pre-existing one
|
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.
|
||||||
"""
|
"""
|
||||||
# TODO: add text labels to visualize()
|
# TODO: add text labels to visualize()
|
||||||
try:
|
try:
|
||||||
from matplotlib import pyplot # type: ignore
|
from matplotlib import pyplot # type: ignore #noqa: PLC0415
|
||||||
import matplotlib.collections # type: ignore
|
import matplotlib.collections # type: ignore #noqa: PLC0415
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.exception('Pattern.visualize() depends on matplotlib!\n'
|
logger.exception('Pattern.visualize() depends on matplotlib!\n'
|
||||||
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
|
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
|
||||||
|
|
@ -1030,48 +1140,155 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
if self.has_refs() and library is None:
|
if self.has_refs() and library is None:
|
||||||
raise PatternError('Must provide a library when visualizing a pattern with refs')
|
raise PatternError('Must provide a library when visualizing a pattern with refs')
|
||||||
|
|
||||||
offset = numpy.asarray(offset, dtype=float)
|
# 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]]] = {}
|
||||||
|
|
||||||
|
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:
|
if not overdraw:
|
||||||
figure = pyplot.figure()
|
figure = pyplot.figure()
|
||||||
pyplot.axis('equal')
|
|
||||||
else:
|
else:
|
||||||
figure = pyplot.gcf()
|
figure = pyplot.gcf()
|
||||||
|
|
||||||
axes = figure.gca()
|
axes = figure.gca()
|
||||||
|
|
||||||
polygons = []
|
if all_polygons:
|
||||||
for shape in chain.from_iterable(self.shapes.values()):
|
|
||||||
polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()]
|
|
||||||
|
|
||||||
mpl_poly_collection = matplotlib.collections.PolyCollection(
|
mpl_poly_collection = matplotlib.collections.PolyCollection(
|
||||||
polygons,
|
all_polygons,
|
||||||
facecolors=fill_color,
|
facecolors = fill_color,
|
||||||
edgecolors=line_color,
|
edgecolors = line_color,
|
||||||
)
|
)
|
||||||
axes.add_collection(mpl_poly_collection)
|
axes.add_collection(mpl_poly_collection)
|
||||||
pyplot.axis('equal')
|
|
||||||
|
|
||||||
for target, refs in self.refs.items():
|
if ports:
|
||||||
if target is None:
|
for port_name, pt_v, pt_rot in port_info:
|
||||||
continue
|
p1 = pt_v
|
||||||
if not refs:
|
angle = pt_rot
|
||||||
continue
|
size = 1.0 # arrow size
|
||||||
assert library is not None
|
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||||
target_pat = library[target]
|
|
||||||
for ref in refs:
|
axes.annotate(
|
||||||
ref.as_pattern(target_pat).visualize(
|
port_name,
|
||||||
library=library,
|
xy = tuple(p1),
|
||||||
offset=offset,
|
xytext = tuple(p2),
|
||||||
overdraw=True,
|
arrowprops = dict(arrowstyle="->", color='g', linewidth=1),
|
||||||
line_color=line_color,
|
color = 'g',
|
||||||
fill_color=fill_color,
|
fontsize = 8,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
axes.autoscale_view()
|
||||||
|
axes.set_aspect('equal')
|
||||||
|
|
||||||
if not overdraw:
|
if not overdraw:
|
||||||
pyplot.xlabel('x')
|
axes.set_xlabel('x')
|
||||||
pyplot.ylabel('y')
|
axes.set_ylabel('y')
|
||||||
pyplot.show()
|
if filename:
|
||||||
|
figure.savefig(filename)
|
||||||
|
else:
|
||||||
|
figure.show()
|
||||||
|
|
||||||
# @overload
|
# @overload
|
||||||
# def place(
|
# def place(
|
||||||
|
|
@ -1114,6 +1331,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
port_map: dict[str, str | None] | None = None,
|
port_map: dict[str, str | None] | None = None,
|
||||||
skip_port_check: bool = False,
|
skip_port_check: bool = False,
|
||||||
append: bool = False,
|
append: bool = False,
|
||||||
|
skip_geometry: bool = False,
|
||||||
) -> Self:
|
) -> Self:
|
||||||
"""
|
"""
|
||||||
Instantiate or append the pattern `other` into the current pattern, adding its
|
Instantiate or append the pattern `other` into the current pattern, adding its
|
||||||
|
|
@ -1145,6 +1363,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
Note that this does not flatten `other`, so its refs will still
|
Note that this does not flatten `other`, so its refs will still
|
||||||
be refs (now inside `self`).
|
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:
|
Returns:
|
||||||
self
|
self
|
||||||
|
|
@ -1159,7 +1381,26 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
port_map = {}
|
port_map = {}
|
||||||
|
|
||||||
if not skip_port_check:
|
if not skip_port_check:
|
||||||
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
|
port_map, overwrite_targets = self._resolve_insert_mapping(
|
||||||
|
other.ports.keys(),
|
||||||
|
map_in=None,
|
||||||
|
map_out=port_map,
|
||||||
|
allow_conflicts=skip_geometry,
|
||||||
|
)
|
||||||
|
for target in overwrite_targets:
|
||||||
|
self.ports.pop(target, None)
|
||||||
|
|
||||||
|
if not skip_geometry:
|
||||||
|
if append:
|
||||||
|
if isinstance(other, Abstract):
|
||||||
|
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
|
||||||
|
if other.annotations is not None and self.annotations is not None:
|
||||||
|
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
|
||||||
|
if annotation_conflicts:
|
||||||
|
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||||
|
elif isinstance(other, Pattern):
|
||||||
|
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
||||||
|
'Use `append=True` if you intended to append the full geometry.')
|
||||||
|
|
||||||
ports = {}
|
ports = {}
|
||||||
for name, port in other.ports.items():
|
for name, port in other.ports.items():
|
||||||
|
|
@ -1176,10 +1417,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
pp.rotate_around(pivot, rotation)
|
pp.rotate_around(pivot, rotation)
|
||||||
pp.translate(offset)
|
pp.translate(offset)
|
||||||
self.ports[name] = pp
|
self.ports[name] = pp
|
||||||
|
self._log_port_update(name)
|
||||||
|
|
||||||
|
if skip_geometry:
|
||||||
|
return self
|
||||||
|
|
||||||
if append:
|
if append:
|
||||||
if isinstance(other, Abstract):
|
|
||||||
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
|
|
||||||
other_copy = other.deepcopy()
|
other_copy = other.deepcopy()
|
||||||
other_copy.ports.clear()
|
other_copy.ports.clear()
|
||||||
if mirrored:
|
if mirrored:
|
||||||
|
|
@ -1188,7 +1431,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
other_copy.translate_elements(offset)
|
other_copy.translate_elements(offset)
|
||||||
self.append(other_copy)
|
self.append(other_copy)
|
||||||
else:
|
else:
|
||||||
assert not isinstance(other, Pattern)
|
|
||||||
ref = Ref(mirrored=mirrored)
|
ref = Ref(mirrored=mirrored)
|
||||||
ref.rotate_around(pivot, rotation)
|
ref.rotate_around(pivot, rotation)
|
||||||
ref.translate(offset)
|
ref.translate(offset)
|
||||||
|
|
@ -1234,6 +1476,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
set_rotation: bool | None = None,
|
set_rotation: bool | None = None,
|
||||||
append: bool = False,
|
append: bool = False,
|
||||||
ok_connections: Iterable[tuple[str, str]] = (),
|
ok_connections: Iterable[tuple[str, str]] = (),
|
||||||
|
skip_geometry: bool = False,
|
||||||
) -> Self:
|
) -> Self:
|
||||||
"""
|
"""
|
||||||
Instantiate or append a pattern into the current pattern, connecting
|
Instantiate or append a pattern into the current pattern, connecting
|
||||||
|
|
@ -1283,11 +1526,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
append: If `True`, `other` is appended instead of being referenced.
|
append: If `True`, `other` is appended instead of being referenced.
|
||||||
Note that this does not flatten `other`, so its refs will still
|
Note that this does not flatten `other`, so its refs will still
|
||||||
be refs (now inside `self`).
|
be refs (now inside `self`).
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
ok_connections: Set of additional allowed ptype combinations.
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
Ptypes accepted by the shared compatibility policy are always
|
||||||
any other ptypte. Non-allowed ptype connections will emit a
|
allowed. Non-allowed ptype connections will emit a warning.
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||||
`(b, a)`.
|
skip_geometry: If `True`, only ports are updated and geometry is
|
||||||
|
skipped. If a valid transform cannot be found (e.g. due to
|
||||||
|
misaligned ports), a 'best-effort' dummy transform is used
|
||||||
|
to ensure new ports are still added at approximate locations,
|
||||||
|
allowing downstream routing to continue.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
|
|
@ -1319,7 +1566,24 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
|
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 = {out_port_name: next(iter(map_in.keys()))}
|
||||||
|
|
||||||
self.check_ports(other.ports.keys(), map_in, map_out)
|
map_out, overwrite_targets = self._resolve_insert_mapping(
|
||||||
|
other.ports.keys(),
|
||||||
|
map_in,
|
||||||
|
map_out,
|
||||||
|
allow_conflicts=skip_geometry,
|
||||||
|
)
|
||||||
|
if not skip_geometry:
|
||||||
|
if append:
|
||||||
|
if isinstance(other, Abstract):
|
||||||
|
raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!')
|
||||||
|
if other.annotations is not None and self.annotations is not None:
|
||||||
|
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
|
||||||
|
if annotation_conflicts:
|
||||||
|
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
|
||||||
|
elif isinstance(other, Pattern):
|
||||||
|
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
|
||||||
|
'Use `append=True` if you intended to append the full geometry.')
|
||||||
|
try:
|
||||||
translation, rotation, pivot = self.find_transform(
|
translation, rotation, pivot = self.find_transform(
|
||||||
other,
|
other,
|
||||||
map_in,
|
map_in,
|
||||||
|
|
@ -1327,15 +1591,34 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
set_rotation = set_rotation,
|
set_rotation = set_rotation,
|
||||||
ok_connections = ok_connections,
|
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
|
# get rid of plugged ports
|
||||||
for ki, vi in map_in.items():
|
for ki, vi in map_in.items():
|
||||||
del self.ports[ki]
|
del self.ports[ki]
|
||||||
|
self._log_port_removal(ki)
|
||||||
map_out[vi] = None
|
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(
|
self.place(
|
||||||
other,
|
other,
|
||||||
offset = translation,
|
offset = translation,
|
||||||
|
|
@ -1345,6 +1628,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
port_map = map_out,
|
port_map = map_out,
|
||||||
skip_port_check = True,
|
skip_port_check = True,
|
||||||
append = append,
|
append = append,
|
||||||
|
skip_geometry = skip_geometry,
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -1378,7 +1662,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
current device.
|
current device.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source: A collection of ports (e.g. Pattern, Builder, or dict)
|
source: A collection of ports (e.g. Pattern, Pather, or dict)
|
||||||
from which to create the interface.
|
from which to create the interface.
|
||||||
in_prefix: Prepended to port names for newly-created ports with
|
in_prefix: Prepended to port names for newly-created ports with
|
||||||
reversed directions compared to the current device.
|
reversed directions compared to the current device.
|
||||||
|
|
@ -1406,9 +1690,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
|
||||||
else:
|
else:
|
||||||
raise PatternError(f'Unable to get ports from {type(source)}: {source}')
|
raise PatternError(f'Unable to get ports from {type(source)}: {source}')
|
||||||
|
|
||||||
if port_map:
|
if port_map is not None:
|
||||||
if isinstance(port_map, dict):
|
if isinstance(port_map, dict):
|
||||||
missing_inkeys = set(port_map.keys()) - set(orig_ports.keys())
|
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}
|
mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map}
|
||||||
else:
|
else:
|
||||||
port_set = set(port_map)
|
port_set = set(port_map)
|
||||||
|
|
|
||||||
311
masque/ports.py
311
masque/ports.py
|
|
@ -2,6 +2,7 @@ from typing import overload, Self, NoReturn, Any
|
||||||
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
||||||
import logging
|
import logging
|
||||||
import functools
|
import functools
|
||||||
|
import copy
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
@ -10,16 +11,17 @@ import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
|
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
|
||||||
from .utils import rotate_offsets_around, rotation_matrix_2d
|
from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d
|
||||||
from .error import PortError, format_stacktrace
|
from .error import PortError, format_stacktrace
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
port_logger = logging.getLogger('masque.ports')
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||||
"""
|
"""
|
||||||
A point at which a `Device` can be snapped to another `Device`.
|
A point at which a `Device` can be snapped to another `Device`.
|
||||||
|
|
||||||
|
|
@ -91,6 +93,12 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
||||||
def copy(self) -> Self:
|
def copy(self) -> Self:
|
||||||
return self.deepcopy()
|
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]:
|
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||||
return numpy.vstack((self.offset, self.offset))
|
return numpy.vstack((self.offset, self.offset))
|
||||||
|
|
||||||
|
|
@ -99,6 +107,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
||||||
self.ptype = ptype
|
self.ptype = ptype
|
||||||
return self
|
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:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
if self.rotation is not None:
|
if self.rotation is not None:
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
|
|
@ -114,6 +143,34 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable):
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
return self
|
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:
|
def __repr__(self) -> str:
|
||||||
if self.rotation is None:
|
if self.rotation is None:
|
||||||
rot = 'any'
|
rot = 'any'
|
||||||
|
|
@ -179,6 +236,19 @@ class PortList(metaclass=ABCMeta):
|
||||||
def ports(self, value: dict[str, Port]) -> None:
|
def ports(self, value: dict[str, Port]) -> None:
|
||||||
pass
|
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
|
@overload
|
||||||
def __getitem__(self, key: str) -> Port:
|
def __getitem__(self, key: str) -> Port:
|
||||||
pass
|
pass
|
||||||
|
|
@ -203,6 +273,12 @@ class PortList(metaclass=ABCMeta):
|
||||||
else: # noqa: RET505
|
else: # noqa: RET505
|
||||||
return {k: self.ports[k] for k in key}
|
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:
|
def __contains__(self, key: str) -> NoReturn:
|
||||||
raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')
|
raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')
|
||||||
|
|
||||||
|
|
@ -232,6 +308,7 @@ class PortList(metaclass=ABCMeta):
|
||||||
raise PortError(f'Port {name} already exists.')
|
raise PortError(f'Port {name} already exists.')
|
||||||
assert name not in self.ports
|
assert name not in self.ports
|
||||||
self.ports[name] = value
|
self.ports[name] = value
|
||||||
|
self._log_port_update(name)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def rename_ports(
|
def rename_ports(
|
||||||
|
|
@ -253,17 +330,147 @@ class PortList(metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
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:
|
if not overwrite:
|
||||||
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
||||||
if duplicates:
|
if duplicates:
|
||||||
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
|
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
|
||||||
|
missing = set(mapping) - set(self.ports)
|
||||||
|
if missing:
|
||||||
|
raise PortError(f'Ports to rename were not found: {missing}')
|
||||||
|
renamed_targets = [vv for vv in mapping.values() if vv is not None]
|
||||||
|
if not allow_collisions:
|
||||||
|
duplicate_targets = {vv for vv in renamed_targets if renamed_targets.count(vv) > 1}
|
||||||
|
if duplicate_targets:
|
||||||
|
raise PortError(f'Renamed ports would collide: {duplicate_targets}')
|
||||||
|
|
||||||
renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
|
winners = {
|
||||||
if None in renamed:
|
target: source
|
||||||
del renamed[None]
|
for source, target in mapping.items()
|
||||||
|
if target is not None
|
||||||
|
}
|
||||||
|
overwritten = {
|
||||||
|
target
|
||||||
|
for target, source in winners.items()
|
||||||
|
if target in self.ports and target not in mapping and target != source
|
||||||
|
}
|
||||||
|
|
||||||
|
for kk, vv in mapping.items():
|
||||||
|
if vv is None or vv != kk:
|
||||||
|
self._log_port_removal(kk)
|
||||||
|
|
||||||
|
source_ports = {kk: self.ports.pop(kk) for kk in mapping}
|
||||||
|
for target in overwritten:
|
||||||
|
self.ports.pop(target, None)
|
||||||
|
|
||||||
|
renamed = {
|
||||||
|
vv: source_ports[kk]
|
||||||
|
for kk, vv in mapping.items()
|
||||||
|
if vv is not None and winners[vv] == kk
|
||||||
|
}
|
||||||
self.ports.update(renamed) # type: ignore
|
self.ports.update(renamed) # type: ignore
|
||||||
return self
|
|
||||||
|
for vv in winners:
|
||||||
|
self._log_port_update(vv)
|
||||||
|
return winners
|
||||||
|
|
||||||
def add_port_pair(
|
def add_port_pair(
|
||||||
self,
|
self,
|
||||||
|
|
@ -285,12 +492,16 @@ class PortList(metaclass=ABCMeta):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
|
if names[0] == names[1]:
|
||||||
|
raise PortError(f'Port names must be distinct: {names[0]!r}')
|
||||||
new_ports = {
|
new_ports = {
|
||||||
names[0]: Port(offset, rotation=rotation, ptype=ptype),
|
names[0]: Port(offset, rotation=rotation, ptype=ptype),
|
||||||
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
|
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
|
||||||
}
|
}
|
||||||
self.check_ports(names)
|
self.check_ports(names)
|
||||||
self.ports.update(new_ports)
|
self.ports.update(new_ports)
|
||||||
|
self._log_port_update(names[0])
|
||||||
|
self._log_port_update(names[1])
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def plugged(
|
def plugged(
|
||||||
|
|
@ -313,13 +524,25 @@ class PortList(metaclass=ABCMeta):
|
||||||
Raises:
|
Raises:
|
||||||
`PortError` if the ports are not properly aligned.
|
`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))
|
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]
|
a_ports = [self.ports[pp] for pp in a_names]
|
||||||
b_ports = [self.ports[pp] for pp in b_names]
|
b_ports = [self.ports[pp] for pp in b_names]
|
||||||
|
|
||||||
a_types = [pp.ptype for pp in a_ports]
|
a_types = [pp.ptype for pp in a_ports]
|
||||||
b_types = [pp.ptype for pp in b_ports]
|
b_types = [pp.ptype for pp in b_ports]
|
||||||
type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
|
type_conflicts = numpy.array([not ptypes_compatible(at, bt)
|
||||||
for at, bt in zip(a_types, b_types, strict=True)])
|
for at, bt in zip(a_types, b_types, strict=True)])
|
||||||
|
|
||||||
if type_conflicts.any():
|
if type_conflicts.any():
|
||||||
|
|
@ -360,6 +583,7 @@ class PortList(metaclass=ABCMeta):
|
||||||
|
|
||||||
for pp in chain(a_names, b_names):
|
for pp in chain(a_names, b_names):
|
||||||
del self.ports[pp]
|
del self.ports[pp]
|
||||||
|
self._log_port_removal(pp)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def check_ports(
|
def check_ports(
|
||||||
|
|
@ -390,45 +614,7 @@ class PortList(metaclass=ABCMeta):
|
||||||
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
`PortError` if there are any duplicate names after `map_in` and `map_out`
|
||||||
are applied.
|
are applied.
|
||||||
"""
|
"""
|
||||||
if map_in is None:
|
self._resolve_insert_mapping(other_names, map_in, map_out)
|
||||||
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
|
return self
|
||||||
|
|
||||||
def find_transform(
|
def find_transform(
|
||||||
|
|
@ -455,11 +641,10 @@ class PortList(metaclass=ABCMeta):
|
||||||
port with `rotation=None`), `set_rotation` must be provided
|
port with `rotation=None`), `set_rotation` must be provided
|
||||||
to indicate how much `other` should be rotated. Otherwise,
|
to indicate how much `other` should be rotated. Otherwise,
|
||||||
`set_rotation` must remain `None`.
|
`set_rotation` must remain `None`.
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
ok_connections: Set of additional allowed ptype combinations.
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
Ptypes accepted by the shared compatibility policy are always
|
||||||
any other ptypte. Non-allowed ptype connections will log a
|
allowed. Non-allowed ptype connections will log a warning.
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||||
`(b, a)`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- The (x, y) translation (performed last)
|
- The (x, y) translation (performed last)
|
||||||
|
|
@ -468,6 +653,8 @@ class PortList(metaclass=ABCMeta):
|
||||||
|
|
||||||
The rotation should be performed before the translation.
|
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()]
|
s_ports = self[map_in.keys()]
|
||||||
o_ports = other[map_in.values()]
|
o_ports = other[map_in.values()]
|
||||||
return self.find_port_transform(
|
return self.find_port_transform(
|
||||||
|
|
@ -506,11 +693,10 @@ class PortList(metaclass=ABCMeta):
|
||||||
port with `rotation=None`), `set_rotation` must be provided
|
port with `rotation=None`), `set_rotation` must be provided
|
||||||
to indicate how much `o_ports` should be rotated. Otherwise,
|
to indicate how much `o_ports` should be rotated. Otherwise,
|
||||||
`set_rotation` must remain `None`.
|
`set_rotation` must remain `None`.
|
||||||
ok_connections: Set of "allowed" ptype combinations. Identical
|
ok_connections: Set of additional allowed ptype combinations.
|
||||||
ptypes are always allowed to connect, as is `'unk'` with
|
Ptypes accepted by the shared compatibility policy are always
|
||||||
any other ptypte. Non-allowed ptype connections will log a
|
allowed. Non-allowed ptype connections will log a warning.
|
||||||
warning. Order is ignored, i.e. `(a, b)` is equivalent to
|
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||||
`(b, a)`.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- The (x, y) translation (performed last)
|
- The (x, y) translation (performed last)
|
||||||
|
|
@ -519,6 +705,8 @@ class PortList(metaclass=ABCMeta):
|
||||||
|
|
||||||
The rotation should be performed before the translation.
|
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()])
|
s_offsets = numpy.array([p.offset for p in s_ports.values()])
|
||||||
o_offsets = numpy.array([p.offset for p in o_ports.values()])
|
o_offsets = numpy.array([p.offset for p in o_ports.values()])
|
||||||
s_types = [p.ptype for p in s_ports.values()]
|
s_types = [p.ptype for p in s_ports.values()]
|
||||||
|
|
@ -535,8 +723,10 @@ class PortList(metaclass=ABCMeta):
|
||||||
o_rotations *= -1
|
o_rotations *= -1
|
||||||
|
|
||||||
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
|
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
|
||||||
type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs)
|
type_conflicts = numpy.array([
|
||||||
for st, ot in zip(s_types, o_types, strict=True)])
|
not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs
|
||||||
|
for st, ot in zip(s_types, o_types, strict=True)
|
||||||
|
])
|
||||||
if type_conflicts.any():
|
if type_conflicts.any():
|
||||||
msg = 'Ports have conflicting types:\n'
|
msg = 'Ports have conflicting types:\n'
|
||||||
for nn, (kk, vv) in enumerate(map_in.items()):
|
for nn, (kk, vv) in enumerate(map_in.items()):
|
||||||
|
|
@ -548,7 +738,7 @@ class PortList(metaclass=ABCMeta):
|
||||||
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
||||||
if not has_rot.any():
|
if not has_rot.any():
|
||||||
if set_rotation is None:
|
if set_rotation is None:
|
||||||
PortError('Must provide set_rotation if rotation is indeterminate')
|
raise PortError('Must provide set_rotation if rotation is indeterminate')
|
||||||
rotations[:] = set_rotation
|
rotations[:] = set_rotation
|
||||||
else:
|
else:
|
||||||
rotations[~has_rot] = rotations[has_rot][0]
|
rotations[~has_rot] = rotations[has_rot][0]
|
||||||
|
|
@ -573,4 +763,3 @@ class PortList(metaclass=ABCMeta):
|
||||||
raise PortError(msg)
|
raise PortError(msg)
|
||||||
|
|
||||||
return translations[0], rotations[0], o_offsets[0]
|
return translations[0], rotations[0], o_offsets[0]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation
|
||||||
from .repetition import Repetition
|
from .repetition import Repetition
|
||||||
from .traits import (
|
from .traits import (
|
||||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||||
Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||||
|
FlippableImpl,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,8 +26,9 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Ref(
|
class Ref(
|
||||||
PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable,
|
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||||
|
Copyable,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
`Ref` provides basic support for nesting Pattern objects within each other.
|
`Ref` provides basic support for nesting Pattern objects within each other.
|
||||||
|
|
@ -42,7 +44,7 @@ class Ref(
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'_mirrored',
|
'_mirrored',
|
||||||
# inherited
|
# inherited
|
||||||
'_offset', '_rotation', 'scale', '_repetition', '_annotations',
|
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
|
||||||
)
|
)
|
||||||
|
|
||||||
_mirrored: bool
|
_mirrored: bool
|
||||||
|
|
@ -84,24 +86,48 @@ class Ref(
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations if annotations is not None else {}
|
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':
|
def __copy__(self) -> 'Ref':
|
||||||
new = Ref(
|
new = Ref(
|
||||||
offset=self.offset.copy(),
|
offset=self.offset.copy(),
|
||||||
rotation=self.rotation,
|
rotation=self.rotation,
|
||||||
scale=self.scale,
|
scale=self.scale,
|
||||||
mirrored=self.mirrored,
|
mirrored=self.mirrored,
|
||||||
repetition=copy.deepcopy(self.repetition),
|
repetition=self.repetition,
|
||||||
annotations=copy.deepcopy(self.annotations),
|
annotations=self.annotations,
|
||||||
)
|
)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
#new.repetition = copy.deepcopy(self.repetition, memo)
|
new._offset = self._offset.copy()
|
||||||
#new.annotations = copy.deepcopy(self.annotations, memo)
|
new.repetition = copy.deepcopy(self.repetition, memo)
|
||||||
|
new.annotations = copy.deepcopy(self.annotations, memo)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
def copy(self) -> 'Ref':
|
||||||
|
return self.deepcopy()
|
||||||
|
|
||||||
def __lt__(self, other: 'Ref') -> bool:
|
def __lt__(self, other: 'Ref') -> bool:
|
||||||
if (self.offset != other.offset).any():
|
if (self.offset != other.offset).any():
|
||||||
return tuple(self.offset) < tuple(other.offset)
|
return tuple(self.offset) < tuple(other.offset)
|
||||||
|
|
@ -116,6 +142,8 @@ class Ref(
|
||||||
return annotations_lt(self.annotations, other.annotations)
|
return annotations_lt(self.annotations, other.annotations)
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
if type(self) is not type(other):
|
||||||
|
return False
|
||||||
return (
|
return (
|
||||||
numpy.array_equal(self.offset, other.offset)
|
numpy.array_equal(self.offset, other.offset)
|
||||||
and self.mirrored == other.mirrored
|
and self.mirrored == other.mirrored
|
||||||
|
|
@ -160,16 +188,16 @@ class Ref(
|
||||||
return pattern
|
return pattern
|
||||||
|
|
||||||
def rotate(self, rotation: float) -> Self:
|
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
|
self.rotation += rotation
|
||||||
if self.repetition is not None:
|
|
||||||
self.repetition.rotate(rotation)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
self.mirror_target(axis)
|
self.mirror_target(axis)
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
if self.repetition is not None:
|
|
||||||
self.repetition.mirror(axis)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror_target(self, axis: int = 0) -> Self:
|
def mirror_target(self, axis: int = 0) -> Self:
|
||||||
|
|
@ -187,10 +215,11 @@ class Ref(
|
||||||
xys = self.offset[None, :]
|
xys = self.offset[None, :]
|
||||||
if self.repetition is not None:
|
if self.repetition is not None:
|
||||||
xys = xys + self.repetition.displacements
|
xys = xys + self.repetition.displacements
|
||||||
transforms = numpy.empty((xys.shape[0], 4))
|
transforms = numpy.empty((xys.shape[0], 5))
|
||||||
transforms[:, :2] = xys
|
transforms[:, :2] = xys
|
||||||
transforms[:, 2] = self.rotation
|
transforms[:, 2] = self.rotation
|
||||||
transforms[:, 3] = self.mirrored
|
transforms[:, 3] = self.mirrored
|
||||||
|
transforms[:, 4] = self.scale
|
||||||
return transforms
|
return transforms
|
||||||
|
|
||||||
def get_bounds_single(
|
def get_bounds_single(
|
||||||
|
|
@ -227,7 +256,10 @@ class Ref(
|
||||||
bounds = numpy.vstack((numpy.min(corners, axis=0),
|
bounds = numpy.vstack((numpy.min(corners, axis=0),
|
||||||
numpy.max(corners, axis=0))) * self.scale + [self.offset]
|
numpy.max(corners, axis=0))) * self.scale + [self.offset]
|
||||||
return bounds
|
return bounds
|
||||||
return self.as_pattern(pattern=pattern).get_bounds(library)
|
|
||||||
|
single_ref = self.deepcopy()
|
||||||
|
single_ref.repetition = None
|
||||||
|
return single_ref.as_pattern(pattern=pattern).get_bounds(library)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
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
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __le__(self, other: 'Repetition') -> bool:
|
def __lt__(self, other: 'Repetition') -> bool:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -64,7 +64,7 @@ class Grid(Repetition):
|
||||||
_a_count: int
|
_a_count: int
|
||||||
""" Number of instances along the direction specified by the `a_vector` """
|
""" Number of instances along the direction specified by the `a_vector` """
|
||||||
|
|
||||||
_b_vector: NDArray[numpy.float64] | None
|
_b_vector: NDArray[numpy.float64]
|
||||||
""" Vector `[x, y]` specifying a second lattice vector for the grid.
|
""" Vector `[x, y]` specifying a second lattice vector for the grid.
|
||||||
Specifies center-to-center spacing between adjacent elements.
|
Specifies center-to-center spacing between adjacent elements.
|
||||||
Can be `None` for a 1D array.
|
Can be `None` for a 1D array.
|
||||||
|
|
@ -113,6 +113,22 @@ class Grid(Repetition):
|
||||||
self.a_count = a_count
|
self.a_count = a_count
|
||||||
self.b_count = b_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
|
@classmethod
|
||||||
def aligned(
|
def aligned(
|
||||||
cls: type[GG],
|
cls: type[GG],
|
||||||
|
|
@ -184,6 +200,8 @@ class Grid(Repetition):
|
||||||
def a_count(self, val: int) -> None:
|
def a_count(self, val: int) -> None:
|
||||||
if val != int(val):
|
if val != int(val):
|
||||||
raise PatternError('a_count must be convertable to an int!')
|
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)
|
self._a_count = int(val)
|
||||||
|
|
||||||
# b_count property
|
# b_count property
|
||||||
|
|
@ -195,13 +213,12 @@ class Grid(Repetition):
|
||||||
def b_count(self, val: int) -> None:
|
def b_count(self, val: int) -> None:
|
||||||
if val != int(val):
|
if val != int(val):
|
||||||
raise PatternError('b_count must be convertable to an int!')
|
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)
|
self._b_count = int(val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def displacements(self) -> NDArray[numpy.float64]:
|
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')
|
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
|
||||||
return (aa.flatten()[:, None] * self.a_vector[None, :]
|
return (aa.flatten()[:, None] * self.a_vector[None, :]
|
||||||
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
|
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
|
||||||
|
|
@ -291,7 +308,7 @@ class Grid(Repetition):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __le__(self, other: Repetition) -> bool:
|
def __lt__(self, other: Repetition) -> bool:
|
||||||
if type(self) is not type(other):
|
if type(self) is not type(other):
|
||||||
return repr(type(self)) < repr(type(other))
|
return repr(type(self)) < repr(type(other))
|
||||||
other = cast('Grid', other)
|
other = cast('Grid', other)
|
||||||
|
|
@ -301,12 +318,8 @@ class Grid(Repetition):
|
||||||
return self.b_count < other.b_count
|
return self.b_count < other.b_count
|
||||||
if not numpy.array_equal(self.a_vector, other.a_vector):
|
if not numpy.array_equal(self.a_vector, other.a_vector):
|
||||||
return tuple(self.a_vector) < tuple(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):
|
if not numpy.array_equal(self.b_vector, other.b_vector):
|
||||||
return tuple(self.a_vector) < tuple(other.a_vector)
|
return tuple(self.b_vector) < tuple(other.b_vector)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -332,7 +345,22 @@ class Arbitrary(Repetition):
|
||||||
|
|
||||||
@displacements.setter
|
@displacements.setter
|
||||||
def displacements(self, val: ArrayLike) -> None:
|
def displacements(self, val: ArrayLike) -> None:
|
||||||
|
try:
|
||||||
vala = numpy.array(val, dtype=float)
|
vala = numpy.array(val, dtype=float)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PatternError('displacements must be convertible to an Nx2 ndarray') from exc
|
||||||
|
|
||||||
|
if vala.size == 0:
|
||||||
|
self._displacements = numpy.empty((0, 2), dtype=float)
|
||||||
|
return
|
||||||
|
|
||||||
|
if vala.ndim == 1:
|
||||||
|
if vala.size != 2:
|
||||||
|
raise PatternError('displacements must be convertible to an Nx2 ndarray')
|
||||||
|
vala = vala.reshape(1, 2)
|
||||||
|
elif vala.ndim != 2 or vala.shape[1] != 2:
|
||||||
|
raise PatternError('displacements must be convertible to an Nx2 ndarray')
|
||||||
|
|
||||||
order = numpy.lexsort(vala.T[::-1]) # sortrows
|
order = numpy.lexsort(vala.T[::-1]) # sortrows
|
||||||
self._displacements = vala[order]
|
self._displacements = vala[order]
|
||||||
|
|
||||||
|
|
@ -350,11 +378,11 @@ class Arbitrary(Repetition):
|
||||||
return (f'<Arbitrary {len(self.displacements)}pts >')
|
return (f'<Arbitrary {len(self.displacements)}pts >')
|
||||||
|
|
||||||
def __eq__(self, other: Any) -> bool:
|
def __eq__(self, other: Any) -> bool:
|
||||||
if not type(other) is not type(self):
|
if type(other) is not type(self):
|
||||||
return False
|
return False
|
||||||
return numpy.array_equal(self.displacements, other.displacements)
|
return numpy.array_equal(self.displacements, other.displacements)
|
||||||
|
|
||||||
def __le__(self, other: Repetition) -> bool:
|
def __lt__(self, other: Repetition) -> bool:
|
||||||
if type(self) is not type(other):
|
if type(self) is not type(other):
|
||||||
return repr(type(self)) < repr(type(other))
|
return repr(type(self)) < repr(type(other))
|
||||||
other = cast('Arbitrary', other)
|
other = cast('Arbitrary', other)
|
||||||
|
|
@ -391,7 +419,9 @@ class Arbitrary(Repetition):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.displacements[1 - axis] *= -1
|
new_displacements = self.displacements.copy()
|
||||||
|
new_displacements[:, 1 - axis] *= -1
|
||||||
|
self.displacements = new_displacements
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
||||||
|
|
@ -402,6 +432,8 @@ class Arbitrary(Repetition):
|
||||||
Returns:
|
Returns:
|
||||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
`[[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_min = numpy.min(self.displacements, axis=0)
|
||||||
xy_max = numpy.max(self.displacements, axis=0)
|
xy_max = numpy.max(self.displacements, axis=0)
|
||||||
return numpy.array((xy_min, xy_max))
|
return numpy.array((xy_min, xy_max))
|
||||||
|
|
@ -416,6 +448,5 @@ class Arbitrary(Repetition):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.displacements *= c
|
self.displacements = self.displacements * c
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from .shape import (
|
||||||
|
|
||||||
from .polygon import Polygon as Polygon
|
from .polygon import Polygon as Polygon
|
||||||
from .poly_collection import PolyCollection as PolyCollection
|
from .poly_collection import PolyCollection as PolyCollection
|
||||||
|
from .rect_collection import RectCollection as RectCollection
|
||||||
from .circle import Circle as Circle
|
from .circle import Circle as Circle
|
||||||
from .ellipse import Ellipse as Ellipse
|
from .ellipse import Ellipse as Ellipse
|
||||||
from .arc import Arc as Arc
|
from .arc import Arc as Arc
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
import copy
|
import copy
|
||||||
import functools
|
import functools
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
@ -13,18 +14,37 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
|
||||||
from ..traits import PositionableImpl
|
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
|
@functools.total_ordering
|
||||||
class Arc(PositionableImpl, Shape):
|
class Arc(PositionableImpl, Shape):
|
||||||
"""
|
"""
|
||||||
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
|
An elliptical arc, formed by cutting off an elliptical ring with two rays.
|
||||||
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
|
By default the rays exit from its center, but they can optionally exit from one of the
|
||||||
|
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
|
||||||
|
a rotation, and a width.
|
||||||
|
|
||||||
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
The 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 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.
|
The start and stop angle are measured counterclockwise from the first (x) radius.
|
||||||
"""
|
"""
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'_radii', '_angles', '_width', '_rotation',
|
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
|
||||||
# Inherited
|
# Inherited
|
||||||
'_offset', '_repetition', '_annotations',
|
'_offset', '_repetition', '_annotations',
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +61,11 @@ class Arc(PositionableImpl, Shape):
|
||||||
_width: float
|
_width: float
|
||||||
""" Width of the arc """
|
""" Width of the arc """
|
||||||
|
|
||||||
|
_angle_ref: ArcAngleRef
|
||||||
|
""" Origin used by start/stop rays """
|
||||||
|
|
||||||
|
AngleRef = ArcAngleRef
|
||||||
|
|
||||||
# radius properties
|
# radius properties
|
||||||
@property
|
@property
|
||||||
def radii(self) -> NDArray[numpy.float64]:
|
def radii(self) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -54,8 +79,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
val = numpy.array(val, dtype=float).flatten()
|
val = numpy.array(val, dtype=float).flatten()
|
||||||
if not val.size == 2:
|
if not val.size == 2:
|
||||||
raise PatternError('Radii must have length 2')
|
raise PatternError('Radii must have length 2')
|
||||||
if not val.min() >= 0:
|
if not val.min() > 0:
|
||||||
raise PatternError('Radii must be non-negative')
|
raise PatternError('Radii must be positive')
|
||||||
self._radii = val
|
self._radii = val
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -64,8 +89,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
|
|
||||||
@radius_x.setter
|
@radius_x.setter
|
||||||
def radius_x(self, val: float) -> None:
|
def radius_x(self, val: float) -> None:
|
||||||
if not val >= 0:
|
if not val > 0:
|
||||||
raise PatternError('Radius must be non-negative')
|
raise PatternError('Radius must be positive')
|
||||||
self._radii[0] = val
|
self._radii[0] = val
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -74,8 +99,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
|
|
||||||
@radius_y.setter
|
@radius_y.setter
|
||||||
def radius_y(self, val: float) -> None:
|
def radius_y(self, val: float) -> None:
|
||||||
if not val >= 0:
|
if not val > 0:
|
||||||
raise PatternError('Radius must be non-negative')
|
raise PatternError('Radius must be positive')
|
||||||
self._radii[1] = val
|
self._radii[1] = val
|
||||||
|
|
||||||
# arc start/stop angle properties
|
# arc start/stop angle properties
|
||||||
|
|
@ -113,6 +138,18 @@ class Arc(PositionableImpl, Shape):
|
||||||
def stop_angle(self, val: float) -> None:
|
def stop_angle(self, val: float) -> None:
|
||||||
self.angles = (self.angles[0], val)
|
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
|
# Rotation property
|
||||||
@property
|
@property
|
||||||
def rotation(self) -> float:
|
def rotation(self) -> float:
|
||||||
|
|
@ -159,34 +196,48 @@ class Arc(PositionableImpl, Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(radii, numpy.ndarray)
|
|
||||||
assert isinstance(angles, numpy.ndarray)
|
|
||||||
assert isinstance(offset, numpy.ndarray)
|
|
||||||
self._radii = radii
|
|
||||||
self._angles = angles
|
|
||||||
self._width = width
|
|
||||||
self._offset = offset
|
|
||||||
self._rotation = rotation
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.radii = radii
|
self.radii = radii
|
||||||
self.angles = angles
|
self.angles = angles
|
||||||
self.width = width
|
self.width = width
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
|
self.angle_ref = angle_ref
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_raw(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
radii: NDArray[numpy.float64],
|
||||||
|
angles: NDArray[numpy.float64],
|
||||||
|
width: float,
|
||||||
|
offset: NDArray[numpy.float64],
|
||||||
|
rotation: float,
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||||
|
) -> 'Arc':
|
||||||
|
new = cls.__new__(cls)
|
||||||
|
new._radii = radii
|
||||||
|
new._angles = angles
|
||||||
|
new._width = width
|
||||||
|
new._offset = offset
|
||||||
|
new._rotation = rotation % (2 * pi)
|
||||||
|
new._angle_ref = ArcAngleRef(angle_ref)
|
||||||
|
new._repetition = repetition
|
||||||
|
new._annotations = annotations
|
||||||
|
return new
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
new._offset = self._offset.copy()
|
||||||
new._radii = self._radii.copy()
|
new._radii = self._radii.copy()
|
||||||
new._angles = self._angles.copy()
|
new._angles = self._angles.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -198,6 +249,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
and numpy.array_equal(self.angles, other.angles)
|
and numpy.array_equal(self.angles, other.angles)
|
||||||
and self.width == other.width
|
and self.width == other.width
|
||||||
and self.rotation == other.rotation
|
and self.rotation == other.rotation
|
||||||
|
and self.angle_ref == other.angle_ref
|
||||||
and self.repetition == other.repetition
|
and self.repetition == other.repetition
|
||||||
and annotations_eq(self.annotations, other.annotations)
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
)
|
)
|
||||||
|
|
@ -214,6 +266,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
return tuple(self.radii) < tuple(other.radii)
|
return tuple(self.radii) < tuple(other.radii)
|
||||||
if not numpy.array_equal(self.angles, other.angles):
|
if not numpy.array_equal(self.angles, other.angles):
|
||||||
return tuple(self.angles) < tuple(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):
|
if not numpy.array_equal(self.offset, other.offset):
|
||||||
return tuple(self.offset) < tuple(other.offset)
|
return tuple(self.offset) < tuple(other.offset)
|
||||||
if self.rotation != other.rotation:
|
if self.rotation != other.rotation:
|
||||||
|
|
@ -230,6 +284,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
if (num_vertices is None) and (max_arclen is None):
|
if (num_vertices is None) and (max_arclen is None):
|
||||||
raise PatternError('Max number of points and arclength left unspecified'
|
raise PatternError('Max number of points and arclength left unspecified'
|
||||||
+ ' (default was also overridden)')
|
+ ' (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
|
r0, r1 = self.radii
|
||||||
|
|
||||||
|
|
@ -256,29 +312,38 @@ class Arc(PositionableImpl, Shape):
|
||||||
return arc_lengths, tt
|
return arc_lengths, tt
|
||||||
|
|
||||||
wh = self.width / 2.0
|
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:
|
if num_vertices is not None:
|
||||||
n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int)
|
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_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()
|
perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum()
|
||||||
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
|
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
|
||||||
max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf)
|
if not (numpy.isnan(implied_arclen) or implied_arclen <= 0):
|
||||||
assert max_arclen is not None
|
arclen_limits.append(implied_arclen)
|
||||||
|
if not arclen_limits:
|
||||||
|
raise PatternError('Arc polygonization could not determine a valid max_arclen')
|
||||||
|
max_arclen = min(arclen_limits)
|
||||||
|
|
||||||
def get_thetas(inner: bool) -> NDArray[numpy.float64]:
|
def get_thetas(inner: bool) -> NDArray[numpy.float64]:
|
||||||
""" Figure out the parameter values at which we should place vertices to meet the arclength constraint"""
|
""" Figure out the parameter values at which we should place vertices to meet the arclength constraint"""
|
||||||
dr = -wh if inner else wh
|
dr = -wh if inner else wh
|
||||||
|
|
||||||
n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int)
|
n_pts = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen)))
|
||||||
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
||||||
|
|
||||||
keep = [0]
|
keep = [0]
|
||||||
removable = (numpy.cumsum(arc_lengths) <= max_arclen)
|
start = 0
|
||||||
start = 1
|
|
||||||
while start < arc_lengths.size:
|
while start < arc_lengths.size:
|
||||||
next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough?
|
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
|
||||||
|
if not removable.any():
|
||||||
|
next_to_keep = start + 1
|
||||||
|
else:
|
||||||
|
next_to_keep = start + numpy.where(removable)[0][-1] + 1
|
||||||
keep.append(next_to_keep)
|
keep.append(next_to_keep)
|
||||||
removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen)
|
start = next_to_keep
|
||||||
start = next_to_keep + 1
|
|
||||||
if keep[-1] != thetas.size - 1:
|
if keep[-1] != thetas.size - 1:
|
||||||
keep.append(thetas.size - 1)
|
keep.append(thetas.size - 1)
|
||||||
|
|
||||||
|
|
@ -310,81 +375,59 @@ class Arc(PositionableImpl, Shape):
|
||||||
return [poly]
|
return [poly]
|
||||||
|
|
||||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||||
"""
|
|
||||||
Equation for rotated ellipse is
|
|
||||||
`x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)`
|
|
||||||
`y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)`
|
|
||||||
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())
|
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
||||||
|
sin_r = numpy.sin(self.rotation)
|
||||||
|
cos_r = numpy.cos(self.rotation)
|
||||||
|
|
||||||
mins = []
|
def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
|
||||||
maxs = []
|
return numpy.array((
|
||||||
|
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
|
||||||
|
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
|
||||||
|
))
|
||||||
|
|
||||||
|
def points_in_interval(rx: float, ry: float, a0: float, a1: float) -> list[NDArray[numpy.float64]]:
|
||||||
|
candidates = [a0, a1]
|
||||||
|
if rx != 0 and ry != 0:
|
||||||
|
tx = numpy.arctan2(-ry * sin_r, rx * cos_r)
|
||||||
|
ty = numpy.arctan2(ry * cos_r, rx * sin_r)
|
||||||
|
candidates.extend((tx, tx + pi, ty, ty + pi))
|
||||||
|
|
||||||
|
lo = min(a0, a1)
|
||||||
|
hi = max(a0, a1)
|
||||||
|
pts = []
|
||||||
|
for base in candidates:
|
||||||
|
k_min = int(numpy.floor((lo - base) / (2 * pi))) - 1
|
||||||
|
k_max = int(numpy.ceil((hi - base) / (2 * pi))) + 1
|
||||||
|
for kk in range(k_min, k_max + 1):
|
||||||
|
tt = base + kk * 2 * pi
|
||||||
|
if lo <= tt <= hi:
|
||||||
|
pts.append(point(rx, ry, tt))
|
||||||
|
return pts
|
||||||
|
|
||||||
|
pts = []
|
||||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||||
wh = sgn * self.width / 2
|
wh = sgn * self.width / 2
|
||||||
rx = self.radius_x + wh
|
rx = self.radius_x + wh
|
||||||
ry = self.radius_y + wh
|
ry = self.radius_y + wh
|
||||||
|
|
||||||
if rx == 0 or ry == 0:
|
if rx == 0 or ry == 0:
|
||||||
# Single point, at origin
|
pts.append(numpy.zeros(2))
|
||||||
mins.append([0, 0])
|
|
||||||
maxs.append([0, 0])
|
|
||||||
continue
|
continue
|
||||||
|
pts.extend(points_in_interval(rx, ry, aa[0], aa[1]))
|
||||||
|
|
||||||
a0, a1 = aa
|
all_pts = numpy.asarray(pts) + self.offset
|
||||||
a0_offset = a0 - (a0 % (2 * pi))
|
return numpy.vstack((numpy.min(all_pts, axis=0),
|
||||||
|
numpy.max(all_pts, axis=0)))
|
||||||
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':
|
def rotate(self, theta: float) -> 'Arc':
|
||||||
self.rotation += theta
|
self.rotation += theta
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Arc':
|
def mirror(self, axis: int = 0) -> 'Arc':
|
||||||
self.offset[axis - 1] *= -1
|
if self.angle_ref != ArcAngleRef.Center:
|
||||||
|
x_major = self.radius_x > self.radius_y
|
||||||
|
y_major = self.radius_y > self.radius_x
|
||||||
|
if (axis == 0 and y_major) or (axis == 1 and x_major):
|
||||||
|
self._swap_focus_ref()
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
self.rotation += axis * pi
|
self.rotation += axis * pi
|
||||||
self.angles *= -1
|
self.angles *= -1
|
||||||
|
|
@ -396,6 +439,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
angle_ref = self.angle_ref
|
||||||
if self.radius_x < self.radius_y:
|
if self.radius_x < self.radius_y:
|
||||||
radii = self.radii / self.radius_x
|
radii = self.radii / self.radius_x
|
||||||
scale = self.radius_x
|
scale = self.radius_x
|
||||||
|
|
@ -406,23 +450,26 @@ class Arc(PositionableImpl, Shape):
|
||||||
scale = self.radius_y
|
scale = self.radius_y
|
||||||
rotation = self.rotation + pi / 2
|
rotation = self.rotation + pi / 2
|
||||||
angles = self.angles - pi / 2
|
angles = self.angles - pi / 2
|
||||||
|
angle_ref = _swapped_focus_ref(angle_ref)
|
||||||
|
|
||||||
delta_angle = angles[1] - angles[0]
|
delta_angle = angles[1] - angles[0]
|
||||||
start_angle = angles[0] % (2 * pi)
|
start_angle = angles[0] % (2 * pi)
|
||||||
if start_angle >= pi:
|
if start_angle >= pi:
|
||||||
start_angle -= pi
|
start_angle -= pi
|
||||||
rotation += pi
|
rotation += pi
|
||||||
|
angle_ref = _swapped_focus_ref(angle_ref)
|
||||||
|
|
||||||
norm_angles = (start_angle, start_angle + delta_angle)
|
norm_angles = (start_angle, start_angle + delta_angle)
|
||||||
rotation %= 2 * pi
|
rotation %= 2 * pi
|
||||||
width = self.width
|
width = self.width
|
||||||
|
|
||||||
return ((type(self), radii, norm_angles, width / norm_value),
|
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
|
||||||
(self.offset, scale / norm_value, rotation, False),
|
(self.offset, scale / norm_value, rotation, False),
|
||||||
lambda: Arc(
|
lambda: Arc(
|
||||||
radii=radii * norm_value,
|
radii=radii * norm_value,
|
||||||
angles=norm_angles,
|
angles=norm_angles,
|
||||||
width=width * norm_value,
|
width=width * norm_value,
|
||||||
|
angle_ref=angle_ref,
|
||||||
))
|
))
|
||||||
|
|
||||||
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -433,27 +480,16 @@ class Arc(PositionableImpl, Shape):
|
||||||
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
a_ranges = self._angles_to_parameters()
|
||||||
|
|
||||||
mins = []
|
cuts = []
|
||||||
maxs = []
|
for index in range(2):
|
||||||
|
edge = []
|
||||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||||
wh = sgn * self.width / 2
|
wh = sgn * self.width / 2
|
||||||
rx = self.radius_x + wh
|
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
|
||||||
ry = self.radius_y + wh
|
cuts.append(edge)
|
||||||
|
return numpy.array(cuts) + self.offset
|
||||||
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]:
|
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -464,22 +500,111 @@ class Arc(PositionableImpl, Shape):
|
||||||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||||
"""
|
"""
|
||||||
aa = []
|
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):
|
for sgn in (-1, +1):
|
||||||
wh = sgn * self.width / 2.0
|
wh = sgn * self.width / 2.0
|
||||||
rx = self.radius_x + wh
|
rx = self.radius_x + wh
|
||||||
ry = self.radius_y + wh
|
ry = self.radius_y + wh
|
||||||
|
|
||||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
|
||||||
sign = numpy.sign(self.angles[1] - self.angles[0])
|
sign = numpy.sign(d_angle)
|
||||||
if sign != numpy.sign(a1 - a0):
|
if sign != numpy.sign(a1 - a0):
|
||||||
a1 += sign * 2 * pi
|
a1 += sign * 2 * pi
|
||||||
|
|
||||||
aa.append((a0, a1))
|
aa.append((a0, a1))
|
||||||
return numpy.array(aa, dtype=float)
|
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:
|
def __repr__(self) -> str:
|
||||||
angles = f' a°{numpy.rad2deg(self.angles)}'
|
angles = f' a°{numpy.rad2deg(self.angles)}'
|
||||||
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||||
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
|
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
|
||||||
|
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
|
||||||
|
|
||||||
|
|
||||||
|
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
|
||||||
|
"""
|
||||||
|
Return the opposite focus reference, or center for center-referenced arcs.
|
||||||
|
"""
|
||||||
|
if angle_ref == ArcAngleRef.FocusPos:
|
||||||
|
return ArcAngleRef.FocusNeg
|
||||||
|
if angle_ref == ArcAngleRef.FocusNeg:
|
||||||
|
return ArcAngleRef.FocusPos
|
||||||
|
return angle_ref
|
||||||
|
|
||||||
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
||||||
|
|
|
||||||
|
|
@ -50,24 +50,33 @@ class Circle(PositionableImpl, Shape):
|
||||||
offset: ArrayLike = (0.0, 0.0),
|
offset: ArrayLike = (0.0, 0.0),
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(offset, numpy.ndarray)
|
|
||||||
self._radius = radius
|
|
||||||
self._offset = offset
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.radius = radius
|
self.radius = radius
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_raw(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
radius: float,
|
||||||
|
offset: NDArray[numpy.float64],
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
) -> 'Circle':
|
||||||
|
new = cls.__new__(cls)
|
||||||
|
new._radius = radius
|
||||||
|
new._offset = offset
|
||||||
|
new._repetition = repetition
|
||||||
|
new._annotations = annotations
|
||||||
|
return new
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
new._offset = self._offset.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -108,7 +117,7 @@ class Circle(PositionableImpl, Shape):
|
||||||
n += [num_vertices]
|
n += [num_vertices]
|
||||||
if max_arclen is not None:
|
if max_arclen is not None:
|
||||||
n += [2 * pi * self.radius / max_arclen]
|
n += [2 * pi * self.radius / max_arclen]
|
||||||
num_vertices = int(round(max(n)))
|
num_vertices = max(3, int(round(max(n))))
|
||||||
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
||||||
xs = numpy.cos(thetas) * self.radius
|
xs = numpy.cos(thetas) * self.radius
|
||||||
ys = numpy.sin(thetas) * self.radius
|
ys = numpy.sin(thetas) * self.radius
|
||||||
|
|
@ -124,7 +133,6 @@ class Circle(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
||||||
self.offset[axis - 1] *= -1
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> 'Circle':
|
def scale_by(self, c: float) -> 'Circle':
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
|
|
||||||
@radii.setter
|
@radii.setter
|
||||||
def radii(self, val: ArrayLike) -> None:
|
def radii(self, val: ArrayLike) -> None:
|
||||||
val = numpy.array(val).flatten()
|
val = numpy.array(val, dtype=float).flatten()
|
||||||
if not val.size == 2:
|
if not val.size == 2:
|
||||||
raise PatternError('Radii must have length 2')
|
raise PatternError('Radii must have length 2')
|
||||||
if not val.min() >= 0:
|
if not val.min() >= 0:
|
||||||
|
|
@ -95,28 +95,37 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(radii, numpy.ndarray)
|
|
||||||
assert isinstance(offset, numpy.ndarray)
|
|
||||||
self._radii = radii
|
|
||||||
self._offset = offset
|
|
||||||
self._rotation = rotation
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.radii = radii
|
self.radii = radii
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_raw(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
radii: NDArray[numpy.float64],
|
||||||
|
offset: NDArray[numpy.float64],
|
||||||
|
rotation: float,
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
) -> Self:
|
||||||
|
new = cls.__new__(cls)
|
||||||
|
new._radii = radii
|
||||||
|
new._offset = offset
|
||||||
|
new._rotation = rotation % pi
|
||||||
|
new._repetition = repetition
|
||||||
|
new._annotations = annotations
|
||||||
|
return new
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
new._offset = self._offset.copy()
|
||||||
new._radii = self._radii.copy()
|
new._radii = self._radii.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -168,7 +177,7 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
n += [num_vertices]
|
n += [num_vertices]
|
||||||
if max_arclen is not None:
|
if max_arclen is not None:
|
||||||
n += [perimeter / max_arclen]
|
n += [perimeter / max_arclen]
|
||||||
num_vertices = int(round(max(n)))
|
num_vertices = max(3, int(round(max(n))))
|
||||||
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
||||||
|
|
||||||
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
|
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
|
||||||
|
|
@ -180,16 +189,19 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
return [poly]
|
return [poly]
|
||||||
|
|
||||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||||
rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
|
cos_r = numpy.cos(self.rotation)
|
||||||
return numpy.vstack((self.offset - rot_radii[0],
|
sin_r = numpy.sin(self.rotation)
|
||||||
self.offset + rot_radii[1]))
|
x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2)
|
||||||
|
y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2)
|
||||||
|
extents = numpy.array((x_extent, y_extent))
|
||||||
|
return numpy.vstack((self.offset - extents,
|
||||||
|
self.offset + extents))
|
||||||
|
|
||||||
def rotate(self, theta: float) -> Self:
|
def rotate(self, theta: float) -> Self:
|
||||||
self.rotation += theta
|
self.rotation += theta
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
self.offset[axis - 1] *= -1
|
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
self.rotation += axis * pi
|
self.rotation += axis * pi
|
||||||
return self
|
return self
|
||||||
|
|
@ -207,7 +219,7 @@ class Ellipse(PositionableImpl, Shape):
|
||||||
radii = self.radii[::-1] / self.radius_y
|
radii = self.radii[::-1] / self.radius_y
|
||||||
scale = self.radius_y
|
scale = self.radius_y
|
||||||
angle = (self.rotation + pi / 2) % pi
|
angle = (self.rotation + pi / 2) % pi
|
||||||
return ((type(self), radii),
|
return ((type(self), tuple(radii.tolist())),
|
||||||
(self.offset, scale / norm_value, angle, False),
|
(self.offset, scale / norm_value, angle, False),
|
||||||
lambda: Ellipse(radii=radii * norm_value))
|
lambda: Ellipse(radii=radii * norm_value))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,16 @@ class PathCap(Enum):
|
||||||
# # defined by path.cap_extensions
|
# # defined by path.cap_extensions
|
||||||
|
|
||||||
def __lt__(self, other: Any) -> bool:
|
def __lt__(self, other: Any) -> bool:
|
||||||
return self.value == other.value
|
if self.__class__ is not other.__class__:
|
||||||
|
return self.__class__.__name__ < other.__class__.__name__
|
||||||
|
# Order: Flush, Square, Circle, SquareCustom
|
||||||
|
order = {
|
||||||
|
PathCap.Flush: 0,
|
||||||
|
PathCap.Square: 1,
|
||||||
|
PathCap.Circle: 2,
|
||||||
|
PathCap.SquareCustom: 3,
|
||||||
|
}
|
||||||
|
return order[self] < order[other]
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
|
|
@ -79,10 +88,10 @@ class Path(Shape):
|
||||||
def cap(self, val: PathCap) -> None:
|
def cap(self, val: PathCap) -> None:
|
||||||
self._cap = PathCap(val)
|
self._cap = PathCap(val)
|
||||||
if self.cap != PathCap.SquareCustom:
|
if self.cap != PathCap.SquareCustom:
|
||||||
self.cap_extensions = None
|
self._cap_extensions = None
|
||||||
elif self.cap_extensions is None:
|
elif self._cap_extensions is None:
|
||||||
# just got set to SquareCustom
|
# just got set to SquareCustom
|
||||||
self.cap_extensions = numpy.zeros(2)
|
self._cap_extensions = numpy.zeros(2)
|
||||||
|
|
||||||
# cap_extensions property
|
# cap_extensions property
|
||||||
@property
|
@property
|
||||||
|
|
@ -192,37 +201,50 @@ class Path(Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._cap_extensions = None # Since .cap setter might access it
|
self._cap_extensions = None # Since .cap setter might access it
|
||||||
|
|
||||||
if raw:
|
|
||||||
assert isinstance(vertices, numpy.ndarray)
|
|
||||||
assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None
|
|
||||||
self._vertices = vertices
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
self._width = width
|
|
||||||
self._cap = cap
|
|
||||||
self._cap_extensions = cap_extensions
|
|
||||||
else:
|
|
||||||
self.vertices = vertices
|
self.vertices = vertices
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
self.width = width
|
self._cap = cap
|
||||||
self.cap = cap
|
if cap == PathCap.SquareCustom and cap_extensions is None:
|
||||||
|
self._cap_extensions = numpy.zeros(2)
|
||||||
|
else:
|
||||||
self.cap_extensions = cap_extensions
|
self.cap_extensions = cap_extensions
|
||||||
|
self.width = width
|
||||||
if rotation:
|
if rotation:
|
||||||
self.rotate(rotation)
|
self.rotate(rotation)
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
self.translate(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':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Path':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._vertices = self._vertices.copy()
|
new._vertices = self._vertices.copy()
|
||||||
new._cap = copy.deepcopy(self._cap, memo)
|
new._cap = copy.deepcopy(self._cap, memo)
|
||||||
new._cap_extensions = copy.deepcopy(self._cap_extensions, memo)
|
new._cap_extensions = copy.deepcopy(self._cap_extensions, memo)
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -253,6 +275,14 @@ class Path(Shape):
|
||||||
if self.cap_extensions is None:
|
if self.cap_extensions is None:
|
||||||
return True
|
return True
|
||||||
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
|
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:
|
if self.repetition != other.repetition:
|
||||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||||
return annotations_lt(self.annotations, other.annotations)
|
return annotations_lt(self.annotations, other.annotations)
|
||||||
|
|
@ -303,9 +333,30 @@ class Path(Shape):
|
||||||
) -> list['Polygon']:
|
) -> list['Polygon']:
|
||||||
extensions = self._calculate_cap_extensions()
|
extensions = self._calculate_cap_extensions()
|
||||||
|
|
||||||
v = remove_colinear_vertices(self.vertices, closed_path=False)
|
v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
||||||
dv = numpy.diff(v, axis=0)
|
dv = numpy.diff(v, axis=0)
|
||||||
dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]
|
norms = numpy.sqrt((dv * dv).sum(axis=1))
|
||||||
|
|
||||||
|
# Filter out zero-length segments if any remained after remove_colinear_vertices
|
||||||
|
valid = (norms > 1e-18)
|
||||||
|
if not numpy.all(valid):
|
||||||
|
# This shouldn't happen much if remove_colinear_vertices is working
|
||||||
|
v = v[numpy.append(valid, True)]
|
||||||
|
dv = numpy.diff(v, axis=0)
|
||||||
|
norms = norms[valid]
|
||||||
|
|
||||||
|
if dv.shape[0] == 0:
|
||||||
|
# All vertices were the same. It's a point.
|
||||||
|
if self.width == 0:
|
||||||
|
return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate
|
||||||
|
if self.cap == PathCap.Circle:
|
||||||
|
return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
|
||||||
|
if self.cap == PathCap.Square:
|
||||||
|
return [Polygon.square(side_length=self.width, offset=v[0])]
|
||||||
|
# Flush or CustomSquare
|
||||||
|
return [Polygon(vertices=numpy.zeros((3, 2)))]
|
||||||
|
|
||||||
|
dvdir = dv / norms[:, None]
|
||||||
|
|
||||||
if self.width == 0:
|
if self.width == 0:
|
||||||
verts = numpy.vstack((v, v[::-1]))
|
verts = numpy.vstack((v, v[::-1]))
|
||||||
|
|
@ -324,11 +375,21 @@ class Path(Shape):
|
||||||
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
|
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
|
||||||
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
|
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
|
||||||
|
|
||||||
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0]
|
try:
|
||||||
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0]
|
# Vectorized solve for all intersections
|
||||||
|
# solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1)
|
||||||
|
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0]
|
||||||
|
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0]
|
||||||
|
except numpy.linalg.LinAlgError:
|
||||||
|
# Fallback to slower lstsq if some segments are parallel (singular matrix)
|
||||||
|
rp = numpy.zeros(As.shape[0])
|
||||||
|
rn = numpy.zeros(As.shape[0])
|
||||||
|
for ii in range(As.shape[0]):
|
||||||
|
rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0]
|
||||||
|
rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0]
|
||||||
|
|
||||||
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
|
intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1]
|
||||||
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
|
intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1]
|
||||||
|
|
||||||
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
|
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
|
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
|
||||||
|
|
@ -396,12 +457,14 @@ class Path(Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Path':
|
def mirror(self, axis: int = 0) -> 'Path':
|
||||||
self.vertices[:, axis - 1] *= -1
|
self.vertices[:, 1 - axis] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> 'Path':
|
def scale_by(self, c: float) -> 'Path':
|
||||||
self.vertices *= c
|
self.vertices *= c
|
||||||
self.width *= c
|
self.width *= c
|
||||||
|
if self.cap_extensions is not None:
|
||||||
|
self.cap_extensions *= c
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
|
@ -418,21 +481,22 @@ class Path(Shape):
|
||||||
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
|
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
|
||||||
for v in normed_vertices])
|
for v in normed_vertices])
|
||||||
|
|
||||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
# Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller
|
||||||
x_min = rotated_vertices[:, 0].argmin()
|
if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat):
|
||||||
if not is_scalar(x_min):
|
reordered_vertices = rotated_vertices[::-1]
|
||||||
y_min = rotated_vertices[x_min, 1].argmin()
|
else:
|
||||||
x_min = cast('Sequence', x_min)[y_min]
|
reordered_vertices = rotated_vertices
|
||||||
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
|
||||||
|
|
||||||
width0 = self.width / norm_value
|
width0 = self.width / norm_value
|
||||||
|
cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions)
|
||||||
|
|
||||||
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap),
|
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0),
|
||||||
(offset, scale / norm_value, rotation, False),
|
(offset, scale / norm_value, rotation, False),
|
||||||
lambda: Path(
|
lambda: Path(
|
||||||
reordered_vertices * norm_value,
|
reordered_vertices * norm_value,
|
||||||
width=self.width * norm_value,
|
width=width0 * norm_value,
|
||||||
cap=self.cap,
|
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':
|
def clean_vertices(self) -> 'Path':
|
||||||
|
|
@ -462,7 +526,7 @@ class Path(Shape):
|
||||||
Returns:
|
Returns:
|
||||||
self
|
self
|
||||||
"""
|
"""
|
||||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
|
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
|
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class PolyCollection(Shape):
|
||||||
_vertex_lists: NDArray[numpy.float64]
|
_vertex_lists: NDArray[numpy.float64]
|
||||||
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
|
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
|
||||||
|
|
||||||
_vertex_offsets: NDArray[numpy.intp]
|
_vertex_offsets: NDArray[numpy.integer[Any]]
|
||||||
""" 1D NDArray specifying the starting offset for each polygon """
|
""" 1D NDArray specifying the starting offset for each polygon """
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -45,7 +45,7 @@ class PolyCollection(Shape):
|
||||||
return self._vertex_lists
|
return self._vertex_lists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vertex_offsets(self) -> NDArray[numpy.intp]:
|
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
|
||||||
"""
|
"""
|
||||||
Starting offset (in `vertex_lists`) for each polygon
|
Starting offset (in `vertex_lists`) for each polygon
|
||||||
"""
|
"""
|
||||||
|
|
@ -56,12 +56,14 @@ class PolyCollection(Shape):
|
||||||
"""
|
"""
|
||||||
Iterator which provides slices which index vertex_lists
|
Iterator which provides slices which index vertex_lists
|
||||||
"""
|
"""
|
||||||
|
if self._vertex_offsets.size == 0:
|
||||||
|
return
|
||||||
for ii, ff in zip(
|
for ii, ff in zip(
|
||||||
self._vertex_offsets,
|
self._vertex_offsets,
|
||||||
chain(self._vertex_offsets, (self._vertex_lists.shape[0],)),
|
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||||
strict=True,
|
strict=True,
|
||||||
):
|
):
|
||||||
yield slice(ii, ff)
|
yield slice(int(ii), int(ff))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||||
|
|
@ -82,7 +84,7 @@ class PolyCollection(Shape):
|
||||||
|
|
||||||
def set_offset(self, val: ArrayLike) -> Self:
|
def set_offset(self, val: ArrayLike) -> Self:
|
||||||
if numpy.any(val):
|
if numpy.any(val):
|
||||||
raise PatternError('Path offset is forced to (0, 0)')
|
raise PatternError('PolyCollection offset is forced to (0, 0)')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def translate(self, offset: ArrayLike) -> Self:
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
|
@ -98,16 +100,7 @@ class PolyCollection(Shape):
|
||||||
rotation: float = 0.0,
|
rotation: float = 0.0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(vertex_lists, numpy.ndarray)
|
|
||||||
assert isinstance(vertex_offsets, numpy.ndarray)
|
|
||||||
self._vertex_lists = vertex_lists
|
|
||||||
self._vertex_offsets = vertex_offsets
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
|
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
|
||||||
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
|
|
@ -117,11 +110,28 @@ class PolyCollection(Shape):
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
self.translate(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:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._vertex_lists = self._vertex_lists.copy()
|
new._vertex_lists = self._vertex_lists.copy()
|
||||||
new._vertex_offsets = self._vertex_offsets.copy()
|
new._vertex_offsets = self._vertex_offsets.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -129,7 +139,7 @@ class PolyCollection(Shape):
|
||||||
return (
|
return (
|
||||||
type(self) is type(other)
|
type(self) is type(other)
|
||||||
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
|
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 self.repetition == other.repetition
|
||||||
and annotations_eq(self.annotations, other.annotations)
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
)
|
)
|
||||||
|
|
@ -168,7 +178,9 @@ class PolyCollection(Shape):
|
||||||
annotations = copy.deepcopy(self.annotations),
|
annotations = copy.deepcopy(self.annotations),
|
||||||
) for vv in self.polygon_vertices]
|
) for vv in self.polygon_vertices]
|
||||||
|
|
||||||
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
|
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
|
||||||
|
if self._vertex_lists.size == 0:
|
||||||
|
return None
|
||||||
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
||||||
numpy.max(self._vertex_lists, axis=0)))
|
numpy.max(self._vertex_lists, axis=0)))
|
||||||
|
|
||||||
|
|
@ -179,7 +191,7 @@ class PolyCollection(Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> Self:
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
self._vertex_lists[:, axis - 1] *= -1
|
self._vertex_lists[:, 1 - axis] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> Self:
|
def scale_by(self, c: float) -> Self:
|
||||||
|
|
@ -210,11 +222,11 @@ class PolyCollection(Shape):
|
||||||
|
|
||||||
# TODO: normalize mirroring?
|
# 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),
|
(offset, scale / norm_value, rotation, False),
|
||||||
lambda: PolyCollection(
|
lambda: PolyCollection(
|
||||||
vertex_lists=rotated_vertices * norm_value,
|
vertex_lists=rotated_vertices * norm_value,
|
||||||
vertex_offsets=self._vertex_offsets,
|
vertex_offsets=self.vertex_offsets.copy(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any, cast, TYPE_CHECKING, Self
|
from typing import Any, cast, TYPE_CHECKING, Self, Literal
|
||||||
import copy
|
import copy
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
|
|
@ -96,11 +96,11 @@ class Polygon(Shape):
|
||||||
@offset.setter
|
@offset.setter
|
||||||
def offset(self, val: ArrayLike) -> None:
|
def offset(self, val: ArrayLike) -> None:
|
||||||
if numpy.any(val):
|
if numpy.any(val):
|
||||||
raise PatternError('Path offset is forced to (0, 0)')
|
raise PatternError('Polygon offset is forced to (0, 0)')
|
||||||
|
|
||||||
def set_offset(self, val: ArrayLike) -> Self:
|
def set_offset(self, val: ArrayLike) -> Self:
|
||||||
if numpy.any(val):
|
if numpy.any(val):
|
||||||
raise PatternError('Path offset is forced to (0, 0)')
|
raise PatternError('Polygon offset is forced to (0, 0)')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def translate(self, offset: ArrayLike) -> Self:
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
|
@ -115,14 +115,7 @@ class Polygon(Shape):
|
||||||
rotation: float = 0.0,
|
rotation: float = 0.0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(vertices, numpy.ndarray)
|
|
||||||
self._vertices = vertices
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.vertices = vertices
|
self.vertices = vertices
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
@ -131,10 +124,25 @@ class Polygon(Shape):
|
||||||
if numpy.any(offset):
|
if numpy.any(offset):
|
||||||
self.translate(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':
|
def __deepcopy__(self, memo: dict | None = None) -> 'Polygon':
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._vertices = self._vertices.copy()
|
new._vertices = self._vertices.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -321,7 +329,7 @@ class Polygon(Shape):
|
||||||
else:
|
else:
|
||||||
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
||||||
|
|
||||||
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition)
|
poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition)
|
||||||
return poly
|
return poly
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -394,7 +402,7 @@ class Polygon(Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Polygon':
|
def mirror(self, axis: int = 0) -> 'Polygon':
|
||||||
self.vertices[:, axis - 1] *= -1
|
self.vertices[:, 1 - axis] *= -1
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def scale_by(self, c: float) -> 'Polygon':
|
def scale_by(self, c: float) -> 'Polygon':
|
||||||
|
|
@ -417,11 +425,15 @@ class Polygon(Shape):
|
||||||
for v in normed_vertices])
|
for v in normed_vertices])
|
||||||
|
|
||||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||||
x_min = rotated_vertices[:, 0].argmin()
|
x_min_val = rotated_vertices[:, 0].min()
|
||||||
if not is_scalar(x_min):
|
x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0]
|
||||||
y_min = rotated_vertices[x_min, 1].argmin()
|
if x_min_inds.size > 1:
|
||||||
x_min = cast('Sequence', x_min)[y_min]
|
y_min_val = rotated_vertices[x_min_inds, 1].min()
|
||||||
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0]
|
||||||
|
start_ind = x_min_inds[tie_breaker]
|
||||||
|
else:
|
||||||
|
start_ind = x_min_inds[0]
|
||||||
|
reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0)
|
||||||
|
|
||||||
# TODO: normalize mirroring?
|
# TODO: normalize mirroring?
|
||||||
|
|
||||||
|
|
@ -462,3 +474,23 @@ class Polygon(Shape):
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
centroid = self.vertices.mean(axis=0)
|
centroid = self.vertices.mean(axis=0)
|
||||||
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
|
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)
|
||||||
|
|
|
||||||
249
masque/shapes/rect_collection.py
Normal file
249
masque/shapes/rect_collection.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
from typing import Any, cast, Self
|
||||||
|
from collections.abc import Iterator
|
||||||
|
import copy
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.typing import NDArray, ArrayLike
|
||||||
|
|
||||||
|
from . import Shape, normalized_shape_tuple
|
||||||
|
from .polygon import Polygon
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..repetition import Repetition
|
||||||
|
from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]:
|
||||||
|
arr = numpy.asarray(rects, dtype=float)
|
||||||
|
if arr.ndim != 2 or arr.shape[1] != 4:
|
||||||
|
raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]')
|
||||||
|
if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]):
|
||||||
|
raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax')
|
||||||
|
if arr.shape[0] <= 1:
|
||||||
|
return arr
|
||||||
|
order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0]))
|
||||||
|
return arr[order]
|
||||||
|
|
||||||
|
|
||||||
|
def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None:
|
||||||
|
x0 = numpy.minimum(rects[:, 0], rects[:, 2])
|
||||||
|
x1 = numpy.maximum(rects[:, 0], rects[:, 2])
|
||||||
|
y0 = numpy.minimum(rects[:, 1], rects[:, 3])
|
||||||
|
y1 = numpy.maximum(rects[:, 1], rects[:, 3])
|
||||||
|
rects[:, 0] = x0
|
||||||
|
rects[:, 1] = y0
|
||||||
|
rects[:, 2] = x1
|
||||||
|
rects[:, 3] = y1
|
||||||
|
|
||||||
|
|
||||||
|
@functools.total_ordering
|
||||||
|
class RectCollection(Shape):
|
||||||
|
"""
|
||||||
|
A collection of axis-aligned rectangles, stored as an Nx4 array of
|
||||||
|
`[xmin, ymin, xmax, ymax]` rows.
|
||||||
|
"""
|
||||||
|
__slots__ = (
|
||||||
|
'_rects',
|
||||||
|
'_repetition', '_annotations',
|
||||||
|
)
|
||||||
|
|
||||||
|
_rects: NDArray[numpy.float64]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rects(self) -> NDArray[numpy.float64]:
|
||||||
|
return self._rects
|
||||||
|
|
||||||
|
@rects.setter
|
||||||
|
def rects(self, val: ArrayLike) -> None:
|
||||||
|
self._rects = _normalize_rects(val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset(self) -> NDArray[numpy.float64]:
|
||||||
|
return numpy.zeros(2)
|
||||||
|
|
||||||
|
@offset.setter
|
||||||
|
def offset(self, val: ArrayLike) -> None:
|
||||||
|
if numpy.any(val):
|
||||||
|
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||||
|
|
||||||
|
def set_offset(self, val: ArrayLike) -> Self:
|
||||||
|
if numpy.any(val):
|
||||||
|
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||||
|
return self
|
||||||
|
|
||||||
|
def translate(self, offset: ArrayLike) -> Self:
|
||||||
|
delta = numpy.asarray(offset, dtype=float).reshape(2)
|
||||||
|
self._rects[:, [0, 2]] += delta[0]
|
||||||
|
self._rects[:, [1, 3]] += delta[1]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rects: ArrayLike,
|
||||||
|
*,
|
||||||
|
offset: ArrayLike = (0.0, 0.0),
|
||||||
|
rotation: float = 0.0,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
) -> None:
|
||||||
|
self.rects = rects
|
||||||
|
self.repetition = repetition
|
||||||
|
self.annotations = annotations
|
||||||
|
if rotation:
|
||||||
|
self.rotate(rotation)
|
||||||
|
if numpy.any(offset):
|
||||||
|
self.translate(offset)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_raw(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
rects: NDArray[numpy.float64],
|
||||||
|
annotations: annotations_t = None,
|
||||||
|
repetition: Repetition | None = None,
|
||||||
|
) -> Self:
|
||||||
|
new = cls.__new__(cls)
|
||||||
|
new._rects = rects
|
||||||
|
new._repetition = repetition
|
||||||
|
new._annotations = annotations
|
||||||
|
return new
|
||||||
|
|
||||||
|
@property
|
||||||
|
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||||
|
for rect in self._rects:
|
||||||
|
xmin, ymin, xmax, ymax = rect
|
||||||
|
yield numpy.array([
|
||||||
|
[xmin, ymin],
|
||||||
|
[xmin, ymax],
|
||||||
|
[xmax, ymax],
|
||||||
|
[xmax, ymin],
|
||||||
|
], dtype=float)
|
||||||
|
|
||||||
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
|
memo = {} if memo is None else memo
|
||||||
|
new = copy.copy(self)
|
||||||
|
new._rects = self._rects.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
|
return new
|
||||||
|
|
||||||
|
def _sorted_rects(self) -> NDArray[numpy.float64]:
|
||||||
|
if self._rects.shape[0] <= 1:
|
||||||
|
return self._rects
|
||||||
|
order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0]))
|
||||||
|
return self._rects[order]
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return (
|
||||||
|
type(self) is type(other)
|
||||||
|
and numpy.array_equal(self._sorted_rects(), other._sorted_rects())
|
||||||
|
and self.repetition == other.repetition
|
||||||
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __lt__(self, other: Shape) -> bool:
|
||||||
|
if type(self) is not type(other):
|
||||||
|
if repr(type(self)) != repr(type(other)):
|
||||||
|
return repr(type(self)) < repr(type(other))
|
||||||
|
return id(type(self)) < id(type(other))
|
||||||
|
|
||||||
|
other = cast('RectCollection', other)
|
||||||
|
self_rects = self._sorted_rects()
|
||||||
|
other_rects = other._sorted_rects()
|
||||||
|
if not numpy.array_equal(self_rects, other_rects):
|
||||||
|
min_len = min(self_rects.shape[0], other_rects.shape[0])
|
||||||
|
eq_mask = self_rects[:min_len] != other_rects[:min_len]
|
||||||
|
eq_lt = self_rects[:min_len] < other_rects[:min_len]
|
||||||
|
eq_lt_masked = eq_lt[eq_mask]
|
||||||
|
if eq_lt_masked.size > 0:
|
||||||
|
return bool(eq_lt_masked.flat[0])
|
||||||
|
return self_rects.shape[0] < other_rects.shape[0]
|
||||||
|
if self.repetition != other.repetition:
|
||||||
|
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||||
|
return annotations_lt(self.annotations, other.annotations)
|
||||||
|
|
||||||
|
def to_polygons(
|
||||||
|
self,
|
||||||
|
num_vertices: int | None = None, # unused # noqa: ARG002
|
||||||
|
max_arclen: float | None = None, # unused # noqa: ARG002
|
||||||
|
) -> list[Polygon]:
|
||||||
|
return [
|
||||||
|
Polygon(
|
||||||
|
vertices=vertices,
|
||||||
|
repetition=copy.deepcopy(self.repetition),
|
||||||
|
annotations=copy.deepcopy(self.annotations),
|
||||||
|
)
|
||||||
|
for vertices in self.polygon_vertices
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||||
|
if self._rects.size == 0:
|
||||||
|
return None
|
||||||
|
mins = self._rects[:, :2].min(axis=0)
|
||||||
|
maxs = self._rects[:, 2:].max(axis=0)
|
||||||
|
return numpy.vstack((mins, maxs))
|
||||||
|
|
||||||
|
def rotate(self, theta: float) -> Self:
|
||||||
|
quarter_turns = int(numpy.rint(theta / (pi / 2)))
|
||||||
|
if not numpy.isclose(theta, quarter_turns * (pi / 2)):
|
||||||
|
raise PatternError('RectCollection only supports Manhattan rotations')
|
||||||
|
turns = quarter_turns % 4
|
||||||
|
if turns == 0 or self._rects.size == 0:
|
||||||
|
return self
|
||||||
|
|
||||||
|
corners = numpy.stack((
|
||||||
|
self._rects[:, [0, 1]],
|
||||||
|
self._rects[:, [0, 3]],
|
||||||
|
self._rects[:, [2, 3]],
|
||||||
|
self._rects[:, [2, 1]],
|
||||||
|
), axis=1)
|
||||||
|
flat = corners.reshape(-1, 2)
|
||||||
|
if turns == 1:
|
||||||
|
rotated = numpy.column_stack((-flat[:, 1], flat[:, 0]))
|
||||||
|
elif turns == 2:
|
||||||
|
rotated = -flat
|
||||||
|
else:
|
||||||
|
rotated = numpy.column_stack((flat[:, 1], -flat[:, 0]))
|
||||||
|
corners = rotated.reshape(corners.shape)
|
||||||
|
self._rects[:, 0] = corners[:, :, 0].min(axis=1)
|
||||||
|
self._rects[:, 1] = corners[:, :, 1].min(axis=1)
|
||||||
|
self._rects[:, 2] = corners[:, :, 0].max(axis=1)
|
||||||
|
self._rects[:, 3] = corners[:, :, 1].max(axis=1)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def mirror(self, axis: int = 0) -> Self:
|
||||||
|
if axis not in (0, 1):
|
||||||
|
raise PatternError('Axis must be 0 or 1')
|
||||||
|
if axis == 0:
|
||||||
|
self._rects[:, [1, 3]] *= -1
|
||||||
|
else:
|
||||||
|
self._rects[:, [0, 2]] *= -1
|
||||||
|
_renormalize_rects_in_place(self._rects)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def scale_by(self, c: float) -> Self:
|
||||||
|
self._rects *= c
|
||||||
|
_renormalize_rects_in_place(self._rects)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
rects = self._sorted_rects()
|
||||||
|
centers = 0.5 * (rects[:, :2] + rects[:, 2:])
|
||||||
|
offset = centers.mean(axis=0)
|
||||||
|
zeroed = rects.copy()
|
||||||
|
zeroed[:, [0, 2]] -= offset[0]
|
||||||
|
zeroed[:, [1, 3]] -= offset[1]
|
||||||
|
normed = zeroed / norm_value
|
||||||
|
return (
|
||||||
|
(type(self), normed.data.tobytes()),
|
||||||
|
(offset, 1.0, 0.0, False),
|
||||||
|
lambda: RectCollection(rects=normed * norm_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
if self._rects.size == 0:
|
||||||
|
return '<RectCollection r0>'
|
||||||
|
centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:])
|
||||||
|
centroid = centers.mean(axis=0)
|
||||||
|
return f'<RectCollection centroid {centroid} r{self._rects.shape[0]}>'
|
||||||
|
|
@ -6,8 +6,8 @@ import numpy
|
||||||
from numpy.typing import NDArray, ArrayLike
|
from numpy.typing import NDArray, ArrayLike
|
||||||
|
|
||||||
from ..traits import (
|
from ..traits import (
|
||||||
Rotatable, Mirrorable, Copyable, Scalable,
|
Copyable, Scalable, FlippableImpl,
|
||||||
Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -26,8 +26,9 @@ normalized_shape_tuple = tuple[
|
||||||
DEFAULT_POLY_NUM_VERTICES = 24
|
DEFAULT_POLY_NUM_VERTICES = 24
|
||||||
|
|
||||||
|
|
||||||
class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||||
PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta):
|
Copyable, Scalable,
|
||||||
|
metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
Class specifying functions common to all shapes.
|
Class specifying functions common to all shapes.
|
||||||
"""
|
"""
|
||||||
|
|
@ -73,7 +74,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def normalized_form(self, norm_value: int) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
"""
|
"""
|
||||||
Writes the shape in a standardized notation, with offset, scale, and rotation
|
Writes the shape in a standardized notation, with offset, scale, and rotation
|
||||||
information separated out from the remaining values.
|
information separated out from the remaining values.
|
||||||
|
|
@ -120,7 +121,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||||
Returns:
|
Returns:
|
||||||
List of `Polygon` objects with grid-aligned edges.
|
List of `Polygon` objects with grid-aligned edges.
|
||||||
"""
|
"""
|
||||||
from . import Polygon
|
from . import Polygon #noqa: PLC0415
|
||||||
|
|
||||||
gx = numpy.unique(grid_x)
|
gx = numpy.unique(grid_x)
|
||||||
gy = numpy.unique(grid_y)
|
gy = numpy.unique(grid_y)
|
||||||
|
|
@ -138,22 +139,24 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||||
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
|
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
|
||||||
dv = v_next - v
|
dv = v_next - v
|
||||||
|
|
||||||
# Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
|
# Find x-index bounds for the line
|
||||||
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
|
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
|
||||||
gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1)
|
gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1))
|
||||||
gxi_max = numpy.max(gxi_range).clip(0, len(gx))
|
gxi_max = int(numpy.max(gxi_range).clip(0, len(gx)))
|
||||||
|
|
||||||
|
if gxi_min < len(gx) - 1:
|
||||||
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
||||||
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
|
||||||
|
|
||||||
if err_xmin >= 0.5:
|
if err_xmin >= 0.5:
|
||||||
gxi_min += 1
|
gxi_min += 1
|
||||||
|
|
||||||
|
if gxi_max > 0 and gxi_max < len(gx):
|
||||||
|
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
||||||
if err_xmax >= 0.5:
|
if err_xmax >= 0.5:
|
||||||
gxi_max += 1
|
gxi_max += 1
|
||||||
|
|
||||||
if abs(dv[0]) < 1e-20:
|
if abs(dv[0]) < 1e-20:
|
||||||
# Vertical line, don't calculate slope
|
# Vertical line, don't calculate slope
|
||||||
xi = [gxi_min, gxi_max - 1]
|
xi = [gxi_min, max(gxi_min, gxi_max - 1)]
|
||||||
ys = numpy.array([v[1], v_next[1]])
|
ys = numpy.array([v[1], v_next[1]])
|
||||||
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
|
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
|
||||||
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
|
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
|
||||||
|
|
@ -249,9 +252,9 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable,
|
||||||
Returns:
|
Returns:
|
||||||
List of `Polygon` objects with grid-aligned edges.
|
List of `Polygon` objects with grid-aligned edges.
|
||||||
"""
|
"""
|
||||||
from . import Polygon
|
from . import Polygon #noqa: PLC0415
|
||||||
import skimage.measure # type: ignore
|
import skimage.measure #noqa: PLC0415
|
||||||
import float_raster
|
import float_raster #noqa: PLC0415
|
||||||
|
|
||||||
grx = numpy.unique(grid_x)
|
grx = numpy.unique(grid_x)
|
||||||
gry = numpy.unique(grid_y)
|
gry = numpy.unique(grid_y)
|
||||||
|
|
|
||||||
|
|
@ -70,31 +70,48 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
*,
|
*,
|
||||||
offset: ArrayLike = (0.0, 0.0),
|
offset: ArrayLike = (0.0, 0.0),
|
||||||
rotation: float = 0.0,
|
rotation: float = 0.0,
|
||||||
|
mirrored: bool = False,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
raw: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
|
||||||
assert isinstance(offset, numpy.ndarray)
|
|
||||||
self._offset = offset
|
|
||||||
self._string = string
|
|
||||||
self._height = height
|
|
||||||
self._rotation = rotation
|
|
||||||
self._repetition = repetition
|
|
||||||
self._annotations = annotations
|
|
||||||
else:
|
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.string = string
|
self.string = string
|
||||||
self.height = height
|
self.height = height
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
|
self.mirrored = mirrored
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
self.font_path = font_path
|
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:
|
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||||
memo = {} if memo is None else memo
|
memo = {} if memo is None else memo
|
||||||
new = copy.copy(self)
|
new = copy.copy(self)
|
||||||
new._offset = self._offset.copy()
|
new._offset = self._offset.copy()
|
||||||
|
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||||
new._annotations = copy.deepcopy(self._annotations)
|
new._annotations = copy.deepcopy(self._annotations)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
@ -105,6 +122,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
and self.string == other.string
|
and self.string == other.string
|
||||||
and self.height == other.height
|
and self.height == other.height
|
||||||
and self.font_path == other.font_path
|
and self.font_path == other.font_path
|
||||||
|
and self.mirrored == other.mirrored
|
||||||
and self.rotation == other.rotation
|
and self.rotation == other.rotation
|
||||||
and self.repetition == other.repetition
|
and self.repetition == other.repetition
|
||||||
and annotations_eq(self.annotations, other.annotations)
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
|
|
@ -124,6 +142,8 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
return self.font_path < other.font_path
|
return self.font_path < other.font_path
|
||||||
if not numpy.array_equal(self.offset, other.offset):
|
if not numpy.array_equal(self.offset, other.offset):
|
||||||
return tuple(self.offset) < tuple(other.offset)
|
return tuple(self.offset) < tuple(other.offset)
|
||||||
|
if self.mirrored != other.mirrored:
|
||||||
|
return self.mirrored < other.mirrored
|
||||||
if self.rotation != other.rotation:
|
if self.rotation != other.rotation:
|
||||||
return self.rotation < other.rotation
|
return self.rotation < other.rotation
|
||||||
if self.repetition != other.repetition:
|
if self.repetition != other.repetition:
|
||||||
|
|
@ -146,7 +166,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
if self.mirrored:
|
if self.mirrored:
|
||||||
poly.mirror()
|
poly.mirror()
|
||||||
poly.scale_by(self.height)
|
poly.scale_by(self.height)
|
||||||
poly.offset = self.offset + [total_advance, 0]
|
poly.translate(self.offset + [total_advance, 0])
|
||||||
poly.rotate_around(self.offset, self.rotation)
|
poly.rotate_around(self.offset, self.rotation)
|
||||||
all_polygons += [poly]
|
all_polygons += [poly]
|
||||||
|
|
||||||
|
|
@ -171,22 +191,25 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
||||||
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
|
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
|
||||||
lambda: Text(
|
lambda: Text(
|
||||||
string=self.string,
|
string=self.string,
|
||||||
height=self.height * norm_value,
|
height=norm_value,
|
||||||
font_path=self.font_path,
|
font_path=self.font_path,
|
||||||
rotation=rotation,
|
rotation=rotation,
|
||||||
).mirror2d(across_x=self.mirrored),
|
).mirror2d(across_x=self.mirrored),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||||
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
|
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
|
||||||
# just convert to polygons instead
|
# just convert to polygons instead
|
||||||
polys = self.to_polygons()
|
polys = self.to_polygons()
|
||||||
|
if not polys:
|
||||||
|
return None
|
||||||
|
|
||||||
pbounds = numpy.full((len(polys), 2, 2), nan)
|
pbounds = numpy.full((len(polys), 2, 2), nan)
|
||||||
for pp, poly in enumerate(polys):
|
for pp, poly in enumerate(polys):
|
||||||
pbounds[pp] = poly.get_bounds_nonempty()
|
pbounds[pp] = poly.get_bounds_nonempty()
|
||||||
bounds = numpy.vstack((
|
bounds = numpy.vstack((
|
||||||
numpy.min(pbounds[: 0, :], axis=0),
|
numpy.min(pbounds[:, 0, :], axis=0),
|
||||||
numpy.max(pbounds[: 1, :], axis=0),
|
numpy.max(pbounds[:, 1, :], axis=0),
|
||||||
))
|
))
|
||||||
|
|
||||||
return bounds
|
return bounds
|
||||||
|
|
@ -202,8 +225,8 @@ def get_char_as_polygons(
|
||||||
char: str,
|
char: str,
|
||||||
resolution: float = 48 * 64,
|
resolution: float = 48 * 64,
|
||||||
) -> tuple[list[NDArray[numpy.float64]], float]:
|
) -> tuple[list[NDArray[numpy.float64]], float]:
|
||||||
from freetype import Face # type: ignore
|
from freetype import Face # type: ignore #noqa: PLC0415
|
||||||
from matplotlib.path import Path # type: ignore
|
from matplotlib.path import Path # type: ignore #noqa: PLC0415
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Get a list of polygons representing a single character.
|
Get a list of polygons representing a single character.
|
||||||
|
|
|
||||||
3
masque/test/__init__.py
Normal file
3
masque/test/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
||||||
|
"""
|
||||||
13
masque/test/conftest.py
Normal file
13
masque/test/conftest.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""
|
||||||
|
|
||||||
|
Test fixtures
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: ARG001
|
||||||
|
from typing import Any
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
|
||||||
|
FixtureRequest = Any
|
||||||
|
PRNG = numpy.random.RandomState(12345)
|
||||||
146
masque/test/helpers.py
Normal file
146
masque/test/helpers.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
from typing import Any
|
||||||
|
from collections.abc import Callable
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from masque import Pather, Port
|
||||||
|
from masque.builder.tools import RenderStep
|
||||||
|
|
||||||
|
|
||||||
|
def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Return lengths for each edge of an implicitly closed vertex loop.
|
||||||
|
"""
|
||||||
|
vv = numpy.asarray(vertices, dtype=float)
|
||||||
|
return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1))
|
||||||
|
|
||||||
|
|
||||||
|
def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None:
|
||||||
|
"""
|
||||||
|
Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`.
|
||||||
|
"""
|
||||||
|
assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None:
|
||||||
|
"""
|
||||||
|
Assert that an object's single-shape bounds match `expected`.
|
||||||
|
"""
|
||||||
|
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_route_data(data: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Return a deterministic, comparison-friendly representation of route data.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0])))
|
||||||
|
if isinstance(data, list | tuple):
|
||||||
|
return tuple(normalized_route_data(value) for value in data)
|
||||||
|
if isinstance(data, numpy.ndarray):
|
||||||
|
return tuple(normalized_route_data(value) for value in data.tolist())
|
||||||
|
if isinstance(data, numpy.generic):
|
||||||
|
return data.item()
|
||||||
|
try:
|
||||||
|
hash(data)
|
||||||
|
except TypeError:
|
||||||
|
return repr(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def route_step_signature(step: RenderStep) -> tuple[Any, ...]:
|
||||||
|
"""
|
||||||
|
Return the stable planning-relevant portion of one rendered route step.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
step.opcode,
|
||||||
|
tuple(round(float(value), 9) for value in step.start_port.offset),
|
||||||
|
None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9),
|
||||||
|
step.start_port.ptype,
|
||||||
|
tuple(round(float(value), 9) for value in step.end_port.offset),
|
||||||
|
None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9),
|
||||||
|
step.end_port.ptype,
|
||||||
|
normalized_route_data(step.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]:
|
||||||
|
"""
|
||||||
|
Return a deterministic signature for a pather route.
|
||||||
|
"""
|
||||||
|
return tuple(route_step_signature(step) for step in pather._paths[portspec])
|
||||||
|
|
||||||
|
|
||||||
|
def route_endpoint(pather: Pather, portspec: str) -> Port:
|
||||||
|
"""
|
||||||
|
Return the endpoint of a routed port, falling back to the live port for empty routes.
|
||||||
|
"""
|
||||||
|
steps = pather._paths[portspec]
|
||||||
|
if not steps:
|
||||||
|
return pather.pattern[portspec]
|
||||||
|
return steps[-1].end_port
|
||||||
|
|
||||||
|
|
||||||
|
def assert_route_endpoint(
|
||||||
|
pather: Pather,
|
||||||
|
portspec: str,
|
||||||
|
expected: Port,
|
||||||
|
*,
|
||||||
|
atol: float = 1e-8,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Assert that a route endpoint matches an expected port pose and ptype.
|
||||||
|
"""
|
||||||
|
actual = route_endpoint(pather, portspec)
|
||||||
|
assert_allclose(actual.offset, expected.offset, atol=atol)
|
||||||
|
if expected.rotation is None:
|
||||||
|
assert actual.rotation is None
|
||||||
|
else:
|
||||||
|
assert actual.rotation is not None
|
||||||
|
assert numpy.isclose(actual.rotation, expected.rotation, atol=atol)
|
||||||
|
assert actual.ptype == expected.ptype
|
||||||
|
|
||||||
|
|
||||||
|
def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None:
|
||||||
|
"""
|
||||||
|
Assert a simple render-step bend budget for route signatures.
|
||||||
|
"""
|
||||||
|
bend_count = sum(1 for step in pather._paths[portspec] if step.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__}')
|
||||||
85
masque/test/test_abstract.py
Normal file
85
masque/test/test_abstract.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
|
from ..abstract import Abstract
|
||||||
|
from ..ports import Port
|
||||||
|
from ..ref import Ref
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_init() -> None:
|
||||||
|
ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)}
|
||||||
|
abs_obj = Abstract("test", ports)
|
||||||
|
assert abs_obj.name == "test"
|
||||||
|
assert len(abs_obj.ports) == 2
|
||||||
|
assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_transform() -> None:
|
||||||
|
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||||
|
# Rotate 90 deg around (0,0)
|
||||||
|
abs_obj.rotate_around((0, 0), pi / 2)
|
||||||
|
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
||||||
|
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
|
||||||
|
assert abs_obj.ports["A"].rotation is not None
|
||||||
|
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||||
|
|
||||||
|
# Mirror across x axis (axis 0): flips y-offset
|
||||||
|
abs_obj.mirror(0)
|
||||||
|
# (0, 10) mirrored(0) -> (0, -10)
|
||||||
|
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
|
||||||
|
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
|
||||||
|
assert abs_obj.ports["A"].rotation is not None
|
||||||
|
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_ref_transform() -> None:
|
||||||
|
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||||
|
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||||
|
|
||||||
|
# Apply ref transform
|
||||||
|
abs_obj.apply_ref_transform(ref)
|
||||||
|
# Ref order: mirror, rotate, scale, translate
|
||||||
|
|
||||||
|
# 1. mirror (across x: y -> -y)
|
||||||
|
# (10, 0) rot 0 -> (10, 0) rot 0
|
||||||
|
|
||||||
|
# 2. rotate pi/2 around (0,0)
|
||||||
|
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
||||||
|
|
||||||
|
# 3. translate (100, 100)
|
||||||
|
# (0, 10) -> (100, 110)
|
||||||
|
|
||||||
|
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
|
||||||
|
assert abs_obj.ports["A"].rotation is not None
|
||||||
|
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_ref_transform_scales_offsets() -> None:
|
||||||
|
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||||
|
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
||||||
|
|
||||||
|
abs_obj.apply_ref_transform(ref)
|
||||||
|
|
||||||
|
assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10)
|
||||||
|
assert abs_obj.ports["A"].rotation is not None
|
||||||
|
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_undo_transform() -> None:
|
||||||
|
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
|
||||||
|
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||||
|
|
||||||
|
abs_obj.undo_ref_transform(ref)
|
||||||
|
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||||
|
assert abs_obj.ports["A"].rotation is not None
|
||||||
|
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_undo_transform_scales_offsets() -> None:
|
||||||
|
abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)})
|
||||||
|
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
||||||
|
|
||||||
|
abs_obj.undo_ref_transform(ref)
|
||||||
|
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||||
|
assert abs_obj.ports["A"].rotation is not None
|
||||||
|
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||||
87
masque/test/test_arc.py
Normal file
87
masque/test/test_arc.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..shapes import Arc
|
||||||
|
from .helpers import assert_closed_edges_within
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_init() -> None:
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
||||||
|
assert_equal(a.radii, [10, 10])
|
||||||
|
assert_equal(a.angles, [0, pi / 2])
|
||||||
|
assert a.width == 2
|
||||||
|
|
||||||
|
def test_arc_to_polygons() -> None:
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
polys = a.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
|
||||||
|
# Quarter-circle ring section with outer radius 11 and inner radius 9.
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_arc_focus_to_polygons() -> None:
|
||||||
|
a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
polys = a.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
|
||||||
|
focus = numpy.array([8.0, 0.0])
|
||||||
|
cuts = a.get_cap_edges()
|
||||||
|
for angle, cut in zip(a.angles, cuts, strict=True):
|
||||||
|
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||||
|
for point in cut:
|
||||||
|
delta = point - focus
|
||||||
|
assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10)
|
||||||
|
assert numpy.dot(direction, delta) > 0
|
||||||
|
|
||||||
|
def test_arc_circle_focus_matches_center() -> None:
|
||||||
|
center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
|
||||||
|
assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices,
|
||||||
|
center.to_polygons(num_vertices=32)[0].vertices,
|
||||||
|
atol=1e-10)
|
||||||
|
|
||||||
|
def test_arc_edge_cases() -> None:
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
||||||
|
a.to_polygons(num_vertices=64)
|
||||||
|
bounds = a.get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
||||||
|
arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200))
|
||||||
|
bounds = arc.get_bounds_single()
|
||||||
|
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None:
|
||||||
|
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4,
|
||||||
|
offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
bounds = arc.get_bounds_single()
|
||||||
|
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
||||||
|
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
|
||||||
|
with pytest.raises(PatternError, match='valid max_arclen'):
|
||||||
|
arc.to_polygons(num_vertices=24)
|
||||||
|
|
||||||
|
def test_focus_arc_rejects_focus_outside_inner_boundary() -> None:
|
||||||
|
arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
with pytest.raises(PatternError, match='inside both arc boundary ellipses'):
|
||||||
|
arc.to_polygons(num_vertices=24)
|
||||||
|
|
||||||
|
def test_focus_arc_max_arclen_limits_segments() -> None:
|
||||||
|
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg)
|
||||||
|
assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2)
|
||||||
|
|
||||||
|
def test_arc_rejects_zero_radii_up_front() -> None:
|
||||||
|
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||||
|
Arc(radii=(0, 5), angles=(0, 1), width=1)
|
||||||
|
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||||
|
Arc(radii=(5, 0), angles=(0, 1), width=1)
|
||||||
|
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||||
|
Arc(radii=(0, 0), angles=(0, 1), width=1)
|
||||||
1219
masque/test/test_autotool_planning.py
Normal file
1219
masque/test/test_autotool_planning.py
Normal file
File diff suppressed because it is too large
Load diff
242
masque/test/test_boolean.py
Normal file
242
masque/test/test_boolean.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
# ruff: noqa: PLC0415
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
from masque.pattern import Pattern
|
||||||
|
from masque.shapes.polygon import Polygon
|
||||||
|
from masque.repetition import Grid
|
||||||
|
from masque.library import Library
|
||||||
|
from masque.error import PatternError
|
||||||
|
|
||||||
|
|
||||||
|
def _poly_area(poly: Polygon) -> float:
|
||||||
|
verts = poly.vertices
|
||||||
|
x = verts[:, 0]
|
||||||
|
y = verts[:, 1]
|
||||||
|
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
|
||||||
|
|
||||||
|
def test_layer_as_polygons_basic() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||||
|
|
||||||
|
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert isinstance(polys[0], Polygon)
|
||||||
|
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||||
|
|
||||||
|
def test_layer_as_polygons_repetition() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
rep = Grid(a_vector=(2, 0), a_count=2)
|
||||||
|
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
|
||||||
|
|
||||||
|
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||||
|
assert len(polys) == 2
|
||||||
|
# First polygon at (0,0)
|
||||||
|
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||||
|
# Second polygon at (2,0)
|
||||||
|
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
|
||||||
|
|
||||||
|
def test_layer_as_polygons_flatten() -> None:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
child = Pattern()
|
||||||
|
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
|
||||||
|
lib['child'] = child
|
||||||
|
|
||||||
|
parent = Pattern()
|
||||||
|
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
|
||||||
|
|
||||||
|
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
||||||
|
assert len(polys) == 1
|
||||||
|
# Child vertices are rotated by the ref and then translated by the ref offset.
|
||||||
|
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
||||||
|
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
||||||
|
|
||||||
|
def test_boolean_import_error() -> None:
|
||||||
|
from masque import boolean
|
||||||
|
# If pyclipper is not installed, this should raise ImportError
|
||||||
|
try:
|
||||||
|
import pyclipper # noqa: F401
|
||||||
|
pytest.skip("pyclipper is installed, cannot test ImportError")
|
||||||
|
except ImportError:
|
||||||
|
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||||
|
boolean([], [], operation='union')
|
||||||
|
|
||||||
|
def test_polygon_boolean_shortcut() -> None:
|
||||||
|
poly = Polygon([[0, 0], [1, 0], [1, 1]])
|
||||||
|
# This should also raise ImportError if pyclipper is missing
|
||||||
|
try:
|
||||||
|
import pyclipper # noqa: F401
|
||||||
|
pytest.skip("pyclipper is installed")
|
||||||
|
except ImportError:
|
||||||
|
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||||
|
poly.boolean(poly)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_intersection_with_pyclipper() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
result = boolean(
|
||||||
|
[Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])],
|
||||||
|
[Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])],
|
||||||
|
operation='intersection',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_polygon_boolean_shortcut_with_pyclipper() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
|
||||||
|
poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||||
|
result = poly.boolean(
|
||||||
|
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||||
|
operation='intersection',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_union_difference_and_xor_with_pyclipper() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||||
|
rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
|
||||||
|
|
||||||
|
union = boolean([rect_a], [rect_b], operation='union')
|
||||||
|
assert len(union) == 1
|
||||||
|
assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10)
|
||||||
|
assert_allclose(_poly_area(union[0]), 7, atol=1e-10)
|
||||||
|
|
||||||
|
difference = boolean([rect_a], [rect_b], operation='difference')
|
||||||
|
assert len(difference) == 1
|
||||||
|
assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10)
|
||||||
|
assert_allclose(_poly_area(difference[0]), 3, atol=1e-10)
|
||||||
|
|
||||||
|
xor = boolean([rect_a], [rect_b], operation='xor')
|
||||||
|
assert len(xor) == 2
|
||||||
|
assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10)
|
||||||
|
xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor)
|
||||||
|
assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
raw_result = boolean(
|
||||||
|
[numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])],
|
||||||
|
numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||||
|
operation='intersection',
|
||||||
|
)
|
||||||
|
assert len(raw_result) == 1
|
||||||
|
assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||||
|
assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10)
|
||||||
|
|
||||||
|
single_shape_result = boolean(
|
||||||
|
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
|
||||||
|
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||||
|
operation='intersection',
|
||||||
|
)
|
||||||
|
assert len(single_shape_result) == 1
|
||||||
|
assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_handles_multi_polygon_inputs() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
result = boolean(
|
||||||
|
[
|
||||||
|
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
|
||||||
|
Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||||
|
Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]),
|
||||||
|
],
|
||||||
|
operation='intersection',
|
||||||
|
)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10)
|
||||||
|
result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result)
|
||||||
|
assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
|
||||||
|
result = boolean([outer], [hole], operation='difference')
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
|
||||||
|
assert_allclose(_poly_area(result[0]), 64, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_nested_hole_and_island_case() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
|
||||||
|
island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]])
|
||||||
|
|
||||||
|
result = boolean([outer, island], [hole], operation='union')
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
|
||||||
|
assert_allclose(_poly_area(result[0]), 100, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_empty_inputs_follow_set_semantics() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
|
||||||
|
|
||||||
|
union = boolean([], [rect], operation='union')
|
||||||
|
assert len(union) == 1
|
||||||
|
assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||||
|
|
||||||
|
intersection = boolean([], [rect], operation='intersection')
|
||||||
|
assert intersection == []
|
||||||
|
|
||||||
|
difference = boolean([], [rect], operation='difference')
|
||||||
|
assert difference == []
|
||||||
|
|
||||||
|
xor = boolean([], [rect], operation='xor')
|
||||||
|
assert len(xor) == 1
|
||||||
|
assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||||
|
|
||||||
|
clip_empty_union = boolean([rect], [], operation='union')
|
||||||
|
assert len(clip_empty_union) == 1
|
||||||
|
assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||||
|
|
||||||
|
clip_empty_intersection = boolean([rect], [], operation='intersection')
|
||||||
|
assert clip_empty_intersection == []
|
||||||
|
|
||||||
|
clip_empty_difference = boolean([rect], [], operation='difference')
|
||||||
|
assert len(clip_empty_difference) == 1
|
||||||
|
assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||||
|
|
||||||
|
clip_empty_xor = boolean([rect], [], operation='xor')
|
||||||
|
assert len(clip_empty_xor) == 1
|
||||||
|
assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boolean_invalid_inputs_raise_pattern_error() -> None:
|
||||||
|
pytest.importorskip("pyclipper")
|
||||||
|
from masque.utils.boolean import boolean
|
||||||
|
|
||||||
|
rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||||
|
|
||||||
|
for bad in (123, object(), [123]):
|
||||||
|
with pytest.raises(PatternError, match='Unsupported type'):
|
||||||
|
boolean([rect], bad, operation='intersection')
|
||||||
485
masque/test/test_build_library.py
Normal file
485
masque/test/test_build_library.py
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
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()
|
||||||
173
masque/test/test_builder.py
Normal file
173
masque/test/test_builder.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
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]))
|
||||||
17
masque/test/test_circle.py
Normal file
17
masque/test/test_circle.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..shapes import Circle, Polygon
|
||||||
|
|
||||||
|
|
||||||
|
def test_circle_init() -> None:
|
||||||
|
c = Circle(radius=10, offset=(5, 5))
|
||||||
|
assert c.radius == 10
|
||||||
|
assert_equal(c.offset, [5, 5])
|
||||||
|
|
||||||
|
def test_circle_to_polygons() -> None:
|
||||||
|
c = Circle(radius=10)
|
||||||
|
polys = c.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert isinstance(polys[0], Polygon)
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
||||||
26
masque/test/test_curve_polygonization.py
Normal file
26
masque/test/test_curve_polygonization.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
|
from ..shapes import Arc, Circle, Ellipse
|
||||||
|
from .helpers import assert_closed_edges_within
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_arclen() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5))
|
||||||
|
polys = e.to_polygons(max_arclen=5)
|
||||||
|
v = polys[0].vertices
|
||||||
|
assert_closed_edges_within(v, 5)
|
||||||
|
assert len(v) > 10
|
||||||
|
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
polys = a.to_polygons(max_arclen=2)
|
||||||
|
assert_closed_edges_within(polys[0].vertices, 2)
|
||||||
|
|
||||||
|
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
||||||
|
for shape in (
|
||||||
|
Circle(radius=10),
|
||||||
|
Ellipse(radii=(10, 20)),
|
||||||
|
Arc(radii=(10, 20), angles=(0, 1), width=2),
|
||||||
|
):
|
||||||
|
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert len(polys[0].vertices) >= 3
|
||||||
168
masque/test/test_dxf.py
Normal file
168
masque/test/test_dxf.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import io
|
||||||
|
import numpy
|
||||||
|
import ezdxf
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import Library
|
||||||
|
from ..shapes import Path as MPath, Polygon
|
||||||
|
from ..repetition import Grid
|
||||||
|
from ..file import dxf
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
|
||||||
|
return bool(
|
||||||
|
numpy.allclose(actual, expected)
|
||||||
|
or numpy.allclose(actual, expected[::-1])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
|
||||||
|
return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_roundtrip(tmp_path: Path):
|
||||||
|
lib = Library()
|
||||||
|
pat = Pattern()
|
||||||
|
|
||||||
|
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
pat.polygon("1", vertices=poly_verts)
|
||||||
|
|
||||||
|
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
|
||||||
|
pat.path("2", vertices=path_verts, width=2)
|
||||||
|
|
||||||
|
# Two-point paths remain paths rather than being polygonized.
|
||||||
|
path2_verts = numpy.array([[40, 0], [50, 10]])
|
||||||
|
pat.path("3", vertices=path2_verts, width=0)
|
||||||
|
|
||||||
|
subpat = Pattern()
|
||||||
|
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
|
lib["sub"] = subpat
|
||||||
|
|
||||||
|
pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3))
|
||||||
|
|
||||||
|
lib["top"] = pat
|
||||||
|
|
||||||
|
dxf_file = tmp_path / "test.dxf"
|
||||||
|
dxf.writefile(lib, "top", dxf_file)
|
||||||
|
|
||||||
|
read_lib, _ = dxf.readfile(dxf_file)
|
||||||
|
|
||||||
|
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||||
|
|
||||||
|
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
|
||||||
|
assert len(polys) >= 1
|
||||||
|
poly_read = polys[0]
|
||||||
|
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
|
||||||
|
|
||||||
|
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
|
||||||
|
assert len(paths) >= 1
|
||||||
|
path_read = paths[0]
|
||||||
|
assert _matches_open_path(path_read.vertices, path_verts)
|
||||||
|
assert path_read.width == 2
|
||||||
|
|
||||||
|
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
|
||||||
|
assert len(paths2) >= 1
|
||||||
|
path2_read = paths2[0]
|
||||||
|
assert _matches_open_path(path2_read.vertices, path2_verts)
|
||||||
|
assert path2_read.width == 0
|
||||||
|
|
||||||
|
assert "sub" in read_lib
|
||||||
|
|
||||||
|
found_grid = False
|
||||||
|
for target, reflist in top_pat.refs.items():
|
||||||
|
if target.upper() == "SUB":
|
||||||
|
for ref in reflist:
|
||||||
|
if isinstance(ref.repetition, Grid):
|
||||||
|
assert ref.repetition.a_count == 2
|
||||||
|
assert ref.repetition.b_count == 3
|
||||||
|
assert_allclose(ref.repetition.a_vector, (10, 0))
|
||||||
|
assert_allclose(ref.repetition.b_vector, (0, 10))
|
||||||
|
found_grid = True
|
||||||
|
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
|
||||||
|
|
||||||
|
def test_dxf_manhattan_precision(tmp_path: Path):
|
||||||
|
lib = Library()
|
||||||
|
sub = Pattern()
|
||||||
|
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
|
lib["sub"] = sub
|
||||||
|
|
||||||
|
top = Pattern()
|
||||||
|
angle = numpy.pi / 2 # 90 degrees
|
||||||
|
top.ref("sub", offset=(0, 0), rotation=angle,
|
||||||
|
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
|
||||||
|
|
||||||
|
lib["top"] = top
|
||||||
|
|
||||||
|
dxf_file = tmp_path / "precision.dxf"
|
||||||
|
dxf.writefile(lib, "top", dxf_file)
|
||||||
|
|
||||||
|
# Near-integer rotated basis vectors round-trip as a Manhattan Grid.
|
||||||
|
read_lib, _ = dxf.readfile(dxf_file)
|
||||||
|
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||||
|
|
||||||
|
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
||||||
|
ref = read_top.refs[target_name][0]
|
||||||
|
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path):
|
||||||
|
lib = Library()
|
||||||
|
sub = Pattern()
|
||||||
|
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
|
lib["sub"] = sub
|
||||||
|
|
||||||
|
top = Pattern()
|
||||||
|
top.ref(
|
||||||
|
"sub",
|
||||||
|
offset=(0, 0),
|
||||||
|
rotation=numpy.pi / 2,
|
||||||
|
repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2),
|
||||||
|
)
|
||||||
|
lib["top"] = top
|
||||||
|
|
||||||
|
dxf_file = tmp_path / "rotated_grid.dxf"
|
||||||
|
dxf.writefile(lib, "top", dxf_file)
|
||||||
|
|
||||||
|
read_lib, _ = dxf.readfile(dxf_file)
|
||||||
|
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||||
|
|
||||||
|
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
||||||
|
ref = read_top.refs[target_name][0]
|
||||||
|
assert isinstance(ref.repetition, Grid)
|
||||||
|
actual = ref.repetition.displacements
|
||||||
|
expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements
|
||||||
|
assert_allclose(
|
||||||
|
actual[numpy.lexsort((actual[:, 1], actual[:, 0]))],
|
||||||
|
expected[numpy.lexsort((expected[:, 1], expected[:, 0]))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_read_legacy_polyline() -> None:
|
||||||
|
doc = ezdxf.new()
|
||||||
|
msp = doc.modelspace()
|
||||||
|
msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True)
|
||||||
|
|
||||||
|
stream = io.StringIO()
|
||||||
|
doc.write(stream)
|
||||||
|
stream.seek(0)
|
||||||
|
|
||||||
|
read_lib, _ = dxf.read(stream)
|
||||||
|
top_pat = read_lib.get("Model") or list(read_lib.values())[0]
|
||||||
|
|
||||||
|
polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)]
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_read_ignores_unreferenced_setup_blocks() -> None:
|
||||||
|
lib = Library({"top": Pattern()})
|
||||||
|
stream = io.StringIO()
|
||||||
|
|
||||||
|
dxf.write(lib, "top", stream)
|
||||||
|
stream.seek(0)
|
||||||
|
|
||||||
|
read_lib, _ = dxf.read(stream)
|
||||||
|
|
||||||
|
assert set(read_lib) == {"Model"}
|
||||||
29
masque/test/test_ellipse.py
Normal file
29
masque/test/test_ellipse.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..shapes import Ellipse
|
||||||
|
|
||||||
|
|
||||||
|
def test_ellipse_init() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
||||||
|
assert_equal(e.radii, [10, 5])
|
||||||
|
assert_equal(e.offset, [1, 2])
|
||||||
|
assert e.rotation == pi / 4
|
||||||
|
|
||||||
|
def test_ellipse_to_polygons() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5))
|
||||||
|
polys = e.to_polygons(num_vertices=64)
|
||||||
|
assert len(polys) == 1
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
|
||||||
|
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
|
||||||
|
bounds = ellipse.get_bounds_single()
|
||||||
|
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
||||||
|
ellipse = Ellipse(radii=(10, 20))
|
||||||
|
ellipse.scale_by(0.5)
|
||||||
|
assert_allclose(ellipse.radii, [5, 10])
|
||||||
24
masque/test/test_fdfd.py
Normal file
24
masque/test/test_fdfd.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# ruff: noqa
|
||||||
|
# ruff: noqa: ARG001
|
||||||
|
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import pytest # type: ignore
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
# from numpy.testing import assert_allclose, assert_array_equal
|
||||||
|
|
||||||
|
from .. import Pattern, Arc, Circle
|
||||||
|
|
||||||
|
|
||||||
|
def test_circle_mirror():
|
||||||
|
cc = Circle(radius=4, offset=(10, 20))
|
||||||
|
cc.flip_across(axis=0) # flip across y=0
|
||||||
|
assert cc.offset[0] == 10
|
||||||
|
assert cc.offset[1] == -20
|
||||||
|
assert cc.radius == 4
|
||||||
|
cc.flip_across(axis=1) # flip across x=0
|
||||||
|
assert cc.offset[0] == -10
|
||||||
|
assert cc.offset[1] == -20
|
||||||
|
assert cc.radius == 4
|
||||||
138
masque/test/test_file_format_roundtrip.py
Normal file
138
masque/test/test_file_format_roundtrip.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
import pytest
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import Library
|
||||||
|
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
|
||||||
|
from ..repetition import Grid, Arbitrary
|
||||||
|
|
||||||
|
def create_test_library(for_gds: bool = False) -> Library:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
pat_poly = Pattern()
|
||||||
|
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
|
||||||
|
lib["polygons"] = pat_poly
|
||||||
|
|
||||||
|
pat_paths = Pattern()
|
||||||
|
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
|
||||||
|
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
|
||||||
|
if for_gds:
|
||||||
|
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
|
||||||
|
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
|
||||||
|
lib["paths"] = pat_paths
|
||||||
|
|
||||||
|
pat_circles = Pattern()
|
||||||
|
if for_gds:
|
||||||
|
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
|
||||||
|
else:
|
||||||
|
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
|
||||||
|
lib["circles"] = pat_circles
|
||||||
|
|
||||||
|
pat_refs = Pattern()
|
||||||
|
pat_refs.ref("polygons", offset=(0, 0))
|
||||||
|
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
|
||||||
|
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
|
||||||
|
lib["refs"] = pat_refs
|
||||||
|
|
||||||
|
pat_rep_shapes = Pattern()
|
||||||
|
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
|
||||||
|
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
|
||||||
|
lib["rep_shapes"] = pat_rep_shapes
|
||||||
|
|
||||||
|
if for_gds:
|
||||||
|
lib.wrap_repeated_shapes()
|
||||||
|
|
||||||
|
return lib
|
||||||
|
|
||||||
|
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
|
||||||
|
from ..file import gdsii
|
||||||
|
lib = create_test_library(for_gds=True)
|
||||||
|
gds_file = tmp_path / "full_test.gds"
|
||||||
|
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||||
|
|
||||||
|
read_lib, _ = gdsii.readfile(gds_file)
|
||||||
|
|
||||||
|
for name in lib:
|
||||||
|
assert name in read_lib
|
||||||
|
|
||||||
|
read_paths = read_lib["paths"]
|
||||||
|
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
|
||||||
|
assert p_flush.cap == MPath.Cap.Flush
|
||||||
|
|
||||||
|
p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
|
||||||
|
assert p_square.cap == MPath.Cap.Square
|
||||||
|
|
||||||
|
p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
|
||||||
|
assert p_circle.cap == MPath.Cap.Circle
|
||||||
|
|
||||||
|
p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
|
||||||
|
assert p_custom.cap == MPath.Cap.SquareCustom
|
||||||
|
assert p_custom.cap_extensions is not None
|
||||||
|
assert_allclose(p_custom.cap_extensions, (1, 5))
|
||||||
|
|
||||||
|
read_refs = read_lib["refs"]
|
||||||
|
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
|
||||||
|
|
||||||
|
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
|
||||||
|
assert len(arefs) == 1
|
||||||
|
assert isinstance(arefs[0].repetition, Grid)
|
||||||
|
assert arefs[0].repetition.a_count == 3
|
||||||
|
assert arefs[0].repetition.b_count == 2
|
||||||
|
|
||||||
|
# GDS stores repeated shapes through refs created by wrap_repeated_shapes().
|
||||||
|
assert len(read_lib["rep_shapes"].refs) > 0
|
||||||
|
|
||||||
|
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
||||||
|
pytest.importorskip("fatamorgana")
|
||||||
|
from ..file import oasis
|
||||||
|
lib = create_test_library(for_gds=False)
|
||||||
|
oas_file = tmp_path / "full_test.oas"
|
||||||
|
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||||
|
|
||||||
|
read_lib, _ = oasis.readfile(oas_file)
|
||||||
|
|
||||||
|
for name in lib:
|
||||||
|
assert name in read_lib
|
||||||
|
|
||||||
|
read_circles = read_lib["circles"]
|
||||||
|
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
|
||||||
|
assert read_circles.shapes[(3, 0)][0].radius == 5
|
||||||
|
|
||||||
|
read_paths = read_lib["paths"]
|
||||||
|
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
|
||||||
|
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
|
||||||
|
|
||||||
|
read_rep_shapes = read_lib["rep_shapes"]
|
||||||
|
poly = read_rep_shapes.shapes[(4, 0)][0]
|
||||||
|
assert poly.repetition is not None
|
||||||
|
assert isinstance(poly.repetition, Grid)
|
||||||
|
assert poly.repetition.a_count == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_rect_collection_roundtrip(tmp_path: Path) -> None:
|
||||||
|
from ..file import gdsii
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
pat = Pattern()
|
||||||
|
pat.shapes[(5, 0)].append(
|
||||||
|
RectCollection(
|
||||||
|
rects=[[0, 0, 10, 5], [20, -5, 30, 10]],
|
||||||
|
annotations={'1': ['rects']},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lib['rects'] = pat
|
||||||
|
|
||||||
|
gds_file = tmp_path / 'rect_collection.gds'
|
||||||
|
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||||
|
|
||||||
|
read_lib, _ = gdsii.readfile(gds_file)
|
||||||
|
polys = read_lib['rects'].shapes[(5, 0)]
|
||||||
|
|
||||||
|
assert len(polys) == 2
|
||||||
|
assert all(isinstance(poly, Polygon) for poly in polys)
|
||||||
|
assert_allclose(polys[0].vertices, [[0, 0], [0, 5], [10, 5], [10, 0]])
|
||||||
|
assert_allclose(polys[1].vertices, [[20, -5], [20, 10], [30, 10], [30, -5]])
|
||||||
|
assert polys[0].annotations == {'1': ['rects']}
|
||||||
|
assert polys[1].annotations == {'1': ['rects']}
|
||||||
80
masque/test/test_gdsii.py
Normal file
80
masque/test/test_gdsii.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
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)
|
||||||
604
masque/test/test_gdsii_arrow.py
Normal file
604
masque/test/test_gdsii_arrow.py
Normal file
|
|
@ -0,0 +1,604 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import klamath
|
||||||
|
import numpy
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip('pyarrow')
|
||||||
|
|
||||||
|
from .. import Ref, Label, PatternError
|
||||||
|
from ..library import Library
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..repetition import Grid
|
||||||
|
from ..shapes import Path as MPath, Polygon, PolyCollection, RectCollection
|
||||||
|
from ..file import gdsii
|
||||||
|
from ..file.gdsii import arrow as gdsii_arrow
|
||||||
|
from ..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())
|
||||||
230
masque/test/test_gdsii_lazy.py
Normal file
230
masque/test/test_gdsii_lazy.py
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
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')
|
||||||
335
masque/test/test_gdsii_lazy_arrow.py
Normal file
335
masque/test/test_gdsii_lazy_arrow.py
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
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)])
|
||||||
24
masque/test/test_gdsii_perf.py
Normal file
24
masque/test/test_gdsii_perf.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
||||||
54
masque/test/test_label.py
Normal file
54
masque/test/test_label.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import copy
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
|
from ..label import Label
|
||||||
|
from ..repetition import Grid
|
||||||
|
from ..utils import annotations_eq
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_init() -> None:
|
||||||
|
lbl = Label("test", offset=(10, 20))
|
||||||
|
assert lbl.string == "test"
|
||||||
|
assert_equal(lbl.offset, [10, 20])
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_transform() -> None:
|
||||||
|
lbl = Label("test", offset=(10, 0))
|
||||||
|
# Rotate 90 deg CCW around (0,0)
|
||||||
|
lbl.rotate_around((0, 0), pi / 2)
|
||||||
|
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
|
||||||
|
|
||||||
|
# Translate
|
||||||
|
lbl.translate((5, 5))
|
||||||
|
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_repetition() -> None:
|
||||||
|
rep = Grid(a_vector=(10, 0), a_count=3)
|
||||||
|
lbl = Label("rep", offset=(0, 0), repetition=rep)
|
||||||
|
assert lbl.repetition is rep
|
||||||
|
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
|
||||||
|
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
|
||||||
|
# seem to automatically include repetition bounds in label.py itself,
|
||||||
|
# it's handled during pattern bounding.
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_copy() -> None:
|
||||||
|
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
|
||||||
|
l2 = copy.deepcopy(l1)
|
||||||
|
|
||||||
|
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
|
||||||
|
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
|
||||||
|
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
|
||||||
|
|
||||||
|
assert l1 == l2
|
||||||
|
assert l1 is not l2
|
||||||
|
l2.offset[0] = 100
|
||||||
|
assert l1.offset[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_eq_unrelated_objects_is_false() -> None:
|
||||||
|
lbl = Label("test")
|
||||||
|
assert not (lbl == None)
|
||||||
|
assert not (lbl == object())
|
||||||
483
masque/test/test_library.py
Normal file
483
masque/test/test_library.py
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
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)
|
||||||
17
masque/test/test_manhattanize.py
Normal file
17
masque/test/test_manhattanize.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
from ..shapes import Polygon
|
||||||
|
|
||||||
|
|
||||||
|
def test_manhattanize() -> None:
|
||||||
|
pytest.importorskip("float_raster")
|
||||||
|
pytest.importorskip("skimage.measure")
|
||||||
|
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
||||||
|
grid = numpy.arange(0, 11, 1)
|
||||||
|
|
||||||
|
manhattan_polys = poly.manhattanize(grid, grid)
|
||||||
|
assert len(manhattan_polys) >= 1
|
||||||
|
for mp in manhattan_polys:
|
||||||
|
dv = numpy.diff(mp.vertices, axis=0)
|
||||||
|
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
||||||
60
masque/test/test_oasis.py
Normal file
60
masque/test/test_oasis.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
from numpy.testing import assert_equal
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import Library
|
||||||
|
from ..shapes import Path as MPath
|
||||||
|
|
||||||
|
|
||||||
|
def test_oasis_roundtrip(tmp_path: Path) -> None:
|
||||||
|
# Skip if fatamorgana is not installed
|
||||||
|
pytest.importorskip("fatamorgana")
|
||||||
|
from ..file import oasis
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
pat1 = Pattern()
|
||||||
|
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
lib["cell1"] = pat1
|
||||||
|
|
||||||
|
oas_file = tmp_path / "test.oas"
|
||||||
|
# OASIS needs units_per_micron
|
||||||
|
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||||
|
|
||||||
|
read_lib, info = oasis.readfile(oas_file)
|
||||||
|
assert "cell1" in read_lib
|
||||||
|
|
||||||
|
# Check bounds
|
||||||
|
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_oasis_properties_to_annotations_merges_repeated_keys() -> None:
|
||||||
|
pytest.importorskip("fatamorgana")
|
||||||
|
import fatamorgana.records as fatrec
|
||||||
|
from ..file.oasis import properties_to_annotations
|
||||||
|
|
||||||
|
annotations = properties_to_annotations(
|
||||||
|
[
|
||||||
|
fatrec.Property("k", [1], is_standard=False),
|
||||||
|
fatrec.Property("k", [2, 3], is_standard=False),
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert annotations == {"k": [1, 2, 3]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_oasis_write_rejects_circle_path_caps() -> None:
|
||||||
|
pytest.importorskip("fatamorgana")
|
||||||
|
from ..file import oasis
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
pat = Pattern()
|
||||||
|
pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle)
|
||||||
|
lib["cell1"] = pat
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match="does not support path cap"):
|
||||||
|
oasis.write(lib, io.BytesIO(), units_per_micron=1000)
|
||||||
96
masque/test/test_pack2d.py
Normal file
96
masque/test/test_pack2d.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns
|
||||||
|
from ..library import Library
|
||||||
|
from ..pattern import Pattern
|
||||||
|
|
||||||
|
|
||||||
|
def test_maxrects_bssf_simple() -> None:
|
||||||
|
# Pack two 10x10 squares into one 20x10 container
|
||||||
|
rects = [[10, 10], [10, 10]]
|
||||||
|
containers = [[0, 0, 20, 10]]
|
||||||
|
|
||||||
|
locs, rejects = maxrects_bssf(rects, containers)
|
||||||
|
|
||||||
|
assert not rejects
|
||||||
|
# They should be at (0,0) and (10,0)
|
||||||
|
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_maxrects_bssf_reject() -> None:
|
||||||
|
# Try to pack a too-large rectangle
|
||||||
|
rects = [[10, 10], [30, 30]]
|
||||||
|
containers = [[0, 0, 20, 20]]
|
||||||
|
|
||||||
|
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
|
||||||
|
assert 1 in rejects # Second rect rejected
|
||||||
|
assert 0 not in rejects
|
||||||
|
|
||||||
|
|
||||||
|
def test_maxrects_bssf_exact_fill_rejects_remaining() -> None:
|
||||||
|
rects = [[20, 20], [1, 1]]
|
||||||
|
containers = [[0, 0, 20, 20]]
|
||||||
|
|
||||||
|
locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True)
|
||||||
|
|
||||||
|
assert tuple(locs[0]) == (0.0, 0.0)
|
||||||
|
assert rejects == {1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_maxrects_bssf_presort_reject_mapping() -> None:
|
||||||
|
rects = [[10, 12], [19, 14], [13, 11]]
|
||||||
|
containers = [[0, 0, 20, 20]]
|
||||||
|
|
||||||
|
_locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True)
|
||||||
|
|
||||||
|
assert rejects == {0, 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_guillotine_bssf_sas_presort_reject_mapping() -> None:
|
||||||
|
rects = [[2, 1], [17, 15], [16, 11]]
|
||||||
|
containers = [[0, 0, 20, 20]]
|
||||||
|
|
||||||
|
_locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True)
|
||||||
|
|
||||||
|
assert rejects == {2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_patterns() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p1 = Pattern()
|
||||||
|
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
lib["p1"] = p1
|
||||||
|
|
||||||
|
p2 = Pattern()
|
||||||
|
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||||
|
lib["p2"] = p2
|
||||||
|
|
||||||
|
# Containers: one 20x20
|
||||||
|
containers = [[0, 0, 20, 20]]
|
||||||
|
# 2um spacing
|
||||||
|
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
|
||||||
|
|
||||||
|
assert not rejects
|
||||||
|
assert len(pat.refs) == 2
|
||||||
|
assert "p1" in pat.refs
|
||||||
|
assert "p2" in pat.refs
|
||||||
|
|
||||||
|
# Check that they don't overlap (simple check via bounds)
|
||||||
|
# p1 size 10x10, effectively 12x12
|
||||||
|
# p2 size 5x5, effectively 7x7
|
||||||
|
# Both should fit in 20x20
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_patterns_reject_names_match_original_patterns() -> None:
|
||||||
|
lib = Library()
|
||||||
|
for name, (lx, ly) in {
|
||||||
|
"p0": (10, 12),
|
||||||
|
"p1": (19, 14),
|
||||||
|
"p2": (13, 11),
|
||||||
|
}.items():
|
||||||
|
pat = Pattern()
|
||||||
|
pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly)
|
||||||
|
lib[name] = pat
|
||||||
|
|
||||||
|
pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0))
|
||||||
|
|
||||||
|
assert set(rejects) == {"p0", "p2"}
|
||||||
|
assert set(pat.refs) == {"p1"}
|
||||||
101
masque/test/test_path.py
Normal file
101
masque/test/test_path.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..shapes import Path, Path as MPath
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_init() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||||
|
assert_equal(p.vertices, [[0, 0], [10, 0]])
|
||||||
|
assert p.width == 2
|
||||||
|
assert p.cap == Path.Cap.Flush
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_to_polygons_flush() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||||
|
polys = p.to_polygons()
|
||||||
|
assert len(polys) == 1
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_equal(bounds, [[0, -1], [10, 1]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_to_polygons_square() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
||||||
|
polys = p.to_polygons()
|
||||||
|
assert len(polys) == 1
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_to_polygons_circle() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
||||||
|
polys = p.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) >= 3
|
||||||
|
|
||||||
|
bounds = p.get_bounds_single()
|
||||||
|
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_custom_cap() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
||||||
|
polys = p.to_polygons()
|
||||||
|
assert len(polys) == 1
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_equal(bounds, [[-5, -1], [20, 1]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_bend() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
||||||
|
polys = p.to_polygons()
|
||||||
|
assert len(polys) == 1
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_equal(bounds, [[0, -1], [11, 10]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_mirror() -> None:
|
||||||
|
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
||||||
|
p.mirror(0)
|
||||||
|
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_scale() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2)
|
||||||
|
p.scale_by(2)
|
||||||
|
assert_equal(p.vertices, [[0, 0], [20, 0]])
|
||||||
|
assert p.width == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_scale_custom_cap_extensions() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||||
|
p.scale_by(3)
|
||||||
|
|
||||||
|
assert_equal(p.vertices, [[0, 0], [30, 0]])
|
||||||
|
assert p.width == 6
|
||||||
|
assert p.cap_extensions is not None
|
||||||
|
assert_allclose(p.cap_extensions, [3, 6])
|
||||||
|
assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None:
|
||||||
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||||
|
|
||||||
|
intrinsic, _extrinsic, ctor = p.normalized_form(5)
|
||||||
|
q = ctor()
|
||||||
|
|
||||||
|
assert intrinsic[-1] == (0.2, 0.4)
|
||||||
|
assert q.width == 2
|
||||||
|
assert q.cap_extensions is not None
|
||||||
|
assert_allclose(q.cap_extensions, [1, 2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_normalized_form_distinguishes_custom_caps() -> None:
|
||||||
|
p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||||
|
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
|
||||||
|
|
||||||
|
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_edge_cases() -> None:
|
||||||
|
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
||||||
|
polys = p.to_polygons()
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
||||||
107
masque/test/test_pather_autotool.py
Normal file
107
masque/test/test_pather_autotool.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from masque import Pather, Library, Pattern, Port
|
||||||
|
from masque.builder.tools import AutoTool
|
||||||
|
|
||||||
|
|
||||||
|
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
||||||
|
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||||
|
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
# Rectangular approximation of a 90 degree bend.
|
||||||
|
if clockwise:
|
||||||
|
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||||
|
pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0)
|
||||||
|
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||||
|
pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype)
|
||||||
|
else:
|
||||||
|
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||||
|
pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius)
|
||||||
|
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||||
|
pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def multi_bend_tool() -> tuple[AutoTool, Library]:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
lib["b1"] = make_bend(2, ptype="wire")
|
||||||
|
b1_abs = lib.abstract("b1")
|
||||||
|
lib["b2"] = make_bend(5, ptype="wire")
|
||||||
|
b2_abs = lib.abstract("b2")
|
||||||
|
|
||||||
|
tool = (
|
||||||
|
AutoTool()
|
||||||
|
.add_straight(make_straight, "wire", "A", length_range=(0, 10))
|
||||||
|
.add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8))
|
||||||
|
.add_bend(b1_abs, "A", "B", clockwise=True, mirror=True)
|
||||||
|
.add_bend(b2_abs, "A", "B", clockwise=True, mirror=True)
|
||||||
|
)
|
||||||
|
return tool, lib
|
||||||
|
|
||||||
|
def test_autotool_uturn() -> None:
|
||||||
|
from masque.builder.tools import AutoTool
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
def make_straight(length: float) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
|
||||||
|
pat.ports['in'] = Port((0, 0), 0)
|
||||||
|
pat.ports['out'] = Port((length, 0), pi)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
bend_pat = Pattern()
|
||||||
|
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
|
||||||
|
bend_pat.ports['in'] = Port((0, 0), 0)
|
||||||
|
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
||||||
|
lib['bend'] = bend_pat
|
||||||
|
|
||||||
|
tool = (
|
||||||
|
AutoTool()
|
||||||
|
.add_straight(make_straight, 'wire', 'in')
|
||||||
|
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), 0)
|
||||||
|
|
||||||
|
p.at('A').uturn(offset=-2000, length=1000)
|
||||||
|
|
||||||
|
# U-turn plan output is transformed into the port extension frame.
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||||
|
|
||||||
|
def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||||
|
tool, lib = multi_bend_tool
|
||||||
|
rp = Pather(lib, tools=tool)
|
||||||
|
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||||
|
|
||||||
|
rp.jog("A", 10, length=20)
|
||||||
|
|
||||||
|
assert_allclose(rp.ports["A"].offset, [-20, -10])
|
||||||
|
assert_allclose(rp.ports["A"].rotation, 0)
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert len(rp.pattern.refs) > 0
|
||||||
|
|
||||||
|
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||||
|
tool, lib = multi_bend_tool
|
||||||
|
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||||
|
|
||||||
|
p.uturn("A", 10, length=5)
|
||||||
|
|
||||||
|
# Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames,
|
||||||
|
# yielding a global endpoint at (-5, -10).
|
||||||
|
assert_allclose(p.ports["A"].offset, [-5, -10])
|
||||||
|
assert_allclose(p.ports["A"].rotation, pi)
|
||||||
601
masque/test/test_pather_constraints.py
Normal file
601
masque/test/test_pather_constraints.py
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
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
|
||||||
538
masque/test/test_pather_core.py
Normal file
538
masque/test/test_pather_core.py
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
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))
|
||||||
120
masque/test/test_pather_place_plug.py
Normal file
120
masque/test/test_pather_place_plug.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
|
from masque import Pather, Library, Pattern, Port
|
||||||
|
from masque.builder.tools import PathTool
|
||||||
|
from masque.error import PortError, PatternError
|
||||||
|
|
||||||
|
|
||||||
|
def test_pather_place_treeview_resolves_once() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, render='deferred')
|
||||||
|
|
||||||
|
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
|
||||||
|
|
||||||
|
p.place(tree)
|
||||||
|
|
||||||
|
assert len(lib) == 1
|
||||||
|
assert 'child' in lib
|
||||||
|
assert 'child' in p.pattern.refs
|
||||||
|
assert 'B' in p.pattern.ports
|
||||||
|
|
||||||
|
def test_pather_plug_treeview_resolves_once() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, render='deferred')
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
|
||||||
|
|
||||||
|
p.plug(tree, {'A': 'B'})
|
||||||
|
|
||||||
|
assert len(lib) == 1
|
||||||
|
assert 'child' in lib
|
||||||
|
assert 'child' in p.pattern.refs
|
||||||
|
assert 'A' not in p.pattern.ports
|
||||||
|
|
||||||
|
def test_pather_failed_plug_does_not_add_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, render='deferred')
|
||||||
|
p.pattern.annotations = {'k': [1]}
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').trace(None, 5000)
|
||||||
|
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||||
|
|
||||||
|
other = Pattern(
|
||||||
|
annotations={'k': [2]},
|
||||||
|
ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match='Annotation keys overlap'):
|
||||||
|
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
|
||||||
|
|
||||||
|
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||||
|
assert set(p.pattern.ports) == {'A'}
|
||||||
|
|
||||||
|
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, render='deferred')
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').straight(5000)
|
||||||
|
p.rename_ports({'A': None})
|
||||||
|
|
||||||
|
other = Pattern(ports={'X': Port((-5000, 0), rotation=0)})
|
||||||
|
p.place(other, port_map={'X': 'A'}, append=True)
|
||||||
|
p.at('A').straight(2000)
|
||||||
|
|
||||||
|
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
|
||||||
|
|
||||||
|
p.render()
|
||||||
|
assert p.pattern.has_shapes()
|
||||||
|
assert 'A' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
||||||
|
|
||||||
|
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, render='deferred')
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').straight(5000)
|
||||||
|
p.rename_ports({'A': None})
|
||||||
|
|
||||||
|
other = Pattern(
|
||||||
|
ports={
|
||||||
|
'X': Port((0, 0), rotation=pi),
|
||||||
|
'Y': Port((-5000, 0), rotation=0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
|
||||||
|
p.at('A').straight(2000)
|
||||||
|
|
||||||
|
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
|
||||||
|
|
||||||
|
p.render()
|
||||||
|
assert p.pattern.has_shapes()
|
||||||
|
assert 'A' in p.pattern.ports
|
||||||
|
assert 'B' not in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
||||||
|
|
||||||
|
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, render='deferred')
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').straight(5000)
|
||||||
|
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||||
|
|
||||||
|
with pytest.raises(PortError, match='Connection destination ports were not found'):
|
||||||
|
p.plugged({'A': 'missing'})
|
||||||
|
|
||||||
|
assert [step.opcode for step in p._paths['A']] == ['L']
|
||||||
|
assert set(p._paths) == {'A'}
|
||||||
659
masque/test/test_pather_primitive_offers.py
Normal file
659
masque/test/test_pather_primitive_offers.py
Normal file
|
|
@ -0,0 +1,659 @@
|
||||||
|
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']
|
||||||
526
masque/test/test_pather_rendering.py
Normal file
526
masque/test/test_pather_rendering.py
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
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
|
||||||
351
masque/test/test_pather_trace_into.py
Normal file
351
masque/test/test_pather_trace_into.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
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),)
|
||||||
310
masque/test/test_pattern.py
Normal file
310
masque/test/test_pattern.py
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
import pytest
|
||||||
|
import copy
|
||||||
|
from typing import cast
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..abstract import Abstract
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..shapes import Polygon
|
||||||
|
from ..ref import Ref
|
||||||
|
from ..ports import Port, PortError
|
||||||
|
from ..label import Label
|
||||||
|
from ..repetition import Grid
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_init() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
assert pat.is_empty()
|
||||||
|
assert not pat.has_shapes()
|
||||||
|
assert not pat.has_refs()
|
||||||
|
assert not pat.has_labels()
|
||||||
|
assert not pat.has_ports()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_with_elements() -> None:
|
||||||
|
poly = Polygon.square(10)
|
||||||
|
label = Label("test", offset=(5, 5))
|
||||||
|
ref = Ref(offset=(100, 100))
|
||||||
|
port = Port((0, 0), 0)
|
||||||
|
|
||||||
|
pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port})
|
||||||
|
|
||||||
|
assert pat.has_shapes()
|
||||||
|
assert pat.has_labels()
|
||||||
|
assert pat.has_refs()
|
||||||
|
assert pat.has_ports()
|
||||||
|
assert not pat.is_empty()
|
||||||
|
assert pat.shapes[(1, 0)] == [poly]
|
||||||
|
assert pat.labels[(1, 2)] == [label]
|
||||||
|
assert pat.refs["sub"] == [ref]
|
||||||
|
assert pat.ports["P1"] == port
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_append() -> None:
|
||||||
|
pat1 = Pattern()
|
||||||
|
pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
|
|
||||||
|
pat2 = Pattern()
|
||||||
|
pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]])
|
||||||
|
|
||||||
|
pat1.append(pat2)
|
||||||
|
assert len(pat1.shapes[(1, 0)]) == 1
|
||||||
|
assert len(pat1.shapes[(2, 0)]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_translate() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
|
pat.ports["P1"] = Port((5, 5), 0)
|
||||||
|
|
||||||
|
pat.translate_elements((10, 20))
|
||||||
|
|
||||||
|
# Polygon.translate adds to vertices, and offset is always (0,0)
|
||||||
|
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20])
|
||||||
|
assert_equal(pat.ports["P1"].offset, [15, 25])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_scale() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
# Polygon.rect sets an offset in its constructor which is immediately translated into vertices
|
||||||
|
pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
|
||||||
|
pat.scale_by(2)
|
||||||
|
|
||||||
|
# Vertices should be scaled
|
||||||
|
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_rotate() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]])
|
||||||
|
# Rotate 90 degrees CCW around (0,0)
|
||||||
|
pat.rotate_around((0, 0), pi / 2)
|
||||||
|
|
||||||
|
# [10, 0] rotated 90 deg around (0,0) is [0, 10]
|
||||||
|
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_mirror() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]])
|
||||||
|
# Mirror across X axis (y -> -y)
|
||||||
|
pat.mirror(0)
|
||||||
|
|
||||||
|
assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_get_bounds() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]])
|
||||||
|
pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]])
|
||||||
|
|
||||||
|
bounds = pat.get_bounds()
|
||||||
|
assert_equal(bounds, [[-5, -5], [10, 10]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_flatten_preserves_ports_only_child() -> None:
|
||||||
|
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
||||||
|
|
||||||
|
parent = Pattern()
|
||||||
|
parent.ref("child", offset=(10, 10))
|
||||||
|
|
||||||
|
parent.flatten({"child": child}, flatten_ports=True)
|
||||||
|
|
||||||
|
assert set(parent.ports) == {"P1"}
|
||||||
|
assert parent.ports["P1"].rotation == 0
|
||||||
|
assert tuple(parent.ports["P1"].offset) == (11.0, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_flatten_repeated_ref_with_ports_raises() -> None:
|
||||||
|
child = Pattern(ports={"P1": Port((1, 2), 0)})
|
||||||
|
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||||
|
|
||||||
|
parent = Pattern()
|
||||||
|
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
|
||||||
|
parent.flatten({"child": child}, flatten_ports=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_place_requires_abstract_for_reference() -> None:
|
||||||
|
parent = Pattern()
|
||||||
|
child = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
|
||||||
|
parent.place(child)
|
||||||
|
|
||||||
|
assert not parent.ports
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_place_append_requires_pattern_atomically() -> None:
|
||||||
|
parent = Pattern()
|
||||||
|
child = Abstract("child", {"A": Port((1, 2), 0)})
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match='Must provide a full `Pattern`'):
|
||||||
|
parent.place(child, append=True)
|
||||||
|
|
||||||
|
assert not parent.ports
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_place_append_annotation_conflict_is_atomic() -> None:
|
||||||
|
parent = Pattern(annotations={"k": [1]})
|
||||||
|
child = Pattern(annotations={"k": [2]}, ports={"A": Port((1, 2), 0)})
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match="Annotation keys overlap"):
|
||||||
|
parent.place(child, append=True)
|
||||||
|
|
||||||
|
assert not parent.ports
|
||||||
|
assert parent.annotations == {"k": [1]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_place_skip_geometry_overwrites_colliding_ports_last_wins() -> None:
|
||||||
|
parent = Pattern(ports={
|
||||||
|
"A": Port((5, 5), 0),
|
||||||
|
"keep": Port((9, 9), 0),
|
||||||
|
})
|
||||||
|
child = Pattern(ports={
|
||||||
|
"X": Port((1, 0), 0),
|
||||||
|
"Y": Port((2, 0), pi / 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
parent.place(child, port_map={"X": "A", "Y": "A"}, skip_geometry=True, append=True)
|
||||||
|
|
||||||
|
assert set(parent.ports) == {"A", "keep"}
|
||||||
|
assert_allclose(parent.ports["A"].offset, (2, 0))
|
||||||
|
assert parent.ports["A"].rotation is not None
|
||||||
|
assert_allclose(parent.ports["A"].rotation, pi / 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_interface() -> None:
|
||||||
|
source = Pattern()
|
||||||
|
source.ports["A"] = Port((10, 20), 0, ptype="test")
|
||||||
|
|
||||||
|
iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_")
|
||||||
|
|
||||||
|
assert "in_A" in iface.ports
|
||||||
|
assert "out_A" in iface.ports
|
||||||
|
assert iface.ports["in_A"].rotation is not None
|
||||||
|
assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10)
|
||||||
|
assert iface.ports["out_A"].rotation is not None
|
||||||
|
assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10)
|
||||||
|
assert iface.ports["in_A"].ptype == "test"
|
||||||
|
assert iface.ports["out_A"].ptype == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_interface_duplicate_port_map_targets_raise() -> None:
|
||||||
|
source = Pattern()
|
||||||
|
source.ports["A"] = Port((10, 20), 0)
|
||||||
|
source.ports["B"] = Port((30, 40), pi)
|
||||||
|
|
||||||
|
with pytest.raises(PortError, match='Duplicate targets in `port_map`'):
|
||||||
|
Pattern.interface(source, port_map={"A": "X", "B": "X"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_interface_empty_port_map_copies_no_ports() -> None:
|
||||||
|
source = Pattern()
|
||||||
|
source.ports["A"] = Port((10, 20), 0)
|
||||||
|
source.ports["B"] = Port((30, 40), pi)
|
||||||
|
|
||||||
|
assert not Pattern.interface(source, port_map={}).ports
|
||||||
|
assert not Pattern.interface(source, port_map=[]).ports
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_plug_requires_abstract_for_reference_atomically() -> None:
|
||||||
|
parent = Pattern(ports={"X": Port((0, 0), 0)})
|
||||||
|
child = Pattern(ports={"A": Port((0, 0), pi)})
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match='Must provide an `Abstract`'):
|
||||||
|
parent.plug(child, {"X": "A"})
|
||||||
|
|
||||||
|
assert set(parent.ports) == {"X"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_plug_append_annotation_conflict_is_atomic() -> None:
|
||||||
|
parent = Pattern(
|
||||||
|
annotations={"k": [1]},
|
||||||
|
ports={"X": Port((0, 0), 0), "Q": Port((9, 9), 0)},
|
||||||
|
)
|
||||||
|
child = Pattern(
|
||||||
|
annotations={"k": [2]},
|
||||||
|
ports={"A": Port((0, 0), pi), "B": Port((5, 0), 0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match="Annotation keys overlap"):
|
||||||
|
parent.plug(child, {"X": "A"}, map_out={"B": "Y"}, append=True)
|
||||||
|
|
||||||
|
assert set(parent.ports) == {"X", "Q"}
|
||||||
|
assert_allclose(parent.ports["X"].offset, (0, 0))
|
||||||
|
assert_allclose(parent.ports["Q"].offset, (9, 9))
|
||||||
|
assert parent.annotations == {"k": [1]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_plug_skip_geometry_overwrites_colliding_ports_last_wins() -> None:
|
||||||
|
parent = Pattern(ports={
|
||||||
|
"A": Port((0, 0), 0, ptype="wire"),
|
||||||
|
"B": Port((99, 99), 0, ptype="wire"),
|
||||||
|
})
|
||||||
|
child = Pattern(ports={
|
||||||
|
"in": Port((0, 0), pi, ptype="wire"),
|
||||||
|
"X": Port((10, 0), 0, ptype="wire"),
|
||||||
|
"Y": Port((20, 0), 0, ptype="wire"),
|
||||||
|
})
|
||||||
|
|
||||||
|
parent.plug(child, {"A": "in"}, map_out={"X": "B", "Y": "B"}, skip_geometry=True, append=True)
|
||||||
|
|
||||||
|
assert "A" not in parent.ports
|
||||||
|
assert "B" in parent.ports
|
||||||
|
assert_allclose(parent.ports["B"].offset, (20, 0))
|
||||||
|
assert parent.ports["B"].rotation is not None
|
||||||
|
assert_allclose(parent.ports["B"].rotation, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_append_port_conflict_is_atomic() -> None:
|
||||||
|
pat1 = Pattern()
|
||||||
|
pat1.ports["A"] = Port((0, 0), 0)
|
||||||
|
|
||||||
|
pat2 = Pattern()
|
||||||
|
pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||||
|
pat2.ports["A"] = Port((1, 0), 0)
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match="Port names overlap"):
|
||||||
|
pat1.append(pat2)
|
||||||
|
|
||||||
|
assert not pat1.shapes
|
||||||
|
assert set(pat1.ports) == {"A"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_append_annotation_conflict_is_atomic() -> None:
|
||||||
|
pat1 = Pattern(annotations={"k": [1]})
|
||||||
|
pat2 = Pattern(annotations={"k": [2]})
|
||||||
|
pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match="Annotation keys overlap"):
|
||||||
|
pat1.append(pat2)
|
||||||
|
|
||||||
|
assert not pat1.shapes
|
||||||
|
assert pat1.annotations == {"k": [1]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_deepcopy_does_not_share_shape_repetitions() -> None:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||||
|
|
||||||
|
pat2 = copy.deepcopy(pat)
|
||||||
|
pat2.scale_by(2)
|
||||||
|
|
||||||
|
assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).repetition.a_vector, [10, 0])
|
||||||
|
assert_allclose(cast("Polygon", pat2.shapes[(1, 0)][0]).repetition.a_vector, [20, 0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_flatten_does_not_mutate_child_repetitions() -> None:
|
||||||
|
child = Pattern()
|
||||||
|
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2))
|
||||||
|
|
||||||
|
parent = Pattern()
|
||||||
|
parent.ref("child", scale=2)
|
||||||
|
|
||||||
|
parent.flatten({"child": child})
|
||||||
|
|
||||||
|
assert_allclose(cast("Polygon", child.shapes[(1, 0)][0]).repetition.a_vector, [10, 0])
|
||||||
89
masque/test/test_poly_collection.py
Normal file
89
masque/test/test_poly_collection.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import pytest
|
||||||
|
from numpy.testing import assert_equal
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..shapes import Circle, Ellipse, Polygon, PolyCollection
|
||||||
|
|
||||||
|
|
||||||
|
def test_poly_collection_init() -> None:
|
||||||
|
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||||
|
offsets = [0, 4]
|
||||||
|
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||||
|
assert len(list(pc.polygon_vertices)) == 2
|
||||||
|
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
|
||||||
|
|
||||||
|
def test_poly_collection_to_polygons() -> None:
|
||||||
|
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||||
|
offsets = [0, 4]
|
||||||
|
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||||
|
polys = pc.to_polygons()
|
||||||
|
assert len(polys) == 2
|
||||||
|
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||||
|
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
|
||||||
|
|
||||||
|
def test_poly_collection_holes() -> None:
|
||||||
|
# PolyCollection represents separate polygon boundaries, including nested boundaries.
|
||||||
|
verts = [
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[10, 10],
|
||||||
|
[0, 10], # Poly 1
|
||||||
|
[2, 2],
|
||||||
|
[2, 8],
|
||||||
|
[8, 8],
|
||||||
|
[8, 2], # Poly 2
|
||||||
|
]
|
||||||
|
offsets = [0, 4]
|
||||||
|
pc = PolyCollection(verts, offsets)
|
||||||
|
polys = pc.to_polygons()
|
||||||
|
assert len(polys) == 2
|
||||||
|
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
|
||||||
|
|
||||||
|
def test_poly_collection_constituent_empty() -> None:
|
||||||
|
# Duplicate offsets create an empty constituent slice between valid polygons.
|
||||||
|
verts = [
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[0, 1], # Tri
|
||||||
|
[10, 10],
|
||||||
|
[11, 10],
|
||||||
|
[11, 11],
|
||||||
|
[10, 11], # Square
|
||||||
|
]
|
||||||
|
offsets = [0, 3, 3]
|
||||||
|
pc = PolyCollection(verts, offsets)
|
||||||
|
with pytest.raises(PatternError):
|
||||||
|
pc.to_polygons()
|
||||||
|
|
||||||
|
def test_poly_collection_valid() -> None:
|
||||||
|
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||||
|
offsets = [0, 3]
|
||||||
|
pc = PolyCollection(verts, offsets)
|
||||||
|
assert len(pc.to_polygons()) == 2
|
||||||
|
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
|
||||||
|
sorted_shapes = sorted(shapes)
|
||||||
|
assert len(sorted_shapes) == 4
|
||||||
|
assert sorted(sorted_shapes) == sorted_shapes
|
||||||
|
|
||||||
|
def test_poly_collection_normalized_form_reconstruction_is_independent() -> None:
|
||||||
|
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
||||||
|
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
||||||
|
|
||||||
|
clone = rebuild()
|
||||||
|
clone.vertex_offsets[:] = [5]
|
||||||
|
|
||||||
|
assert_equal(pc.vertex_offsets, [0])
|
||||||
|
assert_equal(clone.vertex_offsets, [5])
|
||||||
|
|
||||||
|
def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None:
|
||||||
|
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
||||||
|
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
||||||
|
|
||||||
|
first = rebuild()
|
||||||
|
second = rebuild()
|
||||||
|
first.vertex_offsets[:] = [7]
|
||||||
|
|
||||||
|
assert_equal(first.vertex_offsets, [7])
|
||||||
|
assert_equal(second.vertex_offsets, [0])
|
||||||
|
assert_equal(pc.vertex_offsets, [0])
|
||||||
125
masque/test/test_polygon.py
Normal file
125
masque/test/test_polygon.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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]])
|
||||||
354
masque/test/test_ports.py
Normal file
354
masque/test/test_ports.py
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
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)
|
||||||
132
masque/test/test_ports2data.py
Normal file
132
masque/test/test_ports2data.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
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