Compare commits
4 commits
master
...
build_path
| Author | SHA1 | Date | |
|---|---|---|---|
| fff20b3da9 | |||
| 4bae737630 | |||
| 9891ba9e47 | |||
| df320e80cc |
156 changed files with 6854 additions and 37847 deletions
856
MIGRATION.md
856
MIGRATION.md
|
|
@ -1,856 +0,0 @@
|
|||
# Migration Guide
|
||||
|
||||
This guide covers changes between the `master` branch and the current tree.
|
||||
Both `master` and the current tree report `masque.__version__ == '3.4'`; the
|
||||
version string has not yet been bumped for these changes.
|
||||
|
||||
Most downstream changes are in `masque/builder/*`, but there are a few other
|
||||
API changes that may require code updates.
|
||||
|
||||
## Routing API: renamed and consolidated
|
||||
|
||||
The routing helpers were consolidated into a single implementation in
|
||||
`masque/builder/pather.py`.
|
||||
|
||||
The biggest migration point is that the old routing verbs were renamed:
|
||||
|
||||
| Old API | New API |
|
||||
| --- | --- |
|
||||
| `Pather.path(...)` | `Pather.trace(...)` |
|
||||
| `Pather.path_to(...)` | `Pather.trace_to(...)` |
|
||||
| `Pather.mpath(...)` | `Pather.trace(...)` / `Pather.trace_to(...)` with multiple ports |
|
||||
| `Pather.pathS(...)` | `Pather.jog(...)` |
|
||||
| `Pather.pathU(...)` | `Pather.uturn(...)` |
|
||||
| `Pather.path_into(...)` | `Pather.trace_into(...)` |
|
||||
| `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` |
|
||||
| `RenderPather.path(...)` | `Pather(..., render='deferred').trace(...)` |
|
||||
| `RenderPather.path_to(...)` | `Pather(..., render='deferred').trace_to(...)` |
|
||||
| `RenderPather.mpath(...)` | `Pather(..., render='deferred').trace(...)` / `Pather(..., render='deferred').trace_to(...)` |
|
||||
| `RenderPather.pathS(...)` | `Pather(..., render='deferred').jog(...)` |
|
||||
| `RenderPather.pathU(...)` | `Pather(..., render='deferred').uturn(...)` |
|
||||
| `RenderPather.path_into(...)` | `Pather(..., render='deferred').trace_into(...)` |
|
||||
| `RenderPather.path_from(src, dst)` | `Pather(..., render='deferred').at(src).trace_into(dst)` |
|
||||
|
||||
There are also new convenience wrappers:
|
||||
|
||||
- `straight(...)` for `trace_to(..., ccw=None, ...)`
|
||||
- `ccw(...)` for `trace_to(..., ccw=True, ...)`
|
||||
- `cw(...)` for `trace_to(..., ccw=False, ...)`
|
||||
- `jog(...)` for S-bends
|
||||
- `uturn(...)` for U-bends
|
||||
|
||||
Important: `Pather.path()` is no longer the routing API. It now forwards to
|
||||
`Pattern.path()` and creates a geometric `Path` element. Any old routing code
|
||||
that still calls `pather.path(...)` must be renamed.
|
||||
|
||||
### Common rewrites
|
||||
|
||||
```python
|
||||
# old
|
||||
pather.path('VCC', False, 6_000)
|
||||
pather.path_to('VCC', None, x=0)
|
||||
pather.mpath(['GND', 'VCC'], True, xmax=-10_000, spacing=5_000)
|
||||
pather.pathS('VCC', offset=-2_000, length=8_000)
|
||||
pather.pathU('VCC', offset=4_000, length=5_000)
|
||||
pather.path_into('src', 'dst')
|
||||
pather.path_from('src', 'dst')
|
||||
|
||||
# new
|
||||
pather.cw('VCC', 6_000)
|
||||
pather.straight('VCC', x=0)
|
||||
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||
pather.jog('VCC', offset=-2_000, length=8_000)
|
||||
pather.uturn('VCC', offset=4_000, length=5_000)
|
||||
pather.trace_into('src', 'dst')
|
||||
pather.at('src').trace_into('dst')
|
||||
```
|
||||
|
||||
If you prefer the more explicit spelling, `trace(...)` and `trace_to(...)`
|
||||
remain the underlying primitives:
|
||||
|
||||
```python
|
||||
pather.trace('VCC', False, 6_000)
|
||||
pather.trace_to('VCC', None, x=0)
|
||||
```
|
||||
|
||||
## `PortPather` and `.at(...)`
|
||||
|
||||
Routing can now be written in a fluent style via `.at(...)`, which returns a
|
||||
`PortPather`.
|
||||
|
||||
```python
|
||||
(rpather.at('VCC')
|
||||
.trace(False, length=6_000)
|
||||
.trace_to(None, x=0)
|
||||
)
|
||||
```
|
||||
|
||||
This is additive, not required for migration. Existing code can stay with the
|
||||
non-fluent `Pather` methods after renaming the verbs above.
|
||||
|
||||
Old `PortPather` helper names were also cleaned up:
|
||||
|
||||
| Old API | New API |
|
||||
| --- | --- |
|
||||
| `save_copy(...)` | `mark(...)` |
|
||||
| `rename_to(...)` | `rename(...)` |
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# old
|
||||
pp.save_copy('branch')
|
||||
pp.rename_to('feed')
|
||||
|
||||
# new
|
||||
pp.mark('branch')
|
||||
pp.rename('feed')
|
||||
```
|
||||
|
||||
## Imports and module layout
|
||||
|
||||
`Pather` now provides the remaining builder/routing surface in
|
||||
`masque/builder/pather.py`. The old module files
|
||||
`masque/builder/builder.py` and `masque/builder/renderpather.py` were removed.
|
||||
|
||||
Update imports like this:
|
||||
|
||||
```python
|
||||
# old
|
||||
from masque.builder.builder import Builder
|
||||
from masque.builder.renderpather import RenderPather
|
||||
|
||||
# new
|
||||
from masque.builder import Pather
|
||||
|
||||
builder = Pather(...)
|
||||
deferred = Pather(..., render='deferred')
|
||||
```
|
||||
|
||||
The new `Pather` remains importable from both `masque` and `masque.builder`.
|
||||
The removed `Builder` and `RenderPather` names are no longer exported from
|
||||
either location.
|
||||
|
||||
`Pather` now defaults to `render='auto'`, so plain construction replaces the
|
||||
old `Builder` behavior. Use `Pather(..., render='deferred')` where you
|
||||
previously used `RenderPather`.
|
||||
|
||||
## `SimpleTool` was removed and `AutoTool` registration changed
|
||||
|
||||
`SimpleTool` is no longer exported. `AutoTool` remains, but its old public
|
||||
descriptor classes and constructor-oriented configuration were replaced by
|
||||
registration methods. Use it for generated straights and S-bends and reusable
|
||||
bends, U-turns, and transitions.
|
||||
|
||||
### Old `AutoTool`
|
||||
|
||||
```python
|
||||
from masque.builder import AutoTool
|
||||
|
||||
tool = AutoTool(
|
||||
straights=[
|
||||
AutoTool.Straight('m1wire', make_straight, 'input', 'output'),
|
||||
],
|
||||
bends=[
|
||||
AutoTool.Bend(lib.abstract('bend'), 'input', 'output'),
|
||||
],
|
||||
sbends=[],
|
||||
transitions={
|
||||
('m2wire', 'm1wire'): AutoTool.Transition(
|
||||
lib.abstract('via'), 'top', 'bottom'
|
||||
),
|
||||
},
|
||||
default_out_ptype='m1wire',
|
||||
)
|
||||
```
|
||||
|
||||
### New `AutoTool`
|
||||
|
||||
```python
|
||||
from masque.builder import AutoTool
|
||||
|
||||
tool = (
|
||||
AutoTool()
|
||||
.add_straight(make_straight, 'm1wire', 'input')
|
||||
.add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True)
|
||||
.add_transition(lib.abstract('via'), 'top', 'bottom')
|
||||
)
|
||||
```
|
||||
|
||||
The key differences are:
|
||||
|
||||
- `SimpleTool` was removed; use `AutoTool` or implement the new `Tool`
|
||||
primitive-offer interface
|
||||
- `AutoTool.Straight(...)` -> `add_straight(fn, ptype, in_name)`
|
||||
- `AutoTool.Bend(...)` -> `add_bend(abstract, in_name, out_name)`
|
||||
- `AutoTool.SBend(...)` -> `add_sbend(fn, ptype, in_name, out_name)`
|
||||
- reusable native U-turns can be registered with `add_uturn(...)`
|
||||
- transitions are registered with `add_transition(abstract, external_port, internal_port)`
|
||||
- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter
|
||||
|
||||
Primitive costs are now explicit and independent of `AutoTool` registration
|
||||
order. Every `add_*()` method accepts `cost=`, as do the concrete offer
|
||||
factories. A numeric value scales the default geometric cost; for example,
|
||||
`cost=2` makes a primitive twice as expensive. A callable receives the
|
||||
canonical primitive parameter and local endpoint and returns the complete
|
||||
cost:
|
||||
|
||||
```python
|
||||
tool.add_straight(make_straight, 'm1wire', 'input', cost=1.5)
|
||||
tool.add_sbend(
|
||||
make_sbend,
|
||||
'm1wire',
|
||||
'input',
|
||||
'output',
|
||||
cost=lambda jog, endpoint: abs(jog) + 2 * abs(endpoint.x),
|
||||
)
|
||||
```
|
||||
|
||||
`PrimitiveOffer.priority_bias` was removed; custom offers should use `cost`
|
||||
instead. Exact equal-cost candidates still use deterministic discovery order
|
||||
as the final tie-break, but registration order no longer changes their
|
||||
reported cost.
|
||||
|
||||
For two-port primitives, `AutoTool` can infer omitted port names and, for
|
||||
generated straight/S-bend primitives, omitted ptype metadata by sampling an
|
||||
in-domain example. Supply those values explicitly when generation requires
|
||||
route-specific keyword arguments, because metadata inference does not receive
|
||||
`tool_options`.
|
||||
|
||||
## Custom `Tool` subclasses
|
||||
|
||||
If you maintain your own `Tool` subclass, the interface changed:
|
||||
|
||||
- `primitive_offers()` is now the planning boundary
|
||||
- `render()` consumes committed primitive render tokens
|
||||
- `Tool.path(...)`, `traceL()`, `traceS()`, `traceU()`, `planL()`,
|
||||
`planS()`, and `planU()` are no longer part of the public `Tool` API
|
||||
|
||||
In practice, a minimal old implementation like:
|
||||
|
||||
```python
|
||||
class MyTool(Tool):
|
||||
def path(self, ccw, length, **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
should now become:
|
||||
|
||||
```python
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from masque import Port
|
||||
from masque.builder import RenderStep, StraightOffer, Tool
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs):
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def endpoint(length):
|
||||
ptype = out_ptype or in_ptype
|
||||
return Port((length, 0), rotation=3.141592653589793, ptype=ptype)
|
||||
|
||||
def commit(length):
|
||||
return {'length': length}
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype or in_ptype,
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
def render(self, batch: Sequence[RenderStep]):
|
||||
...
|
||||
```
|
||||
|
||||
If a tool does not provide a primitive kind, return `()` for that kind. `Pather`
|
||||
will compose available primitive offers where the route family allows it.
|
||||
|
||||
### Primitive offers
|
||||
|
||||
Tools describe legal routing primitives through `Tool.primitive_offers()`.
|
||||
`Pather` composes those primitive offers to implement `trace()`, `jog()`,
|
||||
`uturn()`, and `trace_into()`.
|
||||
|
||||
For custom tools, construct the concrete offer class that matches the primitive
|
||||
you are exposing:
|
||||
|
||||
- `StraightOffer` for non-turning length-parameterized primitives
|
||||
- `BendOffer` for single-turn length-parameterized primitives
|
||||
- `SOffer` for S-like jog-parameterized primitives
|
||||
- `UOffer` for U-like jog-parameterized primitives
|
||||
|
||||
`PrimitiveOffer` is the shared base type used for generic annotations and
|
||||
common callback behavior. It is not the normal class users should instantiate.
|
||||
The concrete offer classes carry the semantic fields (`length_domain`,
|
||||
`jog_domain`, `ccw`) so tools do not need to encode primitive identity in
|
||||
strings. Each concrete offer now also exposes a class-level canonical `kind`.
|
||||
Tools must return the matching offer kind for the discovery query; for example,
|
||||
`primitive_offers('s', ...)` must return only `SOffer` instances. Mismatches are
|
||||
reported as fatal `ToolContractError`s rather than silently ignored.
|
||||
|
||||
`RenderStep` now stores that same canonical kind. Code that constructs render
|
||||
steps directly must use the kind rather than a legacy opcode:
|
||||
|
||||
```python
|
||||
# old
|
||||
RenderStep('L', tool, start, end, data)
|
||||
|
||||
# new
|
||||
RenderStep('straight', tool, start, end, data)
|
||||
```
|
||||
|
||||
Use `'bend'`, `'s'`, `'u'`, or `'plug'` for the other step types. The read-only
|
||||
`RenderStep.opcode` and `PrimitiveOffer.opcode` properties remain available for
|
||||
code that only consumes steps, but opcodes are now derived rather than stored.
|
||||
|
||||
Minimal straight-only example:
|
||||
|
||||
```python
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from masque import Port
|
||||
from masque.builder import RenderStep, StraightOffer, Tool
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype=None,
|
||||
out_ptype=None,
|
||||
**kwargs,
|
||||
):
|
||||
if kind != 'straight':
|
||||
return ()
|
||||
|
||||
def endpoint(length):
|
||||
ptype = out_ptype or in_ptype
|
||||
return Port((length, 0), rotation=3.141592653589793, ptype=ptype)
|
||||
|
||||
def commit(length):
|
||||
return {'length': length}
|
||||
|
||||
return (StraightOffer(
|
||||
in_ptype=in_ptype,
|
||||
out_ptype=out_ptype or in_ptype,
|
||||
endpoint_planner=endpoint,
|
||||
commit_planner=commit,
|
||||
),)
|
||||
|
||||
def render(self, batch: Sequence[RenderStep]):
|
||||
...
|
||||
```
|
||||
|
||||
Routing entry points now name every supported route argument explicitly.
|
||||
Custom per-route Tool values must be placed under `tool_options`:
|
||||
|
||||
```python
|
||||
pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'})
|
||||
```
|
||||
|
||||
The mapping is unpacked only for `Tool.primitive_offers()`. Route arguments do
|
||||
not leak into that namespace, and tool options are not forwarded to
|
||||
`Tool.render()`. An offer that needs route-specific render behavior must capture
|
||||
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
|
||||
does this automatically for generated straight and S-bend primitives: the
|
||||
mapping is deep-copied per offer discovery and passed to its generator as
|
||||
keyword arguments during rendering. Consequently every AutoTool option value
|
||||
must be deep-copyable, and later mutation of nested caller-owned values does
|
||||
not affect a pending route. AutoTool options must not change generated
|
||||
ports or endpoint geometry. `PathTool` defines no tool options and rejects
|
||||
nonempty mappings.
|
||||
|
||||
AutoTool does not validate generator keyword signatures during planning. A bad
|
||||
keyword therefore raises when the generator runs, normally during rendering.
|
||||
Generators that require route options must provide explicit port metadata at
|
||||
registration; S-bend generators must also provide an explicit `endpoint`.
|
||||
|
||||
Primitive offers are local planning objects:
|
||||
|
||||
- `endpoint_at(parameter)` returns the local output `Port`
|
||||
- `cost_at(parameter)` returns an additive scalar route-selection cost
|
||||
- `bbox_at(parameter)` returns local primitive bounds when a footprint hook is supplied
|
||||
- `parameterized_bbox` may carry opaque future-router footprint metadata
|
||||
- `commit(parameter)` returns opaque render data consumed later by `render()`
|
||||
- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton
|
||||
- domains are validated when an offer is constructed, not when it is first evaluated
|
||||
- selected parameter values must be finite; domains may use infinite open bounds but not `NaN`
|
||||
- straight/bend domains require a finite nonnegative minimum; fixed singleton domains must be finite
|
||||
- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer
|
||||
|
||||
`AutoTool.add_straight(length_range=...)` and
|
||||
`AutoTool.add_sbend(jog_range=...)` now validate their ranges before metadata
|
||||
inference. `jog_range` is an absolute-magnitude range and must have a finite,
|
||||
nonnegative lower bound; AutoTool creates the corresponding positive and
|
||||
negative S offers itself. Invalid ranges now raise immediately instead of
|
||||
registering no offers.
|
||||
|
||||
`ToolContractError` is exported from `masque` and `masque.builder`. It marks a
|
||||
broken Tool contract—such as an endpoint ptype that disagrees with its offer,
|
||||
an invalid evaluated cost, or rendered output that disagrees with the planned
|
||||
endpoint—and is fatal to route fallback. Ordinary `BuildError` raised by a
|
||||
planning callback remains a recoverable candidate rejection. Rendered output
|
||||
rotations are compared modulo one full turn; a port facing exactly backward is
|
||||
no longer accepted as equivalent. A `None` rotation remains an explicit
|
||||
wildcard.
|
||||
|
||||
Custom Tool authors can exercise discovery, offer callbacks, committed data,
|
||||
and one-step rendering without depending on pytest:
|
||||
|
||||
```python
|
||||
from masque.builder import ToolContractCase, validate_tool_contract
|
||||
|
||||
validate_tool_contract(my_tool, (
|
||||
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
|
||||
ToolContractCase('bend', in_ptype='wire', ccw=False),
|
||||
ToolContractCase('bend', in_ptype='wire', ccw=True),
|
||||
ToolContractCase('s', in_ptype='wire', require_offers=False),
|
||||
))
|
||||
```
|
||||
|
||||
Cases derive representative parameters from each returned offer domain and
|
||||
may add explicit probes. Empty discovery is an error unless
|
||||
`require_offers=False`; bbox support is required only with `check_bbox=True`.
|
||||
Validation returns normally on success and otherwise raises an
|
||||
`ExceptionGroup` of contextual `ToolContractError`s.
|
||||
|
||||
Run this validation during Tool development, testing, or application startup.
|
||||
It is the comprehensive semantic preflight for custom Tools; those checks are
|
||||
intentionally not repeated for every offer evaluation in the routing hot path.
|
||||
|
||||
Positional routing bounds (`p`, `pos`, `position`, `x`, and `y`) now require a
|
||||
nearly Manhattan input-port direction. Arbitrarily angled ports remain valid
|
||||
for non-positional/extension routing.
|
||||
|
||||
Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype
|
||||
adapters. Requested `out_ptype` constrains only the final route endpoint; any
|
||||
intermediate ptypes are chosen by the route solver.
|
||||
|
||||
`Tool` subclasses must override `primitive_offers()` and return `()` themselves
|
||||
for recognized unsupported kinds. There is no route-level `plan*()` fallback.
|
||||
Omitted-length S/U behavior comes from direct `SOffer` and `UOffer` endpoint
|
||||
domains or from composed straight/bend primitives.
|
||||
|
||||
Offer constructors accept split `endpoint_planner` and `commit_planner`
|
||||
callbacks. Provide both callbacks or override the offer methods in a subclass;
|
||||
partial callback configurations are rejected during offer construction.
|
||||
|
||||
When writing direct primitive offers, declare the actual endpoint ptype
|
||||
produced by the offer if it can differ from the requested value; `Pather`
|
||||
validates evaluated endpoints against the declared offer ptype.
|
||||
|
||||
Stable imports for custom tool authors live in `masque.builder`. The
|
||||
`masque.builder.planner` module is an internal planner implementation; do not
|
||||
import it from user code.
|
||||
|
||||
`trace_into()` uses the same primitive-offer route selection and defaults to
|
||||
the minimal main-route bend count required by the endpoint relationship: zero
|
||||
for a straight, one for a quarter-turn, and two for S- and U-like connections.
|
||||
Ptype adapters do not consume this bend budget. Set
|
||||
`plan_options={'bend_policy': 'flexible'}` to search bounded route topologies
|
||||
with up to four bend roles, including dogleg and loop-like fallbacks.
|
||||
Bend-family requests then search one-bend routes before three-bend routes;
|
||||
other families search zero-to-two-bend routes before four-bend routes. The
|
||||
first band with a legal route wins. Within that band, candidates are ordered
|
||||
by total primitive-offer cost, adapter count, step count, and deterministic
|
||||
discovery order. The default planner's `strategy` option affects only that
|
||||
final discovery-order tie-break and is also supplied through `plan_options`.
|
||||
|
||||
`plan_options` is reserved for planner-specific per-route policy, while
|
||||
`tool_options` is forwarded only to `Tool.primitive_offers()`. For example:
|
||||
|
||||
```python
|
||||
pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'})
|
||||
```
|
||||
|
||||
Explicit-length `jog()` routes may also be satisfied by composing a straight
|
||||
primitive before or after an omitted-length native S primitive. `uturn()` routes
|
||||
may compose a straight primitive before an omitted-length native U primitive.
|
||||
These compositions are used when they are the lowest-cost legal route for the
|
||||
explicit request.
|
||||
|
||||
`AutoTool` can attach `bbox_at()` hooks to its primitive offers by rendering the
|
||||
selected primitive into a temporary pattern and measuring it. If the rendered
|
||||
primitive contains reusable refs, pass the source library as `bbox_library=...`;
|
||||
normal routing does not require this.
|
||||
|
||||
### Omitted-length routing
|
||||
|
||||
Single-port omitted-length calls now evaluate legal primitive routes at their
|
||||
minimum legal length-like parameter, or at their intrinsic endpoint length when
|
||||
the requested offset fixes the primitive geometry. Cost then selects among
|
||||
those minimum-length candidates:
|
||||
|
||||
```python
|
||||
pather.trace('A', None) # minimum straight-like route
|
||||
pather.jog('A', offset=2) # minimum S-like route for that offset
|
||||
pather.uturn('A', offset=4) # minimum U-like route for that offset
|
||||
```
|
||||
|
||||
For U-turns, use explicit `length=0` to request the old zero-public-length
|
||||
shape:
|
||||
|
||||
```python
|
||||
pather.uturn('A', offset=4, length=0)
|
||||
```
|
||||
|
||||
## Transform semantics changed
|
||||
|
||||
The other major user-visible change is that `mirror()` and `rotate()` are now
|
||||
treated more consistently as intrinsic transforms on low-level objects.
|
||||
|
||||
The practical migration rule is:
|
||||
|
||||
- use `mirror()` / `rotate()` when you want to change the object relative to its
|
||||
own origin
|
||||
- use `flip_across(...)`, `rotate_around(...)`, or container-level transforms
|
||||
when you want to move the object in its parent coordinate system
|
||||
|
||||
### Example: `Port`
|
||||
|
||||
Old behavior:
|
||||
|
||||
```python
|
||||
port.mirror(0) # changed both offset and orientation
|
||||
```
|
||||
|
||||
New behavior:
|
||||
|
||||
```python
|
||||
port.mirror(0) # changes orientation only
|
||||
port.flip_across(axis=0) # old "mirror in the parent pattern" behavior
|
||||
```
|
||||
|
||||
### What to audit
|
||||
|
||||
Check code that calls:
|
||||
|
||||
- `Port.mirror(...)`
|
||||
- `Ref.rotate(...)`
|
||||
- `Ref.mirror(...)`
|
||||
- `Label.rotate_around(...)` / `Label.mirror(...)`
|
||||
- `Abstract.mirror_port_offsets(...)` / `Abstract.mirror_ports(...)`
|
||||
|
||||
If that code expected offsets or repetition grids to move automatically, it
|
||||
needs updating. For whole-pattern transforms, prefer calling `Pattern.mirror()`
|
||||
or `Pattern.rotate_around(...)` at the container level.
|
||||
|
||||
`Abstract.mirror_port_offsets()` and `Abstract.mirror_ports()` were removed.
|
||||
Use `Abstract.mirror(axis)` to mirror both port locations and orientations. If
|
||||
you intentionally need only one half of that operation, update the individual
|
||||
ports explicitly with `Port.flip_across(...)` or `Port.mirror(...)`.
|
||||
|
||||
## Library hierarchy and graph behavior
|
||||
|
||||
`masque/library.py` was split into the `masque.library` package. Imports from
|
||||
the public module remain stable:
|
||||
|
||||
```python
|
||||
from masque import Library, LazyLibrary
|
||||
# or
|
||||
from masque.library import Library, LazyLibrary
|
||||
```
|
||||
|
||||
Code importing the implementation file itself must move to the public package;
|
||||
do not depend on the new internal `base`, `mapping`, or `lazy` module paths.
|
||||
|
||||
Hierarchy helpers now handle dangling references explicitly. The following
|
||||
methods accept `dangling='error' | 'ignore' | 'include'` and default to
|
||||
`'error'`:
|
||||
|
||||
- `child_graph()`
|
||||
- `parent_graph()`
|
||||
- `child_order()`
|
||||
- `find_refs_local()`
|
||||
- `find_refs_global()`
|
||||
- `prune_empty()`
|
||||
|
||||
On `master`, graph construction could expose missing targets implicitly or
|
||||
fail later with a `KeyError`. If dangling refs are intentional, choose the
|
||||
behavior explicitly, for example:
|
||||
|
||||
```python
|
||||
graph = library.child_graph(dangling='include')
|
||||
order = library.child_order(dangling='ignore')
|
||||
```
|
||||
|
||||
Graph cycles and invalid hierarchy states are now reported as `LibraryError`
|
||||
with context. Audit code that caught `KeyError` or `graphlib.CycleError` from
|
||||
these helpers.
|
||||
|
||||
Invalid `dangling=` strings now raise `ValueError`; they no longer fall through
|
||||
to the `include` behavior. Empty lists stored in `Pattern.refs` are consistently
|
||||
treated as absent references by hierarchy and geometry traversal. Code that
|
||||
uses a `defaultdict` lookup such as `pattern.refs[name]` without appending a
|
||||
`Ref` will therefore not create an edge or force that target to be loaded.
|
||||
|
||||
`Library.add()` now resolves the full name plan and remaps references before it
|
||||
starts inserting cells. Name-resolution and preparation failures no longer
|
||||
leave partially-added cells behind. A `rename_theirs` callback now receives an
|
||||
`INameView` containing both existing names and names reserved earlier in the
|
||||
same addition. Use membership, iteration, `len()`, or `get_name()`; the callback
|
||||
argument is not the destination object and does not support pattern lookup or
|
||||
mapping helpers such as `keys()` and `items()`. Update callback annotations from
|
||||
`ILibraryView` to `INameView`. Custom `_merge()` implementations remain
|
||||
responsible for their own rollback if they fail during the final commit.
|
||||
|
||||
Reference transforms returned by `find_refs_local()` and
|
||||
`find_refs_global()` are now Nx5 arrays. The fifth column is the cumulative
|
||||
scale, so rows have the form `(x, y, rotation, mirrored, scale)` rather than the
|
||||
old Nx4 form.
|
||||
|
||||
`BuildReport` mapping fields are now defensively copied and read-only. Copy a
|
||||
field to a new `dict` before adding or removing report entries.
|
||||
|
||||
`PortLoadView`, `LayerMappedView`, `OverlayLibrary`, and source-backed outputs
|
||||
returned by `LibraryBuilder.build()` borrow their sources. They do not close
|
||||
source resources, and the views are not context managers. Keep each lazy
|
||||
source open and unchanged until every borrowing view or overlay is finished,
|
||||
then close the owning source explicitly. An eager `build(output='library')`
|
||||
result is detached and does not need its sources afterward.
|
||||
|
||||
Lazy GDS sources no longer provide `with_ports_from_data()` or
|
||||
`with_port_overrides()` convenience methods. Construct the generic view
|
||||
directly instead: `PortLoadView(source, layers=...)` imports port data, and
|
||||
`PortLoadView(source, ports=..., replace=...)` applies explicit overrides.
|
||||
|
||||
`LayerMappedView(source, map_layer)` lazily remaps shape and label layers while
|
||||
leaving the source untouched. Its default `copy_through=False` forces mapped
|
||||
serialization of every cell. Set `copy_through=True` only when source-aware
|
||||
writers may copy untouched cells unchanged and intentionally skip their layer
|
||||
mapping; persistently accessed cells are mapped and no longer copied through.
|
||||
|
||||
`preflight_source_aware(...)` preserves that per-cell provenance. It returns a
|
||||
borrowing `OverlayLibrary`, skips source-backed cells completely, and applies
|
||||
pattern sorting, named-layer validation, safe empty-cell pruning, and
|
||||
repeated-shape wrapping only to cells without reusable source provenance.
|
||||
Library name order is retained because sorting source-backed cells would
|
||||
require loading them. Always use the returned overlay for writing, and keep the
|
||||
original lazy source open until the write finishes. The comprehensive
|
||||
`preflight(...)` operation continues to materialize every cell when sorting is
|
||||
enabled.
|
||||
|
||||
Generic borrowing views no longer expose GDS-specific `raw_struct_bytes()`,
|
||||
`can_copy_raw_struct()`, or forwarded `library_info` attributes. Keep the
|
||||
owning GDS source or the metadata returned by `readfile()` when direct access is
|
||||
needed. `IBorrowing.source_cell()` now provides format-neutral per-cell
|
||||
provenance; GDS writers use it internally to preserve safe raw copy-through.
|
||||
|
||||
Read-only `subtree()` results are now borrowed lazy views rather than eager
|
||||
`LibraryView` snapshots. Creating one no longer loads its reachable patterns,
|
||||
and the view preserves source ordering, hierarchy metadata, and lazy GDS
|
||||
copy-through. Mutable `Library`, `LazyLibrary`, and `OverlayLibrary` subtrees
|
||||
return the same writable type as their source. Overlay subtrees retain their
|
||||
source layers and lazy GDS capabilities. Mutable subtree containers are
|
||||
structurally independent, but already-materialized patterns remain shared.
|
||||
Keep borrowed sources open and structurally unchanged for the subtree's
|
||||
lifetime.
|
||||
|
||||
`ILibrary` no longer inherits `collections.abc.MutableMapping`. It remains a
|
||||
readable `Mapping` with explicit insert-only item assignment and deletion, but
|
||||
generic mutation helpers such as `update()`, `setdefault()`, `pop()`,
|
||||
`popitem()`, and `clear()` are no longer supplied. Use `add()`, `rename()`,
|
||||
`delete()`, item insertion, and item deletion so library name and reference
|
||||
invariants remain explicit.
|
||||
|
||||
Library-level `referenced_patterns()` and `dangling_refs()` now report only
|
||||
named cell targets and return `set[str]`. Populated refs whose target is `None`
|
||||
are ignored, matching `child_graph()` and recursive geometry behavior;
|
||||
`Pattern.referenced_patterns()` continues to report `None` locally.
|
||||
|
||||
Port-importing views now always process detached patterns, including when the
|
||||
raw source cell was already cached. Code can safely retain and compare raw and
|
||||
processed views without port overrides leaking back into the raw pattern.
|
||||
Once a processed cell is persistently materialized, lazy GDS writers no longer
|
||||
copy the raw source structure for that cell, so later mutations to the returned
|
||||
`Pattern` are serialized. Non-persistent materialization does not mark the cell
|
||||
as changed.
|
||||
|
||||
Underscore-prefixed declarations work through the attribute authoring surface:
|
||||
`builder.cells._helper = pattern` now declares `_helper`. Only the view's exact
|
||||
internal `_library` attribute is reserved.
|
||||
|
||||
Recursive geometry operations now reject cyclic reference hierarchies with a
|
||||
contextual `PatternError` instead of eventually leaking `RecursionError`. This
|
||||
applies to bounds calculation, flattened layer polygon extraction, and
|
||||
visualization as well as the existing flattening checks.
|
||||
|
||||
`LibraryBuilder.validate(names=...)` now accepts either one string or a sequence
|
||||
of strings. Non-string roots raise `TypeError`, and duplicate roots are reduced
|
||||
to their first occurrence. A recipe may build a different `LibraryBuilder`, but
|
||||
calling `build()` or `validate()` recursively on its own active builder now
|
||||
raises `BuildError` before starting another session.
|
||||
|
||||
`LibraryBuilder` is an authoring registry, not an `ILibraryView` or mapping.
|
||||
Use membership, iteration, `keys()`, `get_name()`, assignment, and deletion to
|
||||
manage declarations, then use the library returned by `build()` for reads and
|
||||
hierarchy operations. Recipes that need an active library must receive the
|
||||
builder-owned placeholder as a direct argument:
|
||||
|
||||
```python
|
||||
def make_top(lib: ILibrary) -> Pattern:
|
||||
return Pather(library=lib, ports='device').pattern
|
||||
|
||||
builder.cells.top = cell(make_top)(builder.library)
|
||||
builder.cells.device = cell(factory)(hole_lib=builder.library)
|
||||
```
|
||||
|
||||
The builder-owned `builder.library` placeholder is read-only. Only direct
|
||||
positional and keyword values equal to it are substituted; placeholders nested
|
||||
inside containers are not interpreted. A placeholder from another builder is
|
||||
rejected when the recipe is assigned.
|
||||
|
||||
`IMaterializable` now identifies libraries which support explicit
|
||||
`materialize()` and `materialize_many()` operations. `LibraryBuilder.add()`
|
||||
borrows these marked inputs, while ordinary mappings and `ILibraryView`
|
||||
instances are copied eagerly. Use `add_source()` to force borrowing of an
|
||||
unmarked view. Wrapping a materializable library in `LibraryView` intentionally
|
||||
erases the marker.
|
||||
|
||||
`IBorrowing` separately identifies composite views which retain direct source
|
||||
libraries and expose them through `borrowed_sources()`. Keep those sources open
|
||||
and unchanged for the lifetime of the borrowing view. Owner libraries such as
|
||||
`LazyLibrary` and the lazy GDS readers are materializable but do not implement
|
||||
`IBorrowing`. Borrowing views may expose unchanged per-cell layout provenance
|
||||
through `source_cell()`; format writers decide whether that provenance permits
|
||||
raw copy-through. Raw GDS structure access remains confined to GDS sources and
|
||||
writers.
|
||||
|
||||
`LibraryBuilder`, `OverlayLibrary`, `PortLoadView`, and `LayerMappedView` are new
|
||||
additive library implementations. `LibraryBuilder` supports declarative `@cell`
|
||||
recipes and dependency-aware builds; `OverlayLibrary` composes source libraries
|
||||
without eagerly copying all patterns; `PortLoadView` loads port metadata from
|
||||
labels and/or explicit mappings; `LayerMappedView` remaps shape and label layers
|
||||
on detached materialization.
|
||||
|
||||
Flattening with `flatten_ports=True` now rejects repeated refs whose target has
|
||||
ports, because expanding them would create duplicate port names. Resolve the
|
||||
repetition and assign unique port names before flattening, or use
|
||||
`flatten_ports=False`.
|
||||
|
||||
## GDSII module and lazy-loading changes
|
||||
|
||||
`masque.file.gdsii` changed from a module into a package. The eager klamath
|
||||
API remains available at the old import path, so ordinary `read`, `readfile`,
|
||||
`write`, and `writefile` calls do not need to change:
|
||||
|
||||
```python
|
||||
from masque.file import gdsii
|
||||
library, info = gdsii.readfile('layout.gds')
|
||||
```
|
||||
|
||||
The old `gdsii.load_library()` and `gdsii.load_libraryfile()` entry points were
|
||||
removed. Use the source-backed lazy reader instead:
|
||||
|
||||
```python
|
||||
# old
|
||||
library, info = gdsii.load_libraryfile('layout.gds')
|
||||
|
||||
# new
|
||||
from masque.file.gdsii import lazy
|
||||
|
||||
library, info = lazy.readfile('layout.gds')
|
||||
try:
|
||||
pattern = library['TOP']
|
||||
finally:
|
||||
library.close()
|
||||
```
|
||||
|
||||
`lazy.read(stream)` and `lazy.readfile(path, use_mmap=...)` return a read-only
|
||||
`GdsLibrarySource`. It owns file resources when it opens them and also supports
|
||||
the context-manager protocol. The old `full_load` and `postprocess` arguments
|
||||
are gone; materialize/copy the desired cells and post-process them explicitly.
|
||||
|
||||
An optional Arrow/native backend is available through
|
||||
`masque.file.gdsii.arrow` and `masque.file.gdsii.lazy_arrow`; install the new
|
||||
`arrow` extra to use it. These modules are additive and are not a transparent
|
||||
replacement unless their additional dependencies and native library are
|
||||
available.
|
||||
|
||||
GDS writing now has one entry point for eager and lazy libraries. Use
|
||||
`masque.file.gdsii.write()` or `masque.file.gdsii.writefile()` regardless of
|
||||
which reader produced the library. The `lazy` and `lazy_arrow` modules no
|
||||
longer re-export writer functions. Source-backed libraries continue to infer
|
||||
their header metadata and copy untouched structures directly.
|
||||
|
||||
## Shape construction and geometry additions
|
||||
|
||||
The public `raw=True` constructor shortcut was removed from `Arc`, `Circle`,
|
||||
`Ellipse`, `Path`, `Polygon`, `PolyCollection`, and `Text`. Call their normal
|
||||
constructors without `raw`; `_from_raw()` is an internal fast path and is not a
|
||||
compatibility API.
|
||||
|
||||
```python
|
||||
# old
|
||||
polygon = Polygon(vertices, raw=True)
|
||||
|
||||
# new
|
||||
polygon = Polygon(vertices)
|
||||
```
|
||||
|
||||
`Arc` radii must now be strictly positive rather than merely non-negative.
|
||||
`Arc.angle_ref` is additive and defaults to `Arc.AngleRef.Center`, preserving
|
||||
the previous center-referenced angle interpretation.
|
||||
|
||||
`RectCollection` is a new shape for batches of axis-aligned rectangles and is
|
||||
exported from both `masque` and `masque.shapes`. `Polygon.boolean()` and the
|
||||
top-level `masque.boolean()` helper are also new; install the `boolean` extra
|
||||
for their `pyclipper` dependency.
|
||||
|
||||
## Other user-facing changes
|
||||
|
||||
### File writers
|
||||
|
||||
SVG writing no longer polygonizes or flattens caller-owned patterns in place;
|
||||
it works from detached copies. `svg.writefile(..., annotate_ports=True)` can
|
||||
add port arrows. DXF writing now expands shape repetitions into individual DXF
|
||||
entities, so callers no longer need to wrap repeated shapes solely for DXF
|
||||
output.
|
||||
|
||||
### DXF environments
|
||||
|
||||
If you install the DXF extra, the supported `ezdxf` baseline moved from
|
||||
`~=1.0.2` to `~=1.4`. Any pinned environments should be updated accordingly.
|
||||
|
||||
### Optional dependency names
|
||||
|
||||
The misspelled `manhatanize_slow` extra was corrected to
|
||||
`manhattanize_slow`. A separate `manhattanize` extra now installs the
|
||||
scikit-image implementation. The `arrow` and `boolean` extras are also new.
|
||||
|
||||
### New exports
|
||||
|
||||
These are additive, but available now from `masque` and `masque.builder`:
|
||||
|
||||
- from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`,
|
||||
`PortLoadView`, `LayerMappedView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`,
|
||||
`BuildReport`, `CellProvenance`, and `cell`
|
||||
- from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete
|
||||
primitive-offer classes, structured route error/status types,
|
||||
`ToolContractCase`, and `validate_tool_contract`
|
||||
|
||||
## Minimal migration checklist
|
||||
|
||||
If your code uses the routing stack, do these first:
|
||||
|
||||
1. Replace `path`/`path_to`/`mpath`/`path_into` calls with
|
||||
`trace`/`trace_to`/multi-port `trace`/`trace_into`.
|
||||
2. Replace `SimpleTool` and old `AutoTool` descriptor construction with the
|
||||
new `AutoTool.add_*()` methods.
|
||||
3. Fix imports that still reference `masque.builder.builder` or
|
||||
`masque.builder.renderpather`.
|
||||
4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`.
|
||||
5. Move lazy GDS calls from `gdsii.load_library*()` to
|
||||
`masque.file.gdsii.lazy`.
|
||||
6. Remove `raw=True` from public shape constructors.
|
||||
|
||||
If your code only uses `Pattern`, `Library`, `place()`, and `plug()` without the
|
||||
routing helpers, audit transforms, dangling-reference graph calls, raw shape
|
||||
construction, and any stale imports.
|
||||
281
README.md
281
README.md
|
|
@ -3,278 +3,49 @@
|
|||
Masque is a Python module for designing lithography masks.
|
||||
|
||||
The general idea is to implement something resembling the GDSII file-format, but
|
||||
with some vectorized element types (eg. circles, not just polygons) and the ability
|
||||
to output to multiple formats.
|
||||
with some vectorized element types (eg. circles, not just polygons), better support for
|
||||
E-beam doses, and the ability to output to multiple formats.
|
||||
|
||||
- [Source repository](https://mpxd.net/code/jan/masque)
|
||||
- [PyPI](https://pypi.org/project/masque)
|
||||
- [Github mirror](https://github.com/anewusername/masque)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Requirements:
|
||||
* python >= 3.11
|
||||
* python >= 3.8
|
||||
* numpy
|
||||
* klamath (used for GDSII i/o)
|
||||
|
||||
Optional requirements:
|
||||
* `ezdxf` (DXF i/o): ezdxf
|
||||
* `oasis` (OASIS i/o): fatamorgana
|
||||
* `svg` (SVG output): svgwrite
|
||||
* `visualization` (shape plotting): matplotlib
|
||||
* `text` (`Text` shape): matplotlib, freetype
|
||||
* klamath (used for `gdsii` i/o and library management)
|
||||
* matplotlib (optional, used for `visualization` functions and `text`)
|
||||
* ezdxf (optional, used for `dxf` i/o)
|
||||
* fatamorgana (optional, used for `oasis` i/o)
|
||||
* svgwrite (optional, used for `svg` output)
|
||||
* freetype (optional, used for `text`)
|
||||
|
||||
|
||||
Install with pip:
|
||||
```bash
|
||||
pip install 'masque[oasis,dxf,svg,visualization,text]'
|
||||
pip3 install 'masque[visualization,oasis,dxf,svg,text]'
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
A layout consists of a hierarchy of `Pattern`s stored in a single `Library`.
|
||||
Each `Pattern` can contain `Ref`s pointing at other patterns, `Shape`s, `Label`s, and `Port`s.
|
||||
|
||||
|
||||
Library / Pattern hierarchy:
|
||||
```
|
||||
+-----------------------------------------------------------------------+
|
||||
| Library |
|
||||
| |
|
||||
| Name: "MyChip" ...> Name: "Transistor" |
|
||||
| +---------------------------+ : +---------------------------+ |
|
||||
| | [Pattern] | : | [Pattern] | |
|
||||
| | | : | | |
|
||||
| | shapes: {...} | : | shapes: { | |
|
||||
| | ports: {...} | : | "Si": [<Polygon>, ...] | |
|
||||
| | | : | "M1": [<Polygon>, ...]}| |
|
||||
| | refs: | : | ports: {G, S, D} | |
|
||||
| | "Transistor": [Ref, Ref]|..: +---------------------------+ |
|
||||
| +---------------------------+ |
|
||||
| |
|
||||
| # (`refs` keys resolve to Patterns within the Library) |
|
||||
+-----------------------------------------------------------------------+
|
||||
Alternatively, install from git
|
||||
```bash
|
||||
pip3 install git+https://mpxd.net/code/jan/masque.git@release
|
||||
```
|
||||
|
||||
|
||||
Pattern internals:
|
||||
```
|
||||
+---------------------------------------------------------------+
|
||||
| [Pattern] |
|
||||
| |
|
||||
| shapes: { |
|
||||
| (1, 0): [Polygon, Circle, ...], # Geometry by layer |
|
||||
| (2, 0): [Path, ...] |
|
||||
| "M1" : [Path, ...] |
|
||||
| "M2" : [Polygon, ...] |
|
||||
| } |
|
||||
| |
|
||||
| refs: { # Key sets target name, Ref sets transform |
|
||||
| "my_cell": [ |
|
||||
| Ref(offset=(0,0), rotation=0), |
|
||||
| Ref(offset=(10,0), rotation=R90, repetition=Grid(...)) |
|
||||
| ] |
|
||||
| } |
|
||||
| |
|
||||
| ports: { |
|
||||
| "in": Port(offset=(0,0), rotation=0, ptype="M1"), |
|
||||
| "out": Port(offset=(10,0), rotation=R180, ptype="wg") |
|
||||
| } |
|
||||
| |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
## Translation
|
||||
- `Pattern`: OASIS or GDS "Cell", DXF "Block"
|
||||
- `SubPattern`: GDS "AREF/SREF", OASIS "Placement"
|
||||
- `Shape`: OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline"
|
||||
- `repetition`: OASIS "repetition". GDS "AREF" is a `SubPattern` combined with a `Grid` repetition.
|
||||
- `Label`: OASIS, GDS, DXF "Text".
|
||||
- `annotation`: OASIS or GDS "property"
|
||||
|
||||
|
||||
`masque` departs from several "classic" GDSII paradigms:
|
||||
- A `Pattern` object does not store its own name. A name is only assigned when the pattern is placed
|
||||
into a `Library`, which is effectively a name->`Pattern` mapping.
|
||||
- Layer info for `Shape`ss and `Label`s is not stored in the individual shape and label objects.
|
||||
Instead, the layer is determined by the key for the container dict (e.g. `pattern.shapes[layer]`).
|
||||
* This simplifies many common tasks: filtering `Shape`s by layer, remapping layers, and checking if
|
||||
a layer is empty.
|
||||
* Technically, this allows reusing the same shape or label object across multiple layers. This isn't
|
||||
part of the standard workflow since a mixture of single-use and multi-use shapes could be confusing.
|
||||
* This is similar to the approach used in [KLayout](https://www.klayout.de)
|
||||
- `Ref` target names are also determined in the key of the container dict (e.g. `pattern.refs[target_name]`).
|
||||
* This similarly simplifies filtering `Ref`s by target name, updating to a new target, and checking
|
||||
if a given `Pattern` is referenced.
|
||||
- `Pattern` names are set by their containing `Library` and are not stored in the `Pattern` objects.
|
||||
* This guarantees that there are no duplicate pattern names within any given `Library`.
|
||||
* Likewise, enumerating all the names (and all the `Pattern`s) in a `Library` is straightforward.
|
||||
- Each `Ref`, `Shape`, or `Label` can be repeated multiple times by attaching a `repetition` object to it.
|
||||
* This is similar to how OASIS reptitions are handled, and provides extra flexibility over the GDSII
|
||||
approach of only allowing arrays through AREF (`Ref` + `repetition`).
|
||||
- `Label`s do not have an orientation or presentation
|
||||
* This is in line with how they are used in practice, and how they are represented in OASIS.
|
||||
- Non-polygonal `Shape`s are allowed. For example, elliptical arcs are a basic shape type.
|
||||
* This enables compatibility with OASIS (e.g. circles) and other formats.
|
||||
* `Shape`s provide a `.to_polygons()` method for GDSII compatibility.
|
||||
- Most coordinate values are stored as 64-bit floats internally.
|
||||
* 1 earth radii in nanometers (6e15) is still represented without approximation (53 bit mantissa -> 2^53 > 9e15)
|
||||
* Operations that would otherwise clip/round on are still represented approximately.
|
||||
* Memory usage is usually dominated by other Python overhead.
|
||||
- `Pattern` objects also contain `Port` information, which can be used to "snap" together
|
||||
multiple sub-components by matching up the requested port offsets and rotations.
|
||||
* Port rotations are defined as counter-clockwise angles from the +x axis.
|
||||
* Ports point into the interior of their associated device.
|
||||
* Port rotations may be `None` in the case of non-oriented ports.
|
||||
* Ports have a `ptype` string which is compared in order to catch mismatched connections at build time.
|
||||
* Ports can be exported into/imported from `Label`s stored directly in the layout,
|
||||
editable from standard tools (e.g. KLayout). A default format is provided.
|
||||
## TODO
|
||||
|
||||
In one important way, `masque` stays very orthodox:
|
||||
References are accomplished by listing the target's name, not its `Pattern` object.
|
||||
|
||||
- The main downside of this is that any operations that traverse the hierarchy require
|
||||
both the `Pattern` and the `Library` which is contains its reference targets.
|
||||
- This guarantees that names within a `Library` remain unique at all times.
|
||||
* Since this can be tedious in cases where you don't actually care about the name of a
|
||||
pattern, patterns whose names start with `SINGLE_USE_PREFIX` (default: an underscore)
|
||||
may be silently renamed in order to maintain uniqueness.
|
||||
See `masque.library.SINGLE_USE_PREFIX`, `masque.library._rename_patterns()`,
|
||||
and `ILibrary.add()` for more details.
|
||||
- Having all patterns accessible through the `Library` avoids having to perform a
|
||||
tree traversal for every operation which needs to touch all `Pattern` objects
|
||||
(e.g. deleting a layer everywhere or scaling all patterns).
|
||||
- Since `Pattern` doesn't know its own name, you can't create a reference by passing in
|
||||
a `Pattern` object -- you need to know its name.
|
||||
- You *can* reference a `Pattern` before it is created, so long as you have already decided
|
||||
on its name.
|
||||
- Functions like `Pattern.place()` and `Pattern.plug()` need to receive a pattern's name
|
||||
in order to create a reference, but they also need to access the pattern's ports.
|
||||
* One way to provide this data is through an `Abstract`, generated via
|
||||
`Library.abstract()` or through a `Library.abstract_view()`.
|
||||
* Another way is use `Pather.place()` or `Pather.plug()`, which automatically creates
|
||||
an `Abstract` from its internally-referenced `Library`.
|
||||
|
||||
|
||||
## Glossary
|
||||
- `Library`: A collection of named cells. OASIS or GDS "library" or file.
|
||||
- `Tree`: Any `{name: pattern}` mapping which has only one topcell.
|
||||
- `Pattern`: A collection of geometry, text labels, and reference to other patterns.
|
||||
OASIS or GDS "Cell", DXF "Block".
|
||||
- `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement".
|
||||
- `Shape`: Individual geometric entity. OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline".
|
||||
- `repetition`: Repetition operation. OASIS "repetition".
|
||||
GDS "AREF" is a `Ref` combined with a `Grid` repetition.
|
||||
- `Label`: Text label. Not rendered into geometry. OASIS, GDS, DXF "Text".
|
||||
- `annotation`: Additional metadata. OASIS or GDS "property".
|
||||
|
||||
|
||||
## Syntax, shorthand, and design patterns
|
||||
Most syntax and behavior should follow normal python conventions.
|
||||
There are a few exceptions, either meant to catch common mistakes or to provide a shorthand for common operations:
|
||||
|
||||
### `Library` objects don't allow overwriting already-existing patterns
|
||||
```python3
|
||||
library['mycell'] = pattern0
|
||||
library['mycell'] = pattern1 # Error! 'mycell' already exists and can't be overwritten
|
||||
del library['mycell'] # We can explicitly delete it
|
||||
library['mycell'] = pattern1 # And now it's ok to assign a new value
|
||||
library.delete('mycell') # This also deletes all refs pointing to 'mycell' by default
|
||||
```
|
||||
|
||||
### Insert a newly-made hierarchical pattern (with children) into a layout
|
||||
```python3
|
||||
# Let's say we have a function which returns a new library containing one topcell (and possibly children)
|
||||
tree = make_tree(...)
|
||||
|
||||
# To reference this cell in our layout, we have to add all its children to our `library` first:
|
||||
top_name = tree.top() # get the name of the topcell
|
||||
name_mapping = library.add(tree) # add all patterns from `tree`, renaming eligible conflicting patterns
|
||||
new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed)
|
||||
my_pattern.ref(new_name, ...) # instantiate the cell
|
||||
|
||||
# This can be accomplished as follows
|
||||
new_name = library << tree # Add `tree` into `library` and return the top cell's new name
|
||||
my_pattern.ref(new_name, ...) # instantiate the cell
|
||||
|
||||
# In practice, you may do lots of
|
||||
my_pattern.ref(lib << make_tree(...), ...)
|
||||
|
||||
# With a `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit:
|
||||
my_builder = Pather(library=lib, ...)
|
||||
...
|
||||
my_builder.place(make_tree(...))
|
||||
```
|
||||
|
||||
We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern:
|
||||
```python3
|
||||
anonymous_pattern = Pattern(...)
|
||||
my_pattern.ref(lib << {'_tentative_name': anonymous_pattern}, ...)
|
||||
```
|
||||
|
||||
### Place a hierarchical pattern into a layout, preserving its port info
|
||||
```python3
|
||||
# As above, we have a function that makes a new library containing one topcell (and possibly children)
|
||||
tree = make_tree(...)
|
||||
|
||||
# We need to go get its port info to `place()` it into our existing layout,
|
||||
new_name = library << tree # Add the tree to the library and return its name (see `<<` above)
|
||||
abstract = library.abstract(tree) # An `Abstract` stores a pattern's name and its ports (but no geometry)
|
||||
my_pattern.place(abstract, ...)
|
||||
|
||||
# With shorthand,
|
||||
abstract = library <= tree
|
||||
my_pattern.place(abstract, ...)
|
||||
|
||||
# or
|
||||
my_pattern.place(library << make_tree(...), ...)
|
||||
```
|
||||
|
||||
|
||||
### Quickly add geometry, labels, or refs:
|
||||
Adding elements can be overly verbose:
|
||||
```python3
|
||||
my_pattern.shapes[layer].append(Polygon(vertices, ...))
|
||||
my_pattern.labels[layer] += [Label('my text')]
|
||||
my_pattern.refs[target_name].append(Ref(offset=..., ...))
|
||||
```
|
||||
|
||||
There is shorthand for the most common elements:
|
||||
```python3
|
||||
my_pattern.polygon(layer=layer, vertices=vertices, ...)
|
||||
my_pattern.rect(layer=layer, xctr=..., xmin=..., ymax=..., ly=...) # rectangle; pick 4 of 6 constraints
|
||||
my_pattern.rect(layer=layer, ymin=..., ymax=..., xctr=..., lx=...)
|
||||
my_pattern.path(...)
|
||||
my_pattern.label(layer, 'my_text')
|
||||
my_pattern.ref(target_name, offset=..., ...)
|
||||
```
|
||||
|
||||
### Accessing ports
|
||||
```python3
|
||||
# Square brackets pull from the underlying `.ports` dict:
|
||||
assert pattern['input'] is pattern.ports['input']
|
||||
|
||||
# And you can use them to read multiple ports at once:
|
||||
assert pattern[('input', 'output')] == {
|
||||
'input': pattern.ports['input'],
|
||||
'output': pattern.ports['output'],
|
||||
}
|
||||
|
||||
# But you shouldn't use them for anything except reading
|
||||
pattern['input'] = Port(...) # Error!
|
||||
has_input = ('input' in pattern) # Error!
|
||||
```
|
||||
|
||||
### Building patterns
|
||||
```python3
|
||||
library = Library(...)
|
||||
my_pattern_name, my_pattern = library.mkpat(some_name_generator())
|
||||
...
|
||||
def _make_my_subpattern() -> str:
|
||||
# This function can draw from the outer scope (e.g. `library`) but will not pollute the outer scope
|
||||
# (e.g. the variable `subpattern` will not be accessible from outside the function; you must load it
|
||||
# from within `library`).
|
||||
subpattern_name, subpattern = library.mkpat(...)
|
||||
subpattern.rect(...)
|
||||
...
|
||||
return subpattern_name
|
||||
my_pattern.ref(_make_my_subpattern(), offset=..., ...)
|
||||
```
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
Project-level planned work is tracked in [TODO.md](TODO.md).
|
||||
* Better interface for polygon operations (e.g. with `pyclipper`)
|
||||
- de-embedding
|
||||
- boolean ops
|
||||
* Construct polygons from bitmap using `skimage.find_contours`
|
||||
* Deal with shape repetitions for dxf, svg
|
||||
|
|
|
|||
|
|
@ -2,33 +2,29 @@
|
|||
|
||||
import numpy
|
||||
|
||||
from masque.file import gdsii
|
||||
from masque import Arc, Pattern
|
||||
import masque
|
||||
import masque.file.klamath
|
||||
from masque import shapes
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pat = Pattern()
|
||||
layer = (0, 0)
|
||||
pat.shapes[layer].extend([
|
||||
Arc(
|
||||
def main():
|
||||
pat = masque.Pattern(name='ellip_grating')
|
||||
for rmin in numpy.arange(10, 15, 0.5):
|
||||
pat.shapes.append(shapes.Arc(
|
||||
radii=(rmin, rmin),
|
||||
width=0.1,
|
||||
angles=(-numpy.pi/4, numpy.pi/4),
|
||||
)
|
||||
for rmin in numpy.arange(10, 15, 0.5)]
|
||||
)
|
||||
layer=(0, 0),
|
||||
))
|
||||
|
||||
pat.label(string='grating centerline', offset=(1, 0), layer=(1, 2))
|
||||
pat.labels.append(masque.Label(string='grating centerline', offset=(1, 0), layer=(1, 2)))
|
||||
|
||||
pat.scale_by(1000)
|
||||
pat.visualize()
|
||||
pat2 = pat.copy()
|
||||
pat2.name = 'grating2'
|
||||
|
||||
lib = {
|
||||
'ellip_grating': pat,
|
||||
'grating2': pat.copy(),
|
||||
}
|
||||
|
||||
gdsii.writefile(lib, 'out.gds.gz', meters_per_unit=1e-9, logical_units_per_unit=1e-3)
|
||||
masque.file.klamath.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
from pyclipper import (
|
||||
Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO,
|
||||
)
|
||||
p = Pyclipper()
|
||||
p.AddPaths([
|
||||
[(-10, -10), (-10, 10), (-9, 10), (-9, -10)],
|
||||
[(-10, 10), (10, 10), (10, 9), (-10, 9)],
|
||||
[(10, 10), (10, -10), (9, -10), (9, 10)],
|
||||
[(10, -10), (-10, -10), (-10, -9), (10, -9)],
|
||||
], PT_SUBJECT, closed=True)
|
||||
#p.Execute2?
|
||||
#p.Execute?
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
|
||||
p = Pyclipper()
|
||||
p.AddPaths([
|
||||
[(-10, -10), (-10, 10), (-9, 10), (-9, -10)],
|
||||
[(-10, 10), (10, 10), (10, 9), (-10, 9)],
|
||||
[(10, 10), (10, -10), (9, -10), (9, 10)],
|
||||
[(10, -10), (-10, -10), (-10, -9), (10, -9)],
|
||||
], PT_SUBJECT, closed=True)
|
||||
r = p.Execute2(CT_UNION, PFT_NONZERO, PFT_NONZERO)
|
||||
|
||||
#r.Childs
|
||||
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# pip install pillow scikit-image
|
||||
# or
|
||||
# sudo apt install python3-pil python3-skimage
|
||||
|
||||
from PIL import Image
|
||||
from skimage.measure import find_contours
|
||||
from matplotlib import pyplot
|
||||
import numpy
|
||||
|
||||
from masque import Pattern, Polygon
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
#
|
||||
# Read the image into a numpy array
|
||||
#
|
||||
im = Image.open('./Desktop/Camera/IMG_20220626_091101.jpg')
|
||||
|
||||
aa = numpy.array(im.convert(mode='L').getdata()).reshape(im.height, im.width)
|
||||
|
||||
threshold = (aa.max() - aa.min()) / 2
|
||||
|
||||
#
|
||||
# Find edge contours and plot them
|
||||
#
|
||||
contours = find_contours(aa, threshold)
|
||||
|
||||
pyplot.imshow(aa)
|
||||
for contour in contours:
|
||||
pyplot.plot(contour[:, 1], contour[:, 0], linewidth=2)
|
||||
pyplot.show(block=False)
|
||||
|
||||
#
|
||||
# Create the layout from the contours
|
||||
#
|
||||
pat = Pattern()
|
||||
pat.shapes[(0, 0)].extend([
|
||||
Polygon(vertices=vv) for vv in contours if len(vv) < 1_000
|
||||
])
|
||||
|
||||
lib = {}
|
||||
lib['my_mask_name'] = pat
|
||||
|
||||
writefile(lib, 'test_contours.gds', meters_per_unit=1e-9)
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from masque import LibraryError
|
||||
|
||||
|
||||
READERS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
'gdsii': ('masque.file.gdsii', ('readfile',)),
|
||||
'gdsii_arrow': ('masque.file.gdsii.arrow', ('readfile', 'arrow_import', 'arrow_convert')),
|
||||
}
|
||||
|
||||
|
||||
def _summarize_library(path: Path, elapsed_s: float, info: dict[str, object], lib: object) -> dict[str, object]:
|
||||
assert hasattr(lib, '__len__')
|
||||
assert hasattr(lib, 'tops')
|
||||
tops = lib.tops() # type: ignore[no-any-return, attr-defined]
|
||||
try:
|
||||
unique_top = lib.top() # type: ignore[no-any-return, attr-defined]
|
||||
except LibraryError:
|
||||
unique_top = None
|
||||
|
||||
return {
|
||||
'path': str(path),
|
||||
'elapsed_s': elapsed_s,
|
||||
'library_name': info['name'],
|
||||
'cell_count': len(lib), # type: ignore[arg-type]
|
||||
'topcells': tops,
|
||||
'topcell': unique_top,
|
||||
}
|
||||
|
||||
|
||||
def _summarize_arrow_import(path: Path, elapsed_s: float, arrow_arr: Any) -> dict[str, object]:
|
||||
libarr = arrow_arr[0]
|
||||
return {
|
||||
'path': str(path),
|
||||
'elapsed_s': elapsed_s,
|
||||
'arrow_rows': len(arrow_arr),
|
||||
'library_name': libarr['lib_name'].as_py(),
|
||||
'cell_count': len(libarr['cells']),
|
||||
'layer_count': len(libarr['layers']),
|
||||
}
|
||||
|
||||
|
||||
def _profile_stage(module: Any, stage: str, path: Path) -> dict[str, object]:
|
||||
start = time.perf_counter()
|
||||
|
||||
if stage == 'readfile':
|
||||
lib, info = module.readfile(path)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return _summarize_library(path, elapsed_s, info, lib)
|
||||
|
||||
if stage == 'arrow_import':
|
||||
if hasattr(module, 'readfile_arrow'):
|
||||
libarr, _info = module.readfile_arrow(path)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return {
|
||||
'path': str(path),
|
||||
'elapsed_s': elapsed_s,
|
||||
'arrow_rows': 1,
|
||||
'library_name': libarr['lib_name'].as_py(),
|
||||
'cell_count': len(libarr['cells']),
|
||||
'layer_count': len(libarr['layers']),
|
||||
}
|
||||
|
||||
arrow_arr = module._read_to_arrow(path)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return _summarize_arrow_import(path, elapsed_s, arrow_arr)
|
||||
|
||||
if stage == 'arrow_convert':
|
||||
arrow_arr = module._read_to_arrow(path)
|
||||
libarr = arrow_arr[0]
|
||||
start = time.perf_counter()
|
||||
lib, info = module.read_arrow(libarr)
|
||||
elapsed_s = time.perf_counter() - start
|
||||
return _summarize_library(path, elapsed_s, info, lib)
|
||||
|
||||
raise ValueError(f'Unsupported stage {stage!r}')
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description='Profile GDS readers with a stable end-to-end workload.')
|
||||
parser.add_argument('--reader', choices=sorted(READERS), required=True)
|
||||
parser.add_argument('--stage', default='readfile')
|
||||
parser.add_argument('--path', type=Path, required=True)
|
||||
parser.add_argument('--warmup', type=int, default=1)
|
||||
parser.add_argument('--repeat', type=int, default=1)
|
||||
parser.add_argument('--output-json', type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
module_name, stages = READERS[args.reader]
|
||||
if args.stage not in stages:
|
||||
parser.error(f'reader {args.reader!r} only supports stages: {", ".join(stages)}')
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
path = args.path.expanduser().resolve()
|
||||
|
||||
for _ in range(args.warmup):
|
||||
_profile_stage(module, args.stage, path)
|
||||
|
||||
runs = []
|
||||
for _ in range(args.repeat):
|
||||
runs.append(_profile_stage(module, args.stage, path))
|
||||
|
||||
payload = {
|
||||
'reader': args.reader,
|
||||
'stage': args.stage,
|
||||
'warmup': args.warmup,
|
||||
'repeat': args.repeat,
|
||||
'runs': runs,
|
||||
}
|
||||
rendered = json.dumps(payload, indent=2, sort_keys=True)
|
||||
if args.output_json is not None:
|
||||
args.output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_json.write_text(rendered + '\n')
|
||||
print(rendered)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,138 +1,103 @@
|
|||
from pprint import pprint
|
||||
from pathlib import Path
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
import masque
|
||||
from masque import Pattern, Ref, Arc, Library
|
||||
import masque.file.gdsii
|
||||
import masque.file.klamath
|
||||
import masque.file.dxf
|
||||
import masque.file.oasis
|
||||
from masque import shapes, Pattern, SubPattern
|
||||
from masque.repetition import Grid
|
||||
from masque.file import gdsii, dxf, oasis
|
||||
|
||||
from pprint import pprint
|
||||
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lib = Library()
|
||||
|
||||
cell_name = 'ellip_grating'
|
||||
pat = masque.Pattern()
|
||||
|
||||
layer = (0, 0)
|
||||
def main():
|
||||
pat = masque.Pattern(name='ellip_grating')
|
||||
for rmin in numpy.arange(10, 15, 0.5):
|
||||
pat.shapes[layer].append(Arc(
|
||||
pat.shapes.append(shapes.Arc(
|
||||
radii=(rmin, rmin),
|
||||
width=0.1,
|
||||
angles=(0 * -pi/4, pi/4),
|
||||
angles=(0*-numpy.pi/4, numpy.pi/4),
|
||||
annotations={'1': ['blah']},
|
||||
))
|
||||
|
||||
pat.scale_by(1000)
|
||||
# pat.visualize()
|
||||
lib[cell_name] = pat
|
||||
print(f'\nAdded {cell_name}:')
|
||||
pat2 = pat.copy()
|
||||
pat2.name = 'grating2'
|
||||
|
||||
pat3 = Pattern('sref_test')
|
||||
pat3.subpatterns = [
|
||||
SubPattern(pat, offset=(1e5, 3e5), annotations={'4': ['Hello I am the base subpattern']}),
|
||||
SubPattern(pat, offset=(2e5, 3e5), rotation=pi/3),
|
||||
SubPattern(pat, offset=(3e5, 3e5), rotation=pi/2),
|
||||
SubPattern(pat, offset=(4e5, 3e5), rotation=pi),
|
||||
SubPattern(pat, offset=(5e5, 3e5), rotation=3*pi/2),
|
||||
SubPattern(pat, mirrored=(True, False), offset=(1e5, 4e5)),
|
||||
SubPattern(pat, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3),
|
||||
SubPattern(pat, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2),
|
||||
SubPattern(pat, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi),
|
||||
SubPattern(pat, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2),
|
||||
SubPattern(pat, mirrored=(False, True), offset=(1e5, 5e5)),
|
||||
SubPattern(pat, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3),
|
||||
SubPattern(pat, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2),
|
||||
SubPattern(pat, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi),
|
||||
SubPattern(pat, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2),
|
||||
SubPattern(pat, mirrored=(True, True), offset=(1e5, 6e5)),
|
||||
SubPattern(pat, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3),
|
||||
SubPattern(pat, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2),
|
||||
SubPattern(pat, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi),
|
||||
SubPattern(pat, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2),
|
||||
]
|
||||
|
||||
pprint(pat3)
|
||||
pprint(pat3.subpatterns)
|
||||
pprint(pat.shapes)
|
||||
|
||||
new_name = lib.get_name(cell_name)
|
||||
lib[new_name] = pat.copy()
|
||||
print(f'\nAdded a copy of {cell_name} as {new_name}')
|
||||
|
||||
pat3 = Pattern()
|
||||
pat3.refs[cell_name] = [
|
||||
Ref(offset=(1e5, 3e5), annotations={'4': ['Hello I am the base Ref']}),
|
||||
Ref(offset=(2e5, 3e5), rotation=pi/3),
|
||||
Ref(offset=(3e5, 3e5), rotation=pi/2),
|
||||
Ref(offset=(4e5, 3e5), rotation=pi),
|
||||
Ref(offset=(5e5, 3e5), rotation=3*pi/2),
|
||||
Ref(mirrored=True, offset=(1e5, 4e5)),
|
||||
Ref(mirrored=True, offset=(2e5, 4e5), rotation=pi/3),
|
||||
Ref(mirrored=True, offset=(3e5, 4e5), rotation=pi/2),
|
||||
Ref(mirrored=True, offset=(4e5, 4e5), rotation=pi),
|
||||
Ref(mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2),
|
||||
Ref(offset=(1e5, 5e5)).mirror_target(1),
|
||||
Ref(offset=(2e5, 5e5), rotation=pi/3).mirror_target(1),
|
||||
Ref(offset=(3e5, 5e5), rotation=pi/2).mirror_target(1),
|
||||
Ref(offset=(4e5, 5e5), rotation=pi).mirror_target(1),
|
||||
Ref(offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1),
|
||||
Ref(offset=(1e5, 6e5)).mirror2d_target(True, True),
|
||||
Ref(offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True),
|
||||
Ref(offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True),
|
||||
Ref(offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True),
|
||||
Ref(offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True),
|
||||
]
|
||||
|
||||
lib['sref_test'] = pat3
|
||||
print('\nAdded sref_test:')
|
||||
pprint(pat3)
|
||||
pprint(pat3.refs)
|
||||
|
||||
rep = Grid(
|
||||
a_vector=[1e4, 0],
|
||||
rep = Grid(a_vector=[1e4, 0],
|
||||
b_vector=[0, 1.5e4],
|
||||
a_count=3,
|
||||
b_count=2,
|
||||
)
|
||||
pat4 = Pattern()
|
||||
pat4.refs[cell_name] = [
|
||||
Ref(repetition=rep, offset=(1e5, 3e5)),
|
||||
Ref(repetition=rep, offset=(2e5, 3e5), rotation=pi/3),
|
||||
Ref(repetition=rep, offset=(3e5, 3e5), rotation=pi/2),
|
||||
Ref(repetition=rep, offset=(4e5, 3e5), rotation=pi),
|
||||
Ref(repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2),
|
||||
Ref(repetition=rep, mirrored=True, offset=(1e5, 4e5)),
|
||||
Ref(repetition=rep, mirrored=True, offset=(2e5, 4e5), rotation=pi/3),
|
||||
Ref(repetition=rep, mirrored=True, offset=(3e5, 4e5), rotation=pi/2),
|
||||
Ref(repetition=rep, mirrored=True, offset=(4e5, 4e5), rotation=pi),
|
||||
Ref(repetition=rep, mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2),
|
||||
Ref(repetition=rep, offset=(1e5, 5e5)).mirror_target(1),
|
||||
Ref(repetition=rep, offset=(2e5, 5e5), rotation=pi/3).mirror_target(1),
|
||||
Ref(repetition=rep, offset=(3e5, 5e5), rotation=pi/2).mirror_target(1),
|
||||
Ref(repetition=rep, offset=(4e5, 5e5), rotation=pi).mirror_target(1),
|
||||
Ref(repetition=rep, offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1),
|
||||
Ref(repetition=rep, offset=(1e5, 6e5)).mirror2d_target(True, True),
|
||||
Ref(repetition=rep, offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True),
|
||||
Ref(repetition=rep, offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True),
|
||||
Ref(repetition=rep, offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True),
|
||||
Ref(repetition=rep, offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True),
|
||||
b_count=2,)
|
||||
pat4 = Pattern('aref_test')
|
||||
pat4.subpatterns = [
|
||||
SubPattern(pat, repetition=rep, offset=(1e5, 3e5)),
|
||||
SubPattern(pat, repetition=rep, offset=(2e5, 3e5), rotation=pi/3),
|
||||
SubPattern(pat, repetition=rep, offset=(3e5, 3e5), rotation=pi/2),
|
||||
SubPattern(pat, repetition=rep, offset=(4e5, 3e5), rotation=pi),
|
||||
SubPattern(pat, repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(1e5, 4e5)),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2),
|
||||
SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(1e5, 5e5)),
|
||||
SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3),
|
||||
SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2),
|
||||
SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi),
|
||||
SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(1e5, 6e5)),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi),
|
||||
SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2),
|
||||
]
|
||||
|
||||
lib['aref_test'] = pat4
|
||||
print('\nAdded aref_test')
|
||||
folder = 'layouts/'
|
||||
masque.file.klamath.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3)
|
||||
|
||||
folder = Path('./layouts/')
|
||||
folder.mkdir(exist_ok=True)
|
||||
print(f'...writing files to {folder}...')
|
||||
cells = list(masque.file.klamath.readfile(folder + 'rep.gds.gz')[0].values())
|
||||
masque.file.klamath.writefile(cells, folder + 'rerep.gds.gz', 1e-9, 1e-3)
|
||||
|
||||
gds1 = folder / 'rep.gds.gz'
|
||||
gds2 = folder / 'rerep.gds.gz'
|
||||
print(f'Initial write to {gds1}')
|
||||
gdsii.writefile(lib, gds1, 1e-9, 1e-3)
|
||||
|
||||
print(f'Read back and rewrite to {gds2}')
|
||||
readback_lib, _info = gdsii.readfile(gds1)
|
||||
gdsii.writefile(readback_lib, gds2, 1e-9, 1e-3)
|
||||
|
||||
dxf1 = folder / 'rep.dxf.gz'
|
||||
dxf2 = folder / 'rerep.dxf.gz'
|
||||
print(f'Write aref_test to {dxf1}')
|
||||
dxf.writefile(lib, 'aref_test', dxf1)
|
||||
|
||||
print(f'Read back and rewrite to {dxf2}')
|
||||
dxf_lib, _info = dxf.readfile(dxf1)
|
||||
print(Library(dxf_lib))
|
||||
dxf.writefile(dxf_lib, 'Model', dxf2)
|
||||
masque.file.dxf.writefile(pat4, folder + 'rep.dxf.gz')
|
||||
dxf, info = masque.file.dxf.readfile(folder + 'rep.dxf.gz')
|
||||
masque.file.dxf.writefile(dxf, folder + 'rerep.dxf.gz')
|
||||
|
||||
layer_map = {'base': (0,0), 'mylabel': (1,2)}
|
||||
oas1 = folder / 'rep.oas'
|
||||
oas2 = folder / 'rerep.oas'
|
||||
print(f'Write lib to {oas1}')
|
||||
oasis.writefile(lib, oas1, 1000, layer_map=layer_map)
|
||||
|
||||
print(f'Read back and rewrite to {oas2}')
|
||||
oas_lib, oas_info = oasis.readfile(oas1)
|
||||
oasis.writefile(oas_lib, oas2, 1000, layer_map=layer_map)
|
||||
|
||||
print('OASIS info:')
|
||||
pprint(oas_info)
|
||||
masque.file.oasis.writefile((pat, pat2, pat3, pat4), folder + 'rep.oas.gz', 1000, layer_map=layer_map)
|
||||
oas, info = masque.file.oasis.readfile(folder + 'rep.oas.gz')
|
||||
masque.file.oasis.writefile(list(oas.values()), folder + 'rerep.oas.gz', 1000, layer_map=layer_map)
|
||||
print(info)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
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
|
||||
--------
|
||||
|
||||
- [basic_shapes](basic_shapes.py):
|
||||
* Draw basic geometry
|
||||
* Export to GDS
|
||||
- [devices](devices.py)
|
||||
* Build hierarchical photonic-crystal example devices
|
||||
* Reference other patterns
|
||||
* Add ports to a pattern
|
||||
* Use `Pather` to snap ports together into a circuit
|
||||
* Check for dangling references
|
||||
- [library](library.py)
|
||||
* Continue from `devices.py` by declaring a mixed library with `LibraryBuilder`
|
||||
* Import source-backed GDS cells and register python-generated recipes together
|
||||
* Call `build()` to produce a normal library and report for downstream `Pather` usage and writing
|
||||
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
||||
- [pather](pather.py)
|
||||
* Use `Pather` to route individual wires and wire bundles
|
||||
* Define a custom `Tool` that exposes primitive routing offers
|
||||
* Use primitive offers to automatically transition between path types
|
||||
- [renderpather](renderpather.py)
|
||||
* Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py),
|
||||
but using `Path` shapes instead of `Polygon`s.
|
||||
- [port_pather](port_pather.py)
|
||||
* Use `PortPather` and the `.at()` syntax for more concise routing
|
||||
* Advanced port manipulation and connections
|
||||
|
||||
|
||||
Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating
|
||||
photonic-crystal lattices; it is support code rather than a step-by-step tutorial.
|
||||
|
||||
|
||||
Running
|
||||
-------
|
||||
|
||||
Run from inside the examples directory:
|
||||
```bash
|
||||
cd examples/tutorial
|
||||
python3 basic_shapes.py
|
||||
klayout -e basic_shapes.gds
|
||||
```
|
||||
|
||||
Some tutorials depend on outputs from earlier ones. In particular, `library.py`
|
||||
expects `circuit.gds`, which is generated by `devices.py`.
|
||||
|
|
@ -1,18 +1,21 @@
|
|||
from typing import Tuple, Sequence
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import layer_t, Pattern, Circle, Arc, Ref
|
||||
from masque.repetition import Grid
|
||||
from masque import layer_t, Pattern, SubPattern, Label
|
||||
from masque.shapes import Circle, Arc, Polygon
|
||||
from masque.builder import Device, Port
|
||||
from masque.library import Library, DeviceLibrary
|
||||
import masque.file.gdsii
|
||||
|
||||
|
||||
# Note that masque units are arbitrary, and are only given
|
||||
# physical significance when writing to a file.
|
||||
GDS_OPTS = dict(
|
||||
meters_per_unit = 1e-9, # GDS database unit, 1 nanometer
|
||||
logical_units_per_unit = 1e-3, # GDS display unit, 1 micron
|
||||
)
|
||||
GDS_OPTS = {
|
||||
'meters_per_unit': 1e-9, # GDS database unit, 1 nanometer
|
||||
'logical_units_per_unit': 1e-3, # GDS display unit, 1 micron
|
||||
}
|
||||
|
||||
|
||||
def hole(
|
||||
|
|
@ -27,54 +30,14 @@ def hole(
|
|||
layer: Layer to draw the circle on.
|
||||
|
||||
Returns:
|
||||
Pattern containing a circle.
|
||||
Pattern, named `'hole'`
|
||||
"""
|
||||
pat = Pattern()
|
||||
pat.shapes[layer].append(
|
||||
Circle(radius=radius, offset=(0, 0))
|
||||
)
|
||||
pat = Pattern('hole', shapes=[
|
||||
Circle(radius=radius, offset=(0, 0), layer=layer)
|
||||
])
|
||||
return pat
|
||||
|
||||
|
||||
def hole_array(
|
||||
radius: float,
|
||||
num_x: int = 5,
|
||||
num_y: int = 3,
|
||||
pitch: float = 2000,
|
||||
layer: layer_t = (1, 0),
|
||||
) -> Pattern:
|
||||
"""
|
||||
Generate an array of circular holes using `Repetition`.
|
||||
|
||||
Args:
|
||||
radius: Circle radius.
|
||||
num_x, num_y: Number of holes in x and y.
|
||||
pitch: Center-to-center spacing.
|
||||
layer: Layer to draw the holes on.
|
||||
|
||||
Returns:
|
||||
Pattern containing a grid of holes.
|
||||
"""
|
||||
# First, make a pattern for a single hole
|
||||
hpat = hole(radius, layer)
|
||||
|
||||
# Now, create a pattern that references it multiple times using a Grid
|
||||
pat = Pattern()
|
||||
pat.refs['hole'] = [
|
||||
Ref(
|
||||
offset=(0, 0),
|
||||
repetition=Grid(a_vector=(pitch, 0), a_count=num_x,
|
||||
b_vector=(0, pitch), b_count=num_y)
|
||||
)]
|
||||
|
||||
# We can also add transformed references (rotation, mirroring, etc.)
|
||||
pat.refs['hole'].append(
|
||||
Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True)
|
||||
)
|
||||
|
||||
return pat, hpat
|
||||
|
||||
|
||||
def triangle(
|
||||
radius: float,
|
||||
layer: layer_t = (1, 0),
|
||||
|
|
@ -87,7 +50,7 @@ def triangle(
|
|||
layer: Layer to draw the circle on.
|
||||
|
||||
Returns:
|
||||
Pattern containing a triangle
|
||||
Pattern, named `'triangle'`
|
||||
"""
|
||||
vertices = numpy.array([
|
||||
(numpy.cos( pi / 2), numpy.sin( pi / 2)),
|
||||
|
|
@ -95,8 +58,9 @@ def triangle(
|
|||
(numpy.cos( - pi / 6), numpy.sin( - pi / 6)),
|
||||
]) * radius
|
||||
|
||||
pat = Pattern()
|
||||
pat.polygon(layer, vertices=vertices)
|
||||
pat = Pattern('triangle', shapes=[
|
||||
Polygon(offset=(0, 0), layer=layer, vertices=vertices),
|
||||
])
|
||||
return pat
|
||||
|
||||
|
||||
|
|
@ -114,44 +78,37 @@ def smile(
|
|||
secondary_layer: Layer to draw eyes and smile on.
|
||||
|
||||
Returns:
|
||||
Pattern containing a smiley face
|
||||
Pattern, named `'smile'`
|
||||
"""
|
||||
# Make an empty pattern
|
||||
pat = Pattern()
|
||||
pat = Pattern('smile')
|
||||
|
||||
# Add all the shapes we want
|
||||
pat.shapes[layer] += [
|
||||
Circle(radius=radius, offset=(0, 0)), # Outer circle
|
||||
]
|
||||
|
||||
pat.shapes[secondary_layer] += [
|
||||
Circle(radius=radius / 10, offset=(radius / 3, radius / 3)),
|
||||
Circle(radius=radius / 10, offset=(-radius / 3, radius / 3)),
|
||||
Arc(
|
||||
radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii
|
||||
pat.shapes += [
|
||||
Circle(radius=radius, offset=(0, 0), layer=layer), # Outer circle
|
||||
Circle(radius=radius / 10, offset=(radius / 3, radius / 3), layer=secondary_layer),
|
||||
Circle(radius=radius / 10, offset=(-radius / 3, radius / 3), layer=secondary_layer),
|
||||
Arc(radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii
|
||||
angles=(7 / 6 * pi, 11 / 6 * pi), # Angles limiting the arc
|
||||
width=radius / 10,
|
||||
offset=(0, 0),
|
||||
),
|
||||
layer=secondary_layer),
|
||||
]
|
||||
|
||||
return pat
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lib = {}
|
||||
hole_pat = hole(1000)
|
||||
smile_pat = smile(1000)
|
||||
tri_pat = triangle(1000)
|
||||
|
||||
lib['hole'] = hole(1000)
|
||||
lib['smile'] = smile(1000)
|
||||
lib['triangle'] = triangle(1000)
|
||||
units_per_meter = 1e-9
|
||||
units_per_display_unit = 1e-3
|
||||
|
||||
# Use a Grid to make many holes efficiently
|
||||
lib['grid'], lib['hole'] = hole_array(1000)
|
||||
masque.file.gdsii.writefile([hole_pat, tri_pat, smile_pat], 'basic_shapes.gds', **GDS_OPTS)
|
||||
|
||||
masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS)
|
||||
|
||||
lib['triangle'].visualize()
|
||||
lib['grid'].visualize(lib)
|
||||
smile_pat.visualize()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,22 +1,12 @@
|
|||
"""
|
||||
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 typing import Tuple, Sequence, Dict
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import (
|
||||
layer_t, Pattern, Ref, Pather, Port, Polygon,
|
||||
Library,
|
||||
)
|
||||
from masque.utils import ports2data
|
||||
from masque.file.gdsii import writefile, check_valid_names
|
||||
from masque import layer_t, Pattern, SubPattern, Label
|
||||
from masque.shapes import Polygon
|
||||
from masque.builder import Device, Port, port_utils
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
import pcgen
|
||||
import basic_shapes
|
||||
|
|
@ -27,41 +17,40 @@ LATTICE_CONSTANT = 512
|
|||
RADIUS = LATTICE_CONSTANT / 2 * 0.75
|
||||
|
||||
|
||||
def ports_to_data(pat: Pattern) -> Pattern:
|
||||
def dev2pat(dev: Device) -> Pattern:
|
||||
"""
|
||||
Bake port information into the pattern.
|
||||
Bake port information into the device.
|
||||
This places a label at each port location on layer (3, 0) with text content
|
||||
'name:ptype angle_deg'
|
||||
"""
|
||||
return ports2data.ports_to_data(pat, layer=(3, 0))
|
||||
return port_utils.dev2pat(dev, layer=(3, 0))
|
||||
|
||||
|
||||
def data_to_ports(lib: Mapping[str, Pattern], name: str, pat: Pattern) -> Pattern:
|
||||
def pat2dev(pat: Pattern) -> Device:
|
||||
"""
|
||||
Scan the Pattern to determine port locations. Same port format as `ports_to_data`
|
||||
Scans the Pattern to determine port locations. Same format as `dev2pat`
|
||||
"""
|
||||
return ports2data.data_to_ports(layers=[(3, 0)], library=lib, pattern=pat, name=name)
|
||||
return port_utils.pat2dev(pat, layers=[(3, 0)])
|
||||
|
||||
|
||||
def perturbed_l3(
|
||||
lattice_constant: float,
|
||||
hole: str,
|
||||
hole_lib: Mapping[str, Pattern],
|
||||
hole: Pattern,
|
||||
trench_dose: float = 1.0,
|
||||
trench_layer: layer_t = (1, 0),
|
||||
shifts_a: Sequence[float] = (0.15, 0, 0.075),
|
||||
shifts_r: Sequence[float] = (1.0, 1.0, 1.0),
|
||||
xy_size: tuple[int, int] = (10, 10),
|
||||
xy_size: Tuple[int, int] = (10, 10),
|
||||
perturbed_radius: float = 1.1,
|
||||
trench_width: float = 1200,
|
||||
) -> Pattern:
|
||||
) -> Device:
|
||||
"""
|
||||
Generate a `Pattern` representing a perturbed L3 cavity.
|
||||
Generate a `Device` representing a perturbed L3 cavity.
|
||||
|
||||
Args:
|
||||
lattice_constant: Distance between nearest neighbor holes
|
||||
hole: name of a `Pattern` containing a single hole
|
||||
hole_lib: Library which contains the `Pattern` object for hole.
|
||||
Necessary because we need to know how big it is...
|
||||
hole: `Pattern` object containing a single hole
|
||||
trench_dose: Dose for the trenches. Default 1.0. (Hole dose is 1.0.)
|
||||
trench_layer: Layer for the trenches, default `(1, 0)`.
|
||||
shifts_a: passed to `pcgen.l3_shift`; specifies lattice constant
|
||||
(1 - multiplicative factor) for shifting holes adjacent to
|
||||
|
|
@ -72,277 +61,250 @@ def perturbed_l3(
|
|||
Provided sequence should have same length as `shifts_a`.
|
||||
xy_size: `(x, y)` number of mirror periods in each direction; total size is
|
||||
`2 * n + 1` holes in each direction. Default (10, 10).
|
||||
perturbed_radius: radius of holes perturbed to form an upwards-directed beam
|
||||
perturbed_radius: radius of holes perturbed to form an upwards-driected beam
|
||||
(multiplicative factor). Default 1.1.
|
||||
trench_width: Width of the undercut trenches. Default 1200.
|
||||
trench width: Width of the undercut trenches. Default 1200.
|
||||
|
||||
Returns:
|
||||
`Pattern` object representing the L3 design.
|
||||
`Device` object representing the L3 design.
|
||||
"""
|
||||
print('Generating perturbed L3...')
|
||||
|
||||
# Get hole positions and radii
|
||||
xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=xy_size,
|
||||
perturbed_radius=perturbed_radius,
|
||||
shifts_a=shifts_a,
|
||||
shifts_r=shifts_r)
|
||||
|
||||
# Build the cavity by instancing the supplied `hole` pattern many times.
|
||||
# Using references keeps the pattern compact even though it contains many holes.
|
||||
pat = Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(scale=r, offset=(lattice_constant * x,
|
||||
# Build L3 cavity, using references to the provided hole pattern
|
||||
pat = Pattern(f'L3p-a{lattice_constant:g}rp{perturbed_radius:g}')
|
||||
pat.subpatterns += [
|
||||
SubPattern(hole, scale=r,
|
||||
offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y, r in xyr]
|
||||
|
||||
# Add rectangular undercut aids based on the referenced hole extents.
|
||||
min_xy, max_xy = pat.get_bounds_nonempty(hole_lib)
|
||||
# Add rectangular undercut aids
|
||||
min_xy, max_xy = pat.get_bounds_nonempty()
|
||||
trench_dx = max_xy[0] - min_xy[0]
|
||||
|
||||
pat.shapes[trench_layer] += [
|
||||
Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
|
||||
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width),
|
||||
pat.shapes += [
|
||||
Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width,
|
||||
layer=trench_layer, dose=trench_dose),
|
||||
Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width,
|
||||
layer=trench_layer, dose=trench_dose),
|
||||
]
|
||||
|
||||
# Define the interface in Masque terms: two ports at the left/right extents.
|
||||
# Ports are at outer extents of the device (with y=0)
|
||||
extent = lattice_constant * xy_size[0]
|
||||
pat.ports = dict(
|
||||
input=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
output=Port((extent, 0), rotation=pi, ptype='pcwg'),
|
||||
)
|
||||
ports = {
|
||||
'input': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
'output': Port((extent, 0), rotation=pi, ptype='pcwg'),
|
||||
}
|
||||
|
||||
ports_to_data(pat)
|
||||
return pat
|
||||
return Device(pat, ports)
|
||||
|
||||
|
||||
def waveguide(
|
||||
lattice_constant: float,
|
||||
hole: str,
|
||||
hole: Pattern,
|
||||
length: int,
|
||||
mirror_periods: int,
|
||||
) -> Pattern:
|
||||
) -> Device:
|
||||
"""
|
||||
Generate a `Pattern` representing a photonic crystal line-defect waveguide.
|
||||
Generate a `Device` representing a photonic crystal line-defect waveguide.
|
||||
|
||||
Args:
|
||||
lattice_constant: Distance between nearest neighbor holes
|
||||
hole: name of a `Pattern` containing a single hole
|
||||
hole: `Pattern` object containing a single hole
|
||||
length: Distance (number of mirror periods) between the input and output ports.
|
||||
Ports are placed at lattice sites.
|
||||
mirror_periods: Number of hole rows on each side of the line defect
|
||||
|
||||
Returns:
|
||||
`Pattern` object representing the waveguide.
|
||||
`Device` object representing the waveguide.
|
||||
"""
|
||||
# Generate the normalized lattice locations for the line defect.
|
||||
# Generate hole locations
|
||||
xy = pcgen.waveguide(length=length, num_mirror=mirror_periods)
|
||||
|
||||
# Build the pattern by placing repeated references to the same hole cell.
|
||||
pat = Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(offset=(lattice_constant * x,
|
||||
# Build the pattern
|
||||
pat = Pattern(f'_wg-a{lattice_constant:g}l{length}')
|
||||
pat.subpatterns += [SubPattern(hole, offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y in xy]
|
||||
|
||||
# Publish the device interface as two ports at the outer edges.
|
||||
# Ports are at outer edges, with y=0
|
||||
extent = lattice_constant * length / 2
|
||||
pat.ports = dict(
|
||||
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
right=Port((extent, 0), rotation=pi, ptype='pcwg'),
|
||||
)
|
||||
|
||||
ports_to_data(pat)
|
||||
return pat
|
||||
ports = {
|
||||
'left': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
'right': Port((extent, 0), rotation=pi, ptype='pcwg'),
|
||||
}
|
||||
return Device(pat, ports)
|
||||
|
||||
|
||||
def bend(
|
||||
lattice_constant: float,
|
||||
hole: str,
|
||||
hole: Pattern,
|
||||
mirror_periods: int,
|
||||
) -> Pattern:
|
||||
) -> Device:
|
||||
"""
|
||||
Generate a `Pattern` representing a 60-degree counterclockwise bend in a photonic crystal
|
||||
Generate a `Device` representing a 60-degree counterclockwise bend in a photonic crystal
|
||||
line-defect waveguide.
|
||||
|
||||
Args:
|
||||
lattice_constant: Distance between nearest neighbor holes
|
||||
hole: name of a `Pattern` containing a single hole
|
||||
hole: `Pattern` object containing a single hole
|
||||
mirror_periods: Minimum number of mirror periods on each side of the line defect.
|
||||
|
||||
Returns:
|
||||
`Pattern` object representing the waveguide bend.
|
||||
`Device` object representing the waveguide bend.
|
||||
Ports are named 'left' (input) and 'right' (output).
|
||||
"""
|
||||
# Generate the normalized lattice locations for the bend.
|
||||
# Generate hole locations
|
||||
xy = pcgen.wgbend(num_mirror=mirror_periods)
|
||||
|
||||
# Build the pattern by instancing the shared hole cell.
|
||||
pat = Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(offset=(lattice_constant * x,
|
||||
# Build the pattern
|
||||
pat= Pattern(f'_wgbend-a{lattice_constant:g}l{mirror_periods}')
|
||||
pat.subpatterns += [
|
||||
SubPattern(hole, offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y in xy]
|
||||
|
||||
# Publish the bend interface as two ports.
|
||||
# Figure out port locations.
|
||||
extent = lattice_constant * mirror_periods
|
||||
pat.ports = dict(
|
||||
left=Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
right=Port((extent / 2,
|
||||
ports = {
|
||||
'left': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
'right': Port((extent / 2,
|
||||
extent * numpy.sqrt(3) / 2),
|
||||
rotation=pi * 4 / 3, ptype='pcwg'),
|
||||
)
|
||||
ports_to_data(pat)
|
||||
return pat
|
||||
}
|
||||
return Device(pat, ports)
|
||||
|
||||
|
||||
def y_splitter(
|
||||
lattice_constant: float,
|
||||
hole: str,
|
||||
hole: Pattern,
|
||||
mirror_periods: int,
|
||||
) -> Pattern:
|
||||
) -> Device:
|
||||
"""
|
||||
Generate a `Pattern` representing a photonic crystal line-defect waveguide y-splitter.
|
||||
Generate a `Device` representing a photonic crystal line-defect waveguide y-splitter.
|
||||
|
||||
Args:
|
||||
lattice_constant: Distance between nearest neighbor holes
|
||||
hole: name of a `Pattern` containing a single hole
|
||||
hole: `Pattern` object containing a single hole
|
||||
mirror_periods: Minimum number of mirror periods on each side of the line defect.
|
||||
|
||||
Returns:
|
||||
`Pattern` object representing the y-splitter.
|
||||
`Device` object representing the y-splitter.
|
||||
Ports are named 'in', 'top', and 'bottom'.
|
||||
"""
|
||||
# Generate the normalized lattice locations for the splitter.
|
||||
# Generate hole locations
|
||||
xy = pcgen.y_splitter(num_mirror=mirror_periods)
|
||||
|
||||
# Build the pattern by instancing the shared hole cell.
|
||||
pat = Pattern()
|
||||
pat.refs[hole] += [
|
||||
Ref(offset=(lattice_constant * x,
|
||||
# Build pattern
|
||||
pat = Pattern(f'_wgsplit_half-a{lattice_constant:g}l{mirror_periods}')
|
||||
pat.subpatterns += [
|
||||
SubPattern(hole, offset=(lattice_constant * x,
|
||||
lattice_constant * y))
|
||||
for x, y in xy]
|
||||
|
||||
# Publish the splitter interface as one input and two outputs.
|
||||
# Determine port locations
|
||||
extent = lattice_constant * mirror_periods
|
||||
pat.ports = {
|
||||
ports = {
|
||||
'in': Port((-extent, 0), rotation=0, ptype='pcwg'),
|
||||
'top': Port((extent / 2, extent * numpy.sqrt(3) / 2), rotation=pi * 4 / 3, ptype='pcwg'),
|
||||
'bot': Port((extent / 2, -extent * numpy.sqrt(3) / 2), rotation=pi * 2 / 3, ptype='pcwg'),
|
||||
}
|
||||
|
||||
ports_to_data(pat)
|
||||
return pat
|
||||
return Device(pat, ports)
|
||||
|
||||
|
||||
|
||||
def main(interactive: bool = True) -> None:
|
||||
# First make a couple of reusable primitive cells.
|
||||
shape_lib = {
|
||||
'smile': basic_shapes.smile(RADIUS),
|
||||
'hole': basic_shapes.hole(RADIUS),
|
||||
}
|
||||
def main(interactive: bool = True):
|
||||
# Generate some basic hole patterns
|
||||
smile = basic_shapes.smile(RADIUS)
|
||||
hole = basic_shapes.hole(RADIUS)
|
||||
|
||||
# Then build a small library of higher-level devices from those primitives.
|
||||
# Build some devices
|
||||
a = LATTICE_CONSTANT
|
||||
wg10 = waveguide(lattice_constant=a, hole=hole, length=10, mirror_periods=5).rename('wg10')
|
||||
wg05 = waveguide(lattice_constant=a, hole=hole, length=5, mirror_periods=5).rename('wg05')
|
||||
wg28 = waveguide(lattice_constant=a, hole=hole, length=28, mirror_periods=5).rename('wg28')
|
||||
bend0 = bend(lattice_constant=a, hole=hole, mirror_periods=5).rename('bend0')
|
||||
ysplit = y_splitter(lattice_constant=a, hole=hole, mirror_periods=5).rename('ysplit')
|
||||
l3cav = perturbed_l3(lattice_constant=a, hole=smile, xy_size=(4, 10)).rename('l3cav') # uses smile :)
|
||||
|
||||
devices = {}
|
||||
devices['wg05'] = waveguide(lattice_constant=a, hole='hole', length=5, mirror_periods=5)
|
||||
devices['wg10'] = waveguide(lattice_constant=a, hole='hole', length=10, mirror_periods=5)
|
||||
devices['wg28'] = waveguide(lattice_constant=a, hole='hole', length=28, mirror_periods=5)
|
||||
devices['wg90'] = waveguide(lattice_constant=a, hole='hole', length=90, mirror_periods=5)
|
||||
devices['bend0'] = bend(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 :)
|
||||
|
||||
# Turn the device mapping into a `Library`.
|
||||
# That gives us convenience helpers for hierarchy inspection and abstract views.
|
||||
lib = Library(devices)
|
||||
# Autogenerate port labels so that GDS will also contain port data
|
||||
for device in [wg10, wg05, wg28, l3cav, ysplit, bend0]:
|
||||
dev2pat(device)
|
||||
|
||||
#
|
||||
# Build a circuit
|
||||
#
|
||||
# Create a `Pather`, and register the resulting top cell as "my_circuit".
|
||||
circ = Pather(library=lib, name='my_circuit')
|
||||
circ = Device(name='my_circuit', ports={})
|
||||
|
||||
# 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'})
|
||||
# Start by placing a waveguide. Call its ports "in" and "signal".
|
||||
circ.place(wg10, offset=(0, 0), port_map={'left': 'in', 'right': 'signal'})
|
||||
|
||||
# Extend the signal path by attaching another waveguide.
|
||||
# Because `wg10` only has one unattached port left after the plug, Masque can
|
||||
# infer that it should keep the name `signal`.
|
||||
circ.plug('wg10', {'signal': 'left'})
|
||||
|
||||
# We could have done the following instead:
|
||||
# circ_pat = Pattern()
|
||||
# lib['my_circuit'] = circ_pat
|
||||
# circ_pat.place(lib.abstract('wg10'), ...)
|
||||
# circ_pat.plug(lib.abstract('wg10'), ...)
|
||||
# but `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps
|
||||
# the assembly code focused on port-level intent.
|
||||
# Extend the signal path by attaching the "left" port of a waveguide.
|
||||
# Since there is only one other port ("right") on the waveguide we
|
||||
# are attaching (wg10), it automatically inherits the name "signal".
|
||||
circ.plug(wg10, {'signal': 'left'})
|
||||
|
||||
# Attach a y-splitter to the signal path.
|
||||
# Since the y-splitter has 3 ports total, we can't auto-inherit the
|
||||
# port name, so we have to specify what we want to name the unattached
|
||||
# ports. We can call them "signal1" and "signal2".
|
||||
circ.plug('ysplit', {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'})
|
||||
circ.plug(ysplit, {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'})
|
||||
|
||||
# Add a waveguide to both signal ports, inheriting their names.
|
||||
circ.plug('wg05', {'signal1': 'left'})
|
||||
circ.plug('wg05', {'signal2': 'left'})
|
||||
circ.plug(wg05, {'signal1': 'left'})
|
||||
circ.plug(wg05, {'signal2': 'left'})
|
||||
|
||||
# Add a bend to both branches.
|
||||
# Our bend primitive is defined with a specific orientation, so choosing which
|
||||
# port to plug determines whether the path turns clockwise or counterclockwise.
|
||||
# We could also mirror one instance instead of using opposite ports.
|
||||
circ.plug('bend0', {'signal1': 'right'})
|
||||
circ.plug('bend0', {'signal2': 'left'})
|
||||
# Add a bend to both ports.
|
||||
# Our bend's ports "left" and "right" refer to the original counterclockwise
|
||||
# orientation. We want the bends to turn in opposite directions, so we attach
|
||||
# the "right" port to "signal1" to bend clockwise, and the "left" port
|
||||
# to "signal2" to bend counterclockwise.
|
||||
# We could also use `mirrored=(True, False)` to mirror one of the devices
|
||||
# and then use same device port on both paths.
|
||||
circ.plug(bend0, {'signal1': 'right'})
|
||||
circ.plug(bend0, {'signal2': 'left'})
|
||||
|
||||
# We add some waveguides and a cavity to "signal1".
|
||||
circ.plug('wg10', {'signal1': 'left'})
|
||||
circ.plug('l3cav', {'signal1': 'input'})
|
||||
circ.plug('wg10', {'signal1': 'left'})
|
||||
circ.plug(wg10, {'signal1': 'left'})
|
||||
circ.plug(l3cav, {'signal1': 'input'})
|
||||
circ.plug(wg10, {'signal1': 'left'})
|
||||
|
||||
# `signal2` gets a single waveguide of equivalent overall length.
|
||||
circ.plug('wg28', {'signal2': 'left'})
|
||||
# "signal2" just gets a single of equivalent length
|
||||
circ.plug(wg28, {'signal2': 'left'})
|
||||
|
||||
# Now bend both branches back towards each other.
|
||||
circ.plug('bend0', {'signal1': 'right'})
|
||||
circ.plug('bend0', {'signal2': 'left'})
|
||||
circ.plug('wg05', {'signal1': 'left'})
|
||||
circ.plug('wg05', {'signal2': 'left'})
|
||||
# Now we bend both waveguides back towards each other
|
||||
circ.plug(bend0, {'signal1': 'right'})
|
||||
circ.plug(bend0, {'signal2': 'left'})
|
||||
circ.plug(wg05, {'signal1': 'left'})
|
||||
circ.plug(wg05, {'signal2': 'left'})
|
||||
|
||||
# To join the branches, attach a second y-junction.
|
||||
# This succeeds only if both chosen ports agree on the same translation and
|
||||
# rotation for the inserted device; otherwise Masque raises an exception.
|
||||
circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
|
||||
# To join the waveguides, we attach a second y-junction.
|
||||
# We plug "signal1" into the "bot" port, and "signal2" into the "top" port.
|
||||
# The remaining port gets named "signal_out".
|
||||
# This operation would raise an exception if the ports did not line up
|
||||
# correctly (i.e. they required different rotations or translations of the
|
||||
# y-junction device).
|
||||
circ.plug(ysplit, {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'})
|
||||
|
||||
# Finally, add some more waveguide to "signal_out".
|
||||
circ.plug('wg10', {'signal_out': 'left'})
|
||||
circ.plug(wg10, {'signal_out': 'left'})
|
||||
|
||||
# Bake the top-level port metadata into labels so it survives GDS export.
|
||||
# These labels appear on the circuit cell; individual child devices keep their
|
||||
# own port labels in their own cells.
|
||||
ports_to_data(circ.pattern)
|
||||
|
||||
# Check if we forgot to include any patterns... ooops!
|
||||
if dangling := lib.dangling_refs():
|
||||
print('Warning: The following patterns are referenced, but not present in the'
|
||||
f' library! {dangling}')
|
||||
print('We\'ll solve this by merging in shape_lib, which contains those shapes...')
|
||||
|
||||
lib.add(shape_lib)
|
||||
assert not lib.dangling_refs()
|
||||
|
||||
# We can visualize the design directly, though opening the written GDS is often easier.
|
||||
# We can visualize the design. Usually it's easier to just view the GDS.
|
||||
if interactive:
|
||||
print('Visualizing... this step may be slow')
|
||||
circ.pattern.visualize(lib)
|
||||
circ.pattern.visualize()
|
||||
|
||||
# Write out only the subtree reachable from our top cell.
|
||||
subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use
|
||||
check_valid_names(subtree.keys())
|
||||
writefile(subtree, 'circuit.gds', **GDS_OPTS)
|
||||
# We can also add text labels for our circuit's ports.
|
||||
# They will appear at the uppermost hierarchy level, while the individual
|
||||
# device ports will appear further down, in their respective cells.
|
||||
dev2pat(circ)
|
||||
|
||||
# Write out to GDS
|
||||
writefile(circ.pattern, 'circuit.gds', **GDS_OPTS)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,133 +1,137 @@
|
|||
"""
|
||||
Tutorial: authoring a mixed library with `LibraryBuilder`.
|
||||
|
||||
This example assumes you have already read `devices.py` and generated the
|
||||
`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry
|
||||
itself, but rather how Masque lets you combine imported GDS cells with
|
||||
python-generated recipes, then turn that declaration set into a normal library
|
||||
for downstream assembly and writing.
|
||||
"""
|
||||
from typing import Any
|
||||
from typing import Tuple, Sequence, Callable
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from masque import ILibrary, LibraryBuilder, Pather, Pattern, PortLoadView, cell
|
||||
from masque.file.gdsii import writefile
|
||||
from masque.file.gdsii.lazy import readfile
|
||||
from masque.builder import Device
|
||||
from masque.library import Library, LibDeviceLibrary
|
||||
from masque.file.gdsii import writefile, load_libraryfile
|
||||
|
||||
import pcgen
|
||||
import basic_shapes
|
||||
import devices
|
||||
from devices import pat2dev, dev2pat
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
||||
|
||||
def make_mixed_waveguide(lib: ILibrary) -> Pattern:
|
||||
"""
|
||||
Recipe which assembles imported and generated cells behind the builder API.
|
||||
"""
|
||||
circ = Pather(library=lib, ports='tri_l3cav')
|
||||
|
||||
# First way to specify what we are plugging in: request an explicit abstract.
|
||||
circ.plug(lib.abstract('wg10'), {'input': 'right'})
|
||||
|
||||
# Second way: use an AbstractView, which behaves like a mapping of names
|
||||
# to abstracts.
|
||||
abstracts = lib.abstract_view()
|
||||
circ.plug(abstracts['wg10'], {'output': 'left'})
|
||||
|
||||
# Third way: let Pather resolve a pattern name through its own library.
|
||||
circ.plug('tri_wg10', {'input': 'right'})
|
||||
circ.plug('tri_wg10', {'output': 'left'})
|
||||
|
||||
return circ.pattern
|
||||
|
||||
|
||||
def main() -> None:
|
||||
builder = LibraryBuilder()
|
||||
cells = builder.cells
|
||||
|
||||
# Naming and export policy can be bound once without changing individual
|
||||
# declarations. For example, a foundry-specific group could use:
|
||||
#
|
||||
# cells = builder.cells.prefixed(
|
||||
# 'foundry_',
|
||||
# facade=lambda proxy: devices.ports_to_data(proxy),
|
||||
# )
|
||||
#
|
||||
# Dynamic names use the same view: cells[generated_name] = cell(factory)(...)
|
||||
# Define a `Library`-backed `DeviceLibrary`, which provides lazy evaluation
|
||||
# for device generation code and lazy-loading of GDS contents.
|
||||
device_lib = LibDeviceLibrary()
|
||||
|
||||
#
|
||||
# Load some devices from a GDS file
|
||||
#
|
||||
|
||||
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
|
||||
# imported on first materialization, but the raw source remains untouched
|
||||
# until we build the final library.
|
||||
gds_lib, _properties = readfile('circuit.gds')
|
||||
builder.add_source(PortLoadView(gds_lib, layers=[(3, 0)], max_depth=1))
|
||||
# Scan circuit.gds and prepare to lazy-load its contents
|
||||
pattern_lib, _properties = load_libraryfile('circuit.gds', tag='mycirc01')
|
||||
|
||||
# Add it into the device library by providing a way to read port info
|
||||
# This maintains the lazy evaluation from above, so no patterns
|
||||
# are actually read yet.
|
||||
device_lib.add_library(pattern_lib, pat2dev=pat2dev)
|
||||
|
||||
print('Devices loaded from GDS into library:\n' + pformat(list(device_lib.keys())))
|
||||
|
||||
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
|
||||
|
||||
#
|
||||
# Register some new devices, this time from python code rather than GDS.
|
||||
# Add some new devices to the library, this time from python code rather than GDS
|
||||
#
|
||||
|
||||
cells.triangle = basic_shapes.triangle(devices.RADIUS)
|
||||
opts: dict[str, Any] = dict(
|
||||
lattice_constant=devices.LATTICE_CONSTANT,
|
||||
hole='triangle',
|
||||
)
|
||||
a = devices.LATTICE_CONSTANT
|
||||
tri = basic_shapes.triangle(devices.RADIUS)
|
||||
|
||||
cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts)
|
||||
cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts)
|
||||
cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts)
|
||||
cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts)
|
||||
cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts)
|
||||
cells.tri_l3cav = cell(devices.perturbed_l3)(
|
||||
xy_size=(4, 10),
|
||||
**opts,
|
||||
hole_lib=builder.library,
|
||||
)
|
||||
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder.library)
|
||||
# Convenience function for adding devices
|
||||
# This is roughly equivalent to
|
||||
# `device_lib[name] = lambda: dev2pat(fn())`
|
||||
# but it also guarantees that the resulting pattern is named `name`.
|
||||
def add(name: str, fn: Callable[[], Device]) -> None:
|
||||
device_lib.add_device(name=name, fn=fn, dev2pat=dev2pat)
|
||||
|
||||
# Triangle-based variants. These are defined here, but they won't run until they're
|
||||
# retrieved from the library.
|
||||
add('tri_wg10', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=10, mirror_periods=5))
|
||||
add('tri_wg05', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=5, mirror_periods=5))
|
||||
add('tri_wg28', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=28, mirror_periods=5))
|
||||
add('tri_bend0', lambda: devices.bend(lattice_constant=a, hole=tri, mirror_periods=5))
|
||||
add('tri_ysplit', lambda: devices.y_splitter(lattice_constant=a, hole=tri, mirror_periods=5))
|
||||
add('tri_l3cav', lambda: devices.perturbed_l3(lattice_constant=a, hole=tri, xy_size=(4, 10)))
|
||||
|
||||
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
|
||||
|
||||
#
|
||||
# Build the declaration set into a normal library.
|
||||
# Build a mixed waveguide with an L3 cavity in the middle
|
||||
#
|
||||
|
||||
built, report = builder.build()
|
||||
print('Built library contains:\n' + pformat(list(built.keys())))
|
||||
print('Build dependency graph:\n' + pformat(report.dependency_graph))
|
||||
# Immediately start building from an instance of the L3 cavity
|
||||
circ2 = device_lib['tri_l3cav'].build('mixed_wg_cav')
|
||||
|
||||
print(device_lib['wg10'].ports)
|
||||
circ2.plug(device_lib['wg10'], {'input': 'right'})
|
||||
circ2.plug(device_lib['wg10'], {'output': 'left'})
|
||||
circ2.plug(device_lib['tri_wg10'], {'input': 'right'})
|
||||
circ2.plug(device_lib['tri_wg10'], {'output': 'left'})
|
||||
|
||||
# Add the circuit to the device library.
|
||||
# It has already been generated, so we can use `set_const` as a shorthand for
|
||||
# `device_lib['mixed_wg_cav'] = lambda: circ2`
|
||||
device_lib.set_const(circ2)
|
||||
|
||||
|
||||
#
|
||||
# Continue designing against the built library.
|
||||
# Build a device that could plug into our mixed_wg_cav and joins the two ports
|
||||
#
|
||||
|
||||
# The built result behaves like a normal mutable library, so downstream code
|
||||
# can use Pather, abstract views, and writing without going back through the
|
||||
# builder interface.
|
||||
circ = Pather.interface(source='mixed_wg_cav', library=built)
|
||||
circ.plug('tri_bend0', {'input': 'right'})
|
||||
circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
||||
circ.plug('tri_bend0', {'input': 'right'})
|
||||
circ.plug('bend0', {'output': 'left'})
|
||||
circ.plug('bend0', {'output': 'left'})
|
||||
circ.plug('bend0', {'output': 'left'})
|
||||
circ.plug('tri_wg10', {'input': 'right'})
|
||||
circ.plug('tri_wg28', {'input': 'right'})
|
||||
circ.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
||||
built['loop_segment'] = circ.pattern
|
||||
# We'll be designing against an existing device's interface...
|
||||
circ3 = circ2.as_interface('loop_segment')
|
||||
# ... that lets us continue from where we left off.
|
||||
circ3.plug(device_lib['tri_bend0'], {'input': 'right'})
|
||||
circ3.plug(device_lib['tri_bend0'], {'input': 'left'}, mirrored=(True, False)) # mirror since no tri y-symmetry
|
||||
circ3.plug(device_lib['tri_bend0'], {'input': 'right'})
|
||||
circ3.plug(device_lib['bend0'], {'output': 'left'})
|
||||
circ3.plug(device_lib['bend0'], {'output': 'left'})
|
||||
circ3.plug(device_lib['bend0'], {'output': 'left'})
|
||||
circ3.plug(device_lib['tri_wg10'], {'input': 'right'})
|
||||
circ3.plug(device_lib['tri_wg28'], {'input': 'right'})
|
||||
circ3.plug(device_lib['tri_wg10'], {'input': 'right', 'output': 'left'})
|
||||
|
||||
device_lib.set_const(circ3)
|
||||
|
||||
#
|
||||
# Write all devices into a GDS file.
|
||||
# Write all devices into a GDS file
|
||||
#
|
||||
print('Writing library to file...')
|
||||
writefile(built, 'library.gds', **GDS_OPTS)
|
||||
|
||||
# The default build output is an overlay which borrows its lazy sources.
|
||||
# Close the owning GDS source only after the overlay is no longer needed.
|
||||
gds_lib.close()
|
||||
# This line could be slow, since it generates or loads many of the devices
|
||||
# since they were not all accessed above.
|
||||
all_device_pats = [dev.pattern for dev in device_lib.values()]
|
||||
|
||||
writefile(all_device_pats, 'library.gds', **GDS_OPTS)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
#
|
||||
#class prout:
|
||||
# def place(
|
||||
# self,
|
||||
# other: Device,
|
||||
# label_layer: layer_t = 'WATLAYER',
|
||||
# *,
|
||||
# port_map: Optional[Dict[str, Optional[str]]] = None,
|
||||
# **kwargs,
|
||||
# ) -> 'prout':
|
||||
#
|
||||
# Device.place(self, other, port_map=port_map, **kwargs)
|
||||
# name: Optional[str]
|
||||
# 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.labels += [
|
||||
# Label(string=name, offset=self.ports[name].offset, layer=layer)]
|
||||
# return self
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,538 +0,0 @@
|
|||
"""
|
||||
Manual wire routing tutorial: Pather and primitive offers
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from masque import Pather, Library, Pattern, Port, layer_t
|
||||
from masque.abstract import Abstract
|
||||
from masque.builder import (
|
||||
BendOffer, RenderStep, StraightOffer, Tool, ToolContractCase, validate_tool_contract,
|
||||
)
|
||||
from masque.error import BuildError
|
||||
from masque.file.gdsii import writefile
|
||||
from masque.library import ILibrary, SINGLE_USE_PREFIX
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
|
||||
#
|
||||
# Define some basic wire widths, in nanometers
|
||||
# M2 is the top metal; M1 is below it and connected with vias on V1
|
||||
#
|
||||
M1_WIDTH = 1000
|
||||
V1_WIDTH = 500
|
||||
M2_WIDTH = 4000
|
||||
|
||||
#
|
||||
# First, we can define some functions for generating our wire geometry
|
||||
#
|
||||
|
||||
def make_pad() -> Pattern:
|
||||
"""
|
||||
Create a pattern with a single rectangle of M2, with a single port on the bottom
|
||||
|
||||
Every pad will be an instance of the same pattern, so we will only call this function once.
|
||||
"""
|
||||
pat = Pattern()
|
||||
pat.rect(layer='M2', xctr=0, yctr=0, lx=3 * M2_WIDTH, ly=4 * M2_WIDTH)
|
||||
pat.ports['wire_port'] = Port((0, -2 * M2_WIDTH), rotation=pi / 2, ptype='m2wire')
|
||||
return pat
|
||||
|
||||
|
||||
def make_via(
|
||||
layer_top: layer_t,
|
||||
layer_via: layer_t,
|
||||
layer_bot: layer_t,
|
||||
width_top: float,
|
||||
width_via: float,
|
||||
width_bot: float,
|
||||
ptype_top: str,
|
||||
ptype_bot: str,
|
||||
) -> Pattern:
|
||||
"""
|
||||
Generate three concentric squares, on the provided layers
|
||||
(`layer_top`, `layer_via`, `layer_bot`) and with the provided widths
|
||||
(`width_top`, `width_via`, `width_bot`).
|
||||
|
||||
Two ports are added, with the provided ptypes (`ptype_top`, `ptype_bot`).
|
||||
They are placed at the left edge of the top layer and right edge of the
|
||||
bottom layer, respectively.
|
||||
|
||||
We only have one via type, so we will only call this function once.
|
||||
"""
|
||||
pat = Pattern()
|
||||
pat.rect(layer=layer_via, xctr=0, yctr=0, lx=width_via, ly=width_via)
|
||||
pat.rect(layer=layer_bot, xctr=0, yctr=0, lx=width_bot, ly=width_bot)
|
||||
pat.rect(layer=layer_top, xctr=0, yctr=0, lx=width_top, ly=width_top)
|
||||
pat.ports = {
|
||||
'top': Port(offset=(-width_top / 2, 0), rotation=0, ptype=ptype_top),
|
||||
'bottom': Port(offset=(width_bot / 2, 0), rotation=pi, ptype=ptype_bot),
|
||||
}
|
||||
return pat
|
||||
|
||||
|
||||
def make_bend(layer: layer_t, width: float, ptype: str) -> Pattern:
|
||||
"""
|
||||
Generate a triangular wire, with ports at the left (input) and bottom (output) edges.
|
||||
This is effectively a clockwise wire bend.
|
||||
|
||||
Every bend will be the same, so we only need to call this twice (once each for M1 and M2).
|
||||
We could call it additional times for different wire widths or bend types (e.g. squares).
|
||||
"""
|
||||
pat = Pattern()
|
||||
pat.polygon(layer=layer, vertices=[(0, -width / 2), (0, width / 2), (width, -width / 2)])
|
||||
pat.ports = {
|
||||
'input': Port(offset=(0, 0), rotation=0, ptype=ptype),
|
||||
'output': Port(offset=(width / 2, -width / 2), rotation=pi / 2, ptype=ptype),
|
||||
}
|
||||
return pat
|
||||
|
||||
|
||||
def make_straight_wire(layer: layer_t, width: float, ptype: str, length: float) -> Pattern:
|
||||
"""
|
||||
Generate a straight wire with ports along either end (x=0 and x=length).
|
||||
|
||||
Every waveguide will be single-use, so we'll need to create lots of (mostly unique)
|
||||
`Pattern`s, and this function will get called very often.
|
||||
"""
|
||||
pat = Pattern()
|
||||
pat.rect(layer=layer, xmin=0, xmax=length, yctr=0, ly=width)
|
||||
pat.ports = {
|
||||
'input': Port(offset=(0, 0), rotation=0, ptype=ptype),
|
||||
'output': Port(offset=(length, 0), rotation=pi, ptype=ptype),
|
||||
}
|
||||
return pat
|
||||
|
||||
|
||||
def map_layer(layer: layer_t) -> layer_t:
|
||||
"""
|
||||
Map from a strings to GDS layer numbers
|
||||
"""
|
||||
layer_mapping = {
|
||||
'M1': (10, 0),
|
||||
'M2': (20, 0),
|
||||
'V1': (30, 0),
|
||||
}
|
||||
if isinstance(layer, str):
|
||||
return layer_mapping.get(layer, layer)
|
||||
return layer
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireStraightData:
|
||||
length: float
|
||||
out_transition: 'WireTransitionSpec | None' = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireBendData:
|
||||
straight_length: float
|
||||
ccw: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireTransitionSpec:
|
||||
abstract: Abstract
|
||||
in_port_name: str
|
||||
out_port_name: str
|
||||
|
||||
@property
|
||||
def in_port(self) -> Port:
|
||||
return self.abstract.ports[self.in_port_name]
|
||||
|
||||
@property
|
||||
def out_port(self) -> Port:
|
||||
return self.abstract.ports[self.out_port_name]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WireTransitionData:
|
||||
spec: WireTransitionSpec
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrimitiveWireTool(Tool):
|
||||
"""
|
||||
Minimal routing tool that exposes local routing primitives directly.
|
||||
|
||||
The high-level `Pather` methods below still decide how to compose straights,
|
||||
bends, and ptype transitions. This tool only describes which one-step
|
||||
primitives it can draw and how selected primitives should be rendered.
|
||||
"""
|
||||
layer: layer_t
|
||||
width: float
|
||||
ptype: str
|
||||
bend: Abstract
|
||||
transitions: Sequence[WireTransitionSpec]
|
||||
|
||||
def _straight_pattern(self, length: float) -> Pattern:
|
||||
return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length)
|
||||
|
||||
@staticmethod
|
||||
def _transition_length(spec: WireTransitionSpec) -> float | None:
|
||||
dxy, angle = spec.in_port.measure_travel(spec.out_port)
|
||||
if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0):
|
||||
return None
|
||||
return float(dxy[0])
|
||||
|
||||
def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]:
|
||||
offers: list[StraightOffer] = []
|
||||
for spec in self.transitions:
|
||||
if spec.out_port.ptype != self.ptype:
|
||||
continue
|
||||
if in_ptype not in (None, 'unk', spec.in_port.ptype):
|
||||
continue
|
||||
|
||||
length = self._transition_length(spec)
|
||||
if length is None:
|
||||
continue
|
||||
|
||||
def endpoint_planner(
|
||||
parameter: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
length: float = length,
|
||||
) -> Port:
|
||||
_ = parameter
|
||||
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
|
||||
|
||||
def commit_planner(
|
||||
parameter: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
) -> WireTransitionData:
|
||||
_ = parameter
|
||||
return WireTransitionData(spec)
|
||||
|
||||
offers.append(StraightOffer(
|
||||
in_ptype = spec.in_port.ptype,
|
||||
out_ptype = spec.out_port.ptype,
|
||||
length_domain = (length, length),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
))
|
||||
return tuple(offers)
|
||||
|
||||
def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]:
|
||||
if out_ptype in ('unk', self.ptype):
|
||||
return ()
|
||||
|
||||
offers: list[StraightOffer] = []
|
||||
for spec in self.transitions:
|
||||
if spec.in_port.ptype != self.ptype:
|
||||
continue
|
||||
if out_ptype is not None and spec.out_port.ptype != out_ptype:
|
||||
continue
|
||||
|
||||
transition_length = self._transition_length(spec)
|
||||
if transition_length is None:
|
||||
continue
|
||||
|
||||
def endpoint_planner(
|
||||
length: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
transition_length: float = transition_length,
|
||||
) -> Port:
|
||||
straight_length = length - transition_length
|
||||
if straight_length < 0:
|
||||
raise BuildError(
|
||||
f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}'
|
||||
)
|
||||
return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype)
|
||||
|
||||
def commit_planner(
|
||||
length: float,
|
||||
*,
|
||||
spec: WireTransitionSpec = spec,
|
||||
transition_length: float = transition_length,
|
||||
) -> WireStraightData:
|
||||
endpoint_planner(length)
|
||||
return WireStraightData(length - transition_length, spec)
|
||||
|
||||
offers.append(StraightOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = spec.out_port.ptype,
|
||||
length_domain = (transition_length, numpy.inf),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
))
|
||||
return tuple(offers)
|
||||
|
||||
def primitive_offers(
|
||||
self,
|
||||
kind: Literal['straight', 'bend', 's', 'u'],
|
||||
*,
|
||||
in_ptype: str | None = None,
|
||||
out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes)
|
||||
**kwargs: Any,
|
||||
) -> tuple[StraightOffer | BendOffer, ...]:
|
||||
if kind == 'straight':
|
||||
tool_options = dict(kwargs)
|
||||
|
||||
def endpoint_planner(length: float) -> Port:
|
||||
return Port((length, 0), rotation=pi, ptype=self.ptype)
|
||||
|
||||
def commit_planner(length: float) -> WireStraightData:
|
||||
_ = tool_options
|
||||
return WireStraightData(length)
|
||||
|
||||
native_offer = StraightOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = self.ptype,
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
)
|
||||
return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype))
|
||||
|
||||
if kind == 'bend':
|
||||
ccw = bool(kwargs.pop('ccw'))
|
||||
bend_forward = self.width / 2
|
||||
bend_run = bend_forward if ccw else -bend_forward
|
||||
bend_rotation = -pi / 2 if ccw else pi / 2
|
||||
|
||||
def endpoint_planner(length: float) -> Port:
|
||||
straight_length = length - bend_forward
|
||||
if straight_length < 0:
|
||||
raise BuildError(
|
||||
f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}'
|
||||
)
|
||||
return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype)
|
||||
|
||||
def commit_planner(length: float) -> WireBendData:
|
||||
endpoint_planner(length)
|
||||
return WireBendData(straight_length=length - bend_forward, ccw=ccw)
|
||||
|
||||
return (BendOffer(
|
||||
in_ptype = self.ptype,
|
||||
out_ptype = self.ptype,
|
||||
ccw = ccw,
|
||||
length_domain = (bend_forward, numpy.inf),
|
||||
endpoint_planner = endpoint_planner,
|
||||
commit_planner = commit_planner,
|
||||
),)
|
||||
|
||||
if kind in ('s', 'u'):
|
||||
return ()
|
||||
|
||||
raise BuildError(f'Unrecognized primitive offer kind {kind!r}')
|
||||
|
||||
def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None:
|
||||
if numpy.isclose(data.length, 0) and data.out_transition is None:
|
||||
return
|
||||
|
||||
if not numpy.isclose(data.length, 0):
|
||||
tree.top_pattern().plug(
|
||||
self._straight_pattern(data.length),
|
||||
{port_names[1]: 'input'},
|
||||
append=True,
|
||||
)
|
||||
|
||||
if data.out_transition is not None:
|
||||
self._render_transition(tree, port_names, WireTransitionData(data.out_transition))
|
||||
|
||||
def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None:
|
||||
self._render_straight(tree, port_names, WireStraightData(data.straight_length))
|
||||
tree.top_pattern().plug(
|
||||
self.bend,
|
||||
{port_names[1]: 'input'},
|
||||
mirrored=data.ccw,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None:
|
||||
tree.top_pattern().plug(
|
||||
data.spec.abstract,
|
||||
{port_names[1]: data.spec.in_port_name},
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
batch: Sequence[RenderStep],
|
||||
*,
|
||||
port_names: tuple[str, str] = ('A', 'B'),
|
||||
) -> ILibrary:
|
||||
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire')
|
||||
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype)
|
||||
|
||||
for step in batch:
|
||||
assert step.tool == self
|
||||
if isinstance(step.data, WireTransitionData):
|
||||
self._render_transition(tree, port_names, step.data)
|
||||
elif isinstance(step.data, WireStraightData):
|
||||
self._render_straight(tree, port_names, step.data)
|
||||
elif isinstance(step.data, WireBendData):
|
||||
self._render_bend(tree, port_names, step.data)
|
||||
else:
|
||||
raise BuildError(f'Unexpected primitive render data {type(step.data)}')
|
||||
return tree
|
||||
|
||||
|
||||
def prepare_tools() -> tuple[Library, Tool, Tool]:
|
||||
"""
|
||||
Create some basic library elements and tools for drawing M1 and M2
|
||||
"""
|
||||
# Build some patterns (static cells) using the above functions and store them in a library
|
||||
library = Library()
|
||||
library['pad'] = make_pad()
|
||||
library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH)
|
||||
library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH)
|
||||
library['v1_via'] = make_via(
|
||||
layer_top = 'M2',
|
||||
layer_via = 'V1',
|
||||
layer_bot = 'M1',
|
||||
width_top = M2_WIDTH,
|
||||
width_via = V1_WIDTH,
|
||||
width_bot = M1_WIDTH,
|
||||
ptype_bot = 'm1wire',
|
||||
ptype_top = 'm2wire',
|
||||
)
|
||||
|
||||
#
|
||||
# Now, define two tools.
|
||||
# M1_tool will route on M1, using wires with M1_WIDTH.
|
||||
# M2_tool will route on M2, using wires with M2_WIDTH.
|
||||
#
|
||||
# Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers
|
||||
# directly: it tells `Pather` about native straight/bend primitives and about
|
||||
# via adapters that can transition between M1 and M2 port types.
|
||||
#
|
||||
via = library.abstract('v1_via')
|
||||
via_transitions = (
|
||||
WireTransitionSpec(via, 'top', 'bottom'),
|
||||
WireTransitionSpec(via, 'bottom', 'top'),
|
||||
)
|
||||
|
||||
M1_tool = PrimitiveWireTool(
|
||||
layer = 'M1',
|
||||
width = M1_WIDTH,
|
||||
ptype = 'm1wire',
|
||||
bend = library.abstract('m1_bend'),
|
||||
transitions = via_transitions,
|
||||
)
|
||||
|
||||
M2_tool = PrimitiveWireTool(
|
||||
layer = 'M2',
|
||||
width = M2_WIDTH,
|
||||
ptype = 'm2wire',
|
||||
bend = library.abstract('m2_bend'),
|
||||
transitions = via_transitions,
|
||||
)
|
||||
|
||||
# Custom tools can be checked independently of Pather or pytest. Automatic
|
||||
# probes cover each offer domain; explicit probes can target useful process
|
||||
# dimensions. Unsupported primitive families opt out with require_offers=False.
|
||||
for tool in (M1_tool, M2_tool):
|
||||
validate_tool_contract(tool, (
|
||||
ToolContractCase('straight', in_ptype=tool.ptype, probe_parameters=(10_000,)),
|
||||
ToolContractCase('bend', in_ptype=tool.ptype, ccw=False),
|
||||
ToolContractCase('bend', in_ptype=tool.ptype, ccw=True),
|
||||
ToolContractCase('s', in_ptype=tool.ptype, require_offers=False),
|
||||
ToolContractCase('u', in_ptype=tool.ptype, require_offers=False),
|
||||
))
|
||||
return library, M1_tool, M2_tool
|
||||
|
||||
|
||||
#
|
||||
# Now we can start building up our library (collection of static cells) and pathing tools.
|
||||
#
|
||||
# If any of the operations below are confusing, you can cross-reference against the deferred
|
||||
# `Pather` tutorial, which handles some things more explicitly (e.g. via placement) and simplifies
|
||||
# others (e.g. geometry definition).
|
||||
#
|
||||
def main() -> None:
|
||||
library, M1_tool, M2_tool = prepare_tools()
|
||||
|
||||
#
|
||||
# Create a new pather which writes to `library` and uses `M2_tool` as its default tool.
|
||||
# Then, place some pads and start routing wires!
|
||||
#
|
||||
pather = Pather(library, tools=M2_tool)
|
||||
|
||||
# Place two pads, and define their ports as 'VCC' and 'GND'
|
||||
pather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||
pather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
||||
# Add some labels to make the pads easier to distinguish
|
||||
pather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
||||
pather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||
|
||||
# Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False)
|
||||
# The total distance forward (including the bend's forward component) must be 6um
|
||||
pather.cw('VCC', 6_000)
|
||||
|
||||
# Now path VCC to x=0. This time, don't include any bend.
|
||||
# Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction.
|
||||
pather.straight('VCC', x=0)
|
||||
|
||||
# Path GND forward by 5um, turning clockwise 90 degrees.
|
||||
pather.cw('GND', 5_000)
|
||||
|
||||
# This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend.
|
||||
pather.straight('GND', x=pather['VCC'].offset[0])
|
||||
|
||||
# Now, start using M1_tool for GND.
|
||||
# Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves.
|
||||
# If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here
|
||||
# and achieve the same result without having to define any transitions in M1_tool.
|
||||
# Note that even though we have changed the tool used for GND, the via doesn't get placed until
|
||||
# the next time we route GND (the `pather.ccw()` call below).
|
||||
pather.retool(M1_tool, keys='GND')
|
||||
|
||||
# Bundle together GND and VCC, and path the bundle forward and counterclockwise.
|
||||
# Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000.
|
||||
# Other wires in the bundle (in this case VCC) should be spaced at 5_000 pitch (so VCC ends up at x=-5_000)
|
||||
#
|
||||
# Since we recently retooled GND, its path starts with a via down to M1 (included in the distance
|
||||
# calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2.
|
||||
pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||
|
||||
# Now use M1_tool as the default tool for all ports/signals.
|
||||
# Since VCC does not have an explicitly assigned tool, it will now transition down to M1.
|
||||
pather.retool(M1_tool)
|
||||
|
||||
# Path the GND + VCC bundle forward and counterclockwise by 90 degrees.
|
||||
# The total extension (travel distance along the forward direction) for the longest segment (in
|
||||
# this case the segment being added to GND) should be exactly 50um.
|
||||
# After turning, the wire pitch should be reduced only 1.2um.
|
||||
pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
||||
|
||||
# Make a U-turn with the bundle and expand back out to 4.5um wire pitch.
|
||||
# Here, emin specifies the travel distance for the shortest segment. For the first call
|
||||
# that applies to VCC, and for the second call, that applies to GND; the relative lengths of the
|
||||
# segments depend on their starting positions and their ordering within the bundle.
|
||||
pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
||||
pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
||||
|
||||
# Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been
|
||||
# explicitly assigned a tool.
|
||||
pather.retool(M2_tool)
|
||||
|
||||
# Now path both ports to x=-28_000.
|
||||
# With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is
|
||||
# equivalent.
|
||||
pather.straight(['GND', 'VCC'], xmin=-28_000)
|
||||
|
||||
# Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1.
|
||||
# This results in a via at the end of the wire (instead of having one at the start like we got
|
||||
# when using pather.retool().
|
||||
pather.straight('VCC', x=-50_000, out_ptype='m1wire')
|
||||
|
||||
# Now extend GND out to x=-50_000, using M2 for a portion of the path.
|
||||
# We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice.
|
||||
with pather.toolctx(M2_tool, keys='GND'):
|
||||
pather.straight('GND', x=-40_000)
|
||||
pather.straight('GND', x=-50_000)
|
||||
|
||||
# Save the pather's pattern into our library
|
||||
library['Pather_and_PrimitiveOffers'] = pather.pattern
|
||||
|
||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||
library.map_layers(map_layer)
|
||||
writefile(library, 'pather.gds', **GDS_OPTS)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
Routines for creating normalized 2D lattices and common photonic crystal
|
||||
cavity designs.
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
from typing import Sequence, Tuple
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
|
@ -29,11 +29,8 @@ def triangular_lattice(
|
|||
Returns:
|
||||
`[[x0, y0], [x1, 1], ...]` denoting lattice sites.
|
||||
"""
|
||||
sx, sy = numpy.meshgrid(
|
||||
numpy.arange(dims[0], dtype=float),
|
||||
numpy.arange(dims[1], dtype=float),
|
||||
indexing='ij',
|
||||
)
|
||||
sx, sy = numpy.meshgrid(numpy.arange(dims[0], dtype=float),
|
||||
numpy.arange(dims[1], dtype=float), indexing='ij')
|
||||
|
||||
sx[sy % 2 == 1] += 0.5
|
||||
sy *= numpy.sqrt(3) / 2
|
||||
|
|
@ -50,7 +47,7 @@ def triangular_lattice(
|
|||
elif origin == 'corner':
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f'Invalid value for `origin`: {origin}')
|
||||
raise Exception(f'Invalid value for `origin`: {origin}')
|
||||
|
||||
return xy[xy[:, 0].argsort(), :]
|
||||
|
||||
|
|
@ -197,12 +194,12 @@ def ln_defect(
|
|||
`[[x0, y0], [x1, y1], ...]` for all the holes
|
||||
"""
|
||||
if defect_length % 2 != 1:
|
||||
raise ValueError('defect_length must be odd!')
|
||||
pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims])
|
||||
raise Exception('defect_length must be odd!')
|
||||
p = triangular_lattice([2 * d + 1 for d in mirror_dims])
|
||||
half_length = numpy.floor(defect_length / 2)
|
||||
hole_nums = numpy.arange(-half_length, half_length + 1)
|
||||
holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True)
|
||||
return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :]
|
||||
holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True)
|
||||
return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ]
|
||||
|
||||
|
||||
def ln_shift_defect(
|
||||
|
|
@ -233,8 +230,8 @@ def ln_shift_defect(
|
|||
|
||||
# Shift holes
|
||||
# Expand shifts as necessary
|
||||
tmp_a = numpy.asarray(shifts_a)
|
||||
tmp_r = numpy.asarray(shifts_r)
|
||||
tmp_a = numpy.array(shifts_a)
|
||||
tmp_r = numpy.array(shifts_r)
|
||||
n_shifted = max(tmp_a.size, tmp_r.size)
|
||||
|
||||
shifts_a = numpy.ones(n_shifted)
|
||||
|
|
@ -248,7 +245,7 @@ def ln_shift_defect(
|
|||
for sign in (-1, 1):
|
||||
x_val = sign * (x_removed + ind + 1)
|
||||
which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0)
|
||||
xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
|
||||
xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind])
|
||||
|
||||
return xyr
|
||||
|
||||
|
|
@ -309,7 +306,7 @@ def l3_shift_perturbed_defect(
|
|||
|
||||
# which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2])
|
||||
perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2)))
|
||||
for xy in perturbed_holes:
|
||||
which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1)
|
||||
xyr[which, 2] = perturbed_radius
|
||||
for row in xyr:
|
||||
if numpy.fabs(row) in perturbed_holes:
|
||||
row[2] = perturbed_radius
|
||||
return xyr
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
"""
|
||||
PortPather tutorial: Using .at() syntax
|
||||
"""
|
||||
from masque import Pather, Pattern, Port, R90
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
from pather import map_layer, prepare_tools
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Reuse the same patterns (pads, bends, vias) and tools as in pather.py
|
||||
library, M1_tool, M2_tool = prepare_tools()
|
||||
|
||||
# Create a deferred Pather and place some initial pads (same as Pather tutorial)
|
||||
rpather = Pather(library, tools=M2_tool, render='deferred')
|
||||
|
||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
||||
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
||||
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||
|
||||
#
|
||||
# Routing with .at() chaining
|
||||
#
|
||||
# The .at(port_name) method returns a PortPather object which wraps the Pather
|
||||
# and remembers the selected port(s). This allows method chaining.
|
||||
|
||||
# Route VCC: 6um South, then West to x=0.
|
||||
# (Note: since the port points North into the pad, trace() moves South by default)
|
||||
(rpather.at('VCC')
|
||||
.trace(False, length=6_000) # Move South, turn West (Clockwise)
|
||||
.trace_to(None, x=0) # Continue West to x=0
|
||||
)
|
||||
|
||||
# Route GND: 5um South, then West to match VCC's x-coordinate.
|
||||
rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x)
|
||||
|
||||
|
||||
#
|
||||
# Tool management and manual plugging
|
||||
#
|
||||
# We can use .retool() to change the tool for specific ports.
|
||||
# We can also use .plug() directly on a PortPather.
|
||||
|
||||
# Manually add a via to GND and switch to M1_tool for subsequent segments
|
||||
(rpather.at('GND')
|
||||
.plug('v1_via', 'top')
|
||||
.retool(M1_tool) # this only retools the 'GND' port
|
||||
)
|
||||
|
||||
# We can also pass multiple ports to .at(), and then route them together.
|
||||
# Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via).
|
||||
(rpather.at(['GND', 'VCC'])
|
||||
.trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
|
||||
.retool(M1_tool) # Retools both GND and VCC
|
||||
.set_spacing(1_200) # Default bundle spacing for later bends
|
||||
.trace(True, emax=50_000) # Turn East, moves 50um extension
|
||||
.trace(False, emin=1_000) # U-turn back South
|
||||
.trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing
|
||||
)
|
||||
|
||||
# Retool VCC back to M2 and move both to x=-28k
|
||||
rpather.at('VCC').retool(M2_tool)
|
||||
rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000)
|
||||
|
||||
# Final segments to -50k
|
||||
rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire')
|
||||
with rpather.at('GND').toolctx(M2_tool):
|
||||
rpather.at('GND').trace_to(None, x=-40_000)
|
||||
rpather.at('GND').trace_to(None, x=-50_000)
|
||||
|
||||
|
||||
#
|
||||
# Branching with mark and fork
|
||||
#
|
||||
# .mark(new_name) creates a port copy and keeps the original selected.
|
||||
# .fork(new_name) creates a port copy and selects the new one.
|
||||
|
||||
# Create a tap on GND
|
||||
(rpather.at('GND')
|
||||
.trace(None, length=5_000) # Move GND further West
|
||||
.mark('GND_TAP') # Mark this location for a later branch
|
||||
.jog(offset=-10_000, length=10_000) # Continue GND with an S-bend
|
||||
)
|
||||
|
||||
# Branch VCC and follow the new branch
|
||||
(rpather.at('VCC')
|
||||
.trace(None, length=5_000)
|
||||
.fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH'
|
||||
.trace(True, length=5_000) # VCC_BRANCH turns South
|
||||
)
|
||||
# The original 'VCC' port remains at x=-55k, y=VCC.y
|
||||
|
||||
|
||||
#
|
||||
# Port set management: add, drop, rename, delete
|
||||
#
|
||||
|
||||
# Route the GND_TAP we saved earlier.
|
||||
(rpather.at('GND_TAP')
|
||||
.retool(M1_tool)
|
||||
.trace(True, length=10_000) # Turn South
|
||||
.rename('GND_FEED') # Give it a more descriptive name
|
||||
.retool(M1_tool) # Re-apply tool to the new name
|
||||
)
|
||||
|
||||
# We can manage the active set of ports in a PortPather
|
||||
pp = rpather.at(['VCC_BRANCH', 'GND_FEED'])
|
||||
pp.select('GND') # Now tracking 3 ports
|
||||
pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND
|
||||
pp.trace(None, each=5_000) # Move both 5um forward (length > transition size)
|
||||
|
||||
# We can also delete ports from the pather entirely
|
||||
rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead)
|
||||
|
||||
|
||||
#
|
||||
# Advanced Connections: trace_into
|
||||
#
|
||||
# trace_into routes FROM the selected port TO a target port.
|
||||
|
||||
# Create a destination component
|
||||
dest_ports = {
|
||||
'in_A': Port((0, 0), rotation=R90, ptype='m2wire'),
|
||||
'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire')
|
||||
}
|
||||
library['dest'] = Pattern(ports=dest_ports)
|
||||
# Place dest so that its ports are to the West and South of our current wires.
|
||||
# Rotating by pi/2 makes the ports face West (pointing East).
|
||||
rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'})
|
||||
|
||||
# Connect GND_FEED to DEST_A
|
||||
# Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice.
|
||||
rpather.at('GND_FEED').trace_into('DEST_A')
|
||||
|
||||
# Connect VCC_BRANCH to DEST_B
|
||||
rpather.at('VCC_BRANCH').trace_into('DEST_B')
|
||||
|
||||
|
||||
#
|
||||
# Direct Port Transformations and Metadata
|
||||
#
|
||||
(rpather.at('GND')
|
||||
.set_ptype('m1wire') # Change metadata
|
||||
.translate((1000, 0)) # Shift the port 1um East
|
||||
.rotate(R90 / 2) # Rotate it 45 degrees
|
||||
.set_rotation(R90) # Force it to face West
|
||||
)
|
||||
|
||||
# Demonstrate .plugged() to acknowledge a manual connection
|
||||
# (Normally used when you place components so their ports perfectly overlap)
|
||||
rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2'))
|
||||
rpather.at('TMP1').plugged('TMP2') # Removes both ports
|
||||
|
||||
|
||||
#
|
||||
# Rendering and Saving
|
||||
#
|
||||
# Since routing is deferred, we must call .render() to generate the geometry.
|
||||
rpather.render()
|
||||
|
||||
library['PortPather_Tutorial'] = rpather.pattern
|
||||
library.map_layers(map_layer)
|
||||
writefile(library, 'port_pather.gds', **GDS_OPTS)
|
||||
print("Tutorial complete. Output written to port_pather.gds")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
"""
|
||||
Manual wire routing tutorial: deferred Pather and PathTool
|
||||
"""
|
||||
from masque import Pather, Library
|
||||
from masque.builder import PathTool
|
||||
from masque.file.gdsii import writefile
|
||||
|
||||
from basic_shapes import GDS_OPTS
|
||||
from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via
|
||||
|
||||
|
||||
def main() -> None:
|
||||
#
|
||||
# To illustrate deferred routing with `Pather`, we use `PathTool` instead
|
||||
# of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions)
|
||||
# but when used with `Pather(render='deferred')`, it can consolidate multiple routing steps into
|
||||
# a single `Path` shape.
|
||||
#
|
||||
# We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py`
|
||||
# for more detailed descriptions of the individual pathing steps.
|
||||
#
|
||||
|
||||
# First, we make a library and generate some of the same patterns as in the pather tutorial
|
||||
library = Library()
|
||||
library['pad'] = make_pad()
|
||||
library['v1_via'] = make_via(
|
||||
layer_top = 'M2',
|
||||
layer_via = 'V1',
|
||||
layer_bot = 'M1',
|
||||
width_top = M2_WIDTH,
|
||||
width_via = V1_WIDTH,
|
||||
width_bot = M1_WIDTH,
|
||||
ptype_bot = 'm1wire',
|
||||
ptype_top = 'm2wire',
|
||||
)
|
||||
|
||||
# `PathTool` is more limited than `AutoTool`. It only generates one type of shape
|
||||
# (`Path`), so it only needs to know what layer to draw on, what width to draw with,
|
||||
# and what port type to present.
|
||||
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
|
||||
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
|
||||
rpather = Pather(tools=M2_ptool, library=library, render='deferred')
|
||||
|
||||
# As in the pather tutorial, we make some pads and labels...
|
||||
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})
|
||||
rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'})
|
||||
rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3))
|
||||
rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3))
|
||||
|
||||
# ...and start routing the signals.
|
||||
rpather.cw('VCC', 6_000)
|
||||
rpather.straight('VCC', x=0)
|
||||
rpather.cw('GND', 5_000)
|
||||
rpather.straight('GND', x=rpather.pattern['VCC'].x)
|
||||
|
||||
# `PathTool` doesn't know how to transition betwen metal layers, so we have to
|
||||
# `plug` the via into the GND wire ourselves.
|
||||
rpather.plug('v1_via', {'GND': 'top'})
|
||||
rpather.retool(M1_ptool, keys='GND')
|
||||
rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000)
|
||||
|
||||
# Same thing on the VCC wire when it goes down to M1.
|
||||
rpather.plug('v1_via', {'VCC': 'top'})
|
||||
rpather.retool(M1_ptool)
|
||||
rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200)
|
||||
rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200)
|
||||
rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500)
|
||||
|
||||
# And again when VCC goes back up to M2.
|
||||
rpather.plug('v1_via', {'VCC': 'bottom'})
|
||||
rpather.retool(M2_ptool)
|
||||
rpather.straight(['GND', 'VCC'], xmin=-28_000)
|
||||
|
||||
# Finally, since PathTool has no conception of transitions, we can't
|
||||
# just ask it to transition to an 'm1wire' port at the end of the final VCC segment.
|
||||
# Instead, we have to calculate the via size ourselves, and adjust the final position
|
||||
# to account for it.
|
||||
v1pat = library['v1_via']
|
||||
via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x)
|
||||
|
||||
# alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0]
|
||||
# would take into account the port orientations if we didn't already know they're along x
|
||||
rpather.straight('VCC', x=-50_000 + via_size)
|
||||
rpather.plug('v1_via', {'VCC': 'top'})
|
||||
|
||||
# Render the path we defined
|
||||
rpather.render()
|
||||
library['Deferred_Pather_and_PathTool'] = rpather.pattern
|
||||
|
||||
|
||||
# Convert from text-based layers to numeric layers for GDS, and output the file
|
||||
library.map_layers(map_layer)
|
||||
writefile(library, 'render_pather.gds', **GDS_OPTS)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
"""
|
||||
masque 2D CAD library
|
||||
|
||||
masque is an attempt to make a relatively compact library for designing lithography
|
||||
masque is an attempt to make a relatively small library for designing lithography
|
||||
masks. The general idea is to implement something resembling the GDSII and OASIS file-formats,
|
||||
but with some additional vectorized element types (eg. ellipses, not just polygons), and the
|
||||
ability to interface with multiple file formats.
|
||||
but with some additional vectorized element types (eg. ellipses, not just polygons), better
|
||||
support for E-beam doses, and the ability to interface with multiple file formats.
|
||||
|
||||
`Pattern` is a basic object containing a 2D lithography mask, composed of a list of `Shape`
|
||||
objects, a list of `Label` objects, and a list of references to other `Patterns` (using
|
||||
`Ref`).
|
||||
`SubPattern`).
|
||||
|
||||
`Ref` provides basic support for nesting `Pattern` objects within each other, by adding
|
||||
`SubPattern` provides basic support for nesting `Pattern` objects within each other, by adding
|
||||
offset, rotation, scaling, repetition, and other such properties to a Pattern reference.
|
||||
|
||||
Note that the methods for these classes try to avoid copying wherever possible, so unless
|
||||
|
|
@ -20,91 +20,24 @@
|
|||
NOTES ON INTERNALS
|
||||
==========================
|
||||
- Many of `masque`'s classes make use of `__slots__` to make them faster / smaller.
|
||||
Since `__slots__` doesn't play well with multiple inheritance, often they are left
|
||||
empty for superclasses and it is the subclass's responsibility to set them correctly.
|
||||
- File I/O submodules are not imported by `masque.file` to avoid creating hard dependencies
|
||||
on external file-format reader/writers
|
||||
- Try to accept the broadest-possible inputs: e.g., don't demand an `ILibraryView` if you
|
||||
can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally.
|
||||
Since `__slots__` doesn't play well with multiple inheritance, the `masque.utils.AutoSlots`
|
||||
metaclass is used to auto-generate slots based on superclass type annotations.
|
||||
- File I/O submodules are imported by `masque.file` to avoid creating hard dependencies on
|
||||
external file-format reader/writers
|
||||
- Pattern locking/unlocking is quite slow for large hierarchies.
|
||||
|
||||
"""
|
||||
|
||||
from .utils import (
|
||||
layer_t as layer_t,
|
||||
annotations_t as annotations_t,
|
||||
SupportsBool as SupportsBool,
|
||||
)
|
||||
from .error import (
|
||||
MasqueError as MasqueError,
|
||||
PatternError as PatternError,
|
||||
LibraryError as LibraryError,
|
||||
BuildError as BuildError,
|
||||
)
|
||||
from .shapes import (
|
||||
Shape as Shape,
|
||||
Polygon as Polygon,
|
||||
RectCollection as RectCollection,
|
||||
Path as Path,
|
||||
Circle as Circle,
|
||||
Arc as Arc,
|
||||
Ellipse as Ellipse,
|
||||
)
|
||||
from .label import Label as Label
|
||||
from .ref import Ref as Ref
|
||||
from .pattern import (
|
||||
Pattern as Pattern,
|
||||
map_layers as map_layers,
|
||||
map_targets as map_targets,
|
||||
chain_elements as chain_elements,
|
||||
)
|
||||
from .utils.boolean import boolean as boolean
|
||||
|
||||
from .library import (
|
||||
INameView as INameView,
|
||||
ILibraryView as ILibraryView,
|
||||
ILibrary as ILibrary,
|
||||
IBorrowing as IBorrowing,
|
||||
IMaterializable as IMaterializable,
|
||||
LibraryView as LibraryView,
|
||||
Library as Library,
|
||||
OverlayLibrary as OverlayLibrary,
|
||||
PortLoadView as PortLoadView,
|
||||
LayerMappedView as LayerMappedView,
|
||||
LibraryBuilder as LibraryBuilder,
|
||||
BuildReport as BuildReport,
|
||||
CellProvenance as CellProvenance,
|
||||
LazyLibrary as LazyLibrary,
|
||||
AbstractView as AbstractView,
|
||||
TreeView as TreeView,
|
||||
Tree as Tree,
|
||||
cell as cell,
|
||||
)
|
||||
from .ports import (
|
||||
Port as Port,
|
||||
PortList as PortList,
|
||||
)
|
||||
from .abstract import Abstract as Abstract
|
||||
from .builder import (
|
||||
Tool as Tool,
|
||||
ToolContractError as ToolContractError,
|
||||
Pather as Pather,
|
||||
RouteError as RouteError,
|
||||
RouteFailureDetails as RouteFailureDetails,
|
||||
RouteFailurePolicy as RouteFailurePolicy,
|
||||
MinimumStatus as MinimumStatus,
|
||||
RenderStep as RenderStep,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
PortPather as PortPather,
|
||||
)
|
||||
from .utils import (
|
||||
ports2data as ports2data,
|
||||
oneshot as oneshot,
|
||||
R90 as R90,
|
||||
R180 as R180,
|
||||
)
|
||||
from .error import PatternError, PatternLockedError
|
||||
from .shapes import Shape
|
||||
from .label import Label
|
||||
from .subpattern import SubPattern
|
||||
from .pattern import Pattern
|
||||
from .utils import layer_t, annotations_t
|
||||
from .library import Library, DeviceLibrary
|
||||
|
||||
|
||||
__author__ = 'Jan Petykiewicz'
|
||||
|
||||
__version__ = '4.0a2'
|
||||
__version__ = '2.7'
|
||||
version = __version__ # legacy
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
from typing import Self
|
||||
import copy
|
||||
import logging
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from .ref import Ref
|
||||
from .ports import PortList, Port
|
||||
from .utils import rotation_matrix_2d
|
||||
from .traits import Mirrorable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Abstract(PortList, Mirrorable):
|
||||
"""
|
||||
An `Abstract` is a container for a name and associated ports.
|
||||
|
||||
When snapping a sub-component to an existing pattern, only the name (not contained
|
||||
in a `Pattern` object) and port info is needed, and not the geometry itself.
|
||||
"""
|
||||
# Alternate design option: do we want to store a Ref instead of just a name? then we can translate/rotate/mirror...
|
||||
__slots__ = ('name', '_ports')
|
||||
|
||||
name: str
|
||||
""" Name of the pattern this device references """
|
||||
|
||||
_ports: dict[str, Port]
|
||||
""" Uniquely-named ports which can be used to instances together"""
|
||||
|
||||
@property
|
||||
def ports(self) -> dict[str, Port]:
|
||||
return self._ports
|
||||
|
||||
@ports.setter
|
||||
def ports(self, value: dict[str, Port]) -> None:
|
||||
self._ports = value
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
ports: dict[str, Port],
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.ports = copy.deepcopy(ports)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
s = f'<Abstract {self.name} ['
|
||||
for name, port in self.ports.items():
|
||||
s += f'\n\t{name}: {port}'
|
||||
s += ']>'
|
||||
return s
|
||||
|
||||
def translate_ports(self, offset: ArrayLike) -> Self:
|
||||
"""
|
||||
Translates all ports by the given offset.
|
||||
|
||||
Args:
|
||||
offset: (x, y) to translate by
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.translate(offset)
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
"""
|
||||
Scale this Abstract by the given value
|
||||
(all port offsets are scaled)
|
||||
|
||||
Args:
|
||||
c: factor to scale by
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.offset *= c
|
||||
return self
|
||||
|
||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||
"""
|
||||
Rotate the Abstract around a pivot point.
|
||||
|
||||
Args:
|
||||
pivot: (x, y) location to rotate around
|
||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
pivot = numpy.asarray(pivot, dtype=float)
|
||||
self.translate_ports(-pivot)
|
||||
self.rotate_ports(rotation)
|
||||
self.rotate_port_offsets(rotation)
|
||||
self.translate_ports(+pivot)
|
||||
return self
|
||||
|
||||
def rotate_port_offsets(self, rotation: float) -> Self:
|
||||
"""
|
||||
Rotate the offsets of all ports around (0, 0)
|
||||
|
||||
Args:
|
||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.offset = rotation_matrix_2d(rotation) @ port.offset
|
||||
return self
|
||||
|
||||
def rotate_ports(self, rotation: float) -> Self:
|
||||
"""
|
||||
Rotate each port around its offset (i.e. in place)
|
||||
|
||||
Args:
|
||||
rotation: Angle to rotate by (counter-clockwise, radians)
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
"""
|
||||
Mirror the Abstract across an axis through its origin.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across (0: x-axis, 1: y-axis).
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for port in self.ports.values():
|
||||
port.flip_across(axis=axis)
|
||||
return self
|
||||
|
||||
def apply_ref_transform(self, ref: Ref) -> Self:
|
||||
"""
|
||||
Apply the transform from a `Ref` to the ports of this `Abstract`.
|
||||
This changes the port locations to where they would be in the Ref's parent pattern.
|
||||
|
||||
Args:
|
||||
ref: The ref whose transform should be applied.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if ref.mirrored:
|
||||
self.mirror()
|
||||
self.rotate_ports(ref.rotation)
|
||||
self.rotate_port_offsets(ref.rotation)
|
||||
if ref.scale != 1:
|
||||
self.scale_by(ref.scale)
|
||||
self.translate_ports(ref.offset)
|
||||
return self
|
||||
|
||||
def undo_ref_transform(self, ref: Ref) -> Self:
|
||||
"""
|
||||
Apply the inverse transform from a `Ref` to the ports of this `Abstract`.
|
||||
This changes the port locations to where they would be in the Ref's target (from the parent).
|
||||
|
||||
Args:
|
||||
ref: The ref whose (inverse) transform should be applied.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
# TODO test undo_ref_transform
|
||||
"""
|
||||
self.translate_ports(-ref.offset)
|
||||
if ref.scale != 1:
|
||||
self.scale_by(1 / ref.scale)
|
||||
self.rotate_port_offsets(-ref.rotation)
|
||||
self.rotate_ports(-ref.rotation)
|
||||
if ref.mirrored:
|
||||
self.mirror(0)
|
||||
return self
|
||||
|
|
@ -1,90 +1,3 @@
|
|||
"""
|
||||
Builder helpers for port-based assembly and primitive-offer routing.
|
||||
|
||||
A routing `Tool` describes the primitive route families it can provide by
|
||||
returning `PrimitiveOffer` objects. Each offer is a parameterized planning
|
||||
candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost,
|
||||
optional footprint metadata, and a commit hook for producing tool-specific
|
||||
render data after a concrete parameter has been selected.
|
||||
|
||||
`Pather` owns user-facing route operations such as `trace()`, `jog()`,
|
||||
`uturn()`, and `trace_into()`. The internal planner resolves each operation into
|
||||
one or more `SolverRequest`s. This normalization is why the public routing API
|
||||
can remain a convenient keyword-based interface without making the solver
|
||||
stringly typed internally. A pure solver search selects a `Candidate`, and a
|
||||
`RouteLeg` attaches that candidate to its copied source port and Tool. Only
|
||||
after selection succeeds are the chosen offers materialized through
|
||||
`offer.commit(parameter)` into a `PreparedRouteResult` containing
|
||||
`RenderStep.data`. `Pather` then applies that prepared result to its live ports
|
||||
and pending render queue.
|
||||
|
||||
Selection is pure with respect to caller-owned Pattern, Library, and Pather
|
||||
state. Tool offer discovery and endpoint/cost/bbox callbacks must likewise be
|
||||
deterministic and must not mutate that state. `commit()` is the first
|
||||
selected-offer materialization hook, but it still must not mutate the live
|
||||
layout. `Tool.render()` is the geometry mutation boundary: later,
|
||||
`Pather.render()` batches compatible `RenderStep`s and inserts the resulting
|
||||
geometry into the Pattern and Library.
|
||||
|
||||
`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract.
|
||||
`RenderStep` is `Pather`'s deferred-render record, and
|
||||
`masque.builder.planner` is an internal planner implementation rather than a
|
||||
stable public API.
|
||||
|
||||
Custom Tool authors should run `validate_tool_contract()` as a development or
|
||||
application-startup preflight. It performs the comprehensive semantic checks
|
||||
that are intentionally not repeated during route selection, keeping the normal
|
||||
routing path focused on search rather than contract verification.
|
||||
|
||||
The practical layering is:
|
||||
- user code drives `Pather` and chooses Tools per port or by default,
|
||||
- Tools describe local legal motion primitives without touching Pather state,
|
||||
- the internal router composes those primitives into high-level route shapes,
|
||||
- Pather applies the prepared result to ports, deferred render queues, and the
|
||||
target pattern/library.
|
||||
|
||||
`Pather` intentionally remains a Pattern-oriented facade rather than exposing
|
||||
separate assembly and routing objects: its user model is a working Pattern with
|
||||
routing tools attached. The ownership phases above are internal boundaries,
|
||||
not additional objects callers must coordinate.
|
||||
|
||||
Code outside the builder package should prefer the exports here over importing
|
||||
from `masque.builder.planner`. The planner package is intentionally available
|
||||
for tests and internal maintenance, but it is not the compatibility boundary
|
||||
for custom Tools.
|
||||
"""
|
||||
|
||||
from .pather import (
|
||||
Pather as Pather,
|
||||
PortPather as PortPather,
|
||||
RouteCompletionCallback as RouteCompletionCallback,
|
||||
)
|
||||
from .error import (
|
||||
ToolContractError as ToolContractError,
|
||||
RouteError as RouteError,
|
||||
RouteFailureDetails as RouteFailureDetails,
|
||||
RouteOperation as RouteOperation,
|
||||
RouteFailurePolicy as RouteFailurePolicy,
|
||||
MinimumStatus as MinimumStatus,
|
||||
)
|
||||
from .utils import ell as ell
|
||||
from .tool_testing import (
|
||||
ToolContractCase as ToolContractCase,
|
||||
validate_tool_contract as validate_tool_contract,
|
||||
)
|
||||
from .tools import (
|
||||
Tool as Tool,
|
||||
AutoTool as AutoTool,
|
||||
PathTool as PathTool,
|
||||
RenderStep as RenderStep,
|
||||
RenderStepKind as RenderStepKind,
|
||||
PrimitiveKind as PrimitiveKind,
|
||||
CostCallable as CostCallable,
|
||||
GeneratedEndpointFn as GeneratedEndpointFn,
|
||||
PrimitiveOffer as PrimitiveOffer,
|
||||
StraightOffer as StraightOffer,
|
||||
BendOffer as BendOffer,
|
||||
SOffer as SOffer,
|
||||
UOffer as UOffer,
|
||||
circular_arc_sbend_endpoint as circular_arc_sbend_endpoint,
|
||||
)
|
||||
from .devices import Port, Device
|
||||
from .utils import ell
|
||||
from .tools import Tool
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
"""Shared numeric tolerances for builder geometry and parameter comparisons."""
|
||||
from math import isclose, remainder, tau
|
||||
from typing import Any
|
||||
|
||||
import numpy
|
||||
|
||||
|
||||
GEOMETRY_RTOL = 1e-5
|
||||
GEOMETRY_ATOL = 1e-8
|
||||
DOMAIN_RTOL = 1e-9
|
||||
DOMAIN_ATOL = 1e-12
|
||||
MANHATTAN_ANGLE_RTOL = 1e-9
|
||||
MANHATTAN_ANGLE_ATOL = 1e-9
|
||||
|
||||
|
||||
def scalar_close(a: float, b: float) -> bool:
|
||||
"""Match the solver's existing scalar-comparison behavior."""
|
||||
return isclose(float(a), float(b), rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL)
|
||||
|
||||
|
||||
def array_close(a: Any, b: Any) -> bool:
|
||||
"""Match NumPy's historical builder geometry-comparison behavior."""
|
||||
return bool(numpy.allclose(a, b, rtol=GEOMETRY_RTOL, atol=GEOMETRY_ATOL))
|
||||
|
||||
|
||||
def angles_equal(a: float, b: float) -> bool:
|
||||
"""Return true when two rotations are equal modulo one full turn."""
|
||||
delta = remainder(float(a) - float(b), tau)
|
||||
return isclose(delta, 0.0, rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL)
|
||||
|
||||
|
||||
def manhattan_axis(rotation: float) -> int | None:
|
||||
"""Return 0 for horizontal, 1 for vertical, or None for a non-cardinal angle."""
|
||||
angle = float(rotation) % (numpy.pi / 2)
|
||||
if isclose(
|
||||
angle,
|
||||
0.0,
|
||||
rel_tol=MANHATTAN_ANGLE_RTOL,
|
||||
abs_tol=MANHATTAN_ANGLE_ATOL,
|
||||
) or isclose(
|
||||
angle,
|
||||
numpy.pi / 2,
|
||||
rel_tol=MANHATTAN_ANGLE_RTOL,
|
||||
abs_tol=MANHATTAN_ANGLE_ATOL,
|
||||
):
|
||||
quarter_turn = round(float(rotation) / (numpy.pi / 2))
|
||||
return quarter_turn % 2
|
||||
return None
|
||||
892
masque/builder/devices.py
Normal file
892
masque/builder/devices.py
Normal file
|
|
@ -0,0 +1,892 @@
|
|||
from typing import Dict, Iterable, List, Tuple, Union, TypeVar, Any, Iterator, Optional, Sequence
|
||||
from typing import overload, KeysView, ValuesView
|
||||
import copy
|
||||
import warnings
|
||||
import traceback
|
||||
import logging
|
||||
from collections import Counter
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..subpattern import SubPattern
|
||||
from ..traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable
|
||||
from ..utils import AutoSlots, rotation_matrix_2d
|
||||
from ..error import DeviceError
|
||||
from .tools import Tool
|
||||
from .utils import ell
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
P = TypeVar('P', bound='Port')
|
||||
D = TypeVar('D', bound='Device')
|
||||
O = TypeVar('O', bound='Device')
|
||||
|
||||
|
||||
class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, metaclass=AutoSlots):
|
||||
"""
|
||||
A point at which a `Device` can be snapped to another `Device`.
|
||||
|
||||
Each port has an `offset` ((x, y) position) and may also have a
|
||||
`rotation` (orientation) and a `ptype` (port type).
|
||||
|
||||
The `rotation` is an angle, in radians, measured counterclockwise
|
||||
from the +x axis, pointing inwards into the device which owns the port.
|
||||
The rotation may be set to `None`, indicating that any orientation is
|
||||
allowed (e.g. for a DC electrical port). It is stored modulo 2pi.
|
||||
|
||||
The `ptype` is an arbitrary string, default of `unk` (unknown).
|
||||
"""
|
||||
__slots__ = ('ptype', '_rotation')
|
||||
|
||||
_rotation: Optional[float]
|
||||
""" radians counterclockwise from +x, pointing into device body.
|
||||
Can be `None` to signify undirected port """
|
||||
|
||||
ptype: str
|
||||
""" Port types must match to be plugged together if both are non-zero """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
offset: ArrayLike,
|
||||
rotation: Optional[float],
|
||||
ptype: str = 'unk',
|
||||
) -> None:
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.ptype = ptype
|
||||
|
||||
@property
|
||||
def rotation(self) -> Optional[float]:
|
||||
""" Rotation, radians counterclockwise, pointing into device body. Can be None. """
|
||||
return self._rotation
|
||||
|
||||
@rotation.setter
|
||||
def rotation(self, val: float) -> None:
|
||||
if val is None:
|
||||
self._rotation = None
|
||||
else:
|
||||
if not numpy.size(val) == 1:
|
||||
raise DeviceError('Rotation must be a scalar')
|
||||
self._rotation = val % (2 * pi)
|
||||
|
||||
def get_bounds(self):
|
||||
return numpy.vstack((self.offset, self.offset))
|
||||
|
||||
def set_ptype(self: P, ptype: str) -> P:
|
||||
""" Chainable setter for `ptype` """
|
||||
self.ptype = ptype
|
||||
return self
|
||||
|
||||
def mirror(self: P, axis: int) -> P:
|
||||
self.offset[1 - axis] *= -1
|
||||
if self.rotation is not None:
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
return self
|
||||
|
||||
def rotate(self: P, rotation: float) -> P:
|
||||
if self.rotation is not None:
|
||||
self.rotation += rotation
|
||||
return self
|
||||
|
||||
def set_rotation(self: P, rotation: Optional[float]) -> P:
|
||||
self.rotation = rotation
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.rotation is None:
|
||||
rot = 'any'
|
||||
else:
|
||||
rot = str(numpy.rad2deg(self.rotation))
|
||||
return f'<{self.offset}, {rot}, [{self.ptype}]>'
|
||||
|
||||
|
||||
class Device(Copyable, Mirrorable):
|
||||
"""
|
||||
A `Device` is a combination of a `Pattern` with a set of named `Port`s
|
||||
which can be used to "snap" devices together to make complex layouts.
|
||||
|
||||
`Device`s can be as simple as one or two ports (e.g. an electrical pad
|
||||
or wire), but can also be used to build and represent a large routed
|
||||
layout (e.g. a logical block with multiple I/O connections or even a
|
||||
full chip).
|
||||
|
||||
For convenience, ports can be read out using square brackets:
|
||||
- `device['A'] == Port((0, 0), 0)`
|
||||
- `device[['A', 'B']] == {'A': Port((0, 0), 0), 'B': Port((0, 0), pi)}`
|
||||
|
||||
Examples: Creating a Device
|
||||
===========================
|
||||
- `Device(pattern, ports={'A': port_a, 'C': port_c})` uses an existing
|
||||
pattern and defines some ports.
|
||||
|
||||
- `Device(name='my_dev_name', ports=None)` makes a new empty pattern with
|
||||
default ports ('A' and 'B', in opposite directions, at (0, 0)).
|
||||
|
||||
- `my_device.build('my_layout')` makes a new pattern and instantiates
|
||||
`my_device` in it with offset (0, 0) as a base for further building.
|
||||
|
||||
- `my_device.as_interface('my_component', port_map=['A', 'B'])` makes a new
|
||||
(empty) pattern, copies over ports 'A' and 'B' from `my_device`, and
|
||||
creates additional ports 'in_A' and 'in_B' facing in the opposite
|
||||
directions. This can be used to build a device which can plug into
|
||||
`my_device` (using the 'in_*' ports) but which does not itself include
|
||||
`my_device` as a subcomponent.
|
||||
|
||||
Examples: Adding to a Device
|
||||
============================
|
||||
- `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})`
|
||||
instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B'
|
||||
of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports
|
||||
are removed and any unconnected ports from `subdevice` are added to
|
||||
`my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'.
|
||||
|
||||
- `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport'
|
||||
of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`,
|
||||
argument is provided, and the `inherit_name` argument is not explicitly
|
||||
set to `False`, the unconnected port of `wire` is automatically renamed to
|
||||
'myport'. This allows easy extension of existing ports without changing
|
||||
their names or having to provide `map_out` each time `plug` is called.
|
||||
|
||||
- `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', 'ports', 'tools', '_dead')
|
||||
|
||||
pattern: Pattern
|
||||
""" Layout of this device """
|
||||
|
||||
ports: Dict[str, Port]
|
||||
""" Uniquely-named ports which can be used to snap to other Device instances"""
|
||||
|
||||
tools: Dict[Optional[str], Tool]
|
||||
"""
|
||||
Tool objects are used to dynamically generate new single-use Devices
|
||||
(e.g wires or waveguides) to be plugged into this device.
|
||||
"""
|
||||
|
||||
_dead: bool
|
||||
""" If True, plug()/place() are skipped (for debugging)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern: Optional[Pattern] = None,
|
||||
ports: Optional[Dict[str, Port]] = None,
|
||||
*,
|
||||
tools: Union[None, Tool, Dict[Optional[str], Tool]] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
If `ports` is `None`, two default ports ('A' and 'B') are created.
|
||||
Both are placed at (0, 0) and have default `ptype`, but 'A' has rotation 0
|
||||
(attached devices will be placed to the left) and 'B' has rotation
|
||||
pi (attached devices will be placed to the right).
|
||||
"""
|
||||
if pattern is not None:
|
||||
if name is not None:
|
||||
raise DeviceError('Only one of `pattern` and `name` may be specified')
|
||||
self.pattern = pattern
|
||||
else:
|
||||
if name is None:
|
||||
raise DeviceError('Must specify either `pattern` or `name`')
|
||||
self.pattern = Pattern(name=name)
|
||||
|
||||
if ports is None:
|
||||
self.ports = {
|
||||
'A': Port([0, 0], rotation=0),
|
||||
'B': Port([0, 0], rotation=pi),
|
||||
}
|
||||
else:
|
||||
self.ports = copy.deepcopy(ports)
|
||||
|
||||
if tools is None:
|
||||
self.tools = {}
|
||||
elif isinstance(tools, Tool):
|
||||
self.tools = {None: tools}
|
||||
else:
|
||||
self.tools = tools
|
||||
|
||||
self._dead = False
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: str) -> Port:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: Union[List[str], Tuple[str, ...], KeysView[str], ValuesView[str]]) -> Dict[str, Port]:
|
||||
pass
|
||||
|
||||
def __getitem__(self, key: Union[str, Iterable[str]]) -> Union[Port, Dict[str, Port]]:
|
||||
"""
|
||||
For convenience, ports can be read out using square brackets:
|
||||
- `device['A'] == Port((0, 0), 0)`
|
||||
- `device[['A', 'B']] == {'A': Port((0, 0), 0),
|
||||
'B': Port((0, 0), pi)}`
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
return self.ports[key]
|
||||
else:
|
||||
return {k: self.ports[k] for k in key}
|
||||
|
||||
def rename_ports(
|
||||
self: D,
|
||||
mapping: Dict[str, Optional[str]],
|
||||
overwrite: bool = False,
|
||||
) -> D:
|
||||
"""
|
||||
Renames ports as specified by `mapping`.
|
||||
Ports can be explicitly deleted by mapping them to `None`.
|
||||
|
||||
Args:
|
||||
mapping: Dict of `{'old_name': 'new_name'}` pairs. Names can be mapped
|
||||
to `None` to perform an explicit deletion. `'new_name'` can also
|
||||
overwrite an existing non-renamed port to implicitly delete it if
|
||||
`overwrite` is set to `True`.
|
||||
overwrite: Allows implicit deletion of ports if set to `True`; see `mapping`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if not overwrite:
|
||||
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
||||
if duplicates:
|
||||
raise DeviceError(f'Unrenamed ports would be overwritten: {duplicates}')
|
||||
|
||||
renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()}
|
||||
if None in renamed:
|
||||
del renamed[None]
|
||||
|
||||
self.ports.update(renamed) # type: ignore
|
||||
return self
|
||||
|
||||
def check_ports(
|
||||
self: D,
|
||||
other_names: Iterable[str],
|
||||
map_in: Optional[Dict[str, str]] = None,
|
||||
map_out: Optional[Dict[str, Optional[str]]] = None,
|
||||
) -> D:
|
||||
"""
|
||||
Given the provided port mappings, check that:
|
||||
- All of the ports specified in the mappings exist
|
||||
- There are no duplicate port names after all the mappings are performed
|
||||
|
||||
Args:
|
||||
other_names: List of port names being considered for inclusion into
|
||||
`self.ports` (before mapping)
|
||||
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 unconnected `other_names` ports.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`DeviceError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other_names`.
|
||||
`DeviceError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
"""
|
||||
if map_in is None:
|
||||
map_in = {}
|
||||
|
||||
if map_out is None:
|
||||
map_out = {}
|
||||
|
||||
other = set(other_names)
|
||||
|
||||
missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
|
||||
if missing_inkeys:
|
||||
raise DeviceError(f'`map_in` keys not present in device: {missing_inkeys}')
|
||||
|
||||
missing_invals = set(map_in.values()) - other
|
||||
if missing_invals:
|
||||
raise DeviceError(f'`map_in` values not present in other device: {missing_invals}')
|
||||
|
||||
missing_outkeys = set(map_out.keys()) - other
|
||||
if missing_outkeys:
|
||||
raise DeviceError(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 DeviceError(f'Device ports conflict with existing ports: {conflicts_final}')
|
||||
|
||||
conflicts_partial = other_remaining & mapped_vals
|
||||
if conflicts_partial:
|
||||
raise DeviceError(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 = {k for k, v in map_out_counts.items() if v > 1}
|
||||
if conflicts_out:
|
||||
raise DeviceError(f'Duplicate targets in `map_out`: {conflicts_out}')
|
||||
|
||||
return self
|
||||
|
||||
def build(self, name: str) -> 'Device':
|
||||
"""
|
||||
Begin building a new device around an instance of the current device
|
||||
(rather than modifying the current device).
|
||||
|
||||
Args:
|
||||
name: A name for the new device
|
||||
|
||||
Returns:
|
||||
The new `Device` object.
|
||||
"""
|
||||
pat = Pattern(name)
|
||||
pat.addsp(self.pattern)
|
||||
new = Device(pat, ports=self.ports, tools=self.tools)
|
||||
return new
|
||||
|
||||
def as_interface(
|
||||
self,
|
||||
name: str,
|
||||
in_prefix: str = 'in_',
|
||||
out_prefix: str = '',
|
||||
port_map: Optional[Union[Dict[str, str], Sequence[str]]] = None
|
||||
) -> 'Device':
|
||||
"""
|
||||
Begin building a new device based on all or some of the ports in the
|
||||
current device. Do not include the current device; instead use it
|
||||
to define ports (the "interface") for the new device.
|
||||
|
||||
The ports specified by `port_map` (default: all ports) are copied to
|
||||
new device, and additional (input) ports are created facing in the
|
||||
opposite directions. The specified `in_prefix` and `out_prefix` are
|
||||
prepended to the port names to differentiate them.
|
||||
|
||||
By default, the flipped ports are given an 'in_' prefix and unflipped
|
||||
ports keep their original names, enabling intuitive construction of
|
||||
a device that will "plug into" the current device; the 'in_*' ports
|
||||
are used for plugging the devices together while the original port
|
||||
names are used for building the new device.
|
||||
|
||||
Another use-case could be to build the new device using the 'in_'
|
||||
ports, creating a new device which could be used in place of the
|
||||
current device.
|
||||
|
||||
Args:
|
||||
name: Name for the new device
|
||||
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 device, with an empty pattern and 2x as many ports as
|
||||
listed in port_map.
|
||||
|
||||
Raises:
|
||||
`DeviceError` if `port_map` contains port names not present in the
|
||||
current device.
|
||||
`DeviceError` if applying the prefixes results in duplicate port
|
||||
names.
|
||||
"""
|
||||
if port_map:
|
||||
if isinstance(port_map, dict):
|
||||
missing_inkeys = set(port_map.keys()) - set(self.ports.keys())
|
||||
orig_ports = {port_map[k]: v for k, v in self.ports.items() if k in port_map}
|
||||
else:
|
||||
port_set = set(port_map)
|
||||
missing_inkeys = port_set - set(self.ports.keys())
|
||||
orig_ports = {k: v for k, v in self.ports.items() if k in port_set}
|
||||
|
||||
if missing_inkeys:
|
||||
raise DeviceError(f'`port_map` keys not present in device: {missing_inkeys}')
|
||||
else:
|
||||
orig_ports = self.ports
|
||||
|
||||
ports_in = {f'{in_prefix}{name}': port.deepcopy().rotate(pi)
|
||||
for name, port in orig_ports.items()}
|
||||
ports_out = {f'{out_prefix}{name}': port.deepcopy()
|
||||
for name, port in orig_ports.items()}
|
||||
|
||||
duplicates = set(ports_out.keys()) & set(ports_in.keys())
|
||||
if duplicates:
|
||||
raise DeviceError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}')
|
||||
|
||||
new = Device(name=name, ports={**ports_in, **ports_out}, tools=self.tools)
|
||||
return new
|
||||
|
||||
def plug(
|
||||
self: D,
|
||||
other: O,
|
||||
map_in: Dict[str, str],
|
||||
map_out: Optional[Dict[str, Optional[str]]] = None,
|
||||
*,
|
||||
mirrored: Tuple[bool, bool] = (False, False),
|
||||
inherit_name: bool = True,
|
||||
set_rotation: Optional[bool] = None,
|
||||
) -> D:
|
||||
"""
|
||||
Instantiate the device `other` into the current device, connecting
|
||||
the ports specified by `map_in` and renaming the unconnected
|
||||
ports specified by `map_out`.
|
||||
|
||||
Examples:
|
||||
=========
|
||||
- `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 `inherit_name` argument is not explicitly
|
||||
set to `False`, the unconnected port of `wire` is automatically renamed to
|
||||
'myport'. This allows easy extension of existing ports without changing
|
||||
their names or having to provide `map_out` each time `plug` is called.
|
||||
|
||||
Args:
|
||||
other: A device to instantiate into the current device.
|
||||
map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||
port connections between the two devices.
|
||||
map_out: Dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||
new names for ports in `other`.
|
||||
mirrored: Enables mirroring `other` across the x or y axes prior
|
||||
to connecting any ports.
|
||||
inherit_name: If `True`, and `map_in` specifies only a single port,
|
||||
and `map_out` is `None`, and `other` has only two ports total,
|
||||
then automatically renames the output port of `other` to the
|
||||
name of the port from `self` that appears in `map_in`. This
|
||||
makes it easy to extend a device with simple 2-port devices
|
||||
(e.g. wires) without providing `map_out` each time `plug` is
|
||||
called. See "Examples" above for more info. Default `True`.
|
||||
set_rotation: If the necessary rotation cannot be determined from
|
||||
the ports being connected (i.e. all pairs have at least one
|
||||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `other` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`DeviceError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other_names`.
|
||||
`DeviceError` if there are any duplicate names after `map_in` and `map_out`
|
||||
are applied.
|
||||
`DeviceError` 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 (inherit_name
|
||||
and not map_out
|
||||
and len(map_in) == 1
|
||||
and len(other.ports) == 2):
|
||||
out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values())))
|
||||
map_out = {out_port_name: next(iter(map_in.keys()))}
|
||||
|
||||
if map_out is None:
|
||||
map_out = {}
|
||||
map_out = copy.deepcopy(map_out)
|
||||
|
||||
self.check_ports(other.ports.keys(), map_in, map_out)
|
||||
translation, rotation, pivot = self.find_transform(other, map_in, mirrored=mirrored,
|
||||
set_rotation=set_rotation)
|
||||
|
||||
# get rid of plugged ports
|
||||
for ki, vi in map_in.items():
|
||||
del self.ports[ki]
|
||||
map_out[vi] = None
|
||||
|
||||
self.place(other, offset=translation, rotation=rotation, pivot=pivot,
|
||||
mirrored=mirrored, port_map=map_out, skip_port_check=True)
|
||||
return self
|
||||
|
||||
def place(
|
||||
self: D,
|
||||
other: O,
|
||||
*,
|
||||
offset: ArrayLike = (0, 0),
|
||||
rotation: float = 0,
|
||||
pivot: ArrayLike = (0, 0),
|
||||
mirrored: Tuple[bool, bool] = (False, False),
|
||||
port_map: Optional[Dict[str, Optional[str]]] = None,
|
||||
skip_port_check: bool = False,
|
||||
) -> D:
|
||||
"""
|
||||
Instantiate the device `other` into the current device, adding its
|
||||
ports to those of the current device (but not connecting any ports).
|
||||
|
||||
Mirroring is applied before rotation; translation (`offset`) is applied last.
|
||||
|
||||
Examples:
|
||||
=========
|
||||
- `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})`
|
||||
instantiates `pad` at the specified (x, y) offset and with the specified
|
||||
rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is
|
||||
renamed to 'gnd' so that further routing can use this signal or net name
|
||||
rather than the port name on the original `pad` device.
|
||||
|
||||
Args:
|
||||
other: A device to instantiate into the current device.
|
||||
offset: Offset at which to place `other`. Default (0, 0).
|
||||
rotation: Rotation applied to `other` before placement. Default 0.
|
||||
pivot: Rotation is applied around this pivot point (default (0, 0)).
|
||||
Rotation is applied prior to translation (`offset`).
|
||||
mirrored: Whether `other` should be mirrored across the x and y axes.
|
||||
Mirroring is applied before translation and rotation.
|
||||
port_map: Dict of `{'old_name': 'new_name'}` mappings, specifying
|
||||
new names for ports in `other`. 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.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`DeviceError` if any ports specified in `map_in` or `map_out` do not
|
||||
exist in `self.ports` or `other_names`.
|
||||
`DeviceError` 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 port_map is None:
|
||||
port_map = {}
|
||||
|
||||
if not skip_port_check:
|
||||
self.check_ports(other.ports.keys(), map_in=None, map_out=port_map)
|
||||
|
||||
ports = {}
|
||||
for name, port in other.ports.items():
|
||||
new_name = port_map.get(name, name)
|
||||
if new_name is None:
|
||||
continue
|
||||
ports[new_name] = port
|
||||
|
||||
for name, port in ports.items():
|
||||
p = port.deepcopy()
|
||||
p.mirror2d(mirrored)
|
||||
p.rotate_around(pivot, rotation)
|
||||
p.translate(offset)
|
||||
self.ports[name] = p
|
||||
|
||||
sp = SubPattern(other.pattern, mirrored=mirrored)
|
||||
sp.rotate_around(pivot, rotation)
|
||||
sp.translate(offset)
|
||||
self.pattern.subpatterns.append(sp)
|
||||
return self
|
||||
|
||||
def find_transform(
|
||||
self: D,
|
||||
other: O,
|
||||
map_in: Dict[str, str],
|
||||
*,
|
||||
mirrored: Tuple[bool, bool] = (False, False),
|
||||
set_rotation: Optional[bool] = None,
|
||||
) -> Tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
|
||||
"""
|
||||
Given a device `other` and a mapping `map_in` specifying port connections,
|
||||
find the transform which will correctly align the specified ports.
|
||||
|
||||
Args:
|
||||
other: a device
|
||||
map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||
port connections between the two devices.
|
||||
mirrored: Mirrors `other` across the x or y axes prior to
|
||||
connecting any ports.
|
||||
set_rotation: If the necessary rotation cannot be determined from
|
||||
the ports being connected (i.e. all pairs have at least one
|
||||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `other` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
- The rotation (radians, counterclockwise)
|
||||
- The (x, y) pivot point for the rotation
|
||||
|
||||
The rotation should be performed before the translation.
|
||||
"""
|
||||
s_ports = self[map_in.keys()]
|
||||
o_ports = other[map_in.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()])
|
||||
s_types = [p.ptype for p in s_ports.values()]
|
||||
o_types = [p.ptype for p in o_ports.values()]
|
||||
|
||||
s_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in s_ports.values()])
|
||||
o_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in o_ports.values()])
|
||||
s_has_rot = numpy.array([p.rotation is not None for p in s_ports.values()], dtype=bool)
|
||||
o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool)
|
||||
has_rot = s_has_rot & o_has_rot
|
||||
|
||||
if mirrored[0]:
|
||||
o_offsets[:, 1] *= -1
|
||||
o_rotations *= -1
|
||||
if mirrored[1]:
|
||||
o_offsets[:, 0] *= -1
|
||||
o_rotations *= -1
|
||||
o_rotations += pi
|
||||
|
||||
type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk'
|
||||
for st, ot in zip(s_types, o_types)])
|
||||
if type_conflicts.any():
|
||||
ports = numpy.where(type_conflicts)
|
||||
msg = 'Ports have conflicting types:\n'
|
||||
for nn, (k, v) in enumerate(map_in.items()):
|
||||
if type_conflicts[nn]:
|
||||
msg += f'{k} | {s_types[nn]}:{o_types[nn]} | {v}\n'
|
||||
msg = ''.join(traceback.format_stack()) + '\n' + msg
|
||||
warnings.warn(msg, stacklevel=2)
|
||||
|
||||
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
||||
if not has_rot.any():
|
||||
if set_rotation is None:
|
||||
DeviceError('Must provide set_rotation if rotation is indeterminate')
|
||||
rotations[:] = set_rotation
|
||||
else:
|
||||
rotations[~has_rot] = rotations[has_rot][0]
|
||||
|
||||
if not numpy.allclose(rotations[:1], rotations):
|
||||
rot_deg = numpy.rad2deg(rotations)
|
||||
msg = f'Port orientations do not match:\n'
|
||||
for nn, (k, v) in enumerate(map_in.items()):
|
||||
msg += f'{k} | {rot_deg[nn]:g} | {v}\n'
|
||||
raise DeviceError(msg)
|
||||
|
||||
pivot = o_offsets[0].copy()
|
||||
rotate_offsets_around(o_offsets, pivot, rotations[0])
|
||||
translations = s_offsets - o_offsets
|
||||
if not numpy.allclose(translations[:1], translations):
|
||||
msg = f'Port translations do not match:\n'
|
||||
for nn, (k, v) in enumerate(map_in.items()):
|
||||
msg += f'{k} | {translations[nn]} | {v}\n'
|
||||
raise DeviceError(msg)
|
||||
|
||||
return translations[0], rotations[0], o_offsets[0]
|
||||
|
||||
def translate(self: D, offset: ArrayLike) -> D:
|
||||
"""
|
||||
Translate the pattern and all ports.
|
||||
|
||||
Args:
|
||||
offset: (x, y) distance to translate by
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.translate_elements(offset)
|
||||
for port in self.ports.values():
|
||||
port.translate(offset)
|
||||
return self
|
||||
|
||||
def rotate_around(self: D, pivot: ArrayLike, angle: float) -> D:
|
||||
"""
|
||||
Translate the pattern and all ports.
|
||||
|
||||
Args:
|
||||
offset: (x, y) distance to translate by
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.rotate_around(pivot, angle)
|
||||
for port in self.ports.values():
|
||||
port.rotate_around(pivot, angle)
|
||||
return self
|
||||
|
||||
def mirror(self: D, axis: int) -> D:
|
||||
"""
|
||||
Translate 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)
|
||||
for p in self.ports.values():
|
||||
p.mirror(axis)
|
||||
return self
|
||||
|
||||
def set_dead(self: D) -> D:
|
||||
"""
|
||||
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 rename(self: D, name: str) -> D:
|
||||
"""
|
||||
Renames the pattern and returns the device
|
||||
|
||||
Args:
|
||||
name: The new name
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.pattern.name = name
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
s = f'<Device {self.pattern} ['
|
||||
for name, port in self.ports.items():
|
||||
s += f'\n\t{name}: {port}'
|
||||
s += ']>'
|
||||
return s
|
||||
|
||||
def retool(
|
||||
self: D,
|
||||
tool: Tool,
|
||||
keys: Union[Optional[str], Sequence[Optional[str]]] = None,
|
||||
) -> D:
|
||||
if keys is None or isinstance(keys, str):
|
||||
self.tools[keys] = tool
|
||||
else:
|
||||
for key in keys:
|
||||
self.tools[key] = tool
|
||||
return self
|
||||
|
||||
def path(
|
||||
self: D,
|
||||
portspec: str,
|
||||
ccw: Optional[bool],
|
||||
length: float,
|
||||
*,
|
||||
tool_port_names: Sequence[str] = ('A', 'B'),
|
||||
**kwargs,
|
||||
) -> D:
|
||||
if self._dead:
|
||||
logger.error('Skipping path() since device is dead')
|
||||
return self
|
||||
|
||||
tool = self.tools.get(portspec, self.tools[None])
|
||||
in_ptype = self.ports[portspec].ptype
|
||||
dev = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
|
||||
return self.plug(dev, {portspec: tool_port_names[0]})
|
||||
|
||||
def path_to(
|
||||
self: D,
|
||||
portspec: str,
|
||||
ccw: Optional[bool],
|
||||
position: float,
|
||||
*,
|
||||
tool_port_names: Sequence[str] = ('A', 'B'),
|
||||
**kwargs,
|
||||
) -> D:
|
||||
if self._dead:
|
||||
logger.error('Skipping path_to() since device is dead')
|
||||
return self
|
||||
|
||||
port = self.ports[portspec]
|
||||
x, y = port.offset
|
||||
if port.rotation is None:
|
||||
raise DeviceError(f'Port {portspec} has no rotation and cannot be used for path_to()')
|
||||
|
||||
if not numpy.isclose(port.rotation % (pi / 2), 0):
|
||||
raise DeviceError('path_to was asked to route from non-manhattan port')
|
||||
|
||||
is_horizontal = numpy.isclose(port.rotation % pi, 0)
|
||||
if is_horizontal:
|
||||
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x):
|
||||
raise DeviceError(f'path_to routing to behind source port: x={x:g} to {position:g}')
|
||||
length = numpy.abs(position - x)
|
||||
else:
|
||||
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y):
|
||||
raise DeviceError(f'path_to routing to behind source port: y={y:g} to {position:g}')
|
||||
length = numpy.abs(position - y)
|
||||
|
||||
return self.path(portspec, ccw, length, tool_port_names=tool_port_names, **kwargs)
|
||||
|
||||
def busL(
|
||||
self: D,
|
||||
portspec: Union[str, Sequence[str]],
|
||||
ccw: Optional[bool],
|
||||
*,
|
||||
spacing: Optional[Union[float, ArrayLike]] = None,
|
||||
set_rotation: Optional[float] = None,
|
||||
tool_port_names: Sequence[str] = ('A', 'B'),
|
||||
container_name: str = '_busL',
|
||||
force_container: bool = False,
|
||||
**kwargs,
|
||||
) -> D:
|
||||
if self._dead:
|
||||
logger.error('Skipping busL() since device is dead')
|
||||
return self
|
||||
|
||||
bound_types = set()
|
||||
if 'bound_type' in kwargs:
|
||||
bound_types.add(kwargs['bound_type'])
|
||||
bound = kwargs['bound']
|
||||
for bt in ('emin', 'emax', 'pmin', 'pmax', 'min_past_furthest'):
|
||||
if bt in kwargs:
|
||||
bound_types.add(bt)
|
||||
bound = kwargs[bt]
|
||||
|
||||
if not bound_types:
|
||||
raise DeviceError('No bound type specified for busL')
|
||||
elif len(bound_types) > 1:
|
||||
raise DeviceError(f'Too many bound types specified for busL: {bound_types}')
|
||||
bound_type = tuple(bound_types)[0]
|
||||
|
||||
if isinstance(portspec, str):
|
||||
portspec = [portspec]
|
||||
ports = self[tuple(portspec)]
|
||||
|
||||
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
|
||||
|
||||
if len(ports) == 1 and not force_container:
|
||||
# Not a bus, so having a container just adds noise to the layout
|
||||
port_name = tuple(portspec)[0]
|
||||
return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names)
|
||||
else:
|
||||
dev = Device(name='', ports=ports, tools=self.tools).as_interface(container_name)
|
||||
for name, length in extensions.items():
|
||||
dev.path(name, ccw, length, tool_port_names=tool_port_names)
|
||||
return self.plug(dev, {sp: 'in_' + sp for sp in ports.keys()}) # TODO safe to use 'in_'?
|
||||
|
||||
# TODO def path_join() and def bus_join()?
|
||||
|
||||
|
||||
def rotate_offsets_around(
|
||||
offsets: NDArray[numpy.float64],
|
||||
pivot: NDArray[numpy.float64],
|
||||
angle: float,
|
||||
) -> NDArray[numpy.float64]:
|
||||
offsets -= pivot
|
||||
offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T
|
||||
offsets += pivot
|
||||
return offsets
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
"""Public routing failure diagnostics."""
|
||||
from typing import Any, Literal
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from pprint import pformat
|
||||
from types import MappingProxyType
|
||||
import traceback
|
||||
|
||||
from ..error import BuildError
|
||||
|
||||
|
||||
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn']
|
||||
|
||||
|
||||
class ToolContractError(BuildError):
|
||||
"""A Tool returned data inconsistent with its routing contract."""
|
||||
|
||||
|
||||
class RouteFailurePolicy(Enum):
|
||||
"""Whether route failure may be recovered through alternate/dead planning.
|
||||
|
||||
`RECOVERABLE` means a caller-controlled fallback may try another planning
|
||||
branch. `FATAL` marks an invalid request or broken planning contract that
|
||||
must be reported directly.
|
||||
"""
|
||||
|
||||
RECOVERABLE = auto()
|
||||
FATAL = auto()
|
||||
|
||||
|
||||
class MinimumStatus(Enum):
|
||||
"""Outcome of preferred-minimum-length diagnosis.
|
||||
|
||||
`NOT_EVALUATED` is used when diagnosis is inapplicable, notably for an
|
||||
invalid resolved length. `FOUND` carries `minimum_length`; `NO_ROUTE` means
|
||||
exhaustive planning found no legal unconstrained route; `FAILED` means the
|
||||
secondary diagnostic calculation itself raised a recoverable error.
|
||||
"""
|
||||
|
||||
NOT_EVALUATED = auto()
|
||||
FOUND = auto()
|
||||
NO_ROUTE = auto()
|
||||
FAILED = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteFailureDetails:
|
||||
"""Structured context for a failed Pather routing request."""
|
||||
|
||||
operation: RouteOperation
|
||||
portspec: str
|
||||
in_ptype: str | None
|
||||
out_ptype: str | None
|
||||
request: Mapping[str, Any]
|
||||
resolved_length: float | None
|
||||
resolved_jog: float | None
|
||||
minimum_length: float | None
|
||||
minimum_status: MinimumStatus
|
||||
cause: str
|
||||
minimum_cause: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.minimum_status is MinimumStatus.FOUND and self.minimum_length is None:
|
||||
raise BuildError('MinimumStatus.FOUND requires minimum_length')
|
||||
if self.minimum_status is not MinimumStatus.FOUND and self.minimum_length is not None:
|
||||
raise BuildError(f'{self.minimum_status} requires minimum_length=None')
|
||||
object.__setattr__(self, 'request', MappingProxyType(dict(self.request)))
|
||||
|
||||
|
||||
class RouteError(BuildError):
|
||||
"""A route-selection failure with structured request and saved call-stack diagnostics."""
|
||||
|
||||
details: RouteFailureDetails
|
||||
policy: RouteFailurePolicy
|
||||
_call_stack: tuple[traceback.FrameSummary, ...]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
details: RouteFailureDetails,
|
||||
*,
|
||||
policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE,
|
||||
) -> None:
|
||||
self.details = details
|
||||
self.policy = policy
|
||||
if details.minimum_status is MinimumStatus.NOT_EVALUATED:
|
||||
minimum = 'not evaluated'
|
||||
elif details.minimum_status is MinimumStatus.FOUND:
|
||||
assert details.minimum_length is not None
|
||||
minimum = f'{details.minimum_length:g}'
|
||||
elif details.minimum_status is MinimumStatus.NO_ROUTE:
|
||||
minimum = 'unavailable (no legal route exists at any length)'
|
||||
else:
|
||||
minimum = 'unavailable (minimum-length calculation failed)'
|
||||
|
||||
lines = [
|
||||
f'Unable to plan {details.operation} route for port {details.portspec!r}:',
|
||||
f' in_ptype: {details.in_ptype!r}',
|
||||
f' out_ptype: {details.out_ptype!r}',
|
||||
f' request: {pformat(dict(details.request), compact=True)}',
|
||||
f' resolved_length: {details.resolved_length!r}',
|
||||
f' resolved_jog: {details.resolved_jog!r}',
|
||||
f' preferred_minimum_length: {minimum}',
|
||||
f' cause: {details.cause}',
|
||||
]
|
||||
if details.minimum_cause is not None:
|
||||
lines.append(f' minimum_failure: {details.minimum_cause}')
|
||||
self._call_stack = tuple(traceback.extract_stack()[:-1])
|
||||
super().__init__('\n'.join(lines))
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""Logging helpers for Pather."""
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Iterator, Sequence
|
||||
import logging
|
||||
import numpy
|
||||
from contextlib import contextmanager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .pather import Pather
|
||||
|
||||
|
||||
def _format_log_args(**kwargs) -> str:
|
||||
arg_strs = []
|
||||
for k, v in kwargs.items():
|
||||
if isinstance(v, str | int | float | bool | None):
|
||||
arg_strs.append(f"{k}={v}")
|
||||
elif isinstance(v, numpy.ndarray):
|
||||
arg_strs.append(f"{k}={v.tolist()}")
|
||||
elif isinstance(v, list | tuple) and len(v) <= 10:
|
||||
arg_strs.append(f"{k}={v}")
|
||||
else:
|
||||
arg_strs.append(f"{k}=...")
|
||||
return ", ".join(arg_strs)
|
||||
|
||||
|
||||
class PatherLogger:
|
||||
"""
|
||||
Encapsulates state for Pather diagnostic logging.
|
||||
"""
|
||||
debug: bool
|
||||
indent: int
|
||||
depth: int
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
self.debug = debug
|
||||
self.indent = 0
|
||||
self.depth = 0
|
||||
|
||||
def _log(self, module_name: str, msg: str) -> None:
|
||||
if self.debug and self.depth <= 1:
|
||||
log_obj = logging.getLogger(module_name)
|
||||
log_obj.info(' ' * self.indent + msg)
|
||||
|
||||
@contextmanager
|
||||
def log_operation(
|
||||
self,
|
||||
pather: 'Pather',
|
||||
op: str,
|
||||
portspec: str | Sequence[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[None]:
|
||||
if not self.debug or self.depth > 0:
|
||||
self.depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.depth -= 1
|
||||
return
|
||||
|
||||
target = f"({portspec})" if portspec else ""
|
||||
module_name = pather.__class__.__module__
|
||||
self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}")
|
||||
|
||||
before_ports = {name: port.copy() for name, port in pather.ports.items()}
|
||||
self.depth += 1
|
||||
self.indent += 1
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
after_ports = pather.ports
|
||||
for name in sorted(after_ports.keys()):
|
||||
if name not in before_ports or after_ports[name] != before_ports[name]:
|
||||
self._log(module_name, f"Port {name}: {pather.ports[name].describe()}")
|
||||
for name in sorted(before_ports.keys()):
|
||||
if name not in after_ports:
|
||||
self._log(module_name, f"Port {name}: removed")
|
||||
|
||||
self.indent -= 1
|
||||
self.depth -= 1
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
|||
"""
|
||||
Simplified primitive-offer route planner used by `Pather`.
|
||||
|
||||
This package is the Pather-facing route-selection implementation. It keeps
|
||||
the public Tool contract narrow: offers are evaluated during planning, and
|
||||
offer commits are deferred until after a complete route is selected.
|
||||
"""
|
||||
from .interface import (
|
||||
PreparedRouteAction as PreparedRouteAction,
|
||||
PreparedRouteResult as PreparedRouteResult,
|
||||
RoutePlanningError as RoutePlanningError,
|
||||
RoutePortContext as RoutePortContext,
|
||||
route_failure_policy as route_failure_policy,
|
||||
)
|
||||
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
|
||||
from .planner import TraceIntoBendPolicy as TraceIntoBendPolicy
|
||||
from .planner import RoutingPlanner as RoutingPlanner
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
"""
|
||||
Argument validation and bound resolution for Pather routing calls.
|
||||
|
||||
This module keeps user-facing mode validation outside the solver. It converts
|
||||
single-port positional bounds into local travel lengths and derives multi-port
|
||||
S/U bundle specs before primitive offers are considered.
|
||||
|
||||
The solver expects one coherent route intent at a time. This module enforces
|
||||
that public routing modes are not mixed: explicit length, per-port `each`,
|
||||
positional bounds, and bundle bounds are mutually constrained before any Tool
|
||||
offers are queried. Multi-port S/U bundles are also normalized here into exact
|
||||
per-port public lengths and offsets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: TC001,TC002,TC003
|
||||
from typing import Any
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ...error import BuildError, PortError
|
||||
from ...ports import Port
|
||||
from ...utils import rotation_matrix_2d
|
||||
from .._tolerances import manhattan_axis
|
||||
from .interface import RoutePortContext
|
||||
|
||||
|
||||
POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position')
|
||||
BUNDLE_BOUND_KEYS: tuple[str, ...] = (
|
||||
'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest',
|
||||
)
|
||||
|
||||
|
||||
def finite_scalar(value: Any, name: str, *, nonnegative: bool = False) -> float:
|
||||
"""Return a finite scalar, preserving duck-typed numeric inputs."""
|
||||
try:
|
||||
array = numpy.asarray(value, dtype=float)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise BuildError(f'{name} must be a finite numeric scalar') from err
|
||||
if array.size != 1:
|
||||
raise BuildError(f'{name} must be a scalar; got {array.size} values')
|
||||
result = float(array.reshape(-1)[0])
|
||||
if not numpy.isfinite(result):
|
||||
raise BuildError(f'{name} must be finite')
|
||||
if nonnegative and result < 0:
|
||||
raise BuildError(f'{name} must be nonnegative')
|
||||
return result
|
||||
|
||||
|
||||
def resolved_position_bound(
|
||||
port: Port,
|
||||
bounds: Mapping[str, Any],
|
||||
*,
|
||||
allow_length: bool,
|
||||
) -> tuple[str, Any, float] | None:
|
||||
"""Resolve a single positional bound for a single port into a travel length."""
|
||||
present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None]
|
||||
if not present:
|
||||
return None
|
||||
if len(present) > 1:
|
||||
keys = ', '.join(key for key, _value in present)
|
||||
raise BuildError(f'Provide exactly one positional bound; got {keys}')
|
||||
if not allow_length and bounds.get('length') is not None:
|
||||
raise BuildError('length cannot be combined with a positional bound')
|
||||
|
||||
key, raw_value = present[0]
|
||||
value = finite_scalar(raw_value, f'{key} positional bound')
|
||||
if port.rotation is None:
|
||||
raise BuildError('Ports must have rotation')
|
||||
axis = manhattan_axis(port.rotation)
|
||||
if axis is None:
|
||||
raise BuildError(
|
||||
'Positional bounds require a nearly Manhattan port direction; '
|
||||
f'got rotation {port.rotation:g}'
|
||||
)
|
||||
if axis == 0:
|
||||
if key == 'y':
|
||||
raise BuildError('Port is horizontal')
|
||||
target = Port((value, port.offset[1]), rotation=None)
|
||||
else:
|
||||
if key == 'x':
|
||||
raise BuildError('Port is vertical')
|
||||
target = Port((port.offset[0], value), rotation=None)
|
||||
(travel, _jog), _ = port.measure_travel(target)
|
||||
return key, value, -float(travel)
|
||||
|
||||
|
||||
def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]:
|
||||
"""Return keys whose bound value is explicitly present and non-None."""
|
||||
return [key for key in keys if bounds.get(key) is not None]
|
||||
|
||||
|
||||
def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]:
|
||||
"""Return active multi-port trace bound keys."""
|
||||
return present_keys(bounds, BUNDLE_BOUND_KEYS)
|
||||
|
||||
|
||||
def validate_trace_args(
|
||||
portspec: Sequence[str],
|
||||
*,
|
||||
length: float | None,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Validate mutually-exclusive `trace()` routing modes.
|
||||
|
||||
A trace request is either an explicit single-port length, an `each` length
|
||||
for all ports, a single-port omitted-length solve, or a bundle solve with
|
||||
exactly one bundle bound.
|
||||
"""
|
||||
bundle_bounds = present_bundle_bounds(bounds)
|
||||
if len(bundle_bounds) > 1:
|
||||
args = ', '.join(bundle_bounds)
|
||||
raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}')
|
||||
|
||||
invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds
|
||||
invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds
|
||||
|
||||
if length is not None:
|
||||
if len(portspec) > 1:
|
||||
raise BuildError('length only allowed with a single port')
|
||||
if spacing is not None:
|
||||
invalid_with_length.append('spacing')
|
||||
if invalid_with_length:
|
||||
args = ', '.join(invalid_with_length)
|
||||
raise BuildError(f'length cannot be combined with other routing bounds: {args}')
|
||||
return
|
||||
|
||||
if bounds.get('each') is not None:
|
||||
if spacing is not None:
|
||||
invalid_with_each.append('spacing')
|
||||
if invalid_with_each:
|
||||
args = ', '.join(invalid_with_each)
|
||||
raise BuildError(f'each cannot be combined with other routing bounds: {args}')
|
||||
return
|
||||
|
||||
if not bundle_bounds and len(portspec) == 1:
|
||||
if spacing is not None:
|
||||
raise BuildError('spacing cannot be combined with omitted-length single-port trace()')
|
||||
invalid = present_keys(bounds, ('set_rotation',))
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}')
|
||||
return
|
||||
|
||||
if not bundle_bounds:
|
||||
raise BuildError('No bound type specified for trace()')
|
||||
|
||||
|
||||
def validate_trace_to_positional_args(
|
||||
*,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Reject bound combinations that cannot be mixed with a single positional `trace_to()` target."""
|
||||
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
|
||||
if spacing is not None:
|
||||
invalid.append('spacing')
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}')
|
||||
|
||||
|
||||
def validate_jog_args(
|
||||
portspec: Sequence[str],
|
||||
*,
|
||||
length: float | None,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Validate `jog()` mode constraints before S-route planning.
|
||||
|
||||
Single-port jogs may derive length from a positional bound. Multi-port jogs
|
||||
require spacing and cannot combine omitted length with positional bounds.
|
||||
"""
|
||||
invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds)
|
||||
if len(portspec) == 1 and spacing is not None:
|
||||
invalid.append('spacing')
|
||||
if len(portspec) > 1 and length is None:
|
||||
invalid += present_keys(bounds, POSITION_KEYS)
|
||||
if length is not None:
|
||||
invalid = present_keys(bounds, POSITION_KEYS) + invalid
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}')
|
||||
return
|
||||
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Unsupported routing bounds for jog(): {args}')
|
||||
|
||||
|
||||
def validate_uturn_args(
|
||||
portspec: Sequence[str],
|
||||
*,
|
||||
spacing: float | ArrayLike | None,
|
||||
bounds: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Validate `uturn()` arguments, which do not support positional or bundle-bound keywords."""
|
||||
invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds)
|
||||
if len(portspec) == 1 and spacing is not None:
|
||||
invalid.append('spacing')
|
||||
if invalid:
|
||||
args = ', '.join(invalid)
|
||||
raise BuildError(f'Unsupported routing bounds for uturn(): {args}')
|
||||
|
||||
|
||||
def su_bundle_specs(
|
||||
contexts: Sequence[RoutePortContext],
|
||||
offset: float,
|
||||
length: float,
|
||||
spacing: float | ArrayLike | None,
|
||||
*,
|
||||
route_name: str,
|
||||
) -> tuple[tuple[str, float, float], ...]:
|
||||
"""
|
||||
Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs.
|
||||
|
||||
Ports are ordered from the inside of the first bend outward. The first spec
|
||||
receives the requested base route; later specs add cumulative spacing to
|
||||
both route length and lateral offset so the bundle keeps the requested
|
||||
separation.
|
||||
"""
|
||||
if spacing is None:
|
||||
raise BuildError(f'Must provide spacing for multi-port {route_name}()')
|
||||
finite_scalar(offset, 'offset')
|
||||
finite_scalar(length, 'length', nonnegative=True)
|
||||
|
||||
ports = {context.portspec: context.port for context in contexts}
|
||||
has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool)
|
||||
if not has_rotation.all():
|
||||
raise PortError(f'Ports must have rotation for multi-port {route_name}()')
|
||||
|
||||
rotations = numpy.array([port.rotation for port in ports.values()], dtype=float)
|
||||
if not numpy.allclose(rotations[0], rotations):
|
||||
port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()}
|
||||
raise BuildError(
|
||||
f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n'
|
||||
+ pformat(port_rotations)
|
||||
)
|
||||
|
||||
direction = rotations[0] + pi
|
||||
rot_matrix = rotation_matrix_2d(-direction)
|
||||
orig_offsets = numpy.array([port.offset for port in ports.values()])
|
||||
rot_offsets = (rot_matrix @ orig_offsets.T).T
|
||||
|
||||
first_ccw = bool(offset > 0)
|
||||
y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
|
||||
|
||||
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
|
||||
if numpy.any(spacing_arr < 0):
|
||||
raise BuildError('spacing must be nonnegative')
|
||||
steps: NDArray[numpy.float64] = numpy.zeros(len(ports), dtype=float)
|
||||
if spacing_arr.size == 1:
|
||||
steps[1:] = spacing_arr[0]
|
||||
elif spacing_arr.size == len(ports) - 1:
|
||||
steps[1:] = spacing_arr
|
||||
else:
|
||||
raise BuildError(
|
||||
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
|
||||
f'got length {spacing_arr.size}'
|
||||
)
|
||||
if not numpy.all(numpy.isfinite(steps)):
|
||||
raise BuildError('spacing must contain only finite values')
|
||||
|
||||
names = tuple(ports.keys())
|
||||
ordered_spacings = numpy.cumsum(steps)
|
||||
anchor_y = float(rot_offsets[y_order[0], 1])
|
||||
specs: list[tuple[str, float, float]] = []
|
||||
for order_index, port_index in enumerate(y_order):
|
||||
spacing_offset = float(ordered_spacings[order_index])
|
||||
start_y = float(rot_offsets[port_index, 1])
|
||||
specs.append((
|
||||
names[port_index],
|
||||
float(length) + spacing_offset,
|
||||
float(offset) - start_y + anchor_y + spacing_offset,
|
||||
))
|
||||
return tuple(specs)
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
"""
|
||||
Planner/Pather exchange types.
|
||||
|
||||
`Pather` snapshots live routing state into these records before calling the
|
||||
planner. The planner returns prepared actions that `Pather` can apply without
|
||||
needing to know solver internals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: TC001
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...error import BuildError
|
||||
from ...ports import Port
|
||||
from ..tools import RenderStep, Tool
|
||||
from ..error import RouteError, RouteFailurePolicy, ToolContractError
|
||||
|
||||
|
||||
class RoutePlanningError(BuildError):
|
||||
"""Route-planning error with fallback policy metadata."""
|
||||
|
||||
policy: RouteFailurePolicy
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: object,
|
||||
policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE,
|
||||
) -> None:
|
||||
super().__init__(*args)
|
||||
self.policy = policy
|
||||
|
||||
|
||||
def route_failure_policy(err: Exception) -> RouteFailurePolicy:
|
||||
"""Return typed route recovery policy, defaulting generic errors to recoverable."""
|
||||
if isinstance(err, ToolContractError):
|
||||
return RouteFailurePolicy.FATAL
|
||||
if isinstance(err, RoutePlanningError):
|
||||
return err.policy
|
||||
if isinstance(err, RouteError):
|
||||
return err.policy
|
||||
return RouteFailurePolicy.RECOVERABLE
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutePortContext:
|
||||
"""
|
||||
Immutable planning view of one live Pather port.
|
||||
|
||||
`port` is a copy of the live port so failed route selection leaves Pather
|
||||
state unchanged. `tool` is the already-resolved routing Tool for this
|
||||
portspec.
|
||||
"""
|
||||
portspec: str
|
||||
"""Live Pather port name being planned."""
|
||||
port: Port
|
||||
"""Copied live port used as immutable route input."""
|
||||
tool: Tool
|
||||
"""Resolved Tool for this port."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedRouteAction:
|
||||
"""
|
||||
Prepared mutation for one routed Pather port.
|
||||
|
||||
Pure selection has already completed, and the planner has materialized the
|
||||
selected primitive offers into `render_steps` and computed the final live
|
||||
port. `plug_into`, when set, names the destination port to consume after the
|
||||
route endpoint is applied.
|
||||
"""
|
||||
portspec: str
|
||||
"""Live Pather port name to update."""
|
||||
render_steps: tuple[RenderStep, ...]
|
||||
"""Committed route steps to append to Pather's pending render queue."""
|
||||
final_port: Port
|
||||
"""Final live port value after all route steps."""
|
||||
plug_into: str | None = None
|
||||
"""Optional destination port to consume after the final port is applied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedRouteResult:
|
||||
"""
|
||||
Complete prepared result for one Pather routing operation.
|
||||
|
||||
`actions` contain materialized, committed render data and are applied first.
|
||||
`renames` are deferred until after all route actions so trace-into/thru
|
||||
behavior can be represented without exposing the solver's selected
|
||||
primitive sequence to Pather.
|
||||
"""
|
||||
actions: tuple[PreparedRouteAction, ...]
|
||||
"""Prepared per-port route mutations."""
|
||||
renames: tuple[tuple[str, str], ...] = ()
|
||||
"""Deferred `(old_name, new_name)` port renames applied after actions."""
|
||||
File diff suppressed because it is too large
Load diff
112
masque/builder/port_utils.py
Normal file
112
masque/builder/port_utils.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Functions for writing port data into a Pattern (`dev2pat`) and retrieving it (`pat2dev`).
|
||||
|
||||
These use the format 'name:ptype angle_deg' written into labels, which are placed at
|
||||
the port locations. This particular approach is just a sensible default; feel free to
|
||||
to write equivalent functions for your own format or alternate storage methods.
|
||||
"""
|
||||
from typing import Sequence
|
||||
import logging
|
||||
|
||||
import numpy
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..label import Label
|
||||
from ..utils import rotation_matrix_2d, layer_t
|
||||
from .devices import Device, Port
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dev2pat(device: Device, layer: layer_t) -> Pattern:
|
||||
"""
|
||||
Place a text label at each port location, specifying the port data in the format
|
||||
'name:ptype angle_deg'
|
||||
|
||||
This can be used to debug port locations or to automatically generate ports
|
||||
when reading in a GDS file.
|
||||
|
||||
NOTE that `device` is modified by this function, and `device.pattern` is returned.
|
||||
|
||||
Args:
|
||||
device: The device which is to have its ports labeled. MODIFIED in-place.
|
||||
layer: The layer on which the labels will be placed.
|
||||
|
||||
Returns:
|
||||
`device.pattern`
|
||||
"""
|
||||
for name, port in device.ports.items():
|
||||
if port.rotation is None:
|
||||
angle_deg = numpy.inf
|
||||
else:
|
||||
angle_deg = numpy.rad2deg(port.rotation)
|
||||
device.pattern.labels += [
|
||||
Label(string=f'{name}:{port.ptype} {angle_deg:g}', layer=layer, offset=port.offset)
|
||||
]
|
||||
return device.pattern
|
||||
|
||||
|
||||
def pat2dev(
|
||||
pattern: Pattern,
|
||||
layers: Sequence[layer_t],
|
||||
max_depth: int = 999_999,
|
||||
skip_subcells: bool = True,
|
||||
) -> Device:
|
||||
"""
|
||||
Examine `pattern` for labels specifying port info, and use that info
|
||||
to build a `Device` object.
|
||||
|
||||
Labels are assumed to be placed at the port locations, and have the format
|
||||
'name:ptype angle_deg'
|
||||
|
||||
Args:
|
||||
pattern: Pattern object to scan for labels.
|
||||
layers: Search for labels on all the given layers.
|
||||
max_depth: Maximum hierarcy depth to search. Default 999_999.
|
||||
Reduce this to 0 to avoid ever searching subcells.
|
||||
skip_subcells: If port labels are found at a given hierarcy level,
|
||||
do not continue searching at deeper levels. This allows subcells
|
||||
to contain their own port info (and thus become their own Devices).
|
||||
Default True.
|
||||
|
||||
Returns:
|
||||
The constructed Device object. Port labels are not removed from the pattern.
|
||||
"""
|
||||
ports = {} # Note: could do a list here, if they're not unique
|
||||
annotated_cells = set()
|
||||
def find_ports_each(pat, hierarchy, transform, memo) -> Pattern:
|
||||
if len(hierarchy) > max_depth - 1:
|
||||
return pat
|
||||
|
||||
if skip_subcells and any(parent in annotated_cells for parent in hierarchy):
|
||||
return pat
|
||||
|
||||
labels = [ll for ll in pat.labels if ll.layer in layers]
|
||||
|
||||
if len(labels) == 0:
|
||||
return pat
|
||||
|
||||
if skip_subcells:
|
||||
annotated_cells.add(pat)
|
||||
|
||||
mirr_factor = numpy.array((1, -1)) ** transform[3]
|
||||
rot_matrix = rotation_matrix_2d(transform[2])
|
||||
for label in labels:
|
||||
name, property_string = label.string.split(':')
|
||||
properties = property_string.split(' ')
|
||||
ptype = properties[0]
|
||||
angle_deg = float(properties[1]) if len(ptype) else 0
|
||||
|
||||
xy_global = transform[:2] + rot_matrix @ (label.offset * mirr_factor)
|
||||
angle = numpy.deg2rad(angle_deg) * mirr_factor[0] * mirr_factor[1] + transform[2]
|
||||
|
||||
if name in ports:
|
||||
logger.info(f'Duplicate port {name} in pattern {pattern.name}')
|
||||
|
||||
ports[name] = Port(offset=xy_global, rotation=angle, ptype=ptype)
|
||||
|
||||
return pat
|
||||
|
||||
pattern.dfs(visit_before=find_ports_each, transform=True)
|
||||
return Device(pattern, ports)
|
||||
|
|
@ -1,368 +0,0 @@
|
|||
"""Pytest-independent contract checks for custom routing Tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from math import isfinite
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
||||
from ..library import ILibrary, SINGLE_USE_PREFIX
|
||||
from ..ports import Port
|
||||
from ..utils import ptypes_compatible
|
||||
from ._tolerances import angles_equal, array_close, scalar_close
|
||||
from .error import ToolContractError
|
||||
from .tools import (
|
||||
BendOffer, PrimitiveKind, PrimitiveOffer, RenderStep, SOffer, StraightOffer, Tool, UOffer,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
|
||||
_RESERVED_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolContractCase:
|
||||
"""One primitive-discovery query to exercise against a custom Tool."""
|
||||
|
||||
kind: PrimitiveKind
|
||||
in_ptype: str | None = None
|
||||
out_ptype: str | None = None
|
||||
ccw: bool | None = None
|
||||
tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
|
||||
probe_parameters: tuple[float, ...] = ()
|
||||
require_offers: bool = True
|
||||
check_bbox: bool = False
|
||||
label: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind not in ('straight', 'bend', 's', 'u'):
|
||||
raise ValueError(f'Unrecognized primitive kind {self.kind!r}')
|
||||
if self.kind == 'bend':
|
||||
if self.ccw is None:
|
||||
raise ValueError('Bend ToolContractCase requires ccw')
|
||||
elif self.ccw is not None:
|
||||
raise ValueError('ccw is only valid for bend ToolContractCase')
|
||||
|
||||
try:
|
||||
options = deepcopy(dict(self.tool_options))
|
||||
except Exception as err:
|
||||
raise ValueError('ToolContractCase.tool_options must be a deep-copyable mapping') from err
|
||||
nonstring = [key for key in options if not isinstance(key, str)]
|
||||
if nonstring:
|
||||
raise ValueError(f'ToolContractCase.tool_options keys must be strings; got {nonstring!r}')
|
||||
collisions = sorted(_RESERVED_OPTION_KEYS & options.keys())
|
||||
if collisions:
|
||||
raise ValueError(f'ToolContractCase.tool_options contains reserved keys: {", ".join(collisions)}')
|
||||
|
||||
try:
|
||||
probes = tuple(float(value) for value in self.probe_parameters)
|
||||
except (TypeError, ValueError, OverflowError) as err:
|
||||
raise ValueError('ToolContractCase.probe_parameters must contain numeric scalars') from err
|
||||
if not all(isfinite(value) for value in probes):
|
||||
raise ValueError('ToolContractCase.probe_parameters must be finite')
|
||||
|
||||
object.__setattr__(self, 'ccw', None if self.ccw is None else bool(self.ccw))
|
||||
object.__setattr__(self, 'tool_options', MappingProxyType(options))
|
||||
object.__setattr__(self, 'probe_parameters', probes)
|
||||
|
||||
|
||||
def _automatic_probes(offer: PrimitiveOffer) -> tuple[float, ...]:
|
||||
"""Choose deterministic representative parameters inside one offer domain."""
|
||||
lower, upper = (float(value) for value in offer.parameter_domain)
|
||||
if lower == upper:
|
||||
return (lower,)
|
||||
if numpy.isfinite(lower) and numpy.isfinite(upper):
|
||||
midpoint = lower / 2 + upper / 2
|
||||
if midpoint == upper:
|
||||
midpoint = float(numpy.nextafter(upper, lower))
|
||||
return (lower, midpoint)
|
||||
if numpy.isfinite(lower):
|
||||
step = max(1.0, abs(lower) * 0.1)
|
||||
return (lower, lower + step)
|
||||
if numpy.isfinite(upper):
|
||||
step = max(1.0, abs(upper) * 0.1)
|
||||
return (upper - step, upper - 2 * step)
|
||||
return (-1.0, 1.0)
|
||||
|
||||
|
||||
def _offer_probes(offer: PrimitiveOffer, extras: Sequence[float]) -> tuple[float, ...]:
|
||||
"""Combine automatic and applicable explicit probes without duplicates."""
|
||||
probes: list[float] = []
|
||||
for parameter in (*_automatic_probes(offer), *extras):
|
||||
try:
|
||||
selected = offer.canonicalize_parameter(parameter)
|
||||
except Exception:
|
||||
continue
|
||||
if not any(scalar_close(selected, previous) for previous in probes):
|
||||
probes.append(selected)
|
||||
return tuple(probes)
|
||||
|
||||
|
||||
def _offer_metadata(offer: PrimitiveOffer) -> tuple[Any, ...]:
|
||||
"""Return discovery metadata that must remain stable across repeated queries."""
|
||||
cost_policy: tuple[str, Any]
|
||||
if callable(offer.cost):
|
||||
cost_policy = ('callable', type(offer.cost).__qualname__)
|
||||
else:
|
||||
cost_policy = ('factor', float(offer.cost))
|
||||
return (
|
||||
type(offer),
|
||||
offer.kind,
|
||||
offer.in_ptype,
|
||||
offer.out_ptype,
|
||||
tuple(float(value) for value in offer.parameter_domain),
|
||||
getattr(offer, 'ccw', None),
|
||||
cost_policy,
|
||||
)
|
||||
|
||||
|
||||
def _evaluated_cost(offer: PrimitiveOffer, parameter: float, endpoint: Port) -> float:
|
||||
"""Mirror the solver's one-endpoint base-cost path while honoring overrides."""
|
||||
if type(offer).cost_at is PrimitiveOffer.cost_at:
|
||||
return PrimitiveOffer._cost_for_endpoint(offer, parameter, endpoint)
|
||||
return float(offer.cost_at(parameter))
|
||||
|
||||
|
||||
def validate_tool_contract(tool: Tool, cases: Sequence[ToolContractCase]) -> None:
|
||||
"""Validate Tool discovery, offer callbacks, and one-step rendering.
|
||||
|
||||
All independent violations are collected and raised as one
|
||||
`ExceptionGroup` containing contextual `ToolContractError` instances.
|
||||
"""
|
||||
cases = tuple(cases)
|
||||
if not cases:
|
||||
raise ValueError('validate_tool_contract() requires at least one case')
|
||||
|
||||
errors: list[ToolContractError] = []
|
||||
|
||||
def violation(context: str, message: str, cause: Exception | None = None) -> None:
|
||||
err = ToolContractError(f'{context}: {message}')
|
||||
if cause is not None:
|
||||
err.__cause__ = cause
|
||||
errors.append(err)
|
||||
|
||||
def discover(case: ToolContractCase, context: str, repetition: str) -> tuple[PrimitiveOffer, ...] | None:
|
||||
expected_offer_type = {
|
||||
'straight': StraightOffer,
|
||||
'bend': BendOffer,
|
||||
's': SOffer,
|
||||
'u': UOffer,
|
||||
}[case.kind]
|
||||
try:
|
||||
kwargs = deepcopy(dict(case.tool_options))
|
||||
if case.kind == 'bend':
|
||||
kwargs['ccw'] = case.ccw
|
||||
offers = tool.primitive_offers(
|
||||
case.kind,
|
||||
in_ptype=case.in_ptype,
|
||||
out_ptype=case.out_ptype,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as err:
|
||||
violation(context, f'{repetition} discovery raised {type(err).__name__}: {err}', err)
|
||||
return None
|
||||
|
||||
if not isinstance(offers, tuple):
|
||||
violation(context, f'{repetition} discovery returned {type(offers).__name__}, expected tuple')
|
||||
return None
|
||||
valid = True
|
||||
for offer_index, offer in enumerate(offers):
|
||||
if not isinstance(offer, PrimitiveOffer):
|
||||
violation(
|
||||
context,
|
||||
f'{repetition} discovery item {offer_index} is {type(offer).__name__}, expected PrimitiveOffer',
|
||||
)
|
||||
valid = False
|
||||
elif offer.kind != case.kind:
|
||||
violation(
|
||||
context,
|
||||
f'{repetition} discovery item {offer_index} has kind {offer.kind!r}, expected {case.kind!r}',
|
||||
)
|
||||
valid = False
|
||||
elif not isinstance(offer, expected_offer_type):
|
||||
violation(
|
||||
context,
|
||||
f'{repetition} discovery item {offer_index} is {type(offer).__name__}, '
|
||||
f'expected {expected_offer_type.__name__}',
|
||||
)
|
||||
valid = False
|
||||
return offers if valid else None
|
||||
|
||||
for case_index, case in enumerate(cases):
|
||||
context = case.label or f'case {case_index} ({case.kind})'
|
||||
first = discover(case, context, 'first')
|
||||
second = discover(case, context, 'repeated')
|
||||
if first is None or second is None:
|
||||
continue
|
||||
if case.require_offers and not first:
|
||||
violation(context, 'discovery returned no offers')
|
||||
if len(first) != len(second):
|
||||
violation(context, f'discovery count changed from {len(first)} to {len(second)}')
|
||||
|
||||
matched_explicit = [False] * len(case.probe_parameters)
|
||||
for offer_index, offer in enumerate(first):
|
||||
offer_context = f'{context}, offer {offer_index}'
|
||||
repeated = second[offer_index] if offer_index < len(second) else None
|
||||
if repeated is not None and _offer_metadata(offer) != _offer_metadata(repeated):
|
||||
violation(offer_context, 'discovery metadata changed between repeated queries')
|
||||
|
||||
probes = _offer_probes(offer, case.probe_parameters)
|
||||
for explicit_index, parameter in enumerate(case.probe_parameters):
|
||||
try:
|
||||
offer.canonicalize_parameter(parameter)
|
||||
except Exception:
|
||||
continue
|
||||
matched_explicit[explicit_index] = True
|
||||
|
||||
stable_ptype: str | None = None
|
||||
stable_rotation: float | None = None
|
||||
has_stable_endpoint = False
|
||||
for parameter in probes:
|
||||
probe_context = f'{offer_context}, parameter {parameter:g}'
|
||||
try:
|
||||
endpoint = offer.endpoint_at(parameter)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'endpoint_at() raised {type(err).__name__}: {err}', err)
|
||||
continue
|
||||
if not isinstance(endpoint, Port):
|
||||
violation(probe_context, f'endpoint_at() returned {type(endpoint).__name__}, expected Port')
|
||||
continue
|
||||
if not numpy.all(numpy.isfinite(endpoint.offset)):
|
||||
violation(probe_context, 'endpoint offset must be finite')
|
||||
if endpoint.rotation is None or not numpy.isfinite(endpoint.rotation):
|
||||
violation(probe_context, 'endpoint rotation must be finite and specified')
|
||||
if not ptypes_compatible(endpoint.ptype, offer.out_ptype):
|
||||
violation(probe_context, 'endpoint ptype does not match declared out_ptype')
|
||||
|
||||
if offer.kind in ('straight', 'bend') and not scalar_close(endpoint.x, parameter):
|
||||
violation(probe_context, 'straight/bend endpoint x must equal its parameter')
|
||||
if offer.kind in ('s', 'u') and not scalar_close(endpoint.y, parameter):
|
||||
violation(probe_context, 'S/U endpoint y must equal its parameter')
|
||||
expected_rotation = {
|
||||
'straight': pi,
|
||||
'bend': -pi / 2 if isinstance(offer, BendOffer) and offer.ccw else pi / 2,
|
||||
's': pi,
|
||||
'u': 0.0,
|
||||
}[offer.kind]
|
||||
if endpoint.rotation is not None and not angles_equal(endpoint.rotation, expected_rotation):
|
||||
violation(
|
||||
probe_context,
|
||||
f'endpoint rotation does not match {offer.kind!r} geometry',
|
||||
)
|
||||
|
||||
if has_stable_endpoint:
|
||||
if endpoint.ptype != stable_ptype:
|
||||
violation(probe_context, 'endpoint ptype changes across the offer domain')
|
||||
if (
|
||||
endpoint.rotation is None
|
||||
or stable_rotation is None
|
||||
or not angles_equal(endpoint.rotation, stable_rotation)
|
||||
):
|
||||
violation(probe_context, 'endpoint rotation changes across the offer domain')
|
||||
else:
|
||||
stable_ptype = endpoint.ptype
|
||||
stable_rotation = endpoint.rotation
|
||||
has_stable_endpoint = True
|
||||
|
||||
try:
|
||||
cost = float(_evaluated_cost(offer, parameter, endpoint))
|
||||
if not numpy.isfinite(cost) or cost < 0:
|
||||
violation(probe_context, f'cost must be finite and nonnegative, got {cost!r}')
|
||||
except Exception as err:
|
||||
violation(probe_context, f'cost_at() raised {type(err).__name__}: {err}', err)
|
||||
cost = None
|
||||
|
||||
if repeated is not None:
|
||||
try:
|
||||
repeated_endpoint = repeated.endpoint_at(parameter)
|
||||
repeated_cost = float(_evaluated_cost(repeated, parameter, repeated_endpoint))
|
||||
if (
|
||||
not isinstance(repeated_endpoint, Port)
|
||||
or not array_close(repeated_endpoint.offset, endpoint.offset)
|
||||
or repeated_endpoint.ptype != endpoint.ptype
|
||||
or repeated_endpoint.rotation is None
|
||||
or endpoint.rotation is None
|
||||
or not angles_equal(repeated_endpoint.rotation, endpoint.rotation)
|
||||
):
|
||||
violation(probe_context, 'endpoint result changed after repeated discovery')
|
||||
if cost is not None and not scalar_close(repeated_cost, cost):
|
||||
violation(probe_context, 'cost result changed after repeated discovery')
|
||||
except Exception as err:
|
||||
violation(
|
||||
probe_context,
|
||||
f'repeated offer evaluation raised {type(err).__name__}: {err}',
|
||||
err,
|
||||
)
|
||||
|
||||
if case.check_bbox:
|
||||
try:
|
||||
offer.bbox_at(parameter)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'bbox_at() raised {type(err).__name__}: {err}', err)
|
||||
|
||||
try:
|
||||
data = offer.commit(parameter)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'commit() raised {type(err).__name__}: {err}', err)
|
||||
continue
|
||||
|
||||
try:
|
||||
start = Port((0, 0), rotation=pi, ptype=offer.in_ptype or 'unk')
|
||||
tree = tool.render((RenderStep(offer.kind, tool, start, endpoint.copy(), data),))
|
||||
except Exception as err:
|
||||
violation(probe_context, f'render() raised {type(err).__name__}: {err}', err)
|
||||
continue
|
||||
if not isinstance(tree, ILibrary):
|
||||
violation(probe_context, f'render() returned {type(tree).__name__}, expected ILibrary')
|
||||
continue
|
||||
try:
|
||||
top_name = tree.top()
|
||||
pattern = tree.top_pattern()
|
||||
except Exception as err:
|
||||
violation(probe_context, f'rendered tree has no valid top cell: {err}', err)
|
||||
continue
|
||||
missing = sorted(
|
||||
name
|
||||
for name in tree.dangling_refs(top_name)
|
||||
if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX)
|
||||
)
|
||||
if missing:
|
||||
violation(probe_context, f'rendered tree has missing single-use refs: {missing}')
|
||||
missing_ports = [name for name in ('A', 'B') if name not in pattern.ports]
|
||||
if missing_ports:
|
||||
violation(probe_context, f'rendered top cell is missing ports: {missing_ports}')
|
||||
continue
|
||||
input_port, output_port = pattern.ports['A'], pattern.ports['B']
|
||||
if not ptypes_compatible(input_port.ptype, offer.in_ptype):
|
||||
violation(probe_context, 'rendered input ptype does not match offer in_ptype')
|
||||
try:
|
||||
rendered_offset, rendered_rotation = input_port.measure_travel(output_port)
|
||||
except Exception as err:
|
||||
violation(probe_context, f'unable to measure rendered endpoint: {err}', err)
|
||||
continue
|
||||
if not array_close(rendered_offset, endpoint.offset):
|
||||
violation(probe_context, 'rendered output offset does not match planned endpoint')
|
||||
if (
|
||||
rendered_rotation is None
|
||||
or endpoint.rotation is None
|
||||
or not angles_equal(rendered_rotation, endpoint.rotation)
|
||||
):
|
||||
violation(probe_context, 'rendered output rotation does not match planned endpoint')
|
||||
if not ptypes_compatible(output_port.ptype, endpoint.ptype):
|
||||
violation(probe_context, 'rendered output ptype does not match planned endpoint')
|
||||
|
||||
for parameter, matched in zip(case.probe_parameters, matched_explicit, strict=True):
|
||||
if not matched:
|
||||
violation(context, f'explicit probe {parameter:g} is outside every discovered offer domain')
|
||||
|
||||
if errors:
|
||||
raise ExceptionGroup(
|
||||
f'{type(tool).__name__} failed Tool contract validation with {len(errors)} violation(s)',
|
||||
errors,
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +1,26 @@
|
|||
from typing import TYPE_CHECKING
|
||||
from collections.abc import Mapping
|
||||
from typing import Dict, Tuple, List, Optional, Union, Any, cast, Sequence, TYPE_CHECKING
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ..utils import rotation_matrix_2d, SupportsBool
|
||||
from ..utils import rotation_matrix_2d
|
||||
from ..error import BuildError
|
||||
from ._tolerances import manhattan_axis
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
_EXTENSION_BOUND_TYPES = (
|
||||
'emin', 'min_extension',
|
||||
'emax', 'max_extension',
|
||||
'min_past_furthest',
|
||||
)
|
||||
_POSITION_MIN_BOUND_TYPES = ('pmin', 'min_position', 'xmin', 'ymin')
|
||||
_POSITION_MAX_BOUND_TYPES = ('pmax', 'max_position', 'xmax', 'ymax')
|
||||
_POSITION_BOUND_TYPES = _POSITION_MIN_BOUND_TYPES + _POSITION_MAX_BOUND_TYPES
|
||||
_BOUND_TYPES = _EXTENSION_BOUND_TYPES + _POSITION_BOUND_TYPES
|
||||
from .devices import Port
|
||||
|
||||
|
||||
def ell(
|
||||
ports: Mapping[str, 'Port'],
|
||||
ccw: SupportsBool | None,
|
||||
ports: Dict[str, 'Port'],
|
||||
ccw: Optional[bool],
|
||||
bound_type: str,
|
||||
bound: float | ArrayLike,
|
||||
bound: Union[float, ArrayLike],
|
||||
*,
|
||||
spacing: float | ArrayLike | None = None,
|
||||
set_rotation: float | None = None,
|
||||
) -> dict[str, numpy.float64]:
|
||||
spacing: Optional[Union[float, ArrayLike]] = None,
|
||||
set_rotation: Optional[float] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate extension for each port in order to build a 90-degree bend with the provided
|
||||
channel spacing:
|
||||
|
|
@ -58,7 +45,7 @@ def ell(
|
|||
ccw: Turn direction. `True` means counterclockwise, `False` means clockwise,
|
||||
and `None` means no bend. If `None`, spacing must remain `None` or `0` (default),
|
||||
Otherwise, spacing must be set to a non-`None` value.
|
||||
bound_type: Method used for determining the travel distance; see diagram above.
|
||||
bound_method: Method used for determining the travel distance; see diagram above.
|
||||
Valid values are:
|
||||
- 'min_extension' or 'emin':
|
||||
The total extension value for the furthest-out port (B in the diagram).
|
||||
|
|
@ -66,9 +53,9 @@ def ell(
|
|||
The distance between furthest out-port (B) and the innermost bend (D's bend).
|
||||
- 'max_extension' or 'emax':
|
||||
The total extension value for the closest-in port (C in the diagram).
|
||||
- 'min_position', 'pmin', 'xmin', 'ymin':
|
||||
- 'min_position' or 'pmin':
|
||||
The coordinate of the innermost bend (D's bend).
|
||||
- 'max_position', 'pmax', 'xmax', 'ymax':
|
||||
- 'max_position' or 'pmax':
|
||||
The coordinate of the outermost bend (A's bend).
|
||||
|
||||
`bound` can also be a vector. If specifying an extension (e.g. 'min_extension',
|
||||
|
|
@ -76,7 +63,7 @@ def ell(
|
|||
the x- and y- axes. If specifying a position, it is projected onto
|
||||
the extension direction.
|
||||
|
||||
bound: Value associated with `bound_type`, see above.
|
||||
bound_value: Value associated with `bound_type`, see above.
|
||||
spacing: Distance between adjacent channels. Can be scalar, resulting in evenly
|
||||
spaced channels, or a vector with length one less than `ports`, allowing
|
||||
non-uniform spacing.
|
||||
|
|
@ -94,21 +81,9 @@ def ell(
|
|||
"""
|
||||
if not ports:
|
||||
raise BuildError('Empty port list passed to `ell()`')
|
||||
if bound_type not in _BOUND_TYPES:
|
||||
raise BuildError(f'Invalid bound type {bound_type!r}; expected one of {_BOUND_TYPES}')
|
||||
|
||||
try:
|
||||
bound_arr = numpy.asarray(bound, dtype=float)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise BuildError('bound must be a numeric scalar or length-2 vector') from err
|
||||
if bound_arr.size not in (1, 2):
|
||||
raise BuildError(f'bound must be scalar or have length 2; got {bound_arr.size} values')
|
||||
if not numpy.all(numpy.isfinite(bound_arr)):
|
||||
raise BuildError('bound must contain only finite values')
|
||||
bound_values = bound_arr.reshape(-1)
|
||||
|
||||
if ccw is None:
|
||||
if spacing is not None and not numpy.allclose(spacing, 0):
|
||||
if spacing is not None and not numpy.isclose(spacing, 0):
|
||||
raise BuildError('Spacing must be 0 or None when ccw=None')
|
||||
spacing = 0
|
||||
elif spacing is None:
|
||||
|
|
@ -130,24 +105,10 @@ def ell(
|
|||
raise BuildError('Asked to find aggregation for ports that face in different directions:\n'
|
||||
+ pformat(port_rotations))
|
||||
else:
|
||||
if set_rotation is None:
|
||||
if set_rotation is not None:
|
||||
raise BuildError('set_rotation must be specified if no ports have rotations!')
|
||||
if not numpy.isfinite(set_rotation):
|
||||
raise BuildError('set_rotation must be finite')
|
||||
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float)
|
||||
|
||||
axis = manhattan_axis(float(rotations[0]))
|
||||
if bound_type in _POSITION_BOUND_TYPES and axis is None:
|
||||
raise BuildError(
|
||||
'Positional bounds require a nearly Manhattan port direction; '
|
||||
f'got rotation {rotations[0]:g}'
|
||||
)
|
||||
is_horizontal = axis == 0
|
||||
if bound_type in ('ymin', 'ymax') and is_horizontal:
|
||||
raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!')
|
||||
if bound_type in ('xmin', 'xmax') and not is_horizontal:
|
||||
raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!')
|
||||
|
||||
direction = rotations[0] + pi # direction we want to travel in (+pi relative to port)
|
||||
rot_matrix = rotation_matrix_2d(-direction)
|
||||
|
||||
|
|
@ -155,8 +116,6 @@ def ell(
|
|||
orig_offsets = numpy.array([p.offset for p in ports.values()])
|
||||
rot_offsets = (rot_matrix @ orig_offsets.T).T
|
||||
|
||||
# ordering_base = rot_offsets.T * [[1], [-1 if ccw else 1]] # could work, but this is actually a more complex routing problem
|
||||
# y_order = numpy.lexsort(ordering_base) # (need to make sure we don't collide with the next input port @ same y)
|
||||
y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable')
|
||||
y_ind = numpy.empty_like(y_order, dtype=int)
|
||||
y_ind[y_order] = numpy.arange(y_ind.shape[0])
|
||||
|
|
@ -164,21 +123,8 @@ def ell(
|
|||
if spacing is None:
|
||||
ch_offsets = numpy.zeros_like(y_order)
|
||||
else:
|
||||
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
|
||||
if not numpy.all(numpy.isfinite(spacing_arr)):
|
||||
raise BuildError('spacing must contain only finite values')
|
||||
if numpy.any(spacing_arr < 0):
|
||||
raise BuildError('spacing must be nonnegative')
|
||||
steps: NDArray[numpy.float64] = numpy.zeros(len(y_order), dtype=float)
|
||||
if spacing_arr.size == 1:
|
||||
steps[1:] = spacing_arr[0]
|
||||
elif spacing_arr.size == len(ports) - 1:
|
||||
steps[1:] = spacing_arr
|
||||
else:
|
||||
raise BuildError(
|
||||
f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; '
|
||||
f'got length {spacing_arr.size}'
|
||||
)
|
||||
steps = numpy.zeros_like(y_order)
|
||||
steps[1:] = spacing
|
||||
ch_offsets = numpy.cumsum(steps)[y_ind]
|
||||
|
||||
x_start = rot_offsets[:, 0]
|
||||
|
|
@ -189,7 +135,6 @@ def ell(
|
|||
# D-----------| `d_to_align[3]`
|
||||
#
|
||||
d_to_align = x_start.max() - x_start # distance to travel to align all
|
||||
offsets: NDArray[numpy.float64]
|
||||
if bound_type == 'min_past_furthest':
|
||||
# A------------------V `d_to_exit[0]`
|
||||
# B-----V `d_to_exit[1]`
|
||||
|
|
@ -209,41 +154,43 @@ def ell(
|
|||
travel = d_to_align - (ch_offsets.max() - ch_offsets)
|
||||
offsets = travel - travel.min().clip(max=0)
|
||||
|
||||
if bound_type in _EXTENSION_BOUND_TYPES:
|
||||
if numpy.any(bound_values < 0):
|
||||
raise BuildError(f'Got negative bound for extension: {bound_values}')
|
||||
|
||||
if bound_values.size == 2:
|
||||
horizontal_weight = abs(float(numpy.cos(direction)))
|
||||
vertical_weight = abs(float(numpy.sin(direction)))
|
||||
use_x = horizontal_weight > vertical_weight or numpy.isclose(horizontal_weight, vertical_weight)
|
||||
rot_bound = float(bound_values[0 if use_x else 1])
|
||||
if bound_type in ('emin', 'min_extension',
|
||||
'emax', 'max_extension',
|
||||
'min_past_furthest',):
|
||||
if numpy.size(bound) == 2:
|
||||
bound = cast(Sequence[float], bound)
|
||||
rot_bound = (rot_matrix @ ((bound[0], 0),
|
||||
(0, bound[1])))[0, :]
|
||||
else:
|
||||
rot_bound = float(bound_values[0])
|
||||
bound = cast(float, bound)
|
||||
rot_bound = numpy.array(bound)
|
||||
|
||||
if rot_bound < 0:
|
||||
raise BuildError(f'Got negative bound for extension: {rot_bound}')
|
||||
|
||||
if bound_type in ('emin', 'min_extension', 'min_past_furthest'):
|
||||
offsets += rot_bound
|
||||
elif bound_type in ('emax', 'max_extension'):
|
||||
offsets += rot_bound - offsets.max()
|
||||
offsets += rot_bound.max()
|
||||
elif bound_type in('emax', 'max_extension'):
|
||||
offsets += rot_bound.min() - offsets.max()
|
||||
else:
|
||||
if bound_values.size == 2:
|
||||
rot_bound = float((rot_matrix @ bound_values)[0])
|
||||
if numpy.size(bound) == 2:
|
||||
bound = cast(Sequence[float], bound)
|
||||
rot_bound = (rot_matrix @ bound)[0]
|
||||
else:
|
||||
bound = cast(float, bound)
|
||||
neg = (direction + pi / 4) % (2 * pi) > pi
|
||||
bound_scalar = float(bound_values[0])
|
||||
rot_bound = -bound_scalar if neg else bound_scalar
|
||||
rot_bound = -bound if neg else bound
|
||||
|
||||
min_possible = x_start + offsets
|
||||
if bound_type in _POSITION_MAX_BOUND_TYPES:
|
||||
if bound_type in ('pmax', 'max_position'):
|
||||
extension = rot_bound - min_possible.max()
|
||||
else:
|
||||
elif bound_type in ('pmin', 'min_position'):
|
||||
extension = rot_bound - min_possible.min()
|
||||
|
||||
offsets += extension
|
||||
if extension < 0:
|
||||
ext_floor = -numpy.floor(extension)
|
||||
raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t'
|
||||
+ '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets, strict=True)))
|
||||
raise BuildError(f'Position is too close by at least {-numpy.floor(extension)}. Total extensions would be'
|
||||
+ '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets)))
|
||||
|
||||
result = dict(zip(ports.keys(), offsets, strict=True))
|
||||
result = dict(zip(ports.keys(), offsets))
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
import traceback
|
||||
import pathlib
|
||||
|
||||
|
||||
MASQUE_DIR = str(pathlib.Path(__file__).parent)
|
||||
|
||||
|
||||
class MasqueError(Exception):
|
||||
"""
|
||||
Parent exception for all Masque-related Exceptions
|
||||
|
|
@ -18,6 +11,13 @@ class PatternError(MasqueError):
|
|||
"""
|
||||
pass
|
||||
|
||||
class PatternLockedError(PatternError):
|
||||
"""
|
||||
Exception raised when trying to modify a locked pattern
|
||||
"""
|
||||
def __init__(self):
|
||||
PatternError.__init__(self, 'Tried to modify a locked Pattern, subpattern, or shape')
|
||||
|
||||
|
||||
class LibraryError(MasqueError):
|
||||
"""
|
||||
|
|
@ -26,70 +26,22 @@ class LibraryError(MasqueError):
|
|||
pass
|
||||
|
||||
|
||||
class DeviceLibraryError(MasqueError):
|
||||
"""
|
||||
Exception raised by DeviceLibrary classes
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DeviceError(MasqueError):
|
||||
"""
|
||||
Exception raised by Device and Port objects
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BuildError(MasqueError):
|
||||
"""
|
||||
Exception raised by builder-related functions
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class PortError(MasqueError):
|
||||
"""
|
||||
Exception raised by port-related functions
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class OneShotError(MasqueError):
|
||||
"""
|
||||
Exception raised when a function decorated with `@oneshot` is called more than once
|
||||
"""
|
||||
def __init__(self, func_name: str) -> None:
|
||||
Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once')
|
||||
|
||||
|
||||
def format_stacktrace(
|
||||
stacklevel: int = 1,
|
||||
*,
|
||||
skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,),
|
||||
low_file_prefixes: tuple[str, ...] = ('<frozen', '<runpy', '<string>'),
|
||||
low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', 'concurrent/futures/process.py'),
|
||||
) -> str:
|
||||
"""
|
||||
Utility function for making nicer stack traces (e.g. excluding <frozen runpy> and similar)
|
||||
|
||||
Args:
|
||||
stacklevel: Number of frames to remove from near this function (default is to
|
||||
show caller but not ourselves). Similar to `warnings.warn` and `logging.warning`.
|
||||
skip_file_prefixes: Indicates frames to ignore after counting stack levels; similar
|
||||
to `warnings.warn` *TODO check if this is actually the same effect re:stacklevel*.
|
||||
Forces stacklevel to max(2, stacklevel).
|
||||
Default is to exclude anything within `masque`.
|
||||
low_file_prefixes: Indicates frames to ignore on the other (entry-point) end of the stack,
|
||||
based on prefixes on their filenames.
|
||||
low_file_suffixes: Indicates frames to ignore on the other (entry-point) end of the stack,
|
||||
based on suffixes on their filenames.
|
||||
|
||||
Returns:
|
||||
Formatted trimmed stack trace
|
||||
"""
|
||||
if skip_file_prefixes:
|
||||
stacklevel = max(2, stacklevel)
|
||||
|
||||
stack = traceback.extract_stack()
|
||||
|
||||
bad_inds = [ii + 1 for ii, frame in enumerate(stack)
|
||||
if frame.filename.startswith(low_file_prefixes) or frame.filename.endswith(low_file_suffixes)]
|
||||
first_ok = max([0] + bad_inds)
|
||||
|
||||
last_ok = -stacklevel - 1
|
||||
while last_ok >= -len(stack) and stack[last_ok].filename.startswith(skip_file_prefixes):
|
||||
last_ok -= 1
|
||||
|
||||
if selected := stack[first_ok:last_ok + 1]:
|
||||
pass
|
||||
elif selected := stack[:-stacklevel]:
|
||||
pass # noqa: SIM114 # separate elif for clarity
|
||||
else:
|
||||
selected = stack
|
||||
return ''.join(traceback.format_list(selected))
|
||||
|
|
|
|||
|
|
@ -1,56 +1,45 @@
|
|||
"""
|
||||
DXF file format readers and writers
|
||||
|
||||
Notes:
|
||||
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
|
||||
* ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
|
||||
to unique values, so byte-for-byte reproducibility is not achievable for now
|
||||
"""
|
||||
from typing import Any, cast, TextIO, IO, Literal
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Callable, Sequence
|
||||
from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable
|
||||
import re
|
||||
import io
|
||||
import base64
|
||||
import struct
|
||||
import logging
|
||||
import pathlib
|
||||
import gzip
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
import ezdxf
|
||||
from ezdxf import edgeminer
|
||||
from ezdxf.math import Vec3
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace, Line
|
||||
import numpy # type: ignore
|
||||
import ezdxf # type: ignore
|
||||
|
||||
from .utils import is_gzipped, tmpfile
|
||||
from .. import Pattern, Ref, PatternError, Label
|
||||
from ..library import ILibraryView, LibraryView, Library
|
||||
from ..shapes import Shape, Polygon, Path
|
||||
from .. import Pattern, SubPattern, PatternError, Label, Shape
|
||||
from ..shapes import Polygon, Path
|
||||
from ..repetition import Grid
|
||||
from ..utils import rotation_matrix_2d, layer_t, normalize_mirror
|
||||
from ..utils.boolean import _polytree_to_polygons
|
||||
from ..utils import rotation_matrix_2d, layer_t
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
logger.warning('DXF support is experimental!')
|
||||
logger.warning('DXF support is experimental and only slightly tested!')
|
||||
|
||||
|
||||
DEFAULT_LAYER = 'DEFAULT'
|
||||
|
||||
|
||||
def write(
|
||||
library: Mapping[str, Pattern], # TODO could allow library=None for flat DXF
|
||||
top_name: str,
|
||||
stream: TextIO,
|
||||
pattern: Pattern,
|
||||
stream: io.TextIOBase,
|
||||
*,
|
||||
dxf_version: str = 'AC1024',
|
||||
modify_originals: bool = False,
|
||||
dxf_version='AC1024',
|
||||
disambiguate_func: Callable[[Iterable[Pattern]], None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes
|
||||
into polygons, and then writing patterns as DXF `Block`s, polygons as `LWPolyline`s,
|
||||
and refs as `Insert`s.
|
||||
and subpatterns as `Insert`s.
|
||||
|
||||
The top level pattern's name is not written to the DXF file. Nested patterns keep their
|
||||
names.
|
||||
|
|
@ -60,60 +49,60 @@ def write(
|
|||
tuple: (1, 2) -> '1.2'
|
||||
str: '1.2' -> '1.2' (no change)
|
||||
|
||||
Shape repetitions are expanded into individual DXF entities.
|
||||
It is often a good idea to run `pattern.subpatternize()` prior to calling this function,
|
||||
especially if calling `.polygonize()` will result in very many vertices.
|
||||
|
||||
Other functions you may want to call:
|
||||
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
||||
- `library.dangling_refs()` to check for references to missing patterns
|
||||
- `pattern.polygonize()` for any patterns with shapes other
|
||||
than `masque.shapes.Polygon` or `masque.shapes.Path`
|
||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||
prior to calling this function.
|
||||
|
||||
Only `Grid` repetition objects with manhattan basis vectors are preserved as arrays. Since DXF
|
||||
rotations apply to basis vectors while `masque`'s rotations do not, the basis vectors of an
|
||||
array with rotated instances must be manhattan _after_ having a compensating rotation applied.
|
||||
|
||||
Args:
|
||||
library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced
|
||||
by it are written.
|
||||
top_name: Name of the top-level pattern to write.
|
||||
patterns: A Pattern or list of patterns to write to the stream.
|
||||
stream: Stream object to write to.
|
||||
modify_original: If `True`, the original pattern is modified as part of the writing
|
||||
process. Otherwise, a copy is made and `deepunlock()`-ed.
|
||||
Default `False`.
|
||||
disambiguate_func: Function which takes a list of patterns and alters them
|
||||
to make their names valid and unique. Default is `disambiguate_pattern_names`.
|
||||
WARNING: No additional error checking is performed on the results.
|
||||
"""
|
||||
#TODO consider supporting DXF arcs?
|
||||
if not isinstance(library, ILibraryView):
|
||||
if isinstance(library, dict):
|
||||
library = LibraryView(library)
|
||||
else:
|
||||
library = LibraryView(dict(library))
|
||||
if disambiguate_func is None:
|
||||
disambiguate_func = lambda pats: disambiguate_pattern_names(pats)
|
||||
assert(disambiguate_func is not None)
|
||||
|
||||
pattern = library[top_name]
|
||||
subtree = library.subtree(top_name)
|
||||
if not modify_originals:
|
||||
pattern = pattern.deepcopy().deepunlock()
|
||||
|
||||
# Get a dict of id(pattern) -> pattern
|
||||
patterns_by_id = pattern.referenced_patterns_by_id()
|
||||
disambiguate_func(patterns_by_id.values())
|
||||
|
||||
# Create library
|
||||
lib = ezdxf.new(dxf_version, setup=True)
|
||||
msp = lib.modelspace()
|
||||
_shapes_to_elements(msp, pattern.shapes)
|
||||
_labels_to_texts(msp, pattern.labels)
|
||||
_mrefs_to_drefs(msp, pattern.refs)
|
||||
_subpatterns_to_refs(msp, pattern.subpatterns)
|
||||
|
||||
# Now create a block for each referenced pattern, and add in any shapes
|
||||
for name, pat in subtree.items():
|
||||
assert pat is not None
|
||||
if name == top_name:
|
||||
continue
|
||||
|
||||
block = lib.blocks.new(name=name)
|
||||
for pat in patterns_by_id.values():
|
||||
assert(pat is not None)
|
||||
block = lib.blocks.new(name=pat.name)
|
||||
|
||||
_shapes_to_elements(block, pat.shapes)
|
||||
_labels_to_texts(block, pat.labels)
|
||||
_mrefs_to_drefs(block, pat.refs)
|
||||
_subpatterns_to_refs(block, pat.subpatterns)
|
||||
|
||||
lib.write(stream)
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern],
|
||||
top_name: str,
|
||||
filename: str | pathlib.Path,
|
||||
pattern: Pattern,
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
|
@ -123,42 +112,30 @@ def writefile(
|
|||
Will automatically compress the file if it has a .gz suffix.
|
||||
|
||||
Args:
|
||||
library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced
|
||||
by it are written.
|
||||
top_name: Name of the top-level pattern to write.
|
||||
pattern: `Pattern` to save
|
||||
filename: Filename to save to.
|
||||
*args: passed to `dxf.write`
|
||||
**kwargs: passed to `dxf.write`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
|
||||
gz_stream: IO[bytes]
|
||||
with tmpfile(path) as base_stream:
|
||||
streams: tuple[Any, ...] = (base_stream,)
|
||||
if path.suffix == '.gz':
|
||||
gz_stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb'))
|
||||
streams = (gz_stream,) + streams
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
gz_stream = base_stream
|
||||
stream = io.TextIOWrapper(gz_stream) # type: ignore
|
||||
streams = (stream,) + streams
|
||||
open_func = open
|
||||
|
||||
try:
|
||||
write(library, top_name, stream, *args, **kwargs)
|
||||
finally:
|
||||
for ss in streams:
|
||||
ss.close()
|
||||
with open_func(path, mode='wt') as stream:
|
||||
write(pattern, stream, *args, **kwargs)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
) -> Tuple[Pattern, Dict[str, Any]]:
|
||||
"""
|
||||
Wrapper for `dxf.read()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
Will automatically decompress files with a .gz suffix.
|
||||
|
||||
Args:
|
||||
filename: Filename to save to.
|
||||
|
|
@ -166,7 +143,7 @@ def readfile(
|
|||
**kwargs: passed to `dxf.read`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
if path.suffix == '.gz':
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
open_func = open
|
||||
|
|
@ -177,462 +154,199 @@ def readfile(
|
|||
|
||||
|
||||
def read(
|
||||
stream: TextIO,
|
||||
*,
|
||||
polyline_mode: Literal[0, 1, 2, 3, 4] = 2,
|
||||
contour_accuracy: float = 0.0,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
stream: io.TextIOBase,
|
||||
clean_vertices: bool = True,
|
||||
) -> Tuple[Pattern, Dict[str, Any]]:
|
||||
"""
|
||||
Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are
|
||||
translated into `Pattern` objects; `LWPolyline`s are translated into polygons, and `Insert`s
|
||||
are translated into `Ref` objects.
|
||||
are translated into `SubPattern` objects.
|
||||
|
||||
If an object has no layer it is set to this module's `DEFAULT_LAYER` ("DEFAULT").
|
||||
|
||||
Args:
|
||||
stream: Stream to read from.
|
||||
polyline_mode: Treatment of straight LINE/POLYLINE/LWPOLYLINE geometry:
|
||||
0 selects automatically (1 if SOLID/HATCH exists, otherwise 2 if closed
|
||||
polylines exist, otherwise 3); 1 keeps paths; 2 fills closed zero-width
|
||||
polylines; 3 joins zero-width segments into polygons, keeping open
|
||||
contours as paths; 4 additionally closes open contours. Closure may be
|
||||
indicated by the DXF flag or exactly equal endpoints. Positive-width
|
||||
paths are preserved in every mode. Curved and variable-width entities
|
||||
remain unsupported. Automatic selection uses all imported blocks.
|
||||
contour_accuracy: Nonnegative, finite endpoint joining distance in DXF
|
||||
units, used only in modes 3 and 4. Zero requires exact coincidence.
|
||||
Merged polygons are quantized to 1e-6 DXF units and use even-odd filling
|
||||
(nested contours form holes), matching KLayout's polyline merge modes.
|
||||
clean_vertices: If `True`, remove any redundant vertices when loading polygons.
|
||||
The cleaning process removes any polygons with zero area or <3 vertices.
|
||||
Default `True`.
|
||||
|
||||
Returns:
|
||||
- Library of patterns
|
||||
- Layer metadata
|
||||
- Top level pattern
|
||||
"""
|
||||
if polyline_mode not in (0, 1, 2, 3, 4):
|
||||
raise ValueError(f'Invalid DXF polyline_mode: {polyline_mode!r}')
|
||||
if not numpy.isfinite(contour_accuracy) or contour_accuracy < 0:
|
||||
raise ValueError('DXF contour_accuracy must be finite and nonnegative')
|
||||
lib = ezdxf.read(stream)
|
||||
msp = lib.modelspace()
|
||||
|
||||
blocks_by_name = {
|
||||
bb.name: bb
|
||||
for bb in lib.blocks
|
||||
if not bb.is_any_layout
|
||||
pat = _read_block(msp, clean_vertices)
|
||||
patterns = [pat] + [_read_block(bb, clean_vertices) for bb in lib.blocks if bb.name != '*Model_Space']
|
||||
|
||||
# Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
|
||||
# according to the subpattern.identifier (which is deleted after use).
|
||||
patterns_dict = dict(((p.name, p) for p in patterns))
|
||||
for p in patterns_dict.values():
|
||||
for sp in p.subpatterns:
|
||||
sp.pattern = patterns_dict[sp.identifier[0]]
|
||||
del sp.identifier
|
||||
|
||||
library_info = {
|
||||
'layers': [ll.dxfattribs() for ll in lib.layers]
|
||||
}
|
||||
|
||||
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)
|
||||
return pat, library_info
|
||||
|
||||
|
||||
def _read_block(block, clean_vertices: bool) -> Pattern:
|
||||
pat = Pattern(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])
|
||||
|
||||
blocks = [msp, *(bb for bb in blocks_by_name.values()
|
||||
if not bb.name.startswith('_') or bb.name in referenced)]
|
||||
if polyline_mode == 0:
|
||||
polyline_mode = 3
|
||||
for block in blocks:
|
||||
for element in block:
|
||||
if element.dxftype() in ('SOLID', 'HATCH'):
|
||||
polyline_mode = 1
|
||||
break
|
||||
if isinstance(element, LWPolyline | Polyline):
|
||||
verts = (numpy.asarray(element.get_points('xy')) if isinstance(element, LWPolyline)
|
||||
else numpy.asarray([pp.xyz[:2] for pp in element.points()]))
|
||||
closed = element.closed if isinstance(element, LWPolyline) else element.is_closed
|
||||
if closed or (len(verts) > 1 and numpy.array_equal(verts[0], verts[-1])):
|
||||
polyline_mode = 2
|
||||
if polyline_mode == 1:
|
||||
break
|
||||
|
||||
mlib = Library()
|
||||
for bb in blocks:
|
||||
name, pat = _read_block(bb, polyline_mode=polyline_mode, contour_accuracy=contour_accuracy)
|
||||
mlib[name] = pat
|
||||
|
||||
library_info = dict(
|
||||
layers=[ll.dxfattribs() for ll in lib.layers],
|
||||
)
|
||||
|
||||
return mlib, library_info
|
||||
|
||||
|
||||
def _read_block(
|
||||
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
|
||||
*,
|
||||
polyline_mode: int = 2,
|
||||
contour_accuracy: float = 0.0,
|
||||
) -> tuple[str, Pattern]:
|
||||
name = block.name
|
||||
pat = Pattern()
|
||||
contours: dict[layer_t, list[numpy.ndarray]] = defaultdict(list)
|
||||
for element in block:
|
||||
if isinstance(element, LWPolyline | Polyline):
|
||||
if isinstance(element, LWPolyline):
|
||||
points = numpy.asarray(element.get_points())
|
||||
is_closed = element.closed
|
||||
eltype = element.dxftype()
|
||||
if eltype in ('POLYLINE', 'LWPOLYLINE'):
|
||||
if eltype == 'LWPOLYLINE':
|
||||
points = numpy.array(tuple(element.lwpoints))
|
||||
else:
|
||||
points = numpy.asarray([pp.xyz for pp in element.points()])
|
||||
is_closed = element.is_closed
|
||||
points = numpy.array(tuple(element.points()))
|
||||
attr = element.dxfattribs()
|
||||
layer = attr.get('layer', DEFAULT_LAYER)
|
||||
if len(points) < 2:
|
||||
logger.warning('Ignoring DXF polyline with fewer than two vertices')
|
||||
continue
|
||||
|
||||
width = 0
|
||||
if isinstance(element, LWPolyline):
|
||||
# ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge)
|
||||
if points.shape[1] >= 5:
|
||||
if (points[:, 4] != 0).any():
|
||||
raise PatternError('LWPolyline has bulge (not yet representable in masque!)')
|
||||
if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any():
|
||||
raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)')
|
||||
width = points[0, 2]
|
||||
elif points.shape[1] == 3:
|
||||
# width used to be in column 2
|
||||
width = points[0, 2]
|
||||
else:
|
||||
if any(vertex.dxf.get('bulge', 0) != 0 for vertex in element.vertices):
|
||||
raise PatternError('Polyline has bulge (not yet representable in masque!)')
|
||||
widths = numpy.asarray([
|
||||
(vertex.dxf.get('start_width', attr.get('default_start_width', 0)),
|
||||
vertex.dxf.get('end_width', attr.get('default_end_width', 0)))
|
||||
for vertex in element.vertices
|
||||
])
|
||||
if (widths != widths[0, 0]).any():
|
||||
raise PatternError('Polyline has non-constant width (not yet representable in masque!)')
|
||||
width = widths[0, 0]
|
||||
if points.shape[1] == 2:
|
||||
raise PatternError('Invalid or unimplemented polygon?')
|
||||
#shape = Polygon(layer=layer)
|
||||
elif points.shape[1] > 2:
|
||||
if (points[0, 2] != points[:, 2]).any():
|
||||
raise PatternError('PolyLine has non-constant width (not yet representable in masque!)')
|
||||
elif points.shape[1] == 4 and (points[:, 3] != 0).any():
|
||||
raise PatternError('LWPolyLine has bulge (not yet representable in masque!)')
|
||||
|
||||
width = points[0, 2]
|
||||
if width == 0:
|
||||
width = attr.get('const_width', 0)
|
||||
|
||||
verts = points[:, :2]
|
||||
endpoint_closed = numpy.array_equal(verts[0], verts[-1])
|
||||
if is_closed and not endpoint_closed:
|
||||
verts = numpy.vstack((verts, verts[0]))
|
||||
is_closed = is_closed or endpoint_closed
|
||||
shape: Union[Path, Polygon]
|
||||
if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]):
|
||||
shape = Polygon(layer=layer, vertices=points[:-1, :2])
|
||||
else:
|
||||
shape = Path(layer=layer, width=width, vertices=points[:, :2])
|
||||
|
||||
shape: Path | Polygon
|
||||
if width == 0 and polyline_mode >= 3:
|
||||
contours[layer].append(verts)
|
||||
if clean_vertices:
|
||||
try:
|
||||
shape.clean_vertices()
|
||||
except PatternError:
|
||||
continue
|
||||
if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts):
|
||||
shape = Polygon(vertices=verts[:-1])
|
||||
else:
|
||||
shape = Path(width=width, vertices=verts)
|
||||
|
||||
pat.shapes[layer].append(shape)
|
||||
elif isinstance(element, Line):
|
||||
layer = element.dxf.get('layer', DEFAULT_LAYER)
|
||||
verts = numpy.asarray((element.dxf.start.xyz[:2], element.dxf.end.xyz[:2]))
|
||||
if polyline_mode >= 3:
|
||||
contours[layer].append(verts)
|
||||
else:
|
||||
pat.shapes[layer].append(Path(vertices=verts, width=0))
|
||||
elif isinstance(element, Solid | Trace):
|
||||
attr = element.dxfattribs()
|
||||
layer = attr.get('layer', DEFAULT_LAYER)
|
||||
points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4)
|
||||
if element.has_dxf_attrib(f'vtx{i}')])
|
||||
if len(points) >= 3:
|
||||
# If vtx2 == vtx3, it's a triangle. ezdxf handles this.
|
||||
if len(points) == 4 and numpy.allclose(points[2], points[3]):
|
||||
verts = points[:3, :2]
|
||||
# DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals!
|
||||
elif len(points) == 4:
|
||||
verts = points[[0, 1, 3, 2], :2]
|
||||
else:
|
||||
verts = points[:, :2]
|
||||
pat.shapes[layer].append(Polygon(vertices=verts))
|
||||
elif isinstance(element, Text):
|
||||
args = dict(
|
||||
offset=numpy.asarray(element.get_placement()[1])[:2],
|
||||
layer=element.dxfattribs().get('layer', DEFAULT_LAYER),
|
||||
)
|
||||
pat.shapes.append(shape)
|
||||
|
||||
elif eltype in ('TEXT',):
|
||||
args = {'offset': numpy.array(element.get_pos()[1])[:2],
|
||||
'layer': element.dxfattribs().get('layer', DEFAULT_LAYER),
|
||||
}
|
||||
string = element.dxfattribs().get('text', '')
|
||||
# height = element.dxfattribs().get('height', 0)
|
||||
# if height != 0:
|
||||
# logger.warning('Interpreting DXF TEXT as a label despite nonzero height. '
|
||||
# 'This could be changed in the future by setting a font path in the masque DXF code.')
|
||||
pat.label(string=string, **args)
|
||||
pat.labels.append(Label(string=string, **args))
|
||||
# else:
|
||||
# pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????))
|
||||
elif isinstance(element, Insert):
|
||||
# pat.shapes.append(Text(string=string, height=height, font_path=????))
|
||||
elif eltype in ('INSERT',):
|
||||
attr = element.dxfattribs()
|
||||
xscale = attr.get('xscale', 1)
|
||||
yscale = attr.get('yscale', 1)
|
||||
if abs(xscale) != abs(yscale):
|
||||
logger.warning('Masque does not support per-axis scaling; using x-scaling only!')
|
||||
scale = abs(xscale)
|
||||
mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0))
|
||||
insert_rotation = numpy.deg2rad(attr.get('rotation', 0))
|
||||
rotation = insert_rotation + extra_angle
|
||||
mirrored = (yscale < 0, xscale < 0)
|
||||
rotation = numpy.deg2rad(attr.get('rotation', 0))
|
||||
|
||||
offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]
|
||||
offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2]
|
||||
|
||||
args = dict(
|
||||
target=attr.get('name', None),
|
||||
offset=offset,
|
||||
scale=scale,
|
||||
mirrored=mirrored,
|
||||
rotation=rotation,
|
||||
)
|
||||
args = {
|
||||
'offset': offset,
|
||||
'scale': scale,
|
||||
'mirrored': mirrored,
|
||||
'rotation': rotation,
|
||||
'pattern': None,
|
||||
'identifier': (attr.get('name', None),),
|
||||
}
|
||||
|
||||
if 'column_count' in attr or 'row_count' in attr:
|
||||
col_spacing = attr.get('column_spacing', 0)
|
||||
row_spacing = attr.get('row_spacing', 0)
|
||||
col_count = attr.get('column_count', 1)
|
||||
row_count = attr.get('row_count', 1)
|
||||
local_x = numpy.array((col_spacing, 0.0))
|
||||
local_y = numpy.array((0.0, row_spacing))
|
||||
# Spacing follows only the original INSERT angle, not its scale
|
||||
# or the extra angle introduced by mirror normalization.
|
||||
rot = rotation_matrix_2d(insert_rotation)
|
||||
args['repetition'] = Grid(
|
||||
a_vector=rot @ local_x, b_vector=rot @ local_y,
|
||||
a_count=col_count, b_count=row_count,
|
||||
)
|
||||
pat.ref(**args)
|
||||
if 'column_count' in attr:
|
||||
args['repetition'] = Grid(a_vector=(attr['column_spacing'], 0),
|
||||
b_vector=(0, attr['row_spacing']),
|
||||
a_count=attr['column_count'],
|
||||
b_count=attr['row_count'])
|
||||
pat.subpatterns.append(SubPattern(**args))
|
||||
else:
|
||||
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
|
||||
for layer, vertex_lists in contours.items():
|
||||
pat.shapes[layer].extend(_merge_polylines(vertex_lists, contour_accuracy, auto_close=polyline_mode == 4))
|
||||
return name, pat
|
||||
return pat
|
||||
|
||||
|
||||
def _is_polygon(vertices: NDArray) -> bool:
|
||||
"""At least three distinct, noncollinear points (including self-crossing contours)."""
|
||||
points = numpy.unique(vertices, axis=0)
|
||||
if len(points) < 3:
|
||||
return False
|
||||
vectors = points[1:] - points[0]
|
||||
return bool(numpy.any(vectors[:, 0] * vectors[0, 1] != vectors[:, 1] * vectors[0, 0]))
|
||||
|
||||
|
||||
def _contours(edges: Sequence[edgeminer.Edge], accuracy: float) -> list[tuple[NDArray, bool]]:
|
||||
"""Join each edge once, using indexed endpoint searches rather than loop enumeration."""
|
||||
deposit = edgeminer.Deposit(edges, gap_tol=accuracy)
|
||||
unused = {edge.id for edge in edges}
|
||||
result = []
|
||||
|
||||
def grow(points: list[Vec3]) -> bool:
|
||||
positions = {point: index for index, point in enumerate(points[:-1])}
|
||||
while True:
|
||||
# A walk that started on a dangling segment can encounter a cycle
|
||||
# before returning to its initial point. Extract that rim and keep
|
||||
# the remaining open tail; every segment is still consumed once.
|
||||
contacts = {
|
||||
point for edge in deposit.edges_linked_to(points[-1])
|
||||
for point in (edge.start, edge.end)
|
||||
if point in positions and positions[point] < len(points) - 2
|
||||
and point.distance(points[-1]) <= accuracy
|
||||
}
|
||||
if contacts:
|
||||
point = min(contacts, key=lambda point: (point.distance(points[-1]), point.xyz))
|
||||
index = positions[point]
|
||||
loop = points[index:-1] + [point]
|
||||
result.append((numpy.asarray([pp.xyz[:2] for pp in loop]), True))
|
||||
if index == 0:
|
||||
return True
|
||||
for removed in points[index + 1:-1]:
|
||||
positions.pop(removed, None)
|
||||
del points[index + 1:]
|
||||
positions[points[-1]] = len(points) - 1
|
||||
incoming = points[-1] - points[-2]
|
||||
candidates = []
|
||||
for edge in deposit.edges_linked_to(points[-1]):
|
||||
if edge.id not in unused:
|
||||
continue
|
||||
for oriented in (edge, edge.reversed()):
|
||||
distance = points[-1].distance(oriented.start)
|
||||
if distance <= accuracy:
|
||||
direction = oriented.end - oriented.start
|
||||
# Like KLayout, use endpoint distance then a signed
|
||||
# cross product. Canonical seeds follow clockwise rims.
|
||||
turn = -direction.cross(incoming).z / oriented.length
|
||||
candidates.append((distance, turn, oriented.end.xyz, oriented.id, oriented))
|
||||
if not candidates:
|
||||
return False
|
||||
edge = min(candidates, key=lambda item: item[:4])[-1]
|
||||
unused.remove(edge.id)
|
||||
# Snap the next start to the preceding endpoint when joining a gap.
|
||||
points.append(edge.end)
|
||||
|
||||
# Canonical ordering makes results independent of input order/direction.
|
||||
ordered = sorted(edges, key=lambda edge: sorted((edge.start.xyz, edge.end.xyz)))
|
||||
for seed in ordered:
|
||||
if seed.id not in unused:
|
||||
continue
|
||||
unused.remove(seed.id)
|
||||
edge = seed.reversed() if seed.start.xyz > seed.end.xyz else seed
|
||||
points = [edge.start, edge.end]
|
||||
closed = grow(points)
|
||||
if not closed:
|
||||
points.reverse()
|
||||
closed = grow(points)
|
||||
if not closed:
|
||||
result.append((numpy.asarray([point.xyz[:2] for point in points]), False))
|
||||
return result
|
||||
|
||||
|
||||
def _merge_polylines(
|
||||
vertex_lists: Sequence[NDArray],
|
||||
accuracy: float,
|
||||
*,
|
||||
auto_close: bool,
|
||||
) -> list[Path | Polygon]:
|
||||
"""Assemble one cell/layer's zero-width segments, with KLayout's even-odd fill."""
|
||||
import pyclipper # noqa: PLC0415
|
||||
|
||||
edges = []
|
||||
result: list[Path | Polygon] = []
|
||||
for vertices in vertex_lists:
|
||||
start_count = len(edges)
|
||||
for start, end in zip(vertices[:-1], vertices[1:], strict=True):
|
||||
if not numpy.array_equal(start, end):
|
||||
edges.append(edgeminer.make_edge(start, end))
|
||||
if start_count == len(edges):
|
||||
result.append(Path(vertices=vertices, width=0))
|
||||
|
||||
scale = 1e6
|
||||
clipper = pyclipper.Pyclipper()
|
||||
has_polygons = False
|
||||
for vertices, closed in _contours(edges, accuracy):
|
||||
if (closed or auto_close) and _is_polygon(vertices):
|
||||
# A contour can collapse at the clipping precision. Preserve its
|
||||
# centerline in that case rather than silently dropping geometry.
|
||||
try:
|
||||
added = clipper.AddPath(pyclipper.scale_to_clipper(vertices, scale), pyclipper.PT_SUBJECT, True)
|
||||
except pyclipper.ClipperException:
|
||||
added = False
|
||||
if added:
|
||||
has_polygons = True
|
||||
continue
|
||||
result.append(Path(vertices=vertices, width=0))
|
||||
|
||||
if has_polygons:
|
||||
tree = clipper.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
||||
result.extend(_polytree_to_polygons(tree, scale))
|
||||
return result
|
||||
|
||||
|
||||
def _mrefs_to_drefs(
|
||||
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
|
||||
refs: dict[str | None, list[Ref]],
|
||||
def _subpatterns_to_refs(
|
||||
block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
|
||||
subpatterns: List[SubPattern],
|
||||
) -> None:
|
||||
def mk_blockref(encoded_name: str, ref: Ref) -> None:
|
||||
rotation = numpy.rad2deg(ref.rotation) % 360
|
||||
attribs = dict(
|
||||
xscale=ref.scale,
|
||||
yscale=ref.scale * (-1 if ref.mirrored else 1),
|
||||
rotation=rotation,
|
||||
)
|
||||
for subpat in subpatterns:
|
||||
if subpat.pattern is None:
|
||||
continue
|
||||
encoded_name = subpat.pattern.name
|
||||
|
||||
rep = ref.repetition
|
||||
rotation = (subpat.rotation * 180 / numpy.pi) % 360
|
||||
attribs = {
|
||||
'xscale': subpat.scale * (-1 if subpat.mirrored[1] else 1),
|
||||
'yscale': subpat.scale * (-1 if subpat.mirrored[0] else 1),
|
||||
'rotation': rotation,
|
||||
}
|
||||
|
||||
rep = subpat.repetition
|
||||
if rep is None:
|
||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
||||
block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs)
|
||||
elif isinstance(rep, Grid):
|
||||
a = rep.a_vector
|
||||
b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||
# In masque, the grid basis vectors are NOT rotated by the reference's rotation.
|
||||
# In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing],
|
||||
# which ARE then rotated by the block reference's rotation.
|
||||
# Compensate for that rotation to express the world-space basis in
|
||||
# the local DXF frame. Only locally Manhattan grids fit an INSERT.
|
||||
rotated_a = rotation_matrix_2d(-ref.rotation) @ a
|
||||
rotated_b = rotation_matrix_2d(-ref.rotation) @ b
|
||||
|
||||
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
|
||||
rotated_a = rotation_matrix_2d(-subpat.rotation) @ a
|
||||
rotated_b = rotation_matrix_2d(-subpat.rotation) @ b
|
||||
if rotated_a[1] == 0 and rotated_b[0] == 0:
|
||||
attribs['column_count'] = rep.a_count
|
||||
attribs['row_count'] = rep.b_count
|
||||
attribs['column_spacing'] = rotated_a[0]
|
||||
attribs['row_spacing'] = rotated_b[1]
|
||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
||||
elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8):
|
||||
block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs)
|
||||
elif rotated_a[0] == 0 and rotated_b[1] == 0:
|
||||
attribs['column_count'] = rep.b_count
|
||||
attribs['row_count'] = rep.a_count
|
||||
attribs['column_spacing'] = rotated_b[0]
|
||||
attribs['row_spacing'] = rotated_a[1]
|
||||
block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs)
|
||||
block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs)
|
||||
else:
|
||||
#NOTE: We could still do non-manhattan (but still orthogonal) grids by getting
|
||||
# creative with counter-rotated nested patterns, but probably not worth it.
|
||||
# Instead, just break appart the grid into individual elements:
|
||||
for dd in rep.displacements:
|
||||
block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)
|
||||
block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs)
|
||||
else:
|
||||
for dd in rep.displacements:
|
||||
block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs)
|
||||
|
||||
for target, rseq in refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
for ref in rseq:
|
||||
mk_blockref(target, ref)
|
||||
block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs)
|
||||
|
||||
|
||||
def _shapes_to_elements(
|
||||
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
|
||||
shapes: dict[layer_t, list[Shape]],
|
||||
block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
|
||||
shapes: List[Shape],
|
||||
polygonize_paths: bool = False,
|
||||
) -> None:
|
||||
# Add `LWPolyline`s for each shape.
|
||||
# Could set do paths with width setting, but need to consider endcaps.
|
||||
# TODO: can DXF do paths?
|
||||
for layer, sseq in shapes.items():
|
||||
attribs = dict(layer=_mlayer2dxf(layer))
|
||||
for shape in sseq:
|
||||
displacements = [numpy.zeros(2)]
|
||||
if shape.repetition is not None:
|
||||
displacements = shape.repetition.displacements
|
||||
|
||||
for dd in displacements:
|
||||
if isinstance(shape, Path):
|
||||
# preserve path.
|
||||
# Note: DXF paths don't support endcaps well, so this is still a bit limited.
|
||||
xy = shape.vertices + dd
|
||||
attribs_path = {**attribs}
|
||||
if shape.width > 0:
|
||||
attribs_path['const_width'] = shape.width
|
||||
block.add_lwpolyline(xy, dxfattribs=attribs_path)
|
||||
else:
|
||||
for shape in shapes:
|
||||
attribs = {'layer': _mlayer2dxf(shape.layer)}
|
||||
for polygon in shape.to_polygons():
|
||||
xy_open = polygon.vertices + dd
|
||||
block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs)
|
||||
xy_open = polygon.vertices + polygon.offset
|
||||
xy_closed = numpy.vstack((xy_open, xy_open[0, :]))
|
||||
block.add_lwpolyline(xy_closed, dxfattribs=attribs)
|
||||
|
||||
|
||||
def _labels_to_texts(
|
||||
block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace,
|
||||
labels: dict[layer_t, list[Label]],
|
||||
block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace],
|
||||
labels: List[Label],
|
||||
) -> None:
|
||||
for layer, lseq in labels.items():
|
||||
attribs = dict(layer=_mlayer2dxf(layer))
|
||||
for label in lseq:
|
||||
if label.repetition is None:
|
||||
block.add_text(
|
||||
label.string,
|
||||
dxfattribs=attribs
|
||||
).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||
else:
|
||||
for dd in label.repetition.displacements:
|
||||
block.add_text(
|
||||
label.string,
|
||||
dxfattribs=attribs
|
||||
).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT)
|
||||
for label in labels:
|
||||
attribs = {'layer': _mlayer2dxf(label.layer)}
|
||||
xy = label.offset
|
||||
block.add_text(label.string, dxfattribs=attribs).set_pos(xy, align='BOTTOM_LEFT')
|
||||
|
||||
|
||||
def _mlayer2dxf(layer: layer_t) -> str:
|
||||
|
|
@ -641,5 +355,42 @@ def _mlayer2dxf(layer: layer_t) -> str:
|
|||
if isinstance(layer, int):
|
||||
return str(layer)
|
||||
if isinstance(layer, tuple):
|
||||
return f'{layer[0]:d}.{layer[1]:d}'
|
||||
return f'{layer[0]}.{layer[1]}'
|
||||
raise PatternError(f'Unknown layer type: {layer} ({type(layer)})')
|
||||
|
||||
|
||||
def disambiguate_pattern_names(
|
||||
patterns: Iterable[Pattern],
|
||||
max_name_length: int = 32,
|
||||
suffix_length: int = 6,
|
||||
dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name
|
||||
) -> None:
|
||||
used_names = []
|
||||
for pat in patterns:
|
||||
sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name)
|
||||
|
||||
i = 0
|
||||
suffixed_name = sanitized_name
|
||||
while suffixed_name in used_names or suffixed_name == '':
|
||||
suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII')
|
||||
|
||||
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
|
||||
i += 1
|
||||
|
||||
if sanitized_name == '':
|
||||
logger.warning(f'Empty pattern name saved as "{suffixed_name}"')
|
||||
elif suffixed_name != sanitized_name:
|
||||
if dup_warn_filter is None or dup_warn_filter(pat.name):
|
||||
logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n'
|
||||
+ f' renaming to "{suffixed_name}"')
|
||||
|
||||
if len(suffixed_name) == 0:
|
||||
# Should never happen since zero-length names are replaced
|
||||
raise PatternError(f'Zero-length name after sanitize,\n originally "{pat.name}"')
|
||||
if len(suffixed_name) > max_name_length:
|
||||
raise PatternError(f'Pattern name "{suffixed_name!r}" length > {max_name_length} after encode,\n'
|
||||
+ f' originally "{pat.name}"')
|
||||
|
||||
pat.name = suffixed_name
|
||||
used_names.append(suffixed_name)
|
||||
|
||||
|
|
|
|||
670
masque/file/gdsii.py
Normal file
670
masque/file/gdsii.py
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
"""
|
||||
GDSII file format readers and writers using the `klamath` 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
|
||||
* Creation/modification/access times are set to 1900-01-01 for reproducibility.
|
||||
"""
|
||||
from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional
|
||||
from typing import Sequence, BinaryIO
|
||||
import re
|
||||
import io
|
||||
import mmap
|
||||
import copy
|
||||
import base64
|
||||
import struct
|
||||
import logging
|
||||
import pathlib
|
||||
import gzip
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
import klamath
|
||||
from klamath import records
|
||||
|
||||
from .utils import is_gzipped
|
||||
from .. import Pattern, SubPattern, PatternError, Label, Shape
|
||||
from ..shapes import Polygon, Path
|
||||
from ..repetition import Grid
|
||||
from ..utils import layer_t, normalize_mirror, annotations_t
|
||||
from ..library import Library
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
path_cap_map = {
|
||||
0: Path.Cap.Flush,
|
||||
1: Path.Cap.Circle,
|
||||
2: Path.Cap.Square,
|
||||
4: Path.Cap.SquareCustom,
|
||||
}
|
||||
|
||||
|
||||
def write(
|
||||
patterns: Union[Pattern, Sequence[Pattern]],
|
||||
stream: BinaryIO,
|
||||
meters_per_unit: float,
|
||||
logical_units_per_unit: float = 1,
|
||||
library_name: str = 'masque-klamath',
|
||||
*,
|
||||
modify_originals: bool = False,
|
||||
disambiguate_func: Callable[[Iterable[Pattern]], None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Convert a `Pattern` or list of patterns to a GDSII stream, and then mapping data as follows:
|
||||
Pattern -> GDSII structure
|
||||
SubPattern -> GDSII SREF or AREF
|
||||
Path -> GSDII path
|
||||
Shape (other than path) -> GDSII boundary/ies
|
||||
Label -> GDSII text
|
||||
annnotations -> properties, where possible
|
||||
|
||||
For each shape,
|
||||
layer is chosen to be equal to `shape.layer` if it is an int,
|
||||
or `shape.layer[0]` if it is a tuple
|
||||
datatype is chosen to be `shape.layer[1]` if available,
|
||||
otherwise `0`
|
||||
|
||||
It is often a good idea to run `pattern.subpatternize()` prior to calling this function,
|
||||
especially if calling `.polygonize()` will result in very many vertices.
|
||||
|
||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
patterns: A Pattern or list of patterns to convert.
|
||||
meters_per_unit: Written into the GDSII file, meters per (database) length unit.
|
||||
All distances are assumed to be an integer multiple of this unit, and are stored as such.
|
||||
logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
|
||||
"logical" unit which is different from the "database" unit, for display purposes.
|
||||
Default `1`.
|
||||
library_name: Library name written into the GDSII file.
|
||||
Default 'masque-klamath'.
|
||||
modify_originals: If `True`, the original pattern is modified as part of the writing
|
||||
process. Otherwise, a copy is made and `deepunlock()`-ed.
|
||||
Default `False`.
|
||||
disambiguate_func: Function which takes a list of patterns and alters them
|
||||
to make their names valid and unique. Default is `disambiguate_pattern_names`, which
|
||||
attempts to adhere to the GDSII standard as well as possible.
|
||||
WARNING: No additional error checking is performed on the results.
|
||||
"""
|
||||
if isinstance(patterns, Pattern):
|
||||
patterns = [patterns]
|
||||
|
||||
if disambiguate_func is None:
|
||||
disambiguate_func = disambiguate_pattern_names # type: ignore
|
||||
assert(disambiguate_func is not None) # placate mypy
|
||||
|
||||
if not modify_originals:
|
||||
patterns = [p.deepunlock() for p in copy.deepcopy(patterns)]
|
||||
|
||||
patterns = [p.wrap_repeated_shapes() for p in patterns]
|
||||
|
||||
# Create library
|
||||
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)
|
||||
|
||||
# Get a dict of id(pattern) -> pattern
|
||||
patterns_by_id = {id(pattern): pattern for pattern in patterns}
|
||||
for pattern in patterns:
|
||||
for i, p in pattern.referenced_patterns_by_id().items():
|
||||
patterns_by_id[i] = p
|
||||
|
||||
disambiguate_func(patterns_by_id.values())
|
||||
|
||||
# Now create a structure for each pattern, and add in any Boundary and SREF elements
|
||||
for pat in patterns_by_id.values():
|
||||
elements: List[klamath.elements.Element] = []
|
||||
elements += _shapes_to_elements(pat.shapes)
|
||||
elements += _labels_to_texts(pat.labels)
|
||||
elements += _subpatterns_to_refs(pat.subpatterns)
|
||||
|
||||
klamath.library.write_struct(stream, name=pat.name.encode('ASCII'), elements=elements)
|
||||
records.ENDLIB.write(stream, None)
|
||||
|
||||
|
||||
def writefile(
|
||||
patterns: Union[Sequence[Pattern], Pattern],
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Wrapper for `write()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically compress the file if it has a .gz suffix.
|
||||
|
||||
Args:
|
||||
patterns: `Pattern` or list of patterns to save
|
||||
filename: Filename to save to.
|
||||
*args: passed to `write()`
|
||||
**kwargs: passed to `write()`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if path.suffix == '.gz':
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
open_func = open
|
||||
|
||||
with io.BufferedWriter(open_func(path, mode='wb')) as stream:
|
||||
write(patterns, stream, *args, **kwargs)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
|
||||
"""
|
||||
Wrapper for `read()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to save to.
|
||||
*args: passed to `read()`
|
||||
**kwargs: passed to `read()`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
open_func = open
|
||||
|
||||
with io.BufferedReader(open_func(path, mode='rb')) as stream:
|
||||
results = read(stream, *args, **kwargs)
|
||||
return results
|
||||
|
||||
|
||||
def read(
|
||||
stream: BinaryIO,
|
||||
raw_mode: bool = True,
|
||||
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
|
||||
"""
|
||||
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 SubPattern 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:
|
||||
stream: Stream to read from.
|
||||
raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True.
|
||||
|
||||
Returns:
|
||||
- Dict of pattern_name:Patterns generated from GDSII structures
|
||||
- Dict of GDSII library info
|
||||
"""
|
||||
library_info = _read_header(stream)
|
||||
|
||||
patterns = []
|
||||
found_struct = records.BGNSTR.skip_past(stream)
|
||||
while found_struct:
|
||||
name = records.STRNAME.skip_and_read(stream)
|
||||
pat = read_elements(stream, name=name.decode('ASCII'), raw_mode=raw_mode)
|
||||
patterns.append(pat)
|
||||
found_struct = records.BGNSTR.skip_past(stream)
|
||||
|
||||
# Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
|
||||
# according to the subpattern.identifier (which is deleted after use).
|
||||
patterns_dict = dict(((p.name, p) for p in patterns))
|
||||
for p in patterns_dict.values():
|
||||
for sp in p.subpatterns:
|
||||
sp.pattern = patterns_dict[sp.identifier[0]]
|
||||
del sp.identifier
|
||||
|
||||
return patterns_dict, library_info
|
||||
|
||||
|
||||
def _read_header(stream: BinaryIO) -> Dict[str, Any]:
|
||||
"""
|
||||
Read the file header and create the library_info dict.
|
||||
"""
|
||||
header = klamath.library.FileHeader.read(stream)
|
||||
|
||||
library_info = {'name': header.name.decode('ASCII'),
|
||||
'meters_per_unit': header.meters_per_db_unit,
|
||||
'logical_units_per_unit': header.user_units_per_db_unit,
|
||||
}
|
||||
return library_info
|
||||
|
||||
|
||||
def read_elements(
|
||||
stream: BinaryIO,
|
||||
name: str,
|
||||
raw_mode: bool = True,
|
||||
) -> Pattern:
|
||||
"""
|
||||
Read elements from a GDS structure and build a Pattern from them.
|
||||
|
||||
Args:
|
||||
stream: Seekable stream, positioned at a record boundary.
|
||||
Will be read until an ENDSTR record is consumed.
|
||||
name: Name of the resulting Pattern
|
||||
raw_mode: If True, bypass per-shape data validation. Default True.
|
||||
|
||||
Returns:
|
||||
A pattern containing the elements that were read.
|
||||
"""
|
||||
pat = Pattern(name)
|
||||
|
||||
elements = klamath.library.read_elements(stream)
|
||||
for element in elements:
|
||||
if isinstance(element, klamath.elements.Boundary):
|
||||
poly = _boundary_to_polygon(element, raw_mode)
|
||||
pat.shapes.append(poly)
|
||||
elif isinstance(element, klamath.elements.Path):
|
||||
path = _gpath_to_mpath(element, raw_mode)
|
||||
pat.shapes.append(path)
|
||||
elif isinstance(element, klamath.elements.Text):
|
||||
label = Label(offset=element.xy.astype(float),
|
||||
layer=element.layer,
|
||||
string=element.string.decode('ASCII'),
|
||||
annotations=_properties_to_annotations(element.properties))
|
||||
pat.labels.append(label)
|
||||
elif isinstance(element, klamath.elements.Reference):
|
||||
pat.subpatterns.append(_ref_to_subpat(element))
|
||||
return pat
|
||||
|
||||
|
||||
def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]:
|
||||
""" Helper to turn a layer tuple-or-int into a layer and datatype"""
|
||||
if isinstance(mlayer, int):
|
||||
layer = mlayer
|
||||
data_type = 0
|
||||
elif isinstance(mlayer, tuple):
|
||||
layer = mlayer[0]
|
||||
if len(mlayer) > 1:
|
||||
data_type = mlayer[1]
|
||||
else:
|
||||
data_type = 0
|
||||
else:
|
||||
raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.')
|
||||
return layer, data_type
|
||||
|
||||
|
||||
def _ref_to_subpat(ref: klamath.library.Reference) -> SubPattern:
|
||||
"""
|
||||
Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None
|
||||
and sets the instance .identifier to (struct_name,).
|
||||
"""
|
||||
xy = ref.xy.astype(float)
|
||||
offset = xy[0]
|
||||
repetition = None
|
||||
if ref.colrow is not None:
|
||||
a_count, b_count = ref.colrow
|
||||
a_vector = (xy[1] - offset) / a_count
|
||||
b_vector = (xy[2] - offset) / b_count
|
||||
repetition = Grid(a_vector=a_vector, b_vector=b_vector,
|
||||
a_count=a_count, b_count=b_count)
|
||||
|
||||
subpat = SubPattern(pattern=None,
|
||||
offset=offset,
|
||||
rotation=numpy.deg2rad(ref.angle_deg),
|
||||
scale=ref.mag,
|
||||
mirrored=(ref.invert_y, False),
|
||||
annotations=_properties_to_annotations(ref.properties),
|
||||
repetition=repetition)
|
||||
subpat.identifier = (ref.struct_name.decode('ASCII'),)
|
||||
return subpat
|
||||
|
||||
|
||||
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path:
|
||||
if gpath.path_type in path_cap_map:
|
||||
cap = path_cap_map[gpath.path_type]
|
||||
else:
|
||||
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
||||
|
||||
mpath = Path(vertices=gpath.xy.astype(float),
|
||||
layer=gpath.layer,
|
||||
width=gpath.width,
|
||||
cap=cap,
|
||||
offset=numpy.zeros(2),
|
||||
annotations=_properties_to_annotations(gpath.properties),
|
||||
raw=raw_mode,
|
||||
)
|
||||
if cap == Path.Cap.SquareCustom:
|
||||
mpath.cap_extensions = gpath.extension
|
||||
return mpath
|
||||
|
||||
|
||||
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> Polygon:
|
||||
return Polygon(vertices=boundary.xy[:-1].astype(float),
|
||||
layer=boundary.layer,
|
||||
offset=numpy.zeros(2),
|
||||
annotations=_properties_to_annotations(boundary.properties),
|
||||
raw=raw_mode,
|
||||
)
|
||||
|
||||
|
||||
def _subpatterns_to_refs(subpatterns: List[SubPattern]) -> List[klamath.library.Reference]:
|
||||
refs = []
|
||||
for subpat in subpatterns:
|
||||
if subpat.pattern is None:
|
||||
continue
|
||||
encoded_name = subpat.pattern.name.encode('ASCII')
|
||||
|
||||
# Note: GDS mirrors first and rotates second
|
||||
mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored)
|
||||
rep = subpat.repetition
|
||||
angle_deg = numpy.rad2deg(subpat.rotation + extra_angle) % 360
|
||||
properties = _annotations_to_properties(subpat.annotations, 512)
|
||||
|
||||
if isinstance(rep, Grid):
|
||||
b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||
b_count = rep.b_count if rep.b_count is not None else 1
|
||||
xy: NDArray[numpy.float64] = numpy.array(subpat.offset) + [
|
||||
[0, 0],
|
||||
rep.a_vector * rep.a_count,
|
||||
b_vector * b_count,
|
||||
]
|
||||
aref = klamath.library.Reference(struct_name=encoded_name,
|
||||
xy=numpy.round(xy).astype(int),
|
||||
colrow=(numpy.round(rep.a_count), numpy.round(rep.b_count)),
|
||||
angle_deg=angle_deg,
|
||||
invert_y=mirror_across_x,
|
||||
mag=subpat.scale,
|
||||
properties=properties)
|
||||
refs.append(aref)
|
||||
elif rep is None:
|
||||
ref = klamath.library.Reference(struct_name=encoded_name,
|
||||
xy=numpy.round([subpat.offset]).astype(int),
|
||||
colrow=None,
|
||||
angle_deg=angle_deg,
|
||||
invert_y=mirror_across_x,
|
||||
mag=subpat.scale,
|
||||
properties=properties)
|
||||
refs.append(ref)
|
||||
else:
|
||||
new_srefs = [klamath.library.Reference(struct_name=encoded_name,
|
||||
xy=numpy.round([subpat.offset + dd]).astype(int),
|
||||
colrow=None,
|
||||
angle_deg=angle_deg,
|
||||
invert_y=mirror_across_x,
|
||||
mag=subpat.scale,
|
||||
properties=properties)
|
||||
for dd in rep.displacements]
|
||||
refs += new_srefs
|
||||
return refs
|
||||
|
||||
|
||||
def _properties_to_annotations(properties: Dict[int, bytes]) -> annotations_t:
|
||||
return {str(k): [v.decode()] for k, v in properties.items()}
|
||||
|
||||
|
||||
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Dict[int, bytes]:
|
||||
cum_len = 0
|
||||
props = {}
|
||||
for key, vals in annotations.items():
|
||||
try:
|
||||
i = int(key)
|
||||
except ValueError:
|
||||
raise PatternError(f'Annotation key {key} is not convertable to an integer')
|
||||
if not (0 < i < 126):
|
||||
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])')
|
||||
|
||||
val_strings = ' '.join(str(val) for val in vals)
|
||||
b = val_strings.encode()
|
||||
if len(b) > 126:
|
||||
raise PatternError(f'Annotation value {b!r} is longer than 126 characters!')
|
||||
cum_len += numpy.ceil(len(b) / 2) * 2 + 2
|
||||
if cum_len > max_len:
|
||||
raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}')
|
||||
props[i] = b
|
||||
return props
|
||||
|
||||
|
||||
def _shapes_to_elements(
|
||||
shapes: List[Shape],
|
||||
polygonize_paths: bool = False,
|
||||
) -> List[klamath.elements.Element]:
|
||||
elements: List[klamath.elements.Element] = []
|
||||
# Add a Boundary element for each shape, and Path elements if necessary
|
||||
for shape in shapes:
|
||||
layer, data_type = _mlayer2gds(shape.layer)
|
||||
properties = _annotations_to_properties(shape.annotations, 128)
|
||||
if isinstance(shape, Path) and not polygonize_paths:
|
||||
xy = numpy.round(shape.vertices + shape.offset).astype(int)
|
||||
width = numpy.round(shape.width).astype(int)
|
||||
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
|
||||
|
||||
extension: Tuple[int, int]
|
||||
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
||||
extension = tuple(shape.cap_extensions) # type: ignore
|
||||
else:
|
||||
extension = (0, 0)
|
||||
|
||||
path = klamath.elements.Path(layer=(layer, data_type),
|
||||
xy=xy,
|
||||
path_type=path_type,
|
||||
width=width,
|
||||
extension=extension,
|
||||
properties=properties)
|
||||
elements.append(path)
|
||||
elif isinstance(shape, Polygon):
|
||||
polygon = shape
|
||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
|
||||
xy_closed[-1] = xy_closed[0]
|
||||
boundary = klamath.elements.Boundary(layer=(layer, data_type),
|
||||
xy=xy_closed,
|
||||
properties=properties)
|
||||
elements.append(boundary)
|
||||
else:
|
||||
for polygon in shape.to_polygons():
|
||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
|
||||
xy_closed[-1] = xy_closed[0]
|
||||
boundary = klamath.elements.Boundary(layer=(layer, data_type),
|
||||
xy=xy_closed,
|
||||
properties=properties)
|
||||
elements.append(boundary)
|
||||
return elements
|
||||
|
||||
|
||||
def _labels_to_texts(labels: List[Label]) -> List[klamath.elements.Text]:
|
||||
texts = []
|
||||
for label in labels:
|
||||
properties = _annotations_to_properties(label.annotations, 128)
|
||||
layer, text_type = _mlayer2gds(label.layer)
|
||||
xy = numpy.round([label.offset]).astype(int)
|
||||
text = klamath.elements.Text(layer=(layer, text_type),
|
||||
xy=xy,
|
||||
string=label.string.encode('ASCII'),
|
||||
properties=properties,
|
||||
presentation=0, # TODO maybe set some of these?
|
||||
angle_deg=0,
|
||||
invert_y=False,
|
||||
width=0,
|
||||
path_type=0,
|
||||
mag=1)
|
||||
texts.append(text)
|
||||
return texts
|
||||
|
||||
|
||||
def disambiguate_pattern_names(
|
||||
patterns: Sequence[Pattern],
|
||||
max_name_length: int = 32,
|
||||
suffix_length: int = 6,
|
||||
dup_warn_filter: Optional[Callable[[str], bool]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
patterns: List of patterns to disambiguate
|
||||
max_name_length: Names longer than this will be truncated
|
||||
suffix_length: Names which get truncated are truncated by this many extra characters. This is to
|
||||
leave room for a suffix if one is necessary.
|
||||
dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives
|
||||
the cell name and returns `False` if the warning should be suppressed and `True` if it should
|
||||
be displayed. Default displays all warnings.
|
||||
"""
|
||||
used_names = []
|
||||
for pat in set(patterns):
|
||||
# Shorten names which already exceed max-length
|
||||
if len(pat.name) > max_name_length:
|
||||
shortened_name = pat.name[:max_name_length - suffix_length]
|
||||
logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n'
|
||||
+ f' shortening to "{shortened_name}" before generating suffix')
|
||||
else:
|
||||
shortened_name = pat.name
|
||||
|
||||
# Remove invalid characters
|
||||
sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name)
|
||||
|
||||
# Add a suffix that makes the name unique
|
||||
i = 0
|
||||
suffixed_name = sanitized_name
|
||||
while suffixed_name in used_names or suffixed_name == '':
|
||||
suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII')
|
||||
|
||||
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
|
||||
i += 1
|
||||
|
||||
if sanitized_name == '':
|
||||
logger.warning(f'Empty pattern name saved as "{suffixed_name}"')
|
||||
elif suffixed_name != sanitized_name:
|
||||
if dup_warn_filter is None or dup_warn_filter(pat.name):
|
||||
logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n'
|
||||
+ f' renaming to "{suffixed_name}"')
|
||||
|
||||
# Encode into a byte-string and perform some final checks
|
||||
encoded_name = suffixed_name.encode('ASCII')
|
||||
if len(encoded_name) == 0:
|
||||
# Should never happen since zero-length names are replaced
|
||||
raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"')
|
||||
if len(encoded_name) > max_name_length:
|
||||
raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n'
|
||||
+ f' originally "{pat.name}"')
|
||||
|
||||
pat.name = suffixed_name
|
||||
used_names.append(suffixed_name)
|
||||
|
||||
|
||||
def load_library(
|
||||
stream: BinaryIO,
|
||||
tag: str,
|
||||
is_secondary: Optional[Callable[[str], bool]] = None,
|
||||
*,
|
||||
full_load: bool = False,
|
||||
) -> Tuple[Library, 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.
|
||||
tag: Unique identifier that will be used to identify this data source
|
||||
is_secondary: Function which takes a structure name and returns
|
||||
True if the structure should only be used as a subcell
|
||||
and not appear in the main Library interface.
|
||||
Default always returns False.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Library object, allowing for deferred load of structures.
|
||||
Additional library info (dict, same format as from `read`).
|
||||
"""
|
||||
if is_secondary is None:
|
||||
def is_secondary(k: str) -> bool:
|
||||
return False
|
||||
assert(is_secondary is not None)
|
||||
|
||||
stream.seek(0)
|
||||
lib = Library()
|
||||
|
||||
if full_load:
|
||||
# Full load approach (immediately load everything)
|
||||
patterns, library_info = read(stream)
|
||||
for name, pattern in patterns.items():
|
||||
lib.set_const(name, tag, pattern, secondary=is_secondary(name))
|
||||
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)
|
||||
return read_elements(stream, name, raw_mode=True)
|
||||
|
||||
lib.set_value(name, tag, mkstruct, secondary=is_secondary(name))
|
||||
|
||||
return lib, library_info
|
||||
|
||||
|
||||
def load_libraryfile(
|
||||
filename: Union[str, pathlib.Path],
|
||||
tag: str,
|
||||
is_secondary: Optional[Callable[[str], bool]] = None,
|
||||
*,
|
||||
use_mmap: bool = True,
|
||||
full_load: bool = False,
|
||||
) -> Tuple[Library, 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
|
||||
tag: Unique identifier for library, see `load_library`
|
||||
is_secondary: Function specifying subcess, see `load_library`
|
||||
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`.
|
||||
|
||||
Returns:
|
||||
Library object, allowing for deferred load of structures.
|
||||
Additional library info (dict, same format as from `read`).
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
if mmap:
|
||||
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
|
||||
base_stream = gzip.open(path, mode='rb')
|
||||
stream = io.BytesIO(base_stream.read())
|
||||
else:
|
||||
base_stream = gzip.open(path, mode='rb')
|
||||
stream = io.BufferedReader(base_stream)
|
||||
else:
|
||||
base_stream = open(path, mode='rb')
|
||||
if mmap:
|
||||
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
else:
|
||||
stream = io.BufferedReader(base_stream)
|
||||
return load_library(stream, tag, is_secondary)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"""
|
||||
GDSII file format readers and writers.
|
||||
"""
|
||||
from .klamath import check_valid_names as check_valid_names
|
||||
from .klamath import read as read
|
||||
from .klamath import readfile as readfile
|
||||
from .writer import write as write
|
||||
from .writer import writefile as writefile
|
||||
|
|
@ -1,843 +0,0 @@
|
|||
# ruff: noqa: ARG001
|
||||
"""
|
||||
GDSII file format readers and writers using the `TODO` library.
|
||||
|
||||
Note that GDSII references follow the same convention as `masque`,
|
||||
with this order of operations:
|
||||
1. Mirroring
|
||||
2. Rotation
|
||||
3. Scaling
|
||||
4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets)
|
||||
|
||||
Scaling, rotation, and mirroring apply to individual instances, not grid
|
||||
vectors or offsets.
|
||||
|
||||
Notes:
|
||||
* absolute positioning is not supported
|
||||
* PLEX is not supported
|
||||
* ELFLAGS are not supported
|
||||
* GDS does not support library- or structure-level annotations
|
||||
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
|
||||
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
|
||||
|
||||
TODO writing
|
||||
TODO warn on boxes, nodes
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from functools import cache
|
||||
from importlib.machinery import EXTENSION_SUFFIXES
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import gzip
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from klamath.basic import KlamathError
|
||||
import numpy
|
||||
import pyarrow
|
||||
from pyarrow.cffi import ffi
|
||||
|
||||
from ..utils import is_gzipped
|
||||
from ... import Pattern, Ref, PatternError, Label
|
||||
from ...shapes import Polygon, Path, PolyCollection, RectCollection
|
||||
from ...repetition import Grid
|
||||
from ...library import Library
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
import mmap
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ...utils import annotations_t
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ffi.cdef(
|
||||
"""
|
||||
const char* last_error_message(void);
|
||||
int read_path(const char* path, struct ArrowArray* array, struct ArrowSchema* schema);
|
||||
int scan_bytes(uint8_t* data, size_t size, struct ArrowArray* array, struct ArrowSchema* schema);
|
||||
int read_cells_bytes(
|
||||
uint8_t* data,
|
||||
size_t size,
|
||||
uint64_t* ranges,
|
||||
size_t range_count,
|
||||
struct ArrowArray* array,
|
||||
struct ArrowSchema* schema
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
_PATH_CAP_MAP = {
|
||||
0: Path.Cap.Flush,
|
||||
1: Path.Cap.Circle,
|
||||
2: Path.Cap.Square,
|
||||
4: Path.Cap.SquareCustom,
|
||||
}
|
||||
|
||||
|
||||
def _packed_layer_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int16]:
|
||||
layer = (values >> numpy.uint32(16)).astype(numpy.uint16).view(numpy.int16)
|
||||
dtype = (values & numpy.uint32(0xffff)).astype(numpy.uint16).view(numpy.int16)
|
||||
return numpy.stack((layer, dtype), axis=-1)
|
||||
|
||||
|
||||
def _packed_counts_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int64]:
|
||||
a_count = (values >> numpy.uint32(16)).astype(numpy.uint16).astype(numpy.int64)
|
||||
b_count = (values & numpy.uint32(0xffff)).astype(numpy.uint16).astype(numpy.int64)
|
||||
return numpy.stack((a_count, b_count), axis=-1)
|
||||
|
||||
|
||||
def _packed_xy_u64_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int32]:
|
||||
xx = (values >> numpy.uint64(32)).astype(numpy.uint32).view(numpy.int32)
|
||||
yy = (values & numpy.uint64(0xffff_ffff)).astype(numpy.uint32).view(numpy.int32)
|
||||
return numpy.stack((xx, yy), axis=-1)
|
||||
|
||||
|
||||
def _local_library_filename() -> str:
|
||||
if sys.platform.startswith('linux'):
|
||||
return 'libklamath_rs_ext.so'
|
||||
if sys.platform == 'darwin':
|
||||
return 'libklamath_rs_ext.dylib'
|
||||
if sys.platform == 'win32':
|
||||
return 'klamath_rs_ext.dll'
|
||||
raise OSError(f'Unsupported platform for klamath_rs_ext: {sys.platform!r}')
|
||||
|
||||
|
||||
def _installed_library_candidates() -> list[pathlib.Path]:
|
||||
candidates: list[pathlib.Path] = []
|
||||
|
||||
try:
|
||||
spec = importlib.util.find_spec('klamath_rs_ext.klamath_rs_ext')
|
||||
except ModuleNotFoundError:
|
||||
spec = None
|
||||
if spec is not None and spec.origin is not None:
|
||||
candidates.append(pathlib.Path(spec.origin))
|
||||
|
||||
try:
|
||||
pkg_spec = importlib.util.find_spec('klamath_rs_ext')
|
||||
except ModuleNotFoundError:
|
||||
pkg_spec = None
|
||||
if pkg_spec is not None and pkg_spec.submodule_search_locations is not None:
|
||||
for location in pkg_spec.submodule_search_locations:
|
||||
pkg_dir = pathlib.Path(location)
|
||||
for suffix in EXTENSION_SUFFIXES:
|
||||
candidates.extend(sorted(pkg_dir.glob(f'klamath_rs_ext*{suffix}')))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _repo_library_candidates() -> list[pathlib.Path]:
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[3]
|
||||
library_name = _local_library_filename()
|
||||
return [
|
||||
repo_root / 'klamath-rs' / 'target' / 'release' / library_name,
|
||||
repo_root / 'klamath-rs' / 'target' / 'debug' / library_name,
|
||||
]
|
||||
|
||||
|
||||
def _find_klamath_rs_library() -> pathlib.Path | None:
|
||||
env_path = os.environ.get('KLAMATH_RS_EXT_LIB')
|
||||
if env_path:
|
||||
candidate = pathlib.Path(env_path).expanduser()
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
|
||||
seen: set[pathlib.Path] = set()
|
||||
for candidate in _installed_library_candidates() + _repo_library_candidates():
|
||||
resolved = candidate.expanduser()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
if resolved.exists():
|
||||
return resolved.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return _find_klamath_rs_library() is not None
|
||||
|
||||
|
||||
@cache
|
||||
def _get_clib() -> Any:
|
||||
lib_path = _find_klamath_rs_library()
|
||||
if lib_path is None:
|
||||
raise ImportError(
|
||||
'Could not locate klamath_rs_ext shared library. '
|
||||
'Build klamath-rs with `cargo build --release --manifest-path klamath-rs/Cargo.toml` '
|
||||
'or set KLAMATH_RS_EXT_LIB to the built library path.'
|
||||
)
|
||||
return ffi.dlopen(str(lib_path))
|
||||
|
||||
|
||||
def _read_annotations(
|
||||
prop_offs: NDArray[numpy.integer[Any]],
|
||||
prop_key: NDArray[numpy.integer[Any]],
|
||||
prop_val: list[str],
|
||||
ee: int,
|
||||
) -> annotations_t:
|
||||
prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1]
|
||||
if prop_ii >= prop_ff:
|
||||
return None
|
||||
return {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)}
|
||||
|
||||
|
||||
def _read_to_arrow(
|
||||
filename: str | pathlib.Path,
|
||||
) -> pyarrow.Array:
|
||||
path = pathlib.Path(filename).expanduser().resolve()
|
||||
ptr_array = ffi.new('struct ArrowArray[]', 1)
|
||||
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
|
||||
if is_gzipped(path):
|
||||
with gzip.open(path, mode='rb') as src:
|
||||
data = src.read()
|
||||
with tempfile.NamedTemporaryFile(suffix='.gds', delete=False) as tmp_stream:
|
||||
tmp_stream.write(data)
|
||||
tmp_name = tmp_stream.name
|
||||
try:
|
||||
_call_native(_get_clib().read_path(tmp_name.encode(), ptr_array, ptr_schema), 'read_path')
|
||||
finally:
|
||||
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
||||
else:
|
||||
_call_native(_get_clib().read_path(str(path).encode(), ptr_array, ptr_schema), 'read_path')
|
||||
return _import_arrow_array(ptr_array, ptr_schema)
|
||||
|
||||
|
||||
def _import_arrow_array(ptr_array: Any, ptr_schema: Any) -> pyarrow.Array:
|
||||
iptr_schema = int(ffi.cast('uintptr_t', ptr_schema))
|
||||
iptr_array = int(ffi.cast('uintptr_t', ptr_array))
|
||||
return pyarrow.Array._import_from_c(iptr_array, iptr_schema)
|
||||
|
||||
|
||||
def _call_native(status: int, action: str) -> None:
|
||||
if status == 0:
|
||||
return
|
||||
|
||||
err_ptr = _get_clib().last_error_message()
|
||||
if err_ptr == ffi.NULL:
|
||||
raise KlamathError(f'{action} failed')
|
||||
|
||||
message = ffi.string(err_ptr).decode(errors='replace')
|
||||
raise KlamathError(message)
|
||||
|
||||
|
||||
def _scan_buffer_to_arrow(buffer: bytes | mmap.mmap | memoryview) -> pyarrow.Array:
|
||||
ptr_array = ffi.new('struct ArrowArray[]', 1)
|
||||
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
|
||||
buf_view = memoryview(buffer)
|
||||
cbuf = ffi.from_buffer('uint8_t[]', buf_view)
|
||||
_call_native(_get_clib().scan_bytes(cbuf, len(buf_view), ptr_array, ptr_schema), 'scan_bytes')
|
||||
return _import_arrow_array(ptr_array, ptr_schema)
|
||||
|
||||
|
||||
def _read_selected_cells_to_arrow(
|
||||
buffer: bytes | mmap.mmap | memoryview,
|
||||
ranges: NDArray[numpy.uint64],
|
||||
) -> pyarrow.Array:
|
||||
ptr_array = ffi.new('struct ArrowArray[]', 1)
|
||||
ptr_schema = ffi.new('struct ArrowSchema[]', 1)
|
||||
buf_view = memoryview(buffer)
|
||||
cbuf = ffi.from_buffer('uint8_t[]', buf_view)
|
||||
flat_ranges = numpy.require(ranges, dtype=numpy.uint64, requirements=('C_CONTIGUOUS', 'ALIGNED'))
|
||||
cranges = ffi.from_buffer('uint64_t[]', flat_ranges)
|
||||
_call_native(
|
||||
_get_clib().read_cells_bytes(cbuf, len(buf_view), cranges, int(flat_ranges.shape[0]), ptr_array, ptr_schema),
|
||||
'read_cells_bytes',
|
||||
)
|
||||
return _import_arrow_array(ptr_array, ptr_schema)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
"""
|
||||
Read a GDSII file from a path into `masque.Library` / `Pattern` objects.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to read.
|
||||
|
||||
For callers that can consume Arrow directly, prefer `readfile_arrow()`
|
||||
to skip Python `Pattern` construction entirely.
|
||||
"""
|
||||
arrow_arr = _read_to_arrow(filename)
|
||||
assert len(arrow_arr) == 1
|
||||
|
||||
results = read_arrow(arrow_arr[0])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def readfile_arrow(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[pyarrow.StructScalar, dict[str, Any]]:
|
||||
"""
|
||||
Read a GDSII file into the native Arrow representation without converting
|
||||
it into `masque.Library` / `Pattern` objects.
|
||||
|
||||
This is the lowest-overhead public read path exposed by this module.
|
||||
|
||||
Args:
|
||||
filename: Filename to read.
|
||||
|
||||
Returns:
|
||||
- Arrow struct scalar for the library payload
|
||||
- dict of GDSII library info
|
||||
"""
|
||||
arrow_arr = _read_to_arrow(filename)
|
||||
assert len(arrow_arr) == 1
|
||||
libarr = arrow_arr[0]
|
||||
return libarr, _read_header(libarr)
|
||||
|
||||
|
||||
def read_arrow(
|
||||
libarr: pyarrow.Array,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
"""
|
||||
# TODO check GDSII file for cycles!
|
||||
Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are
|
||||
translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs
|
||||
are translated into Ref objects.
|
||||
|
||||
Additional library info is returned in a dict, containing:
|
||||
'name': name of the library
|
||||
'meters_per_unit': number of meters per database unit (all values are in database units)
|
||||
'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns)
|
||||
per database unit
|
||||
|
||||
Args:
|
||||
libarr: Arrow library payload as returned by `readfile_arrow()`.
|
||||
|
||||
Returns:
|
||||
- dict of pattern_name:Patterns generated from GDSII structures
|
||||
- dict of GDSII library info
|
||||
"""
|
||||
library_info = _read_header(libarr)
|
||||
|
||||
layer_names_np = _packed_layer_u32_to_pairs(libarr['layers'].values.to_numpy())
|
||||
layer_tups = [(int(pair[0]), int(pair[1])) for pair in layer_names_np]
|
||||
|
||||
cell_ids = libarr['cells'].values.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
|
||||
# Masque geometry is mutable and supports fractional transforms. Convert
|
||||
# coordinates in bulk before slicing them into objects; scan-only and raw
|
||||
# GDS copy-through workflows never enter this materialization path.
|
||||
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().astype(float).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().astype(float).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().astype(float).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().astype(float).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()).astype(float),
|
||||
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()).astype(float),
|
||||
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float),
|
||||
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()).astype(float),
|
||||
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()).astype(float),
|
||||
xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()).astype(float),
|
||||
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()).astype(float),
|
||||
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, dtype=float),
|
||||
))
|
||||
|
||||
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.float64],
|
||||
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)
|
||||
|
|
@ -1,501 +0,0 @@
|
|||
"""
|
||||
GDSII file format readers and writers using the `klamath` 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)
|
||||
"""
|
||||
from typing import IO, Any
|
||||
from collections.abc import Iterable, Mapping, Callable
|
||||
from types import MappingProxyType
|
||||
import logging
|
||||
import pathlib
|
||||
import gzip
|
||||
import string
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
import klamath
|
||||
from klamath import records
|
||||
|
||||
from ..utils import is_gzipped
|
||||
from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||
from ...shapes import Polygon, Path, RectCollection
|
||||
from ...repetition import Grid
|
||||
from ...utils import layer_t, annotations_t
|
||||
from ...library import Library
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_PATH_CAP_MAP = {
|
||||
0: Path.Cap.Flush,
|
||||
1: Path.Cap.Circle,
|
||||
2: Path.Cap.Square,
|
||||
4: Path.Cap.SquareCustom,
|
||||
}
|
||||
|
||||
_EMPTY_PROPERTIES: Mapping[int, bytes] = MappingProxyType({})
|
||||
|
||||
|
||||
def _rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
|
||||
return numpy.rint(val).astype(numpy.int32)
|
||||
|
||||
|
||||
def _write_header(
|
||||
stream: IO[bytes],
|
||||
meters_per_unit: float,
|
||||
logical_units_per_unit: float,
|
||||
library_name: str,
|
||||
) -> None:
|
||||
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)
|
||||
|
||||
|
||||
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
|
||||
elements: list[klamath.elements.Element] = []
|
||||
elements += _shapes_to_elements(pat.shapes)
|
||||
elements += _labels_to_texts(pat.labels)
|
||||
elements += _mrefs_to_grefs(pat.refs)
|
||||
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
|
||||
|
||||
|
||||
def _write_footer(stream: IO[bytes]) -> None:
|
||||
records.ENDLIB.write(stream, None)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
"""
|
||||
Wrapper for `read()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to save to.
|
||||
*args: passed to `read()`
|
||||
**kwargs: passed to `read()`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
open_func = open
|
||||
|
||||
with open_func(path, mode='rb') as stream:
|
||||
results = read(stream, *args, **kwargs)
|
||||
return results
|
||||
|
||||
|
||||
def read(
|
||||
stream: IO[bytes],
|
||||
raw_mode: bool = True,
|
||||
) -> 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:
|
||||
stream: Stream to read from.
|
||||
raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True.
|
||||
|
||||
Returns:
|
||||
- dict of pattern_name:Patterns generated from GDSII structures
|
||||
- dict of GDSII library info
|
||||
"""
|
||||
library_info = _read_header(stream)
|
||||
|
||||
mlib = Library()
|
||||
found_struct = records.BGNSTR.skip_past(stream)
|
||||
while found_struct:
|
||||
name = records.STRNAME.skip_and_read(stream)
|
||||
pat = _read_elements(stream, raw_mode=raw_mode)
|
||||
mlib[name.decode('ASCII')] = pat
|
||||
found_struct = records.BGNSTR.skip_past(stream)
|
||||
|
||||
return mlib, library_info
|
||||
|
||||
|
||||
def _read_header(stream: IO[bytes]) -> dict[str, Any]:
|
||||
"""
|
||||
Read the file header and create the library_info dict.
|
||||
"""
|
||||
header = klamath.library.FileHeader.read(stream)
|
||||
|
||||
library_info = {'name': header.name.decode('ASCII'),
|
||||
'meters_per_unit': header.meters_per_db_unit,
|
||||
'logical_units_per_unit': header.user_units_per_db_unit,
|
||||
}
|
||||
return library_info
|
||||
|
||||
|
||||
def _read_elements(
|
||||
stream: IO[bytes],
|
||||
raw_mode: bool = True,
|
||||
) -> Pattern:
|
||||
"""
|
||||
Read elements from a GDS structure and build a Pattern from them.
|
||||
|
||||
Args:
|
||||
stream: Seekable stream, positioned at a record boundary.
|
||||
Will be read until an ENDSTR record is consumed.
|
||||
name: Name of the resulting Pattern
|
||||
raw_mode: If True, bypass per-shape data validation. Default True.
|
||||
|
||||
Returns:
|
||||
A pattern containing the elements that were read.
|
||||
"""
|
||||
pat = Pattern()
|
||||
|
||||
elements = klamath.library.read_elements(stream)
|
||||
for element in elements:
|
||||
if isinstance(element, klamath.elements.Boundary):
|
||||
layer, poly = _boundary_to_polygon(element, raw_mode)
|
||||
pat.shapes[layer].append(poly)
|
||||
elif isinstance(element, klamath.elements.Path):
|
||||
layer, path = _gpath_to_mpath(element, raw_mode)
|
||||
pat.shapes[layer].append(path)
|
||||
elif isinstance(element, klamath.elements.Text):
|
||||
pat.label(
|
||||
layer=element.layer,
|
||||
offset=element.xy.astype(float),
|
||||
string=element.string.decode('ASCII'),
|
||||
annotations=_properties_to_annotations(element.properties),
|
||||
)
|
||||
elif isinstance(element, klamath.elements.Reference):
|
||||
target, ref = _gref_to_mref(element)
|
||||
pat.refs[target].append(ref)
|
||||
return pat
|
||||
|
||||
|
||||
def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]:
|
||||
""" Helper to turn a layer tuple-or-int into a layer and datatype"""
|
||||
if isinstance(mlayer, int):
|
||||
layer = mlayer
|
||||
data_type = 0
|
||||
elif isinstance(mlayer, tuple):
|
||||
layer = mlayer[0]
|
||||
if len(mlayer) > 1:
|
||||
data_type = mlayer[1]
|
||||
else:
|
||||
data_type = 0
|
||||
else:
|
||||
raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.')
|
||||
return layer, data_type
|
||||
|
||||
|
||||
def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
|
||||
"""
|
||||
Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name.
|
||||
"""
|
||||
xy = ref.xy.astype(float)
|
||||
offset = xy[0]
|
||||
repetition = None
|
||||
if ref.colrow is not None:
|
||||
a_count, b_count = ref.colrow
|
||||
a_vector = (xy[1] - offset) / a_count
|
||||
b_vector = (xy[2] - offset) / b_count
|
||||
repetition = Grid(a_vector=a_vector, b_vector=b_vector,
|
||||
a_count=a_count, b_count=b_count)
|
||||
|
||||
target = ref.struct_name.decode('ASCII')
|
||||
mref = Ref(
|
||||
offset=offset,
|
||||
rotation=numpy.deg2rad(ref.angle_deg),
|
||||
scale=ref.mag,
|
||||
mirrored=ref.invert_y,
|
||||
annotations=_properties_to_annotations(ref.properties),
|
||||
repetition=repetition,
|
||||
)
|
||||
return target, mref
|
||||
|
||||
|
||||
def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]:
|
||||
if gpath.path_type in _PATH_CAP_MAP:
|
||||
cap = _PATH_CAP_MAP[gpath.path_type]
|
||||
else:
|
||||
raise PatternError(f'Unrecognized path type: {gpath.path_type}')
|
||||
|
||||
vertices = gpath.xy.astype(float)
|
||||
annotations = _properties_to_annotations(gpath.properties)
|
||||
cap_extensions = None
|
||||
if cap == Path.Cap.SquareCustom:
|
||||
cap_extensions = numpy.asarray(gpath.extension, dtype=float)
|
||||
|
||||
if raw_mode:
|
||||
mpath = Path._from_raw(
|
||||
vertices=vertices,
|
||||
width=gpath.width,
|
||||
cap=cap,
|
||||
cap_extensions=cap_extensions,
|
||||
annotations=annotations,
|
||||
)
|
||||
else:
|
||||
mpath = Path(
|
||||
vertices=vertices,
|
||||
width=gpath.width,
|
||||
cap=cap,
|
||||
cap_extensions=cap_extensions,
|
||||
offset=numpy.zeros(2),
|
||||
annotations=annotations,
|
||||
)
|
||||
return gpath.layer, mpath
|
||||
|
||||
|
||||
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
|
||||
vertices = boundary.xy[:-1].astype(float)
|
||||
annotations = _properties_to_annotations(boundary.properties)
|
||||
if raw_mode:
|
||||
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
|
||||
else:
|
||||
poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations)
|
||||
return boundary.layer, poly
|
||||
|
||||
|
||||
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
|
||||
grefs = []
|
||||
for target, rseq in refs.items():
|
||||
if target is None:
|
||||
continue
|
||||
encoded_name = target.encode('ASCII')
|
||||
for ref in rseq:
|
||||
# Note: GDS also mirrors first and rotates second
|
||||
rep = ref.repetition
|
||||
angle_deg = numpy.rad2deg(ref.rotation) % 360
|
||||
properties = _annotations_to_properties(ref.annotations, 512)
|
||||
|
||||
if isinstance(rep, Grid):
|
||||
b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||
b_count = rep.b_count if rep.b_count is not None else 1
|
||||
xy = numpy.asarray(ref.offset) + numpy.array([
|
||||
[0.0, 0.0],
|
||||
rep.a_vector * rep.a_count,
|
||||
b_vector * b_count,
|
||||
])
|
||||
aref = klamath.library.Reference(
|
||||
struct_name=encoded_name,
|
||||
xy=_rint_cast(xy),
|
||||
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
|
||||
angle_deg=angle_deg,
|
||||
invert_y=ref.mirrored,
|
||||
mag=ref.scale,
|
||||
properties=properties,
|
||||
)
|
||||
grefs.append(aref)
|
||||
elif rep is None:
|
||||
sref = klamath.library.Reference(
|
||||
struct_name=encoded_name,
|
||||
xy=_rint_cast([ref.offset]),
|
||||
colrow=None,
|
||||
angle_deg=angle_deg,
|
||||
invert_y=ref.mirrored,
|
||||
mag=ref.scale,
|
||||
properties=properties,
|
||||
)
|
||||
grefs.append(sref)
|
||||
else:
|
||||
new_srefs = [
|
||||
klamath.library.Reference(
|
||||
struct_name=encoded_name,
|
||||
xy=_rint_cast([ref.offset + dd]),
|
||||
colrow=None,
|
||||
angle_deg=angle_deg,
|
||||
invert_y=ref.mirrored,
|
||||
mag=ref.scale,
|
||||
properties=properties,
|
||||
)
|
||||
for dd in rep.displacements]
|
||||
grefs += new_srefs
|
||||
return grefs
|
||||
|
||||
|
||||
def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t:
|
||||
if not properties:
|
||||
return None
|
||||
return {str(k): [v.decode()] for k, v in properties.items()}
|
||||
|
||||
|
||||
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]:
|
||||
if annotations is None:
|
||||
return _EMPTY_PROPERTIES
|
||||
cum_len = 0
|
||||
props = {}
|
||||
for key, vals in annotations.items():
|
||||
try:
|
||||
i = int(key)
|
||||
except ValueError as err:
|
||||
raise PatternError(f'Annotation key {key} is not convertable to an integer') from err
|
||||
if not (0 < i <= 126):
|
||||
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,126])')
|
||||
|
||||
val_strings = ' '.join(str(val) for val in vals)
|
||||
b = val_strings.encode()
|
||||
if len(b) > 126:
|
||||
raise PatternError(f'Annotation value {b!r} is longer than 126 characters!')
|
||||
cum_len += numpy.ceil(len(b) / 2) * 2 + 2
|
||||
if cum_len > max_len:
|
||||
raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}')
|
||||
props[i] = b
|
||||
return props
|
||||
|
||||
|
||||
def _shapes_to_elements(
|
||||
shapes: dict[layer_t, list[Shape]],
|
||||
polygonize_paths: bool = False,
|
||||
) -> list[klamath.elements.Element]:
|
||||
elements: list[klamath.elements.Element] = []
|
||||
# Add a Boundary element for each shape, and Path elements if necessary
|
||||
for mlayer, sseq in shapes.items():
|
||||
layer, data_type = _mlayer2gds(mlayer)
|
||||
for shape in sseq:
|
||||
if shape.repetition is not None:
|
||||
raise PatternError('Shape repetitions are not supported by GDS.'
|
||||
' Please call library.wrap_repeated_shapes() before writing to file.')
|
||||
|
||||
properties = _annotations_to_properties(shape.annotations, 128)
|
||||
if isinstance(shape, Path) and not polygonize_paths:
|
||||
xy = _rint_cast(shape.vertices + shape.offset)
|
||||
width = _rint_cast(shape.width)
|
||||
path_type = next(k for k, v in _PATH_CAP_MAP.items() if v == shape.cap) # reverse lookup
|
||||
|
||||
extension: tuple[int, int]
|
||||
if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None:
|
||||
extension = tuple(_rint_cast(shape.cap_extensions))
|
||||
else:
|
||||
extension = (0, 0)
|
||||
|
||||
path = klamath.elements.Path(
|
||||
layer=(layer, data_type),
|
||||
xy=xy,
|
||||
path_type=path_type,
|
||||
width=int(width),
|
||||
extension=extension,
|
||||
properties=properties,
|
||||
)
|
||||
elements.append(path)
|
||||
elif isinstance(shape, RectCollection):
|
||||
for rect in shape.rects:
|
||||
xy_closed = numpy.empty((5, 2), dtype=numpy.int32)
|
||||
xy_closed[0] = _rint_cast((rect[0], rect[1]))
|
||||
xy_closed[1] = _rint_cast((rect[0], rect[3]))
|
||||
xy_closed[2] = _rint_cast((rect[2], rect[3]))
|
||||
xy_closed[3] = _rint_cast((rect[2], rect[1]))
|
||||
xy_closed[4] = xy_closed[0]
|
||||
boundary = klamath.elements.Boundary(
|
||||
layer=(layer, data_type),
|
||||
xy=xy_closed,
|
||||
properties=properties,
|
||||
)
|
||||
elements.append(boundary)
|
||||
elif isinstance(shape, Polygon):
|
||||
polygon = shape
|
||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
|
||||
xy_closed[-1] = xy_closed[0]
|
||||
boundary = klamath.elements.Boundary(
|
||||
layer=(layer, data_type),
|
||||
xy=xy_closed,
|
||||
properties=properties,
|
||||
)
|
||||
elements.append(boundary)
|
||||
else:
|
||||
for polygon in shape.to_polygons():
|
||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
|
||||
xy_closed[-1] = xy_closed[0]
|
||||
boundary = klamath.elements.Boundary(
|
||||
layer=(layer, data_type),
|
||||
xy=xy_closed,
|
||||
properties=properties,
|
||||
)
|
||||
elements.append(boundary)
|
||||
return elements
|
||||
|
||||
|
||||
def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]:
|
||||
texts = []
|
||||
for mlayer, lseq in labels.items():
|
||||
layer, text_type = _mlayer2gds(mlayer)
|
||||
for label in lseq:
|
||||
properties = _annotations_to_properties(label.annotations, 128)
|
||||
xy = _rint_cast([label.offset])
|
||||
text = klamath.elements.Text(
|
||||
layer=(layer, text_type),
|
||||
xy=xy,
|
||||
string=label.string.encode('ASCII'),
|
||||
properties=properties,
|
||||
presentation=0, # font number & alignment -- unused by us
|
||||
angle_deg=0, # rotation -- unused by us
|
||||
invert_y=False, # inversion -- unused by us
|
||||
width=0, # stroke width -- unused by us
|
||||
path_type=0, # text path endcaps, unused
|
||||
mag=1, # size -- unused by us
|
||||
)
|
||||
texts.append(text)
|
||||
return texts
|
||||
|
||||
|
||||
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
|
||||
|
||||
"""
|
||||
names = tuple(names)
|
||||
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_lengths))
|
||||
|
||||
if bad_chars or bad_lengths:
|
||||
raise LibraryError('Library contains invalid names, see log above')
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
"""
|
||||
Classic source-backed lazy GDSII reader built on the pure-python klamath path.
|
||||
|
||||
This module provides the non-Arrow half of Masque's lazy GDS architecture:
|
||||
|
||||
- `GdsLibrarySource` scans a GDS stream once to discover library metadata,
|
||||
struct order, and child edges without materializing every cell.
|
||||
- cells are materialized on demand through the classic `gdsii` decoder
|
||||
whenever a caller indexes the lazy view
|
||||
- untouched cells can be copied directly to another GDS file without
|
||||
materializing them
|
||||
- the source can be wrapped in `PortLoadView` or merged through
|
||||
`OverlayLibrary`
|
||||
|
||||
The public surface intentionally parallels `gdsii.lazy_arrow` closely so that
|
||||
callers can swap between the classic and Arrow-backed implementations with
|
||||
minimal changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import IO, TYPE_CHECKING, Any, cast
|
||||
import gzip
|
||||
import io
|
||||
import logging
|
||||
import mmap
|
||||
import pathlib
|
||||
|
||||
import klamath
|
||||
from klamath import records
|
||||
|
||||
from . import klamath as gdsii_klamath
|
||||
from ..utils import is_gzipped
|
||||
from ...error import LibraryError
|
||||
from ...library import (
|
||||
ILibraryView,
|
||||
IMaterializable,
|
||||
LibraryView,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ...pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SourceHandle:
|
||||
""" Owns the underlying stream and any companion file handle for a source. """
|
||||
path: pathlib.Path | None
|
||||
stream: IO[bytes]
|
||||
handle: IO[bytes] | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
self.stream.close()
|
||||
if self.handle is not None and self.handle is not self.stream:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CellScan:
|
||||
""" Scan-time metadata for one cell in the source stream. """
|
||||
offset: int
|
||||
struct_start: int
|
||||
struct_end: int
|
||||
children: set[str]
|
||||
|
||||
|
||||
def _open_source_stream(
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool,
|
||||
) -> _SourceHandle:
|
||||
path = pathlib.Path(filename).expanduser().resolve()
|
||||
if is_gzipped(path):
|
||||
if use_mmap:
|
||||
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
|
||||
with gzip.open(path, mode='rb') as gzip_stream:
|
||||
data = gzip_stream.read()
|
||||
return _SourceHandle(path=path, stream=io.BytesIO(data))
|
||||
source_stream = cast('IO[bytes]', gzip.open(path, mode='rb')) # noqa: SIM115
|
||||
return _SourceHandle(path=path, stream=source_stream)
|
||||
|
||||
if use_mmap:
|
||||
handle = path.open(mode='rb', buffering=0)
|
||||
mapped = cast('IO[bytes]', mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ))
|
||||
return _SourceHandle(path=path, stream=mapped, handle=handle)
|
||||
|
||||
source_stream = path.open(mode='rb')
|
||||
return _SourceHandle(path=path, stream=source_stream)
|
||||
|
||||
|
||||
def _scan_library(
|
||||
stream: IO[bytes],
|
||||
) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]:
|
||||
library_info = gdsii_klamath._read_header(stream)
|
||||
order: list[str] = []
|
||||
cells: dict[str, _CellScan] = {}
|
||||
|
||||
while True:
|
||||
struct_start = stream.tell()
|
||||
if not records.BGNSTR.skip_past(stream):
|
||||
break
|
||||
name = records.STRNAME.skip_and_read(stream).decode('ASCII')
|
||||
offset = stream.tell()
|
||||
elements = klamath.library.read_elements(stream)
|
||||
struct_end = stream.tell()
|
||||
children = {
|
||||
element.struct_name.decode('ASCII')
|
||||
for element in elements
|
||||
if isinstance(element, klamath.elements.Reference)
|
||||
}
|
||||
order.append(name)
|
||||
cells[name] = _CellScan(
|
||||
offset=offset,
|
||||
struct_start=struct_start,
|
||||
struct_end=struct_end,
|
||||
children=children,
|
||||
)
|
||||
|
||||
return library_info, order, cells
|
||||
|
||||
|
||||
class GdsLibrarySource(ILibraryView, IMaterializable):
|
||||
"""
|
||||
Read-only library backed by a seekable GDS stream.
|
||||
|
||||
Cells are scanned once up front to discover order, byte ranges, and child
|
||||
edges. Untouched structures can be copied directly, while accessed cells
|
||||
are materialized through the classic GDS decoder.
|
||||
|
||||
The source owns the stream lifetime, preserves on-disk ordering through
|
||||
`source_order()`, and answers graph queries from scan metadata whenever
|
||||
possible so callers can inspect hierarchy without forcing a full load.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: _SourceHandle,
|
||||
library_info: dict[str, Any],
|
||||
cell_order: Sequence[str],
|
||||
cells: dict[str, _CellScan],
|
||||
) -> None:
|
||||
self.path = source.path
|
||||
self.library_info = library_info
|
||||
self._source = source
|
||||
self._cell_order = tuple(cell_order)
|
||||
self._cells = cells
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
self._lookups_in_progress: list[str] = []
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool = True,
|
||||
) -> GdsLibrarySource:
|
||||
source = _open_source_stream(filename, use_mmap=use_mmap)
|
||||
source.stream.seek(0)
|
||||
library_info, cell_order, cells = _scan_library(source.stream)
|
||||
return cls(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.materialize(key, persist=True)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._cell_order)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cell_order)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._cells
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return self._cell_order
|
||||
|
||||
def can_copy_raw_struct(self, name: str) -> bool:
|
||||
"""Return whether `name` still matches its original GDS structure."""
|
||||
return name in self._cells and name not in self._cache
|
||||
|
||||
def raw_struct_bytes(self, name: str) -> bytes:
|
||||
"""Read the original complete GDS structure for `name`."""
|
||||
cell = self._cells[name]
|
||||
stream = self._source.stream
|
||||
stream.seek(cell.struct_start)
|
||||
data = stream.read(cell.struct_end - cell.struct_start)
|
||||
if len(data) != cell.struct_end - cell.struct_start:
|
||||
raise LibraryError(f'Unexpected end of GDS source while copying structure {name!r}')
|
||||
return data
|
||||
|
||||
def _decode_pattern(self, name: str) -> Pattern:
|
||||
if name not in self._cells:
|
||||
raise KeyError(name)
|
||||
|
||||
if name in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||
raise LibraryError(
|
||||
f'Detected circular reference or recursive lookup of "{name}".\n'
|
||||
f'Lookup chain: {chain}\n'
|
||||
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
||||
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
|
||||
)
|
||||
|
||||
self._lookups_in_progress.append(name)
|
||||
try:
|
||||
self._source.stream.seek(self._cells[name].offset)
|
||||
pat = gdsii_klamath._read_elements(self._source.stream, raw_mode=True)
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
|
||||
return pat
|
||||
|
||||
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
|
||||
if name in self._cache:
|
||||
return self._cache[name]
|
||||
|
||||
pat = self._decode_pattern(name)
|
||||
|
||||
if persist:
|
||||
self._cache[name] = pat
|
||||
return pat
|
||||
|
||||
def materialize_detached(self, name: str) -> Pattern:
|
||||
if name in self._cache:
|
||||
return self._cache[name].deepcopy()
|
||||
return self._decode_pattern(name)
|
||||
|
||||
def materialize_many_detached(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
) -> LibraryView:
|
||||
return LibraryView({
|
||||
name: self.materialize_detached(name)
|
||||
for name in dict.fromkeys(names)
|
||||
})
|
||||
|
||||
def _raw_children(self, name: str) -> set[str]:
|
||||
if name in self._cache:
|
||||
return super()._raw_children(name)
|
||||
return set(self._cells[name].children)
|
||||
|
||||
def _raw_ref_transforms(
|
||||
self,
|
||||
parent: str,
|
||||
target: str,
|
||||
) -> list[NDArray[numpy.float64]]:
|
||||
if parent in self._cache:
|
||||
return super()._raw_ref_transforms(parent, target)
|
||||
pat = self.materialize(parent, persist=False)
|
||||
return [ref.as_transforms() for ref in pat.refs.get(target, ())]
|
||||
|
||||
def close(self) -> None:
|
||||
self._source.close()
|
||||
|
||||
def __enter__(self) -> GdsLibrarySource:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def read(
|
||||
stream: IO[bytes],
|
||||
) -> tuple[GdsLibrarySource, dict[str, Any]]:
|
||||
source = _SourceHandle(path=None, stream=stream)
|
||||
stream.seek(0)
|
||||
library_info, cell_order, cells = _scan_library(stream)
|
||||
lib = GdsLibrarySource(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
|
||||
return lib, library_info
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
*,
|
||||
use_mmap: bool = True,
|
||||
) -> tuple[GdsLibrarySource, dict[str, Any]]:
|
||||
lib = GdsLibrarySource.from_file(filename, use_mmap=use_mmap)
|
||||
return lib, lib.library_info
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
"""
|
||||
Lazy GDSII readers and writers backed by native Arrow scan/materialize paths.
|
||||
|
||||
This module is intentionally separate from `gdsii.arrow` so the eager read path
|
||||
keeps its current behavior and performance profile.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import IO, TYPE_CHECKING, Any
|
||||
import gzip
|
||||
import logging
|
||||
import mmap
|
||||
import pathlib
|
||||
|
||||
import numpy
|
||||
|
||||
from . import arrow
|
||||
from ..utils import is_gzipped
|
||||
from ...library import (
|
||||
ILibraryView,
|
||||
IMaterializable,
|
||||
LibraryView,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
|
||||
from numpy.typing import NDArray
|
||||
import pyarrow
|
||||
|
||||
from ...pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StructRange:
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SourceBuffer:
|
||||
path: pathlib.Path
|
||||
data: bytes | mmap.mmap
|
||||
handle: IO[bytes] | None = None
|
||||
|
||||
def raw_slice(self, start: int, end: int) -> bytes:
|
||||
return self.data[start:end]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScanRefs:
|
||||
offsets: NDArray[numpy.integer[Any]]
|
||||
targets: NDArray[numpy.integer[Any]]
|
||||
xy: NDArray[numpy.int32]
|
||||
xy0: NDArray[numpy.int32]
|
||||
xy1: NDArray[numpy.int32]
|
||||
counts: NDArray[numpy.int64]
|
||||
invert_y: NDArray[numpy.bool_ | numpy.bool]
|
||||
angle_rad: NDArray[numpy.floating[Any]]
|
||||
scale: NDArray[numpy.floating[Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CellScan:
|
||||
cell_id: int
|
||||
struct_range: _StructRange
|
||||
ref_start: int
|
||||
ref_stop: int
|
||||
children: set[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScanPayload:
|
||||
libarr: pyarrow.StructScalar
|
||||
library_info: dict[str, Any]
|
||||
cell_names: list[str]
|
||||
cell_order: list[str]
|
||||
cells: dict[str, _CellScan]
|
||||
refs: _ScanRefs
|
||||
|
||||
def _open_source_buffer(path: pathlib.Path) -> _SourceBuffer:
|
||||
if is_gzipped(path):
|
||||
with gzip.open(path, mode='rb') as stream:
|
||||
data = stream.read()
|
||||
return _SourceBuffer(path=path, data=data)
|
||||
|
||||
handle = path.open(mode='rb', buffering=0)
|
||||
mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
return _SourceBuffer(path=path, data=mapped, handle=handle)
|
||||
|
||||
|
||||
def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload:
|
||||
library_info = arrow._read_header(libarr)
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
|
||||
cells = libarr['cells']
|
||||
cell_values = cells.values
|
||||
cell_ids = cell_values.field('id').to_numpy()
|
||||
struct_starts = cell_values.field('struct_start_offset').to_numpy()
|
||||
struct_ends = cell_values.field('struct_end_offset').to_numpy()
|
||||
|
||||
refs = cell_values.field('refs')
|
||||
ref_values = refs.values
|
||||
ref_offsets = refs.offsets.to_numpy()
|
||||
targets = ref_values.field('target').to_numpy()
|
||||
xy = arrow._packed_xy_u64_to_pairs(ref_values.field('xy').to_numpy())
|
||||
xy0 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy0').to_numpy())
|
||||
xy1 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy1').to_numpy())
|
||||
counts = arrow._packed_counts_u32_to_pairs(ref_values.field('counts').to_numpy())
|
||||
invert_y = ref_values.field('invert_y').to_numpy(zero_copy_only=False)
|
||||
angle_rad = ref_values.field('angle_rad').to_numpy()
|
||||
scale = ref_values.field('scale').to_numpy()
|
||||
|
||||
ref_payload = _ScanRefs(
|
||||
offsets=ref_offsets,
|
||||
targets=targets,
|
||||
xy=xy,
|
||||
xy0=xy0,
|
||||
xy1=xy1,
|
||||
counts=counts,
|
||||
invert_y=invert_y,
|
||||
angle_rad=angle_rad,
|
||||
scale=scale,
|
||||
)
|
||||
|
||||
cell_order = [cell_names[int(cell_id)] for cell_id in cell_ids]
|
||||
cell_scan: dict[str, _CellScan] = {}
|
||||
for cc, name in enumerate(cell_order):
|
||||
ref_start = int(ref_offsets[cc])
|
||||
ref_stop = int(ref_offsets[cc + 1])
|
||||
children = {
|
||||
cell_names[int(target)]
|
||||
for target in targets[ref_start:ref_stop]
|
||||
}
|
||||
cell_scan[name] = _CellScan(
|
||||
cell_id=int(cell_ids[cc]),
|
||||
struct_range=_StructRange(int(struct_starts[cc]), int(struct_ends[cc])),
|
||||
ref_start=ref_start,
|
||||
ref_stop=ref_stop,
|
||||
children=children,
|
||||
)
|
||||
|
||||
return _ScanPayload(
|
||||
libarr=libarr,
|
||||
library_info=library_info,
|
||||
cell_names=cell_names,
|
||||
cell_order=cell_order,
|
||||
cells=cell_scan,
|
||||
refs=ref_payload,
|
||||
)
|
||||
|
||||
def _make_ref_rows(
|
||||
xy: NDArray[numpy.integer[Any]],
|
||||
angle_rad: NDArray[numpy.floating[Any]],
|
||||
invert_y: NDArray[numpy.bool_ | numpy.bool],
|
||||
scale: NDArray[numpy.floating[Any]],
|
||||
) -> NDArray[numpy.float64]:
|
||||
rows = numpy.empty((len(xy), 5), dtype=float)
|
||||
rows[:, :2] = xy
|
||||
rows[:, 2] = angle_rad
|
||||
rows[:, 3] = invert_y.astype(float)
|
||||
rows[:, 4] = scale
|
||||
return rows
|
||||
|
||||
|
||||
def _expand_aref_row(
|
||||
xy: NDArray[numpy.integer[Any]],
|
||||
xy0: NDArray[numpy.integer[Any]],
|
||||
xy1: NDArray[numpy.integer[Any]],
|
||||
counts: NDArray[numpy.integer[Any]],
|
||||
angle_rad: float,
|
||||
invert_y: bool,
|
||||
scale: float,
|
||||
) -> NDArray[numpy.float64]:
|
||||
a_count = int(counts[0])
|
||||
b_count = int(counts[1])
|
||||
aa, bb = numpy.meshgrid(numpy.arange(a_count), numpy.arange(b_count), indexing='ij')
|
||||
displacements = aa.reshape(-1, 1) * xy0[None, :] + bb.reshape(-1, 1) * xy1[None, :]
|
||||
rows = numpy.empty((displacements.shape[0], 5), dtype=float)
|
||||
rows[:, :2] = xy + displacements
|
||||
rows[:, 2] = angle_rad
|
||||
rows[:, 3] = float(invert_y)
|
||||
rows[:, 4] = scale
|
||||
return rows
|
||||
|
||||
|
||||
class ArrowLibrary(ILibraryView, IMaterializable):
|
||||
"""
|
||||
Read-only library backed by the native lazy Arrow scan schema.
|
||||
|
||||
Materializing a cell via `__getitem__` caches a real `Pattern` for that cell.
|
||||
Cached cells are treated as edited for future writes from this module.
|
||||
"""
|
||||
|
||||
path: pathlib.Path
|
||||
library_info: dict[str, Any]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
path: pathlib.Path,
|
||||
payload: _ScanPayload,
|
||||
source: _SourceBuffer,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.library_info = payload.library_info
|
||||
self._payload = payload
|
||||
self._name_to_id = {name: cell_id for cell_id, name in enumerate(payload.cell_names)}
|
||||
self._source = source
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename: str | pathlib.Path) -> ArrowLibrary:
|
||||
path = pathlib.Path(filename).expanduser().resolve()
|
||||
source = _open_source_buffer(path)
|
||||
scan_arr = arrow._scan_buffer_to_arrow(source.data)
|
||||
assert len(scan_arr) == 1
|
||||
payload = _extract_scan_payload(scan_arr[0])
|
||||
return cls(path=path, payload=payload, source=source)
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.materialize(key, persist=True)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._payload.cell_order)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._payload.cell_order)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._payload.cells
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return tuple(self._payload.cell_order)
|
||||
|
||||
def raw_struct_bytes(self, name: str) -> bytes:
|
||||
struct_range = self._payload.cells[name].struct_range
|
||||
return self._source.raw_slice(struct_range.start, struct_range.end)
|
||||
|
||||
def can_copy_raw_struct(self, name: str) -> bool:
|
||||
return name not in self._cache
|
||||
|
||||
def materialize_many(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool = True,
|
||||
) -> LibraryView:
|
||||
mats = self._materialize_patterns(names, persist=persist, detached=False)
|
||||
return LibraryView(mats)
|
||||
|
||||
def materialize_many_detached(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
) -> LibraryView:
|
||||
mats = self._materialize_patterns(names, persist=False, detached=True)
|
||||
return LibraryView(mats)
|
||||
|
||||
def _materialize_patterns(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool,
|
||||
detached: bool,
|
||||
) -> dict[str, Pattern]:
|
||||
ordered_names = list(dict.fromkeys(names))
|
||||
missing = [name for name in ordered_names if name not in self._payload.cells]
|
||||
if missing:
|
||||
raise KeyError(missing[0])
|
||||
|
||||
materialized: dict[str, Pattern] = {}
|
||||
uncached = [name for name in ordered_names if name not in self._cache]
|
||||
if uncached:
|
||||
ranges = numpy.asarray(
|
||||
[
|
||||
[
|
||||
self._payload.cells[name].struct_range.start,
|
||||
self._payload.cells[name].struct_range.end,
|
||||
]
|
||||
for name in uncached
|
||||
],
|
||||
dtype=numpy.uint64,
|
||||
)
|
||||
arrow_arr = arrow._read_selected_cells_to_arrow(self._source.data, ranges)
|
||||
assert len(arrow_arr) == 1
|
||||
selected_lib, _info = arrow.read_arrow(arrow_arr[0])
|
||||
for name in uncached:
|
||||
pat = selected_lib[name]
|
||||
materialized[name] = pat
|
||||
if persist:
|
||||
self._cache[name] = pat
|
||||
|
||||
for name in ordered_names:
|
||||
if name not in materialized:
|
||||
cached = self._cache[name]
|
||||
materialized[name] = cached.deepcopy() if detached else cached
|
||||
return materialized
|
||||
|
||||
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
|
||||
return self._materialize_patterns((name,), persist=persist, detached=False)[name]
|
||||
|
||||
def materialize_detached(self, name: str) -> Pattern:
|
||||
return self._materialize_patterns((name,), persist=False, detached=True)[name]
|
||||
|
||||
def _raw_children(self, name: str) -> set[str]:
|
||||
if name in self._cache:
|
||||
return super()._raw_children(name)
|
||||
return set(self._payload.cells[name].children)
|
||||
|
||||
def _collect_raw_transforms(self, cell: _CellScan, target_id: int) -> list[NDArray[numpy.float64]]:
|
||||
refs = self._payload.refs
|
||||
start = cell.ref_start
|
||||
stop = cell.ref_stop
|
||||
if stop <= start:
|
||||
return []
|
||||
|
||||
targets = refs.targets[start:stop]
|
||||
mask = targets == target_id
|
||||
if not mask.any():
|
||||
return []
|
||||
|
||||
rows: list[NDArray[numpy.float64]] = []
|
||||
counts = refs.counts[start:stop]
|
||||
unit_mask = mask & (counts[:, 0] == 1) & (counts[:, 1] == 1)
|
||||
if unit_mask.any():
|
||||
rows.append(_make_ref_rows(
|
||||
refs.xy[start:stop][unit_mask],
|
||||
refs.angle_rad[start:stop][unit_mask],
|
||||
refs.invert_y[start:stop][unit_mask],
|
||||
refs.scale[start:stop][unit_mask],
|
||||
))
|
||||
|
||||
aref_indices = numpy.nonzero(mask & ~unit_mask)[0]
|
||||
for idx in aref_indices:
|
||||
abs_idx = start + int(idx)
|
||||
rows.append(_expand_aref_row(
|
||||
xy=refs.xy[abs_idx],
|
||||
xy0=refs.xy0[abs_idx],
|
||||
xy1=refs.xy1[abs_idx],
|
||||
counts=refs.counts[abs_idx],
|
||||
angle_rad=float(refs.angle_rad[abs_idx]),
|
||||
invert_y=bool(refs.invert_y[abs_idx]),
|
||||
scale=float(refs.scale[abs_idx]),
|
||||
))
|
||||
return rows
|
||||
|
||||
def close(self) -> None:
|
||||
data = self._source.data
|
||||
if isinstance(data, mmap.mmap):
|
||||
data.close()
|
||||
if self._source.handle is not None:
|
||||
self._source.handle.close()
|
||||
self._source.handle = None
|
||||
|
||||
def __enter__(self) -> ArrowLibrary:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def _raw_ref_transforms(
|
||||
self,
|
||||
parent: str,
|
||||
target: str,
|
||||
) -> list[NDArray[numpy.float64]]:
|
||||
if parent in self._cache:
|
||||
return super()._raw_ref_transforms(parent, target)
|
||||
target_id = self._name_to_id.get(target)
|
||||
if target_id is None or parent not in self._payload.cells:
|
||||
return []
|
||||
return self._collect_raw_transforms(self._payload.cells[parent], target_id)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||
lib = ArrowLibrary.from_file(filename)
|
||||
return lib, lib.library_info
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"""
|
||||
GDSII writer for eager and source-backed libraries.
|
||||
|
||||
The generic mutable overlay and ports-importing view live in `masque.library`.
|
||||
This module preserves source-backed GDS copy-through behavior where possible,
|
||||
falling back to normal pattern serialization when a cell has been materialized
|
||||
or remapped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import IO, TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
||||
import gzip
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
from . import klamath
|
||||
from ..utils import tmpfile
|
||||
from ...error import LibraryError
|
||||
from ...library import IBorrowing, ILibraryView, IMaterializable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ...pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _GdsInfoSource(Protocol):
|
||||
"""Structural capability for propagating GDS header metadata."""
|
||||
library_info: dict[str, Any]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _GdsRawCellSource(Protocol):
|
||||
"""GDS-specific raw-structure copy-through capability."""
|
||||
|
||||
def can_copy_raw_struct(self, name: str) -> bool: ...
|
||||
|
||||
def raw_struct_bytes(self, name: str) -> bytes: ...
|
||||
|
||||
|
||||
def _resolve_raw_struct(
|
||||
library: ILibraryView,
|
||||
name: str,
|
||||
) -> tuple[_GdsRawCellSource, str] | None:
|
||||
"""Resolve an unchanged visible cell to a copyable raw GDS structure."""
|
||||
current = library
|
||||
current_name = name
|
||||
seen: set[tuple[int, str]] = set()
|
||||
while True:
|
||||
key = (id(current), current_name)
|
||||
if key in seen:
|
||||
return None
|
||||
seen.add(key)
|
||||
|
||||
if isinstance(current, _GdsRawCellSource):
|
||||
if current.can_copy_raw_struct(current_name):
|
||||
return current, current_name
|
||||
return None
|
||||
|
||||
if not isinstance(current, IBorrowing):
|
||||
return None
|
||||
source_cell = current.source_cell(current_name)
|
||||
if source_cell is None:
|
||||
return None
|
||||
source, source_name = source_cell
|
||||
if source_name != current_name:
|
||||
return None
|
||||
current = source
|
||||
current_name = source_name
|
||||
|
||||
|
||||
def _get_write_info(
|
||||
library: Mapping[str, Pattern] | ILibraryView,
|
||||
*,
|
||||
meters_per_unit: float | None,
|
||||
logical_units_per_unit: float | None,
|
||||
library_name: str | None,
|
||||
) -> tuple[float, float, str]:
|
||||
if meters_per_unit is not None and logical_units_per_unit is not None and library_name is not None:
|
||||
return meters_per_unit, logical_units_per_unit, library_name
|
||||
|
||||
infos: list[dict[str, Any]] = []
|
||||
stack: list[Mapping[str, Pattern] | ILibraryView] = [library]
|
||||
seen: set[int] = set()
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if id(current) in seen:
|
||||
continue
|
||||
seen.add(id(current))
|
||||
if isinstance(current, _GdsInfoSource) and isinstance(current.library_info, dict):
|
||||
infos.append(current.library_info)
|
||||
if isinstance(current, IBorrowing):
|
||||
stack.extend(reversed(current.borrowed_sources()))
|
||||
|
||||
if infos:
|
||||
unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos}
|
||||
if len(unit_pairs) > 1:
|
||||
raise LibraryError('Merged lazy GDS sources must have identical units before writing')
|
||||
info = infos[0]
|
||||
meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit
|
||||
logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit
|
||||
name = info['name'] if library_name is None else library_name
|
||||
return meters, logical, name
|
||||
|
||||
if meters_per_unit is None:
|
||||
raise LibraryError('meters_per_unit is required when writing a library without GDS metadata')
|
||||
logical = 1 if logical_units_per_unit is None else logical_units_per_unit
|
||||
name = 'masque-klamath' if library_name is None else library_name
|
||||
return meters_per_unit, logical, name
|
||||
|
||||
|
||||
def write(
|
||||
library: Mapping[str, Pattern] | ILibraryView,
|
||||
stream: IO[bytes],
|
||||
meters_per_unit: float | None = None,
|
||||
logical_units_per_unit: float | None = None,
|
||||
library_name: str | None = None,
|
||||
) -> None:
|
||||
"""Write an eager or source-backed library to a GDSII stream."""
|
||||
meters_per_unit, logical_units_per_unit, library_name = _get_write_info(
|
||||
library,
|
||||
meters_per_unit=meters_per_unit,
|
||||
logical_units_per_unit=logical_units_per_unit,
|
||||
library_name=library_name,
|
||||
)
|
||||
|
||||
klamath._write_header(stream, meters_per_unit, logical_units_per_unit, library_name)
|
||||
|
||||
names = library.source_order() if isinstance(library, ILibraryView) else tuple(library)
|
||||
for name in names:
|
||||
if isinstance(library, ILibraryView):
|
||||
raw_struct = _resolve_raw_struct(library, name)
|
||||
if raw_struct is not None:
|
||||
raw_source, source_name = raw_struct
|
||||
stream.write(raw_source.raw_struct_bytes(source_name))
|
||||
continue
|
||||
|
||||
if isinstance(library, IMaterializable):
|
||||
pat = library.materialize(name, persist=False)
|
||||
else:
|
||||
pat = library[name]
|
||||
klamath._write_pattern_struct(stream, name, pat)
|
||||
|
||||
klamath._write_footer(stream)
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern] | ILibraryView,
|
||||
filename: str | pathlib.Path,
|
||||
meters_per_unit: float | None = None,
|
||||
logical_units_per_unit: float | None = None,
|
||||
library_name: str | None = None,
|
||||
) -> None:
|
||||
"""Write an eager or source-backed library to a path, compressing `.gz` files."""
|
||||
path = pathlib.Path(filename)
|
||||
with tmpfile(path) as base_stream:
|
||||
if path.suffix == '.gz':
|
||||
stream = cast('IO[bytes]', gzip.GzipFile(
|
||||
filename='',
|
||||
mtime=0,
|
||||
fileobj=base_stream,
|
||||
mode='wb',
|
||||
compresslevel=6,
|
||||
))
|
||||
try:
|
||||
write(library, stream, meters_per_unit, logical_units_per_unit, library_name)
|
||||
finally:
|
||||
stream.close()
|
||||
else:
|
||||
write(library, base_stream, meters_per_unit, logical_units_per_unit, library_name)
|
||||
2
masque/file/klamath.py
Normal file
2
masque/file/klamath.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# FOr backwards compatibility
|
||||
from .gdsii import *
|
||||
|
|
@ -10,36 +10,33 @@ Note that OASIS references follow the same convention as `masque`,
|
|||
|
||||
Scaling, rotation, and mirroring apply to individual instances, not grid
|
||||
vectors or offsets.
|
||||
|
||||
Notes:
|
||||
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
|
||||
"""
|
||||
from typing import Any, IO, cast
|
||||
from collections.abc import Sequence, Iterable, Mapping, Callable
|
||||
from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional
|
||||
import re
|
||||
import io
|
||||
import copy
|
||||
import base64
|
||||
import struct
|
||||
import logging
|
||||
import pathlib
|
||||
import gzip
|
||||
import string
|
||||
from pprint import pformat
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
import fatamorgana
|
||||
import fatamorgana.records as fatrec
|
||||
from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference
|
||||
|
||||
from .utils import is_gzipped, tmpfile
|
||||
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||
from ..library import Library, ILibrary
|
||||
from ..shapes import Path, Circle
|
||||
from .utils import clean_pattern_vertices, is_gzipped
|
||||
from .. import Pattern, SubPattern, PatternError, Label, Shape
|
||||
from ..shapes import Polygon, Path, Circle
|
||||
from ..repetition import Grid, Arbitrary, Repetition
|
||||
from ..utils import layer_t, annotations_t
|
||||
from ..utils import layer_t, normalize_mirror, annotations_t
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
logger.warning('OASIS support is experimental!')
|
||||
logger.warning('OASIS support is experimental and mostly untested!')
|
||||
|
||||
|
||||
path_cap_map = {
|
||||
|
|
@ -48,23 +45,21 @@ path_cap_map = {
|
|||
PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
|
||||
}
|
||||
|
||||
#TODO implement more shape types in OASIS?
|
||||
|
||||
def rint_cast(val: ArrayLike) -> NDArray[numpy.int64]:
|
||||
return numpy.rint(val).astype(numpy.int64)
|
||||
|
||||
#TODO implement more shape types?
|
||||
|
||||
def build(
|
||||
library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable!
|
||||
patterns: Union[Pattern, Sequence[Pattern]],
|
||||
units_per_micron: int,
|
||||
layer_map: dict[str, int | tuple[int, int]] | None = None,
|
||||
layer_map: Optional[Dict[str, Union[int, Tuple[int, int]]]] = None,
|
||||
*,
|
||||
annotations: annotations_t | None = None,
|
||||
modify_originals: bool = False,
|
||||
disambiguate_func: Optional[Callable[[Iterable[Pattern]], None]] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
) -> fatamorgana.OasisLayout:
|
||||
"""
|
||||
Convert a collection of {name: Pattern} pairs to an OASIS stream, writing patterns
|
||||
as OASIS cells, refs as Placement records, and mapping other shapes and labels
|
||||
to equivalent record types (Polygon, Path, Circle, Text).
|
||||
Convert a `Pattern` or list of patterns to an OASIS stream, writing patterns
|
||||
as OASIS cells, subpatterns as Placement records, and other shapes and labels
|
||||
mapped to equivalent record types (Polygon, Path, Circle, Text).
|
||||
Other shape types may be converted to polygons if no equivalent
|
||||
record type exists (or is not implemented here yet).
|
||||
|
||||
|
|
@ -76,17 +71,14 @@ def build(
|
|||
If a layer map is provided, layer strings will be converted
|
||||
automatically, and layer names will be written to the file.
|
||||
|
||||
Other functions you may want to call:
|
||||
- `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names
|
||||
- `library.dangling_refs()` to check for references to missing patterns
|
||||
- `pattern.polygonize()` for any patterns with shapes other
|
||||
than `masque.shapes.Polygon`, `masque.shapes.Path`, or `masque.shapes.Circle`
|
||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
library: A {name: Pattern} mapping of patterns to write.
|
||||
patterns: A Pattern or list of patterns to convert.
|
||||
units_per_micron: Written into the OASIS file, number of grid steps per micrometer.
|
||||
All distances are assumed to be an integer multiple of the grid step, and are stored as such.
|
||||
layer_map: dictionary which translates layer names into layer numbers. If this argument is
|
||||
layer_map: Dictionary which translates layer names into layer numbers. If this argument is
|
||||
provided, input shapes and labels are allowed to have layer names instead of numbers.
|
||||
It is assumed that geometry and text share the same layer names, and each name is
|
||||
assigned only to a single layer (not a range).
|
||||
|
|
@ -94,23 +86,31 @@ def build(
|
|||
into numbers, omit this argument, and manually generate the required
|
||||
`fatamorgana.records.LayerName` entries.
|
||||
Default is an empty dict (no names provided).
|
||||
modify_originals: If `True`, the original pattern is modified as part of the writing
|
||||
process. Otherwise, a copy is made and `deepunlock()`-ed.
|
||||
Default `False`.
|
||||
disambiguate_func: Function which takes a list of patterns and alters them
|
||||
to make their names valid and unique. Default is `disambiguate_pattern_names`.
|
||||
annotations: dictionary of key-value pairs which are saved as library-level properties
|
||||
|
||||
Returns:
|
||||
`fatamorgana.OasisLayout`
|
||||
"""
|
||||
if not isinstance(library, ILibrary):
|
||||
if isinstance(library, dict):
|
||||
library = Library(library)
|
||||
else:
|
||||
library = Library(dict(library))
|
||||
if isinstance(patterns, Pattern):
|
||||
patterns = [patterns]
|
||||
|
||||
if layer_map is None:
|
||||
layer_map = {}
|
||||
|
||||
if disambiguate_func is None:
|
||||
disambiguate_func = disambiguate_pattern_names
|
||||
|
||||
if annotations is None:
|
||||
annotations = {}
|
||||
|
||||
if not modify_originals:
|
||||
patterns = [p.deepunlock() for p in copy.deepcopy(patterns)]
|
||||
|
||||
# Create library
|
||||
lib = fatamorgana.OasisLayout(unit=units_per_micron, validation=None)
|
||||
lib.properties = annotations_to_properties(annotations)
|
||||
|
|
@ -119,38 +119,44 @@ def build(
|
|||
for name, layer_num in layer_map.items():
|
||||
layer, data_type = _mlayer2oas(layer_num)
|
||||
lib.layers += [
|
||||
fatrec.LayerName(
|
||||
nstring = name,
|
||||
layer_interval = (layer, layer),
|
||||
type_interval = (data_type, data_type),
|
||||
is_textlayer = tt,
|
||||
)
|
||||
fatrec.LayerName(nstring=name,
|
||||
layer_interval=(layer, layer),
|
||||
type_interval=(data_type, data_type),
|
||||
is_textlayer=tt)
|
||||
for tt in (True, False)]
|
||||
|
||||
def layer2oas(mlayer: layer_t) -> tuple[int, int]:
|
||||
assert layer_map is not None
|
||||
def layer2oas(mlayer: layer_t) -> Tuple[int, int]:
|
||||
assert(layer_map is not None)
|
||||
layer_num = layer_map[mlayer] if isinstance(mlayer, str) else mlayer
|
||||
return _mlayer2oas(layer_num)
|
||||
else:
|
||||
layer2oas = _mlayer2oas
|
||||
|
||||
# Get a dict of id(pattern) -> pattern
|
||||
patterns_by_id = {id(pattern): pattern for pattern in patterns}
|
||||
for pattern in patterns:
|
||||
for i, p in pattern.referenced_patterns_by_id().items():
|
||||
patterns_by_id[i] = p
|
||||
|
||||
disambiguate_func(patterns_by_id.values())
|
||||
|
||||
# Now create a structure for each pattern
|
||||
for name, pat in library.items():
|
||||
structure = fatamorgana.Cell(name=name)
|
||||
for pat in patterns_by_id.values():
|
||||
structure = fatamorgana.Cell(name=pat.name)
|
||||
lib.cells.append(structure)
|
||||
|
||||
structure.properties += annotations_to_properties(pat.annotations)
|
||||
|
||||
structure.geometry += _shapes_to_elements(pat.shapes, layer2oas)
|
||||
structure.geometry += _labels_to_texts(pat.labels, layer2oas)
|
||||
structure.placements += _refs_to_placements(pat.refs)
|
||||
structure.placements += _subpatterns_to_placements(pat.subpatterns)
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def write(
|
||||
library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable!
|
||||
stream: IO[bytes],
|
||||
patterns: Union[Sequence[Pattern], Pattern],
|
||||
stream: io.BufferedIOBase,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
|
@ -159,18 +165,18 @@ def write(
|
|||
for details.
|
||||
|
||||
Args:
|
||||
library: A {name: Pattern} mapping of patterns to write.
|
||||
patterns: A Pattern or list of patterns to write to file.
|
||||
stream: Stream to write to.
|
||||
*args: passed to `oasis.build()`
|
||||
**kwargs: passed to `oasis.build()`
|
||||
"""
|
||||
lib = build(library, *args, **kwargs)
|
||||
lib = build(patterns, *args, **kwargs)
|
||||
lib.write(stream)
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable!
|
||||
filename: str | pathlib.Path,
|
||||
patterns: Union[Sequence[Pattern], Pattern],
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
|
@ -180,42 +186,35 @@ def writefile(
|
|||
Will automatically compress the file if it has a .gz suffix.
|
||||
|
||||
Args:
|
||||
library: A {name: Pattern} mapping of patterns to write.
|
||||
patterns: `Pattern` or list of patterns to save
|
||||
filename: Filename to save to.
|
||||
*args: passed to `oasis.build()`
|
||||
**kwargs: passed to `oasis.build()`
|
||||
*args: passed to `oasis.write`
|
||||
**kwargs: passed to `oasis.write`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
|
||||
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'))
|
||||
streams += (stream,)
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
stream = base_stream
|
||||
open_func = open
|
||||
|
||||
try:
|
||||
write(library, stream, *args, **kwargs)
|
||||
finally:
|
||||
for ss in streams:
|
||||
ss.close()
|
||||
with io.BufferedWriter(open_func(path, mode='wb')) as stream:
|
||||
write(patterns, stream, *args, **kwargs)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: str | pathlib.Path,
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
|
||||
"""
|
||||
Wrapper for `oasis.read()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to load from.
|
||||
*args: passed to `oasis.read()`
|
||||
**kwargs: passed to `oasis.read()`
|
||||
filename: Filename to save to.
|
||||
*args: passed to `oasis.read`
|
||||
**kwargs: passed to `oasis.read`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
|
|
@ -223,18 +222,19 @@ def readfile(
|
|||
else:
|
||||
open_func = open
|
||||
|
||||
with open_func(path, mode='rb') as stream:
|
||||
with io.BufferedReader(open_func(path, mode='rb')) as stream:
|
||||
results = read(stream, *args, **kwargs)
|
||||
return results
|
||||
|
||||
|
||||
def read(
|
||||
stream: IO[bytes],
|
||||
) -> tuple[Library, dict[str, Any]]:
|
||||
stream: io.BufferedIOBase,
|
||||
clean_vertices: bool = True,
|
||||
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
|
||||
"""
|
||||
Read a OASIS file and translate it into a dict of Pattern objects. OASIS cells are
|
||||
translated into Pattern objects; Polygons are translated into polygons, and Placements
|
||||
are translated into Ref objects.
|
||||
are translated into SubPattern objects.
|
||||
|
||||
Additional library info is returned in a dict, containing:
|
||||
'units_per_micrometer': number of database units per micrometer (all values are in database units)
|
||||
|
|
@ -243,15 +243,18 @@ def read(
|
|||
|
||||
Args:
|
||||
stream: Stream to read from.
|
||||
clean_vertices: If `True`, remove any redundant vertices when loading polygons.
|
||||
The cleaning process removes any polygons with zero area or <3 vertices.
|
||||
Default `True`.
|
||||
|
||||
Returns:
|
||||
- dict of `pattern_name`:`Pattern`s generated from OASIS cells
|
||||
- dict of OASIS library info
|
||||
- Dict of `pattern_name`:`Pattern`s generated from OASIS cells
|
||||
- Dict of OASIS library info
|
||||
"""
|
||||
|
||||
lib = fatamorgana.OasisLayout.read(stream)
|
||||
|
||||
library_info: dict[str, Any] = {
|
||||
library_info: Dict[str, Any] = {
|
||||
'units_per_micrometer': lib.unit,
|
||||
'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings),
|
||||
}
|
||||
|
|
@ -261,76 +264,72 @@ def read(
|
|||
layer_map[str(layer_name.nstring)] = layer_name
|
||||
library_info['layer_map'] = layer_map
|
||||
|
||||
mlib = Library()
|
||||
patterns = []
|
||||
for cell in lib.cells:
|
||||
if isinstance(cell.name, int):
|
||||
cell_name = lib.cellnames[cell.name].nstring.string
|
||||
else:
|
||||
cell_name = cell.name.string
|
||||
|
||||
pat = Pattern()
|
||||
pat = Pattern(name=cell_name)
|
||||
for element in cell.geometry:
|
||||
if isinstance(element, fatrec.XElement):
|
||||
logger.warning('Skipping XElement record')
|
||||
# note XELEMENT has no repetition
|
||||
continue
|
||||
|
||||
assert not isinstance(element.repetition, fatamorgana.ReuseRepetition)
|
||||
assert(not isinstance(element.repetition, fatamorgana.ReuseRepetition))
|
||||
repetition = repetition_fata2masq(element.repetition)
|
||||
|
||||
# Switch based on element type:
|
||||
if isinstance(element, fatrec.Polygon):
|
||||
# Drop last point (`fatamorgana` returns explicity closed list; we use implicit close)
|
||||
# also need `cumsum` to convert from deltas to locations
|
||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list()[:-1])), axis=0)
|
||||
|
||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
vertices = vertices,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
annotations = annotations,
|
||||
repetition = repetition,
|
||||
)
|
||||
poly = Polygon(vertices=vertices,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
annotations=annotations,
|
||||
repetition=repetition)
|
||||
|
||||
pat.shapes.append(poly)
|
||||
|
||||
elif isinstance(element, fatrec.Path):
|
||||
vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0)
|
||||
|
||||
cap_start = path_cap_map[element.get_extension_start()[0]]
|
||||
cap_end = path_cap_map[element.get_extension_end()[0]]
|
||||
if cap_start != cap_end:
|
||||
raise PatternError('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
|
||||
raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types
|
||||
cap = cap_start
|
||||
|
||||
path_args: dict[str, Any] = {}
|
||||
path_args: Dict[str, Any] = {}
|
||||
if cap == Path.Cap.SquareCustom:
|
||||
path_args['cap_extensions'] = numpy.array((
|
||||
element.get_extension_start()[1],
|
||||
element.get_extension_end()[1],
|
||||
))
|
||||
path_args['cap_extensions'] = numpy.array((element.get_extension_start()[1],
|
||||
element.get_extension_end()[1]))
|
||||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.path(
|
||||
vertices = vertices,
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
annotations = annotations,
|
||||
width = element.get_half_width() * 2,
|
||||
cap = cap,
|
||||
**path_args,
|
||||
)
|
||||
path = Path(vertices=vertices,
|
||||
layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
width=element.get_half_width() * 2,
|
||||
cap=cap,
|
||||
**path_args)
|
||||
|
||||
pat.shapes.append(path)
|
||||
|
||||
elif isinstance(element, fatrec.Rectangle):
|
||||
width = element.get_width()
|
||||
height = element.get_height()
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||
annotations = annotations,
|
||||
rect = Polygon(layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height),
|
||||
annotations=annotations,
|
||||
)
|
||||
pat.shapes.append(rect)
|
||||
|
||||
elif isinstance(element, fatrec.Trapezoid):
|
||||
vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (element.get_width(), element.get_height())
|
||||
|
|
@ -358,13 +357,13 @@ def read(
|
|||
vertices[2, 0] -= b
|
||||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
layer=element.get_layer_tuple(),
|
||||
trapz = Polygon(layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
vertices=vertices,
|
||||
annotations=annotations,
|
||||
)
|
||||
pat.shapes.append(trapz)
|
||||
|
||||
elif isinstance(element, fatrec.CTrapezoid):
|
||||
cttype = element.get_ctrapezoid_type()
|
||||
|
|
@ -413,24 +412,22 @@ def read(
|
|||
vertices[0, 1] += width
|
||||
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
pat.polygon(
|
||||
layer=element.get_layer_tuple(),
|
||||
ctrapz = Polygon(layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
vertices=vertices,
|
||||
annotations=annotations,
|
||||
)
|
||||
pat.shapes.append(ctrapz)
|
||||
|
||||
elif isinstance(element, fatrec.Circle):
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
layer = element.get_layer_tuple()
|
||||
circle = Circle(
|
||||
circle = Circle(layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
radius=float(element.get_radius()),
|
||||
)
|
||||
pat.shapes[layer].append(circle)
|
||||
radius=float(element.get_radius()))
|
||||
pat.shapes.append(circle)
|
||||
|
||||
elif isinstance(element, fatrec.Text):
|
||||
annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings)
|
||||
|
|
@ -439,30 +436,38 @@ def read(
|
|||
string = lib.textstrings[str_or_ref].string
|
||||
else:
|
||||
string = str_or_ref.string
|
||||
pat.label(
|
||||
layer = element.get_layer_tuple(),
|
||||
offset = element.get_xy(),
|
||||
repetition = repetition,
|
||||
annotations = annotations,
|
||||
string = string,
|
||||
)
|
||||
label = Label(layer=element.get_layer_tuple(),
|
||||
offset=element.get_xy(),
|
||||
repetition=repetition,
|
||||
annotations=annotations,
|
||||
string=string)
|
||||
pat.labels.append(label)
|
||||
|
||||
else:
|
||||
logger.warning(f'Skipping record {element} (unimplemented)')
|
||||
continue
|
||||
|
||||
for placement in cell.placements:
|
||||
target, ref = _placement_to_ref(placement, lib)
|
||||
if isinstance(target, int):
|
||||
target = lib.cellnames[target].nstring.string
|
||||
pat.refs[target].append(ref)
|
||||
pat.subpatterns.append(_placement_to_subpat(placement, lib))
|
||||
|
||||
mlib[cell_name] = pat
|
||||
if clean_vertices:
|
||||
clean_pattern_vertices(pat)
|
||||
patterns.append(pat)
|
||||
|
||||
return mlib, library_info
|
||||
# Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
|
||||
# according to the subpattern.identifier (which is deleted after use).
|
||||
patterns_dict = dict(((p.name, p) for p in patterns))
|
||||
for p in patterns_dict.values():
|
||||
for sp in p.subpatterns:
|
||||
ident = sp.identifier[0]
|
||||
name = ident if isinstance(ident, str) else lib.cellnames[ident].nstring.string
|
||||
sp.pattern = patterns_dict[name]
|
||||
del sp.identifier
|
||||
|
||||
return patterns_dict, library_info
|
||||
|
||||
|
||||
def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]:
|
||||
def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]:
|
||||
""" Helper to turn a layer tuple-or-int into a layer and datatype"""
|
||||
if isinstance(mlayer, int):
|
||||
layer = mlayer
|
||||
|
|
@ -474,168 +479,182 @@ def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]:
|
|||
else:
|
||||
data_type = 0
|
||||
else:
|
||||
raise PatternError(f'Invalid layer for OASIS: {mlayer}. Note that OASIS layers cannot be '
|
||||
raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be '
|
||||
f'strings unless a layer map is provided.')
|
||||
return layer, data_type
|
||||
|
||||
|
||||
def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> tuple[int | str, Ref]:
|
||||
def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> SubPattern:
|
||||
"""
|
||||
Helper function to create a Ref from a placment. Also returns the placement name (or id).
|
||||
Helper function to create a SubPattern from a placment. Sets subpat.pattern to None
|
||||
and sets the instance .identifier to (struct_name,).
|
||||
"""
|
||||
assert not isinstance(placement.repetition, fatamorgana.ReuseRepetition)
|
||||
assert(not isinstance(placement.repetition, fatamorgana.ReuseRepetition))
|
||||
xy = numpy.array((placement.x, placement.y))
|
||||
mag = placement.magnification if placement.magnification is not None else 1
|
||||
|
||||
pname = placement.get_name()
|
||||
name: int | str = pname if isinstance(pname, int) else pname.string # TODO deal with referenced names
|
||||
|
||||
name = pname if isinstance(pname, int) else pname.string
|
||||
annotations = properties_to_annotations(placement.properties, lib.propnames, lib.propstrings)
|
||||
if placement.angle is None:
|
||||
rotation = 0
|
||||
else:
|
||||
rotation = numpy.deg2rad(float(placement.angle))
|
||||
ref = Ref(
|
||||
offset=xy,
|
||||
mirrored=placement.flip,
|
||||
subpat = SubPattern(offset=xy,
|
||||
pattern=None,
|
||||
mirrored=(placement.flip, False),
|
||||
rotation=rotation,
|
||||
scale=float(mag),
|
||||
identifier=(name,),
|
||||
repetition=repetition_fata2masq(placement.repetition),
|
||||
annotations=annotations,
|
||||
)
|
||||
return name, ref
|
||||
annotations=annotations)
|
||||
return subpat
|
||||
|
||||
|
||||
def _refs_to_placements(
|
||||
refs: dict[str | None, list[Ref]],
|
||||
) -> list[fatrec.Placement]:
|
||||
placements = []
|
||||
for target, rseq in refs.items():
|
||||
if target is None:
|
||||
def _subpatterns_to_placements(
|
||||
subpatterns: List[SubPattern],
|
||||
) -> List[fatrec.Placement]:
|
||||
refs = []
|
||||
for subpat in subpatterns:
|
||||
if subpat.pattern is None:
|
||||
continue
|
||||
for ref in rseq:
|
||||
# Note: OASIS also mirrors first and rotates second
|
||||
frep, rep_offset = repetition_masq2fata(ref.repetition)
|
||||
|
||||
offset = rint_cast(ref.offset + rep_offset)
|
||||
angle = numpy.rad2deg(ref.rotation) % 360
|
||||
placement = fatrec.Placement(
|
||||
name=target,
|
||||
flip=ref.mirrored,
|
||||
# Note: OASIS mirrors first and rotates second
|
||||
mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored)
|
||||
frep, rep_offset = repetition_masq2fata(subpat.repetition)
|
||||
|
||||
offset = numpy.round(subpat.offset + rep_offset).astype(int)
|
||||
angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360
|
||||
ref = fatrec.Placement(
|
||||
name=subpat.pattern.name,
|
||||
flip=mirror_across_x,
|
||||
angle=angle,
|
||||
magnification=ref.scale,
|
||||
properties=annotations_to_properties(ref.annotations),
|
||||
magnification=subpat.scale,
|
||||
properties=annotations_to_properties(subpat.annotations),
|
||||
x=offset[0],
|
||||
y=offset[1],
|
||||
repetition=frep,
|
||||
)
|
||||
repetition=frep)
|
||||
|
||||
placements.append(placement)
|
||||
return placements
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
def _shapes_to_elements(
|
||||
shapes: dict[layer_t, list[Shape]],
|
||||
layer2oas: Callable[[layer_t], tuple[int, int]],
|
||||
) -> list[fatrec.Polygon | fatrec.Path | fatrec.Circle]:
|
||||
shapes: List[Shape],
|
||||
layer2oas: Callable[[layer_t], Tuple[int, int]],
|
||||
) -> List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]]:
|
||||
# Add a Polygon record for each shape, and Path elements if necessary
|
||||
elements: list[fatrec.Polygon | fatrec.Path | fatrec.Circle] = []
|
||||
for mlayer, sseq in shapes.items():
|
||||
layer, datatype = layer2oas(mlayer)
|
||||
for shape in sseq:
|
||||
elements: List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]] = []
|
||||
for shape in shapes:
|
||||
layer, datatype = layer2oas(shape.layer)
|
||||
repetition, rep_offset = repetition_masq2fata(shape.repetition)
|
||||
properties = annotations_to_properties(shape.annotations)
|
||||
if isinstance(shape, Circle):
|
||||
offset = rint_cast(shape.offset + rep_offset)
|
||||
radius = rint_cast(shape.radius)
|
||||
circle = fatrec.Circle(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
radius = cast('int', radius),
|
||||
x = offset[0],
|
||||
y = offset[1],
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
)
|
||||
offset = numpy.round(shape.offset + rep_offset).astype(int)
|
||||
radius = numpy.round(shape.radius).astype(int)
|
||||
circle = fatrec.Circle(layer=layer,
|
||||
datatype=datatype,
|
||||
radius=radius,
|
||||
x=offset[0],
|
||||
y=offset[1],
|
||||
properties=properties,
|
||||
repetition=repetition)
|
||||
elements.append(circle)
|
||||
elif isinstance(shape, Path):
|
||||
xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset)
|
||||
deltas = rint_cast(numpy.diff(shape.vertices, axis=0))
|
||||
half_width = rint_cast(shape.width / 2)
|
||||
path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup
|
||||
if path_type is None:
|
||||
raise PatternError(f'OASIS writer does not support path cap {shape.cap}')
|
||||
extensions = None if shape.cap_extensions is None else rint_cast(shape.cap_extensions)
|
||||
extension_start = (path_type, extensions[0] if extensions is not None else None)
|
||||
extension_end = (path_type, extensions[1] if extensions is not None else None)
|
||||
path = fatrec.Path(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
point_list = cast('Sequence[Sequence[int]]', deltas),
|
||||
half_width = cast('int', half_width),
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
extension_start = extension_start, # TODO implement multiple cap types?
|
||||
extension_end = extension_end,
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
xy = numpy.round(shape.offset + shape.vertices[0] + rep_offset).astype(int)
|
||||
deltas = numpy.round(numpy.diff(shape.vertices, axis=0)).astype(int)
|
||||
half_width = numpy.round(shape.width / 2).astype(int)
|
||||
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
|
||||
extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None)
|
||||
extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None)
|
||||
path = fatrec.Path(layer=layer,
|
||||
datatype=datatype,
|
||||
point_list=deltas,
|
||||
half_width=half_width,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
extension_start=extension_start, # TODO implement multiple cap types?
|
||||
extension_end=extension_end,
|
||||
properties=properties,
|
||||
repetition=repetition,
|
||||
)
|
||||
elements.append(path)
|
||||
else:
|
||||
for polygon in shape.to_polygons():
|
||||
xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset)
|
||||
points = rint_cast(numpy.diff(polygon.vertices, axis=0))
|
||||
elements.append(fatrec.Polygon(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
point_list = cast('list[list[int]]', points),
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
))
|
||||
xy = numpy.round(polygon.offset + polygon.vertices[0] + rep_offset).astype(int)
|
||||
points = numpy.round(numpy.diff(polygon.vertices, axis=0)).astype(int)
|
||||
elements.append(fatrec.Polygon(layer=layer,
|
||||
datatype=datatype,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
point_list=points,
|
||||
properties=properties,
|
||||
repetition=repetition))
|
||||
return elements
|
||||
|
||||
|
||||
def _labels_to_texts(
|
||||
labels: dict[layer_t, list[Label]],
|
||||
layer2oas: Callable[[layer_t], tuple[int, int]],
|
||||
) -> list[fatrec.Text]:
|
||||
labels: List[Label],
|
||||
layer2oas: Callable[[layer_t], Tuple[int, int]],
|
||||
) -> List[fatrec.Text]:
|
||||
texts = []
|
||||
for mlayer, lseq in labels.items():
|
||||
layer, datatype = layer2oas(mlayer)
|
||||
for label in lseq:
|
||||
for label in labels:
|
||||
layer, datatype = layer2oas(label.layer)
|
||||
repetition, rep_offset = repetition_masq2fata(label.repetition)
|
||||
xy = rint_cast(label.offset + rep_offset)
|
||||
xy = numpy.round(label.offset + rep_offset).astype(int)
|
||||
properties = annotations_to_properties(label.annotations)
|
||||
texts.append(fatrec.Text(
|
||||
layer = layer,
|
||||
datatype = datatype,
|
||||
x = xy[0],
|
||||
y = xy[1],
|
||||
string = label.string,
|
||||
properties = properties,
|
||||
repetition = repetition,
|
||||
))
|
||||
texts.append(fatrec.Text(layer=layer,
|
||||
datatype=datatype,
|
||||
x=xy[0],
|
||||
y=xy[1],
|
||||
string=label.string,
|
||||
properties=properties,
|
||||
repetition=repetition))
|
||||
return texts
|
||||
|
||||
|
||||
def disambiguate_pattern_names(
|
||||
patterns,
|
||||
dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name
|
||||
) -> None:
|
||||
used_names = []
|
||||
for pat in patterns:
|
||||
sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name)
|
||||
|
||||
i = 0
|
||||
suffixed_name = sanitized_name
|
||||
while suffixed_name in used_names or suffixed_name == '':
|
||||
suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII')
|
||||
|
||||
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
|
||||
i += 1
|
||||
|
||||
if sanitized_name == '':
|
||||
logger.warning(f'Empty pattern name saved as "{suffixed_name}"')
|
||||
elif suffixed_name != sanitized_name:
|
||||
if dup_warn_filter is None or dup_warn_filter(pat.name):
|
||||
logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n'
|
||||
+ f' renaming to "{suffixed_name}"')
|
||||
|
||||
if len(suffixed_name) == 0:
|
||||
# Should never happen since zero-length names are replaced
|
||||
raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"')
|
||||
|
||||
pat.name = suffixed_name
|
||||
used_names.append(suffixed_name)
|
||||
|
||||
|
||||
def repetition_fata2masq(
|
||||
rep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None,
|
||||
) -> Repetition | None:
|
||||
mrep: Repetition | None
|
||||
rep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None],
|
||||
) -> Optional[Repetition]:
|
||||
mrep: Optional[Repetition]
|
||||
if isinstance(rep, fatamorgana.GridRepetition):
|
||||
mrep = Grid(
|
||||
a_vector = rep.a_vector,
|
||||
b_vector = rep.b_vector,
|
||||
a_count = rep.a_count,
|
||||
b_count = rep.b_count,
|
||||
)
|
||||
mrep = Grid(a_vector=rep.a_vector,
|
||||
b_vector=rep.b_vector,
|
||||
a_count=rep.a_count,
|
||||
b_count=rep.b_count)
|
||||
elif isinstance(rep, fatamorgana.ArbitraryRepetition):
|
||||
displacements = numpy.cumsum(numpy.column_stack((
|
||||
rep.x_displacements,
|
||||
rep.y_displacements,
|
||||
)), axis=0)
|
||||
displacements = numpy.cumsum(numpy.column_stack((rep.x_displacements,
|
||||
rep.y_displacements)), axis=0)
|
||||
displacements = numpy.vstack(([0, 0], displacements))
|
||||
mrep = Arbitrary(displacements)
|
||||
elif rep is None:
|
||||
|
|
@ -644,45 +663,38 @@ def repetition_fata2masq(
|
|||
|
||||
|
||||
def repetition_masq2fata(
|
||||
rep: Repetition | None,
|
||||
) -> tuple[
|
||||
fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None,
|
||||
tuple[int, int]
|
||||
]:
|
||||
frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None
|
||||
rep: Optional[Repetition],
|
||||
) -> Tuple[Union[fatamorgana.GridRepetition,
|
||||
fatamorgana.ArbitraryRepetition,
|
||||
None],
|
||||
Tuple[int, int]]:
|
||||
frep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None]
|
||||
if isinstance(rep, Grid):
|
||||
a_vector = rint_cast(rep.a_vector)
|
||||
a_count = int(rep.a_count)
|
||||
if rep.b_count > 1:
|
||||
b_vector = rint_cast(rep.b_vector)
|
||||
b_count = int(rep.b_count)
|
||||
else:
|
||||
b_vector = None
|
||||
b_count = None
|
||||
|
||||
b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None
|
||||
a_count = rint_cast(rep.a_count)
|
||||
b_count = rint_cast(rep.b_count) if rep.b_count is not None else None
|
||||
frep = fatamorgana.GridRepetition(
|
||||
a_vector = a_vector,
|
||||
b_vector = b_vector,
|
||||
a_count = a_count,
|
||||
b_count = b_count,
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
offset = (0, 0)
|
||||
elif isinstance(rep, Arbitrary):
|
||||
diffs = numpy.diff(rep.displacements, axis=0)
|
||||
diff_ints = rint_cast(diffs)
|
||||
frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore
|
||||
offset = tuple(rep.displacements[0, :])
|
||||
frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1])
|
||||
offset = rep.displacements[0, :]
|
||||
else:
|
||||
assert rep is None
|
||||
assert(rep is None)
|
||||
frep = None
|
||||
offset = (0, 0)
|
||||
return frep, offset
|
||||
|
||||
|
||||
def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Property]:
|
||||
def annotations_to_properties(annotations: annotations_t) -> List[fatrec.Property]:
|
||||
#TODO determine is_standard based on key?
|
||||
if annotations is None:
|
||||
return []
|
||||
properties = []
|
||||
for key, values in annotations.items():
|
||||
vals = [AString(v) if isinstance(v, str) else v
|
||||
|
|
@ -692,24 +704,24 @@ def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Propert
|
|||
|
||||
|
||||
def properties_to_annotations(
|
||||
properties: list[fatrec.Property],
|
||||
propnames: dict[int, NString],
|
||||
propstrings: dict[int, AString],
|
||||
properties: List[fatrec.Property],
|
||||
propnames: Dict[int, NString],
|
||||
propstrings: Dict[int, AString],
|
||||
) -> annotations_t:
|
||||
annotations = {}
|
||||
for proprec in properties:
|
||||
assert proprec.name is not None
|
||||
assert(proprec.name is not None)
|
||||
if isinstance(proprec.name, int):
|
||||
key = propnames[proprec.name].string
|
||||
else:
|
||||
key = proprec.name.string
|
||||
values: list[str | float | int] = []
|
||||
values: List[Union[str, float, int]] = []
|
||||
|
||||
assert proprec.values is not None
|
||||
assert(proprec.values is not None)
|
||||
for value in proprec.values:
|
||||
if isinstance(value, float | int):
|
||||
if isinstance(value, (float, int)):
|
||||
values.append(value)
|
||||
elif isinstance(value, NString | AString):
|
||||
elif isinstance(value, (NString, AString)):
|
||||
values.append(value.string)
|
||||
elif isinstance(value, PropStringReference):
|
||||
values.append(propstrings[value.ref].string) # dereference
|
||||
|
|
@ -717,27 +729,9 @@ def properties_to_annotations(
|
|||
string = repr(value)
|
||||
logger.warning(f'Converting property value for key ({key}) to string ({string})')
|
||||
values.append(string)
|
||||
annotations.setdefault(key, []).extend(values)
|
||||
annotations[key] = values
|
||||
return annotations
|
||||
|
||||
|
||||
def check_valid_names(
|
||||
names: Iterable[str],
|
||||
) -> 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 + string.punctuation + ' ')
|
||||
|
||||
bad_chars = [
|
||||
name for name in names
|
||||
if not set(name).issubset(allowed_chars)
|
||||
]
|
||||
|
||||
if bad_chars:
|
||||
raise LibraryError('Names contain invalid characters:\n' + pformat(bad_chars))
|
||||
properties = [fatrec.Property(key, vals, is_standard=False)
|
||||
for key, vals in annotations.items()]
|
||||
return properties
|
||||
|
|
|
|||
580
masque/file/python_gdsii.py
Normal file
580
masque/file/python_gdsii.py
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
"""
|
||||
GDSII file format readers and writers using python-gdsii
|
||||
|
||||
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
|
||||
"""
|
||||
from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional
|
||||
from typing import Sequence
|
||||
import re
|
||||
import io
|
||||
import copy
|
||||
import base64
|
||||
import struct
|
||||
import logging
|
||||
import pathlib
|
||||
import gzip
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
# python-gdsii
|
||||
import gdsii.library #type: ignore
|
||||
import gdsii.structure #type: ignore
|
||||
import gdsii.elements #type: ignore
|
||||
|
||||
from .utils import clean_pattern_vertices, is_gzipped
|
||||
from .. import Pattern, SubPattern, PatternError, Label, Shape
|
||||
from ..shapes import Polygon, Path
|
||||
from ..repetition import Grid
|
||||
from ..utils import get_bit, set_bit, layer_t, normalize_mirror, annotations_t
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
path_cap_map = {
|
||||
None: Path.Cap.Flush,
|
||||
0: Path.Cap.Flush,
|
||||
1: Path.Cap.Circle,
|
||||
2: Path.Cap.Square,
|
||||
4: Path.Cap.SquareCustom,
|
||||
}
|
||||
|
||||
|
||||
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
|
||||
return numpy.rint(val, dtype=numpy.int32, casting='unsafe')
|
||||
|
||||
|
||||
def build(
|
||||
patterns: Union[Pattern, Sequence[Pattern]],
|
||||
meters_per_unit: float,
|
||||
logical_units_per_unit: float = 1,
|
||||
library_name: str = 'masque-gdsii-write',
|
||||
*,
|
||||
modify_originals: bool = False,
|
||||
disambiguate_func: Callable[[Iterable[Pattern]], None] = None,
|
||||
) -> gdsii.library.Library:
|
||||
"""
|
||||
Convert a `Pattern` or list of patterns to a GDSII stream, by first calling
|
||||
`.polygonize()` to change the shapes into polygons, and then writing patterns
|
||||
as GDSII structures, polygons as boundary elements, and subpatterns as structure
|
||||
references (sref).
|
||||
|
||||
For each shape,
|
||||
layer is chosen to be equal to `shape.layer` if it is an int,
|
||||
or `shape.layer[0]` if it is a tuple
|
||||
datatype is chosen to be `shape.layer[1]` if available,
|
||||
otherwise `0`
|
||||
|
||||
It is often a good idea to run `pattern.subpatternize()` prior to calling this function,
|
||||
especially if calling `.polygonize()` will result in very many vertices.
|
||||
|
||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
patterns: A Pattern or list of patterns to convert.
|
||||
meters_per_unit: Written into the GDSII file, meters per (database) length unit.
|
||||
All distances are assumed to be an integer multiple of this unit, and are stored as such.
|
||||
logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a
|
||||
"logical" unit which is different from the "database" unit, for display purposes.
|
||||
Default `1`.
|
||||
library_name: Library name written into the GDSII file.
|
||||
Default 'masque-gdsii-write'.
|
||||
modify_originals: If `True`, the original pattern is modified as part of the writing
|
||||
process. Otherwise, a copy is made and `deepunlock()`-ed.
|
||||
Default `False`.
|
||||
disambiguate_func: Function which takes a list of patterns and alters them
|
||||
to make their names valid and unique. Default is `disambiguate_pattern_names`, which
|
||||
attempts to adhere to the GDSII standard as well as possible.
|
||||
WARNING: No additional error checking is performed on the results.
|
||||
|
||||
Returns:
|
||||
`gdsii.library.Library`
|
||||
"""
|
||||
if isinstance(patterns, Pattern):
|
||||
patterns = [patterns]
|
||||
|
||||
if disambiguate_func is None:
|
||||
disambiguate_func = disambiguate_pattern_names # type: ignore
|
||||
assert(disambiguate_func is not None) # placate mypy
|
||||
|
||||
if not modify_originals:
|
||||
patterns = [p.deepunlock() for p in copy.deepcopy(patterns)]
|
||||
|
||||
patterns = [p.wrap_repeated_shapes() for p in patterns]
|
||||
|
||||
# Create library
|
||||
lib = gdsii.library.Library(version=600,
|
||||
name=library_name.encode('ASCII'),
|
||||
logical_unit=logical_units_per_unit,
|
||||
physical_unit=meters_per_unit)
|
||||
|
||||
# Get a dict of id(pattern) -> pattern
|
||||
patterns_by_id = {id(pattern): pattern for pattern in patterns}
|
||||
for pattern in patterns:
|
||||
for i, p in pattern.referenced_patterns_by_id().items():
|
||||
patterns_by_id[i] = p
|
||||
|
||||
disambiguate_func(patterns_by_id.values())
|
||||
|
||||
# Now create a structure for each pattern, and add in any Boundary and SREF elements
|
||||
for pat in patterns_by_id.values():
|
||||
structure = gdsii.structure.Structure(name=pat.name.encode('ASCII'))
|
||||
lib.append(structure)
|
||||
|
||||
structure += _shapes_to_elements(pat.shapes)
|
||||
structure += _labels_to_texts(pat.labels)
|
||||
structure += _subpatterns_to_refs(pat.subpatterns)
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def write(
|
||||
patterns: Union[Pattern, Sequence[Pattern]],
|
||||
stream: io.BufferedIOBase,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Write a `Pattern` or list of patterns to a GDSII file.
|
||||
See `masque.file.gdsii.build()` for details.
|
||||
|
||||
Args:
|
||||
patterns: A Pattern or list of patterns to write to file.
|
||||
stream: Stream to write to.
|
||||
*args: passed to `masque.file.gdsii.build()`
|
||||
**kwargs: passed to `masque.file.gdsii.build()`
|
||||
"""
|
||||
lib = build(patterns, *args, **kwargs)
|
||||
lib.save(stream)
|
||||
return
|
||||
|
||||
def writefile(
|
||||
patterns: Union[Sequence[Pattern], Pattern],
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Wrapper for `masque.file.gdsii.write()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically compress the file if it has a .gz suffix.
|
||||
|
||||
Args:
|
||||
patterns: `Pattern` or list of patterns to save
|
||||
filename: Filename to save to.
|
||||
*args: passed to `masque.file.gdsii.write`
|
||||
**kwargs: passed to `masque.file.gdsii.write`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if path.suffix == '.gz':
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
open_func = open
|
||||
|
||||
with io.BufferedWriter(open_func(path, mode='wb')) as stream:
|
||||
write(patterns, stream, *args, **kwargs)
|
||||
|
||||
|
||||
def readfile(
|
||||
filename: Union[str, pathlib.Path],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
|
||||
"""
|
||||
Wrapper for `masque.file.gdsii.read()` that takes a filename or path instead of a stream.
|
||||
|
||||
Will automatically decompress gzipped files.
|
||||
|
||||
Args:
|
||||
filename: Filename to save to.
|
||||
*args: passed to `masque.file.gdsii.read`
|
||||
**kwargs: passed to `masque.file.gdsii.read`
|
||||
"""
|
||||
path = pathlib.Path(filename)
|
||||
if is_gzipped(path):
|
||||
open_func: Callable = gzip.open
|
||||
else:
|
||||
open_func = open
|
||||
|
||||
with io.BufferedReader(open_func(path, mode='rb')) as stream:
|
||||
results = read(stream, *args, **kwargs)
|
||||
return results
|
||||
|
||||
|
||||
def read(
|
||||
stream: io.BufferedIOBase,
|
||||
clean_vertices: bool = True,
|
||||
) -> Tuple[Dict[str, Pattern], Dict[str, Any]]:
|
||||
"""
|
||||
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 SubPattern 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:
|
||||
stream: Stream to read from.
|
||||
clean_vertices: If `True`, remove any redundant vertices when loading polygons.
|
||||
The cleaning process removes any polygons with zero area or <3 vertices.
|
||||
Default `True`.
|
||||
|
||||
Returns:
|
||||
- Dict of pattern_name:Patterns generated from GDSII structures
|
||||
- Dict of GDSII library info
|
||||
"""
|
||||
|
||||
lib = gdsii.library.Library.load(stream)
|
||||
|
||||
library_info = {'name': lib.name.decode('ASCII'),
|
||||
'meters_per_unit': lib.physical_unit,
|
||||
'logical_units_per_unit': lib.logical_unit,
|
||||
}
|
||||
|
||||
raw_mode = True # Whether to construct shapes in raw mode (less error checking)
|
||||
|
||||
patterns = []
|
||||
for structure in lib:
|
||||
pat = Pattern(name=structure.name.decode('ASCII'))
|
||||
for element in structure:
|
||||
# Switch based on element type:
|
||||
if isinstance(element, gdsii.elements.Boundary):
|
||||
poly = _boundary_to_polygon(element, raw_mode)
|
||||
pat.shapes.append(poly)
|
||||
|
||||
if isinstance(element, gdsii.elements.Path):
|
||||
path = _gpath_to_mpath(element, raw_mode)
|
||||
pat.shapes.append(path)
|
||||
|
||||
elif isinstance(element, gdsii.elements.Text):
|
||||
label = Label(offset=element.xy.astype(float),
|
||||
layer=(element.layer, element.text_type),
|
||||
string=element.string.decode('ASCII'))
|
||||
pat.labels.append(label)
|
||||
|
||||
elif isinstance(element, (gdsii.elements.SRef, gdsii.elements.ARef)):
|
||||
pat.subpatterns.append(_ref_to_subpat(element))
|
||||
|
||||
if clean_vertices:
|
||||
clean_pattern_vertices(pat)
|
||||
patterns.append(pat)
|
||||
|
||||
# Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries
|
||||
# according to the subpattern.identifier (which is deleted after use).
|
||||
patterns_dict = dict(((p.name, p) for p in patterns))
|
||||
for p in patterns_dict.values():
|
||||
for sp in p.subpatterns:
|
||||
sp.pattern = patterns_dict[sp.identifier[0].decode('ASCII')]
|
||||
del sp.identifier
|
||||
|
||||
return patterns_dict, library_info
|
||||
|
||||
|
||||
def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]:
|
||||
""" Helper to turn a layer tuple-or-int into a layer and datatype"""
|
||||
if isinstance(mlayer, int):
|
||||
layer = mlayer
|
||||
data_type = 0
|
||||
elif isinstance(mlayer, tuple):
|
||||
layer = mlayer[0]
|
||||
if len(mlayer) > 1:
|
||||
data_type = mlayer[1]
|
||||
else:
|
||||
data_type = 0
|
||||
else:
|
||||
raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.')
|
||||
return layer, data_type
|
||||
|
||||
|
||||
def _ref_to_subpat(
|
||||
element: Union[gdsii.elements.SRef,
|
||||
gdsii.elements.ARef]
|
||||
) -> SubPattern:
|
||||
"""
|
||||
Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None
|
||||
and sets the instance .identifier to (struct_name,).
|
||||
|
||||
NOTE: "Absolute" means not affected by parent elements.
|
||||
That's not currently supported by masque at all (and not planned).
|
||||
"""
|
||||
rotation = 0.0
|
||||
offset = numpy.array(element.xy[0], dtype=float)
|
||||
scale = 1.0
|
||||
mirror_across_x = False
|
||||
repetition = None
|
||||
|
||||
if element.strans is not None:
|
||||
if element.mag is not None:
|
||||
scale = element.mag
|
||||
# Bit 13 means absolute scale
|
||||
if get_bit(element.strans, 15 - 13):
|
||||
raise PatternError('Absolute scale is not implemented in masque!')
|
||||
if element.angle is not None:
|
||||
rotation = numpy.deg2rad(element.angle)
|
||||
# Bit 14 means absolute rotation
|
||||
if get_bit(element.strans, 15 - 14):
|
||||
raise PatternError('Absolute rotation is not implemented in masque!')
|
||||
# Bit 0 means mirror x-axis
|
||||
if get_bit(element.strans, 15 - 0):
|
||||
mirror_across_x = True
|
||||
|
||||
if isinstance(element, gdsii.elements.ARef):
|
||||
a_count = element.cols
|
||||
b_count = element.rows
|
||||
a_vector = (element.xy[1] - offset) / a_count
|
||||
b_vector = (element.xy[2] - offset) / b_count
|
||||
repetition = Grid(a_vector=a_vector, b_vector=b_vector,
|
||||
a_count=a_count, b_count=b_count)
|
||||
|
||||
subpat = SubPattern(pattern=None,
|
||||
offset=offset,
|
||||
rotation=rotation,
|
||||
scale=scale,
|
||||
mirrored=(mirror_across_x, False),
|
||||
annotations=_properties_to_annotations(element.properties),
|
||||
repetition=repetition)
|
||||
subpat.identifier = (element.struct_name,)
|
||||
return subpat
|
||||
|
||||
|
||||
def _gpath_to_mpath(element: gdsii.elements.Path, raw_mode: bool) -> Path:
|
||||
if element.path_type in path_cap_map:
|
||||
cap = path_cap_map[element.path_type]
|
||||
else:
|
||||
raise PatternError(f'Unrecognized path type: {element.path_type}')
|
||||
|
||||
args = {'vertices': element.xy.astype(float),
|
||||
'layer': (element.layer, element.data_type),
|
||||
'width': element.width if element.width is not None else 0.0,
|
||||
'cap': cap,
|
||||
'offset': numpy.zeros(2),
|
||||
'annotations': _properties_to_annotations(element.properties),
|
||||
'raw': raw_mode,
|
||||
}
|
||||
|
||||
if cap == Path.Cap.SquareCustom:
|
||||
args['cap_extensions'] = numpy.zeros(2)
|
||||
if element.bgn_extn is not None:
|
||||
args['cap_extensions'][0] = element.bgn_extn
|
||||
if element.end_extn is not None:
|
||||
args['cap_extensions'][1] = element.end_extn
|
||||
|
||||
return Path(**args)
|
||||
|
||||
|
||||
def _boundary_to_polygon(element: gdsii.elements.Boundary, raw_mode: bool) -> Polygon:
|
||||
args = {'vertices': element.xy[:-1].astype(float),
|
||||
'layer': (element.layer, element.data_type),
|
||||
'offset': numpy.zeros(2),
|
||||
'annotations': _properties_to_annotations(element.properties),
|
||||
'raw': raw_mode,
|
||||
}
|
||||
return Polygon(**args)
|
||||
|
||||
|
||||
def _subpatterns_to_refs(
|
||||
subpatterns: List[SubPattern],
|
||||
) -> List[Union[gdsii.elements.ARef, gdsii.elements.SRef]]:
|
||||
refs = []
|
||||
for subpat in subpatterns:
|
||||
if subpat.pattern is None:
|
||||
continue
|
||||
encoded_name = subpat.pattern.name.encode('ASCII')
|
||||
|
||||
# Note: GDS mirrors first and rotates second
|
||||
mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored)
|
||||
rep = subpat.repetition
|
||||
|
||||
new_refs: List[Union[gdsii.elements.SRef, gdsii.elements.ARef]]
|
||||
ref: Union[gdsii.elements.SRef, gdsii.elements.ARef]
|
||||
if isinstance(rep, Grid):
|
||||
b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2)
|
||||
b_count = rep.b_count if rep.b_count is not None else 1
|
||||
xy: NDArray[numpy.float64] = numpy.array(subpat.offset) + [
|
||||
[0, 0],
|
||||
rep.a_vector * rep.a_count,
|
||||
b_vector * b_count,
|
||||
]
|
||||
ref = gdsii.elements.ARef(
|
||||
struct_name=encoded_name,
|
||||
xy=rint_cast(xy),
|
||||
cols=rint_cast(rep.a_count),
|
||||
rows=rint_cast(rep.b_count),
|
||||
)
|
||||
new_refs = [ref]
|
||||
elif rep is None:
|
||||
ref = gdsii.elements.SRef(
|
||||
struct_name=encoded_name,
|
||||
xy=rint_cast([subpat.offset]),
|
||||
)
|
||||
new_refs = [ref]
|
||||
else:
|
||||
new_refs = [gdsii.elements.SRef(
|
||||
struct_name=encoded_name,
|
||||
xy=rint_cast([subpat.offset + dd]),
|
||||
)
|
||||
for dd in rep.displacements]
|
||||
|
||||
for ref in new_refs:
|
||||
ref.angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360
|
||||
# strans must be non-None for angle and mag to take effect
|
||||
ref.strans = set_bit(0, 15 - 0, mirror_across_x)
|
||||
ref.mag = subpat.scale
|
||||
ref.properties = _annotations_to_properties(subpat.annotations, 512)
|
||||
|
||||
refs += new_refs
|
||||
return refs
|
||||
|
||||
|
||||
def _properties_to_annotations(properties: List[Tuple[int, bytes]]) -> annotations_t:
|
||||
return {str(k): [v.decode()] for k, v in properties}
|
||||
|
||||
|
||||
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> List[Tuple[int, bytes]]:
|
||||
cum_len = 0
|
||||
props = []
|
||||
for key, vals in annotations.items():
|
||||
try:
|
||||
i = int(key)
|
||||
except ValueError:
|
||||
raise PatternError(f'Annotation key {key} is not convertable to an integer')
|
||||
if not (0 < i < 126):
|
||||
raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])')
|
||||
|
||||
val_strings = ' '.join(str(val) for val in vals)
|
||||
b = val_strings.encode()
|
||||
if len(b) > 126:
|
||||
raise PatternError(f'Annotation value {b!r} is longer than 126 characters!')
|
||||
cum_len += numpy.ceil(len(b) / 2) * 2 + 2
|
||||
if cum_len > max_len:
|
||||
raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}')
|
||||
props.append((i, b))
|
||||
return props
|
||||
|
||||
|
||||
def _shapes_to_elements(
|
||||
shapes: List[Shape],
|
||||
polygonize_paths: bool = False,
|
||||
) -> List[Union[gdsii.elements.Boundary, gdsii.elements.Path]]:
|
||||
elements: List[Union[gdsii.elements.Boundary, gdsii.elements.Path]] = []
|
||||
# Add a Boundary element for each shape, and Path elements if necessary
|
||||
for shape in shapes:
|
||||
layer, data_type = _mlayer2gds(shape.layer)
|
||||
properties = _annotations_to_properties(shape.annotations, 128)
|
||||
if isinstance(shape, Path) and not polygonize_paths:
|
||||
xy = rint_cast(shape.vertices + shape.offset)
|
||||
width = rint_cast(shape.width)
|
||||
path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup
|
||||
path = gdsii.elements.Path(layer=layer,
|
||||
data_type=data_type,
|
||||
xy=xy)
|
||||
path.path_type = path_type
|
||||
path.width = width
|
||||
path.properties = properties
|
||||
elements.append(path)
|
||||
else:
|
||||
for polygon in shape.to_polygons():
|
||||
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
|
||||
numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe')
|
||||
xy_closed[-1] = xy_closed[0]
|
||||
boundary = gdsii.elements.Boundary(
|
||||
layer=layer,
|
||||
data_type=data_type,
|
||||
xy=xy_closed,
|
||||
)
|
||||
boundary.properties = properties
|
||||
elements.append(boundary)
|
||||
return elements
|
||||
|
||||
|
||||
def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.Text]:
|
||||
texts = []
|
||||
for label in labels:
|
||||
properties = _annotations_to_properties(label.annotations, 128)
|
||||
layer, text_type = _mlayer2gds(label.layer)
|
||||
xy = rint_cast([label.offset])
|
||||
text = gdsii.elements.Text(
|
||||
layer=layer,
|
||||
text_type=text_type,
|
||||
xy=xy,
|
||||
string=label.string.encode('ASCII'),
|
||||
)
|
||||
text.properties = properties
|
||||
texts.append(text)
|
||||
return texts
|
||||
|
||||
|
||||
def disambiguate_pattern_names(
|
||||
patterns: Sequence[Pattern],
|
||||
max_name_length: int = 32,
|
||||
suffix_length: int = 6,
|
||||
dup_warn_filter: Optional[Callable[[str], bool]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
patterns: List of patterns to disambiguate
|
||||
max_name_length: Names longer than this will be truncated
|
||||
suffix_length: Names which get truncated are truncated by this many extra characters. This is to
|
||||
leave room for a suffix if one is necessary.
|
||||
dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives
|
||||
the cell name and returns `False` if the warning should be suppressed and `True` if it should
|
||||
be displayed. Default displays all warnings.
|
||||
"""
|
||||
used_names = []
|
||||
for pat in set(patterns):
|
||||
# Shorten names which already exceed max-length
|
||||
if len(pat.name) > max_name_length:
|
||||
shortened_name = pat.name[:max_name_length - suffix_length]
|
||||
logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n'
|
||||
+ f' shortening to "{shortened_name}" before generating suffix')
|
||||
else:
|
||||
shortened_name = pat.name
|
||||
|
||||
# Remove invalid characters
|
||||
sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name)
|
||||
|
||||
# Add a suffix that makes the name unique
|
||||
i = 0
|
||||
suffixed_name = sanitized_name
|
||||
while suffixed_name in used_names or suffixed_name == '':
|
||||
suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII')
|
||||
|
||||
suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A')
|
||||
i += 1
|
||||
|
||||
if sanitized_name == '':
|
||||
logger.warning(f'Empty pattern name saved as "{suffixed_name}"')
|
||||
elif suffixed_name != sanitized_name:
|
||||
if dup_warn_filter is None or dup_warn_filter(pat.name):
|
||||
logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n'
|
||||
+ f' renaming to "{suffixed_name}"')
|
||||
|
||||
# Encode into a byte-string and perform some final checks
|
||||
encoded_name = suffixed_name.encode('ASCII')
|
||||
if len(encoded_name) == 0:
|
||||
# Should never happen since zero-length names are replaced
|
||||
raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"')
|
||||
if len(encoded_name) > max_name_length:
|
||||
raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n'
|
||||
+ f' originally "{pat.name}"')
|
||||
|
||||
pat.name = suffixed_name
|
||||
used_names.append(suffixed_name)
|
||||
|
|
@ -1,76 +1,34 @@
|
|||
"""
|
||||
SVG file format readers and writers
|
||||
"""
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
import warnings
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike
|
||||
import svgwrite # type: ignore
|
||||
|
||||
from .utils import mangle_name
|
||||
from .. import Pattern, Ref
|
||||
from ..library import IMaterializable
|
||||
from ..utils import rotation_matrix_2d
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ref_to_svg_transform(ref: Ref) -> str:
|
||||
linear = rotation_matrix_2d(ref.rotation) * ref.scale
|
||||
if ref.mirrored:
|
||||
linear = linear @ numpy.diag((1.0, -1.0))
|
||||
|
||||
a = linear[0, 0]
|
||||
b = linear[1, 0]
|
||||
c = linear[0, 1]
|
||||
d = linear[1, 1]
|
||||
e = ref.offset[0]
|
||||
f = ref.offset[1]
|
||||
return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})'
|
||||
|
||||
|
||||
def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]:
|
||||
svg_ids: dict[str, str] = {}
|
||||
seen_ids: set[str] = set()
|
||||
for name in names:
|
||||
base_id = mangle_name(name)
|
||||
svg_id = base_id
|
||||
suffix = 1
|
||||
while svg_id in seen_ids:
|
||||
suffix += 1
|
||||
svg_id = f'{base_id}_{suffix}'
|
||||
seen_ids.add(svg_id)
|
||||
svg_ids[name] = svg_id
|
||||
return svg_ids
|
||||
|
||||
|
||||
def _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]:
|
||||
if isinstance(library, IMaterializable):
|
||||
detached = library.materialize_many_detached(tuple(library))
|
||||
return dict(detached.items())
|
||||
return {name: pat.deepcopy() for name, pat in library.items()}
|
||||
from .. import Pattern
|
||||
|
||||
|
||||
def writefile(
|
||||
library: Mapping[str, Pattern],
|
||||
top: str,
|
||||
pattern: Pattern,
|
||||
filename: str,
|
||||
custom_attributes: bool = False,
|
||||
annotate_ports: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Write a Pattern to an SVG file, by first calling .polygonize() on a detached
|
||||
materialized copy
|
||||
Write a Pattern to an SVG file, by first calling .polygonize() on it
|
||||
to change the shapes into polygons, and then writing patterns as SVG
|
||||
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
|
||||
groups (<g>, inside <defs>), polygons as paths (<path>), and subpatterns
|
||||
as <use> elements.
|
||||
|
||||
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
|
||||
is written to the relevant elements.
|
||||
Note that this function modifies the Pattern.
|
||||
|
||||
It is often a good idea to run `pattern.dedup()` on pattern prior to
|
||||
If `custom_attributes` is `True`, non-standard `pattern_layer` and `pattern_dose` attributes
|
||||
are written to the relevant elements.
|
||||
|
||||
It is often a good idea to run `pattern.subpatternize()` on pattern prior to
|
||||
calling this function, especially if calling `.polygonize()` will result in very
|
||||
many vertices.
|
||||
|
||||
|
|
@ -78,24 +36,19 @@ def writefile(
|
|||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
library: Mapping of pattern names to patterns.
|
||||
top: Name of the top-level pattern to render.
|
||||
pattern: Pattern to write to file. Modified by this function.
|
||||
filename: Filename to write to.
|
||||
custom_attributes: Whether to write non-standard `pattern_layer` attribute to the
|
||||
SVG elements.
|
||||
annotate_ports: If True, draw an arrow for each port (similar to
|
||||
`Pattern.visualize(..., ports=True)`).
|
||||
custom_attributes: Whether to write non-standard `pattern_layer` and
|
||||
`pattern_dose` attributes to the SVG elements.
|
||||
"""
|
||||
detached = _detached_library(library)
|
||||
pattern = detached[top]
|
||||
|
||||
# Polygonize pattern
|
||||
pattern.polygonize()
|
||||
|
||||
bounds = pattern.get_bounds(library=detached)
|
||||
bounds = pattern.get_bounds()
|
||||
if bounds is None:
|
||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox')
|
||||
else:
|
||||
bounds_min, bounds_max = bounds
|
||||
|
||||
|
|
@ -105,86 +58,65 @@ def writefile(
|
|||
# Create file
|
||||
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
|
||||
debug=(not custom_attributes))
|
||||
svg_ids = _make_svg_ids(detached)
|
||||
|
||||
# Now create a group for each pattern and add in any Boundary and Use elements
|
||||
for name, pat in detached.items():
|
||||
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
|
||||
# Get a dict of id(pattern) -> pattern
|
||||
patterns_by_id = {**(pattern.referenced_patterns_by_id()), id(pattern): pattern} # type: Dict[int, Optional[Pattern]]
|
||||
|
||||
for layer, shapes in pat.shapes.items():
|
||||
for shape in shapes:
|
||||
# Now create a group for each row in sd_table (ie, each pattern + dose combination)
|
||||
# and add in any Boundary and Use elements
|
||||
for pat in patterns_by_id.values():
|
||||
if pat is None:
|
||||
continue
|
||||
svg_group = svg.g(id=mangle_name(pat), fill='blue', stroke='red')
|
||||
|
||||
for shape in pat.shapes:
|
||||
for polygon in shape.to_polygons():
|
||||
path_spec = poly2path(polygon.vertices + polygon.offset)
|
||||
|
||||
path = svg.path(d=path_spec)
|
||||
if custom_attributes:
|
||||
path['pattern_layer'] = layer
|
||||
path['pattern_layer'] = polygon.layer
|
||||
path['pattern_dose'] = polygon.dose
|
||||
|
||||
svg_group.add(path)
|
||||
|
||||
if annotate_ports:
|
||||
# Draw arrows for the ports, pointing into the device (per port definition)
|
||||
for port_name, port in pat.ports.items():
|
||||
if port.rotation is not None:
|
||||
p1 = port.offset
|
||||
angle = port.rotation
|
||||
size = 1.0 # arrow size
|
||||
p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||
|
||||
# head
|
||||
head_angle = 0.5
|
||||
h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)])
|
||||
h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)])
|
||||
|
||||
line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2)
|
||||
head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2)
|
||||
|
||||
svg_group.add(line)
|
||||
svg_group.add(head)
|
||||
svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green'))
|
||||
|
||||
for target, refs in pat.refs.items():
|
||||
if target is None:
|
||||
for subpat in pat.subpatterns:
|
||||
if subpat.pattern is None:
|
||||
continue
|
||||
for ref in refs:
|
||||
transform = _ref_to_svg_transform(ref)
|
||||
use = svg.use(href='#' + svg_ids[target], transform=transform)
|
||||
transform = f'scale({subpat.scale:g}) rotate({subpat.rotation:g}) translate({subpat.offset[0]:g},{subpat.offset[1]:g})'
|
||||
use = svg.use(href='#' + mangle_name(subpat.pattern), transform=transform)
|
||||
if custom_attributes:
|
||||
use['pattern_dose'] = subpat.dose
|
||||
svg_group.add(use)
|
||||
|
||||
svg.defs.add(svg_group)
|
||||
svg.add(svg.use(href='#' + svg_ids[top]))
|
||||
svg.add(svg.use(href='#' + mangle_name(pattern)))
|
||||
svg.save()
|
||||
|
||||
|
||||
def writefile_inverted(
|
||||
library: Mapping[str, Pattern],
|
||||
top: str,
|
||||
filename: str,
|
||||
) -> None:
|
||||
def writefile_inverted(pattern: Pattern, filename: str):
|
||||
"""
|
||||
Write an inverted Pattern to an SVG file, by first calling `.polygonize()` and
|
||||
`.flatten()` on it to change the shapes into polygons, then drawing a bounding
|
||||
box and drawing the polygons with reverse vertex order inside it, all within
|
||||
one `<path>` element.
|
||||
|
||||
Note that this function modifies the Pattern.
|
||||
|
||||
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
|
||||
prior to calling this function.
|
||||
|
||||
Args:
|
||||
library: Mapping of pattern names to patterns.
|
||||
top: Name of the top-level pattern to render.
|
||||
pattern: Pattern to write to file. Modified by this function.
|
||||
filename: Filename to write to.
|
||||
"""
|
||||
detached = _detached_library(library)
|
||||
pattern = detached[top]
|
||||
|
||||
# Polygonize and flatten pattern
|
||||
pattern.polygonize().flatten(detached)
|
||||
pattern.polygonize().flatten()
|
||||
|
||||
bounds = pattern.get_bounds(library=detached)
|
||||
bounds = pattern.get_bounds()
|
||||
if bounds is None:
|
||||
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
|
||||
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
|
||||
warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox')
|
||||
else:
|
||||
bounds_min, bounds_max = bounds
|
||||
|
||||
|
|
@ -202,8 +134,7 @@ def writefile_inverted(
|
|||
path_spec = poly2path(slab_edge)
|
||||
|
||||
# Draw polygons with reversed vertex order
|
||||
for _layer, shapes in pattern.shapes.items():
|
||||
for shape in shapes:
|
||||
for shape in pattern.shapes:
|
||||
for polygon in shape.to_polygons():
|
||||
path_spec += poly2path(polygon.vertices[::-1] + polygon.offset)
|
||||
|
||||
|
|
@ -221,9 +152,9 @@ def poly2path(vertices: ArrayLike) -> str:
|
|||
Returns:
|
||||
SVG path-string.
|
||||
"""
|
||||
verts = numpy.asarray(vertices)
|
||||
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032
|
||||
verts = numpy.array(vertices, copy=False)
|
||||
commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1])
|
||||
for vertex in verts[1:]:
|
||||
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032
|
||||
commands += 'L{:g},{:g}'.format(vertex[0], vertex[1])
|
||||
commands += ' Z '
|
||||
return commands
|
||||
|
|
|
|||
|
|
@ -1,284 +1,29 @@
|
|||
"""
|
||||
Helper functions for file reading and writing
|
||||
"""
|
||||
from typing import IO
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Set, Tuple, List
|
||||
import re
|
||||
import copy
|
||||
import pathlib
|
||||
import logging
|
||||
import tempfile
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from pprint import pformat
|
||||
from itertools import chain
|
||||
|
||||
from .. import Pattern, PatternError, Library, LibraryError
|
||||
from ..library import (
|
||||
IBorrowing, ILibraryView, OverlayLibrary, SINGLE_USE_PREFIX,
|
||||
dangling_mode_t,
|
||||
)
|
||||
from .. import Pattern, PatternError
|
||||
from ..shapes import Polygon, Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _has_source_provenance(library: ILibraryView, name: str) -> bool:
|
||||
"""Return whether `name` has an uninterrupted borrowing provenance chain."""
|
||||
current = library
|
||||
current_name = name
|
||||
seen: set[tuple[int, str]] = set()
|
||||
followed_source = False
|
||||
while isinstance(current, IBorrowing):
|
||||
key = (id(current), current_name)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
source_cell = current.source_cell(current_name)
|
||||
if source_cell is None:
|
||||
return False
|
||||
followed_source = True
|
||||
current, current_name = source_cell
|
||||
return followed_source
|
||||
|
||||
|
||||
def _prune_checked_empty(
|
||||
library: OverlayLibrary,
|
||||
checked_names: set[str],
|
||||
*,
|
||||
dangling: dangling_mode_t,
|
||||
) -> set[str]:
|
||||
"""Prune checked empty cells without modifying source-backed parents."""
|
||||
parent_graph = library.parent_graph(dangling=dangling)
|
||||
source_backed = set(library) - checked_names
|
||||
|
||||
def safely_empty(name: str) -> bool:
|
||||
return (
|
||||
name in library
|
||||
and name in checked_names
|
||||
and not (parent_graph.get(name, set()) & source_backed)
|
||||
and library[name].is_empty()
|
||||
)
|
||||
|
||||
empty = {name for name in checked_names if safely_empty(name)}
|
||||
pruned: set[str] = set()
|
||||
while empty:
|
||||
parents: set[str] = set()
|
||||
for name in empty:
|
||||
name_parents = parent_graph.get(name, set())
|
||||
del library[name]
|
||||
checked_names.discard(name)
|
||||
for parent in name_parents & checked_names:
|
||||
if parent in library and name in library[parent].refs:
|
||||
del library[parent].refs[name]
|
||||
parents |= name_parents
|
||||
pruned |= empty
|
||||
empty = {parent for parent in parents if safely_empty(parent)}
|
||||
return pruned
|
||||
|
||||
|
||||
def _wrap_checked_repeated_shapes(
|
||||
library: OverlayLibrary,
|
||||
checked_names: set[str],
|
||||
) -> None:
|
||||
"""Wrap repetitions in checked cells while leaving source-backed cells untouched."""
|
||||
for pattern_name in tuple(checked_names):
|
||||
if pattern_name not in library:
|
||||
continue
|
||||
pattern = library[pattern_name]
|
||||
for layer in pattern.shapes:
|
||||
new_shapes = []
|
||||
for shape in pattern.shapes[layer]:
|
||||
if shape.repetition is None:
|
||||
new_shapes.append(shape)
|
||||
continue
|
||||
name = library.get_name(SINGLE_USE_PREFIX + 'rep')
|
||||
library[name] = Pattern(shapes={layer: [shape]})
|
||||
checked_names.add(name)
|
||||
pattern.ref(name, repetition=shape.repetition)
|
||||
shape.repetition = None
|
||||
pattern.shapes[layer] = new_shapes
|
||||
|
||||
for layer in pattern.labels:
|
||||
new_labels = []
|
||||
for label in pattern.labels[layer]:
|
||||
if label.repetition is None:
|
||||
new_labels.append(label)
|
||||
continue
|
||||
name = library.get_name(SINGLE_USE_PREFIX + 'rep')
|
||||
library[name] = Pattern(labels={layer: [label]})
|
||||
checked_names.add(name)
|
||||
pattern.ref(name, repetition=label.repetition)
|
||||
label.repetition = None
|
||||
pattern.labels[layer] = new_labels
|
||||
|
||||
|
||||
def preflight_source_aware(
|
||||
lib: ILibraryView,
|
||||
sort: bool = True,
|
||||
sort_elements: bool = False,
|
||||
allow_dangling_refs: bool | None = None,
|
||||
allow_named_layers: bool = True,
|
||||
prune_empty_patterns: bool = False,
|
||||
wrap_repeated_shapes: bool = False,
|
||||
) -> OverlayLibrary:
|
||||
def mangle_name(pattern: Pattern, dose_multiplier: float = 1.0) -> str:
|
||||
"""
|
||||
Preflight cells without reusable source provenance.
|
||||
|
||||
Returns a borrowing `OverlayLibrary`. Cells with uninterrupted
|
||||
`IBorrowing.source_cell()` provenance remain source-backed and receive no
|
||||
per-pattern checks. Other cells are detached into the overlay and checked.
|
||||
Keep `lib` and its borrowed sources open for the result's lifetime.
|
||||
Create a name using `pattern.name`, `id(pattern)`, and the dose multiplier.
|
||||
|
||||
Args:
|
||||
sort: Whether to sort checked pattern contents. Library name order is
|
||||
retained because sorting source-backed cells would require loading them.
|
||||
sort_elements: Whether to sort elements within checked patterns.
|
||||
allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not
|
||||
in the provided library. If `True`, no check is performed; if `False`, a `LibraryError`
|
||||
is raised instead.
|
||||
allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by
|
||||
a string in a checked pattern instead of a number (or tuple).
|
||||
prune_empty_patterns: Recursively delete checked empty patterns when
|
||||
doing so does not require modifying a source-backed parent.
|
||||
wrap_repeated_shapes: Turn repeated shapes in checked patterns into
|
||||
repeated refs containing non-repeated shapes.
|
||||
|
||||
Returns:
|
||||
A borrowing overlay containing checked patterns and source-backed cells.
|
||||
"""
|
||||
checked_names = {
|
||||
name
|
||||
for name in lib
|
||||
if not _has_source_provenance(lib, name)
|
||||
}
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lib)
|
||||
|
||||
if sort:
|
||||
for name in sorted(checked_names):
|
||||
overlay[name].sort(sort_elements=sort_elements)
|
||||
|
||||
if not allow_dangling_refs:
|
||||
refs = overlay.referenced_patterns()
|
||||
dangling = refs - set(overlay.keys())
|
||||
if dangling:
|
||||
msg = 'Dangling refs found: ' + pformat(dangling)
|
||||
if allow_dangling_refs is None:
|
||||
logger.warning(msg)
|
||||
else:
|
||||
raise LibraryError(msg)
|
||||
|
||||
if not allow_named_layers:
|
||||
checked_named_layers: Mapping[str, set] = defaultdict(set)
|
||||
for name in checked_names:
|
||||
pattern = overlay[name]
|
||||
for layer in chain(pattern.shapes.keys(), pattern.labels.keys()):
|
||||
if isinstance(layer, str):
|
||||
checked_named_layers[name].add(layer)
|
||||
checked_named_layers = dict(checked_named_layers)
|
||||
if checked_named_layers:
|
||||
raise PatternError('Non-numeric layers found:' + pformat(checked_named_layers))
|
||||
|
||||
if prune_empty_patterns:
|
||||
prune_dangling: dangling_mode_t = 'error' if allow_dangling_refs is False else 'ignore'
|
||||
pruned = _prune_checked_empty(overlay, checked_names, dangling=prune_dangling)
|
||||
if pruned:
|
||||
logger.info(f'Preflight pruned {len(pruned)} checked empty patterns')
|
||||
logger.debug('Pruned: ' + pformat(pruned))
|
||||
else:
|
||||
logger.debug('Preflight found no safely prunable checked patterns')
|
||||
|
||||
if wrap_repeated_shapes:
|
||||
_wrap_checked_repeated_shapes(overlay, checked_names)
|
||||
|
||||
return overlay
|
||||
|
||||
|
||||
def preflight(
|
||||
lib: Library,
|
||||
sort: bool = True,
|
||||
sort_elements: bool = False,
|
||||
allow_dangling_refs: bool | None = None,
|
||||
allow_named_layers: bool = True,
|
||||
prune_empty_patterns: bool = False,
|
||||
wrap_repeated_shapes: bool = False,
|
||||
) -> Library:
|
||||
"""
|
||||
Run a standard set of useful operations and checks on an entire library.
|
||||
|
||||
This helper is not copy-isolating. When `sort=True`, it constructs a new
|
||||
`Library` wrapper around the same `Pattern` objects after sorting them in
|
||||
place. Later mutating steps may still mutate caller-owned patterns. Deep-copy
|
||||
the library first when isolation is required.
|
||||
|
||||
Args:
|
||||
sort: Whether to sort patterns by name and sort each pattern's contents.
|
||||
sort_elements: Whether to sort elements within each pattern. Requires
|
||||
`sort=True`.
|
||||
allow_dangling_refs: If `None`, warn about missing targets. If `True`,
|
||||
skip the check. If `False`, raise `LibraryError`.
|
||||
allow_named_layers: If `False`, raise `PatternError` for string layers.
|
||||
prune_empty_patterns: Recursively delete empty patterns.
|
||||
wrap_repeated_shapes: Move shape and label repetitions onto wrapping refs.
|
||||
|
||||
Returns:
|
||||
`lib`, or an equivalent name-sorted `Library` when `sort=True`.
|
||||
"""
|
||||
mutable_lib = lib
|
||||
if sort:
|
||||
mutable_lib = Library(dict(sorted(
|
||||
(nn, pp.sort(sort_elements=sort_elements)) for nn, pp in mutable_lib.items()
|
||||
)))
|
||||
|
||||
if not allow_dangling_refs:
|
||||
refs = mutable_lib.referenced_patterns()
|
||||
dangling = refs - set(mutable_lib.keys())
|
||||
if dangling:
|
||||
msg = 'Dangling refs found: ' + pformat(dangling)
|
||||
if allow_dangling_refs is None:
|
||||
logger.warning(msg)
|
||||
else:
|
||||
raise LibraryError(msg)
|
||||
|
||||
if not allow_named_layers:
|
||||
named_layers: Mapping[str, set] = defaultdict(set)
|
||||
for name, pat in mutable_lib.items():
|
||||
for layer in chain(pat.shapes.keys(), pat.labels.keys()):
|
||||
if isinstance(layer, str):
|
||||
named_layers[name].add(layer)
|
||||
named_layers = dict(named_layers)
|
||||
if named_layers:
|
||||
raise PatternError('Non-numeric layers found:' + pformat(named_layers))
|
||||
|
||||
if prune_empty_patterns:
|
||||
prune_dangling: dangling_mode_t = 'error' if allow_dangling_refs is False else 'ignore'
|
||||
pruned = mutable_lib.prune_empty(dangling=prune_dangling)
|
||||
if pruned:
|
||||
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
|
||||
logger.debug('Pruned: ' + pformat(pruned))
|
||||
else:
|
||||
logger.debug('Preflight found no empty patterns')
|
||||
|
||||
if wrap_repeated_shapes:
|
||||
mutable_lib.wrap_repeated_shapes()
|
||||
|
||||
return mutable_lib
|
||||
|
||||
|
||||
def mangle_name(name: str) -> str:
|
||||
"""
|
||||
Sanitize a name.
|
||||
|
||||
Args:
|
||||
name: Name we want to mangle.
|
||||
pattern: Pattern whose name we want to mangle.
|
||||
dose_multiplier: Dose multiplier to mangle with.
|
||||
|
||||
Returns:
|
||||
Mangled name.
|
||||
"""
|
||||
expression = re.compile(r'[^A-Za-z0-9_\?\$]')
|
||||
sanitized_name = expression.sub('_', name)
|
||||
full_name = '{}_{}_{}'.format(pattern.name, dose_multiplier, id(pattern))
|
||||
sanitized_name = expression.sub('_', full_name)
|
||||
return sanitized_name
|
||||
|
||||
|
||||
|
|
@ -293,43 +38,149 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern:
|
|||
Returns:
|
||||
pat
|
||||
"""
|
||||
for shapes in pat.shapes.values():
|
||||
remove_inds = []
|
||||
for ii, shape in enumerate(shapes):
|
||||
if not isinstance(shape, Polygon | Path):
|
||||
for ii, shape in enumerate(pat.shapes):
|
||||
if not isinstance(shape, (Polygon, Path)):
|
||||
continue
|
||||
try:
|
||||
shape.clean_vertices()
|
||||
except PatternError:
|
||||
remove_inds.append(ii)
|
||||
for ii in sorted(remove_inds, reverse=True):
|
||||
del shapes[ii]
|
||||
del pat.shapes[ii]
|
||||
return pat
|
||||
|
||||
|
||||
def make_dose_table(patterns: List[Pattern], dose_multiplier: float = 1.0) -> Set[Tuple[int, float]]:
|
||||
"""
|
||||
Create a set containing `(id(pat), written_dose)` for each pattern (including subpatterns)
|
||||
|
||||
Args:
|
||||
pattern: Source Patterns.
|
||||
dose_multiplier: Multiplier for all written_dose entries.
|
||||
|
||||
Returns:
|
||||
`{(id(subpat.pattern), written_dose), ...}`
|
||||
"""
|
||||
dose_table = {(id(pattern), dose_multiplier) for pattern in patterns}
|
||||
for pattern in patterns:
|
||||
for subpat in pattern.subpatterns:
|
||||
if subpat.pattern is None:
|
||||
continue
|
||||
subpat_dose_entry = (id(subpat.pattern), subpat.dose * dose_multiplier)
|
||||
if subpat_dose_entry not in dose_table:
|
||||
subpat_dose_table = make_dose_table([subpat.pattern], subpat.dose * dose_multiplier)
|
||||
dose_table = dose_table.union(subpat_dose_table)
|
||||
return dose_table
|
||||
|
||||
|
||||
def dtype2dose(pattern: Pattern) -> Pattern:
|
||||
"""
|
||||
For each shape in the pattern, if the layer is a tuple, set the
|
||||
layer to the tuple's first element and set the dose to the
|
||||
tuple's second element.
|
||||
|
||||
Generally intended for use with `Pattern.apply()`.
|
||||
|
||||
Args:
|
||||
pattern: Pattern to modify
|
||||
|
||||
Returns:
|
||||
pattern
|
||||
"""
|
||||
for shape in pattern.shapes:
|
||||
if isinstance(shape.layer, tuple):
|
||||
shape.dose = shape.layer[1]
|
||||
shape.layer = shape.layer[0]
|
||||
return pattern
|
||||
|
||||
|
||||
def dose2dtype(
|
||||
patterns: List[Pattern],
|
||||
) -> Tuple[List[Pattern], List[float]]:
|
||||
"""
|
||||
For each shape in each pattern, set shape.layer to the tuple
|
||||
(base_layer, datatype), where:
|
||||
layer is chosen to be equal to the original shape.layer if it is an int,
|
||||
or shape.layer[0] if it is a tuple. `str` layers raise a PatterError.
|
||||
datatype is chosen arbitrarily, based on calcualted dose for each shape.
|
||||
Shapes with equal calcualted dose will have the same datatype.
|
||||
A list of doses is retured, providing a mapping between datatype
|
||||
(list index) and dose (list entry).
|
||||
|
||||
Note that this function modifies the input Pattern(s).
|
||||
|
||||
Args:
|
||||
patterns: A `Pattern` or list of patterns to write to file. Modified by this function.
|
||||
|
||||
Returns:
|
||||
(patterns, dose_list)
|
||||
patterns: modified input patterns
|
||||
dose_list: A list of doses, providing a mapping between datatype (int, list index)
|
||||
and dose (float, list entry).
|
||||
"""
|
||||
# Get a dict of id(pattern) -> pattern
|
||||
patterns_by_id = {id(pattern): pattern for pattern in patterns}
|
||||
for pattern in patterns:
|
||||
for i, p in pattern.referenced_patterns_by_id().items():
|
||||
patterns_by_id[i] = p
|
||||
|
||||
# Get a table of (id(pat), written_dose) for each pattern and subpattern
|
||||
sd_table = make_dose_table(patterns)
|
||||
|
||||
# Figure out all the unique doses necessary to write this pattern
|
||||
# This means going through each row in sd_table and adding the dose values needed to write
|
||||
# that subpattern at that dose level
|
||||
dose_vals = set()
|
||||
for pat_id, pat_dose in sd_table:
|
||||
pat = patterns_by_id[pat_id]
|
||||
for shape in pat.shapes:
|
||||
dose_vals.add(shape.dose * pat_dose)
|
||||
|
||||
if len(dose_vals) > 256:
|
||||
raise PatternError('Too many dose values: {}, maximum 256 when using dtypes.'.format(len(dose_vals)))
|
||||
|
||||
dose_vals_list = list(dose_vals)
|
||||
|
||||
# Create a new pattern for each non-1-dose entry in the dose table
|
||||
# and update the shapes to reflect their new dose
|
||||
new_pats = {} # (id, dose) -> new_pattern mapping
|
||||
for pat_id, pat_dose in sd_table:
|
||||
if pat_dose == 1:
|
||||
new_pats[(pat_id, pat_dose)] = patterns_by_id[pat_id]
|
||||
continue
|
||||
|
||||
old_pat = patterns_by_id[pat_id]
|
||||
pat = old_pat.copy() # keep old subpatterns
|
||||
pat.shapes = copy.deepcopy(old_pat.shapes)
|
||||
pat.labels = copy.deepcopy(old_pat.labels)
|
||||
|
||||
encoded_name = mangle_name(pat, pat_dose)
|
||||
if len(encoded_name) == 0:
|
||||
raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(pat.name))
|
||||
pat.name = encoded_name
|
||||
|
||||
for shape in pat.shapes:
|
||||
data_type = dose_vals_list.index(shape.dose * pat_dose)
|
||||
if isinstance(shape.layer, int):
|
||||
shape.layer = (shape.layer, data_type)
|
||||
elif isinstance(shape.layer, tuple):
|
||||
shape.layer = (shape.layer[0], data_type)
|
||||
else:
|
||||
raise PatternError(f'Invalid layer for gdsii: {shape.layer}')
|
||||
|
||||
new_pats[(pat_id, pat_dose)] = pat
|
||||
|
||||
# Go back through all the dose-specific patterns and fix up their subpattern entries
|
||||
for (pat_id, pat_dose), pat in new_pats.items():
|
||||
for subpat in pat.subpatterns:
|
||||
dose_mult = subpat.dose * pat_dose
|
||||
subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)]
|
||||
|
||||
return patterns, dose_vals_list
|
||||
|
||||
|
||||
def is_gzipped(path: pathlib.Path) -> bool:
|
||||
with path.open('rb') as stream:
|
||||
with open(path, 'rb') as stream:
|
||||
magic_bytes = stream.read(2)
|
||||
return magic_bytes == b'\x1f\x8b'
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]:
|
||||
"""
|
||||
Context manager which allows you to write to a temporary file,
|
||||
and move that file into its final location only after the write
|
||||
has finished.
|
||||
"""
|
||||
path = pathlib.Path(path)
|
||||
suffixes = ''.join(path.suffixes)
|
||||
with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream:
|
||||
try:
|
||||
yield tmp_stream
|
||||
except Exception:
|
||||
pathlib.Path(tmp_stream.name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
try:
|
||||
shutil.move(tmp_stream.name, path)
|
||||
finally:
|
||||
pathlib.Path(tmp_stream.name).unlink(missing_ok=True)
|
||||
|
|
|
|||
133
masque/label.py
133
masque/label.py
|
|
@ -1,30 +1,31 @@
|
|||
from typing import Self, Any
|
||||
from typing import Tuple, Dict, Optional, TypeVar
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from .repetition import Repetition
|
||||
from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key
|
||||
from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable
|
||||
from .utils import rotation_matrix_2d, layer_t, AutoSlots, annotations_t
|
||||
from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl, RepeatableImpl
|
||||
from .traits import AnnotatableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable):
|
||||
L = TypeVar('L', bound='Label')
|
||||
|
||||
|
||||
class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
Pivotable, Copyable, metaclass=AutoSlots):
|
||||
"""
|
||||
A text annotation with a position (but no size; it is not drawn)
|
||||
A text annotation with a position and layer (but no size; it is not drawn)
|
||||
"""
|
||||
__slots__ = (
|
||||
'_string',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations',
|
||||
)
|
||||
__slots__ = ( '_string', 'identifier')
|
||||
|
||||
_string: str
|
||||
""" Label string """
|
||||
|
||||
identifier: Tuple
|
||||
""" Arbitrary identifier tuple, useful for keeping track of history when flattening """
|
||||
|
||||
'''
|
||||
---- Properties
|
||||
'''
|
||||
|
|
@ -45,66 +46,38 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
string: str,
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t | None = None,
|
||||
layer: layer_t = 0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
identifier: Tuple = (),
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = identifier
|
||||
self.string = string
|
||||
self.offset = numpy.array(offset, dtype=float)
|
||||
self.offset = numpy.array(offset, dtype=float, copy=True)
|
||||
self.layer = layer
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.set_locked(locked)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
string: str,
|
||||
*,
|
||||
offset: NDArray[numpy.float64],
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._string = string
|
||||
new._offset = offset
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
return type(self)(
|
||||
string=self.string,
|
||||
def __copy__(self: L) -> L:
|
||||
return type(self)(string=self.string,
|
||||
offset=self.offset.copy(),
|
||||
layer=self.layer,
|
||||
repetition=self.repetition,
|
||||
annotations=copy.copy(self.annotations),
|
||||
)
|
||||
locked=self.locked,
|
||||
identifier=self.identifier)
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
def __deepcopy__(self: L, memo: Dict = None) -> L:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
LockableImpl.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations, memo)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __lt__(self, other: 'Label') -> bool:
|
||||
if self.string != other.string:
|
||||
return self.string < other.string
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (
|
||||
self.string == other.string
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self:
|
||||
def rotate_around(self: L, pivot: ArrayLike, rotation: float) -> L:
|
||||
"""
|
||||
Rotate the label around a point.
|
||||
|
||||
|
|
@ -115,37 +88,13 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
pivot = numpy.asarray(pivot, dtype=float)
|
||||
pivot = numpy.array(pivot, dtype=float)
|
||||
self.translate(-pivot)
|
||||
if self.repetition is not None:
|
||||
self.repetition.rotate(rotation)
|
||||
self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset)
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
|
||||
"""
|
||||
Extrinsic transformation: Flip the label across a line in the pattern's
|
||||
coordinate system. This affects both the label's offset and its
|
||||
repetition grid.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x).
|
||||
x: Vertical line x=val to mirror across.
|
||||
y: Horizontal line y=val to mirror across.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
|
||||
self.translate(-pivot)
|
||||
if self.repetition is not None:
|
||||
self.repetition.mirror(axis)
|
||||
self.offset[1 - axis] *= -1
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Return the bounds of the label.
|
||||
|
||||
|
|
@ -157,3 +106,17 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
Bounds [[xmin, xmax], [ymin, ymax]]
|
||||
"""
|
||||
return numpy.array([self.offset, self.offset])
|
||||
|
||||
def lock(self: L) -> L:
|
||||
PositionableImpl._lock(self)
|
||||
LockableImpl.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self: L) -> L:
|
||||
LockableImpl.unlock(self)
|
||||
PositionableImpl._unlock(self)
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<Label "{self.string}" l{self.layer} o{self.offset}{locked}>'
|
||||
|
|
|
|||
|
|
@ -1,35 +1,2 @@
|
|||
"""Library classes for managing name-to-pattern mappings."""
|
||||
from .utils import (
|
||||
INameView as INameView,
|
||||
SINGLE_USE_PREFIX as SINGLE_USE_PREFIX,
|
||||
Tree as Tree,
|
||||
TreeView as TreeView,
|
||||
b64suffix as b64suffix,
|
||||
dangling_mode_t as dangling_mode_t,
|
||||
visitor_function_t as visitor_function_t,
|
||||
)
|
||||
from .base import (
|
||||
AbstractView as AbstractView,
|
||||
ILibrary as ILibrary,
|
||||
ILibraryView as ILibraryView,
|
||||
)
|
||||
from .capabilities import (
|
||||
IBorrowing as IBorrowing,
|
||||
IMaterializable as IMaterializable,
|
||||
)
|
||||
from .mapping import (
|
||||
Library as Library,
|
||||
LibraryView as LibraryView,
|
||||
)
|
||||
from .overlay import (
|
||||
OverlayLibrary as OverlayLibrary,
|
||||
PortLoadView as PortLoadView,
|
||||
LayerMappedView as LayerMappedView,
|
||||
)
|
||||
from .build import (
|
||||
LibraryBuilder as LibraryBuilder,
|
||||
BuildReport as BuildReport,
|
||||
CellProvenance as CellProvenance,
|
||||
cell as cell,
|
||||
)
|
||||
from .lazy import LazyLibrary as LazyLibrary
|
||||
from .library import Library, PatternGenerator
|
||||
from .device_library import DeviceLibrary, LibDeviceLibrary
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,72 +0,0 @@
|
|||
"""Optional capabilities implemented by lazy and borrowing libraries."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ..pattern import Pattern
|
||||
from .base import ILibraryView
|
||||
from .mapping import LibraryView
|
||||
|
||||
|
||||
class IMaterializable(ABC):
|
||||
"""Capability for libraries which support explicit pattern materialization."""
|
||||
|
||||
@abstractmethod
|
||||
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
|
||||
"""Materialize one pattern, optionally retaining it in the library's cache."""
|
||||
|
||||
def materialize_many(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
*,
|
||||
persist: bool = True,
|
||||
) -> LibraryView:
|
||||
"""Materialize a de-duplicated sequence into a plain read-only view."""
|
||||
from .mapping import LibraryView # noqa: PLC0415
|
||||
|
||||
return LibraryView({
|
||||
name: self.materialize(name, persist=persist)
|
||||
for name in dict.fromkeys(names)
|
||||
})
|
||||
|
||||
def materialize_detached(self, name: str) -> Pattern:
|
||||
"""Materialize a caller-owned pattern which is safe to mutate."""
|
||||
return self.materialize(name, persist=False).deepcopy()
|
||||
|
||||
def materialize_many_detached(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
) -> LibraryView:
|
||||
"""Materialize caller-owned patterns without retaining them in this library."""
|
||||
from .mapping import LibraryView # noqa: PLC0415
|
||||
|
||||
materialized = self.materialize_many(names, persist=False)
|
||||
return LibraryView({
|
||||
name: materialized[name].deepcopy()
|
||||
for name in dict.fromkeys(names)
|
||||
})
|
||||
|
||||
|
||||
class IBorrowing(ABC):
|
||||
"""Capability for library views which directly borrow other libraries."""
|
||||
|
||||
@abstractmethod
|
||||
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
|
||||
"""Return the source views directly borrowed by this library."""
|
||||
|
||||
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None: # noqa: ARG002
|
||||
"""
|
||||
Return a direct source cell with unchanged layout data, if available.
|
||||
|
||||
The source may use a different name, which is returned alongside it.
|
||||
Port metadata may differ because ports are not layout-file content.
|
||||
The result is not recursively resolved: consumers must follow further
|
||||
borrowing views themselves and enforce format-specific constraints such
|
||||
as whether the visible and source names must match. `None` means the
|
||||
source cannot be reused safely or no source provenance is available.
|
||||
"""
|
||||
return None
|
||||
298
masque/library/device_library.py
Normal file
298
masque/library/device_library.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""
|
||||
DeviceLibrary class for managing unique name->device mappings and
|
||||
deferred loading or creation.
|
||||
"""
|
||||
from typing import Dict, Callable, TypeVar, TYPE_CHECKING
|
||||
from typing import Any, Tuple, Union, Iterator
|
||||
import logging
|
||||
from pprint import pformat
|
||||
|
||||
from ..error import DeviceLibraryError
|
||||
from ..library import Library
|
||||
from ..builder import Device
|
||||
from .. import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
D = TypeVar('D', bound='DeviceLibrary')
|
||||
L = TypeVar('L', bound='LibDeviceLibrary')
|
||||
|
||||
|
||||
class DeviceLibrary:
|
||||
"""
|
||||
This class maps names to functions which generate or load the
|
||||
relevant `Device` object.
|
||||
|
||||
This class largely functions the same way as `Library`, but
|
||||
operates on `Device`s rather than `Patterns` and thus has no
|
||||
need for distinctions between primary/secondary devices (as
|
||||
there is no inter-`Device` hierarchy).
|
||||
|
||||
Each device is cached the first time it is used. The cache can
|
||||
be disabled by setting the `enable_cache` attribute to `False`.
|
||||
"""
|
||||
generators: Dict[str, Callable[[], Device]]
|
||||
cache: Dict[Union[str, Tuple[str, str]], Device]
|
||||
enable_cache: bool = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.generators = {}
|
||||
self.cache = {}
|
||||
|
||||
def __setitem__(self, key: str, value: Callable[[], Device]) -> None:
|
||||
self.generators[key] = value
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.generators[key]
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __getitem__(self, key: str) -> Device:
|
||||
if self.enable_cache and key in self.cache:
|
||||
logger.debug(f'found {key} in cache')
|
||||
return self.cache[key]
|
||||
|
||||
logger.debug(f'loading {key}')
|
||||
dev = self.generators[key]()
|
||||
self.cache[key] = dev
|
||||
return dev
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self.generators
|
||||
|
||||
def keys(self) -> Iterator[str]:
|
||||
return iter(self.generators.keys())
|
||||
|
||||
def values(self) -> Iterator[Device]:
|
||||
return iter(self[key] for key in self.keys())
|
||||
|
||||
def items(self) -> Iterator[Tuple[str, Device]]:
|
||||
return iter((key, self[key]) for key in self.keys())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<DeviceLibrary with keys ' + repr(list(self.generators.keys())) + '>'
|
||||
|
||||
def set_const(self, const: Device) -> None:
|
||||
"""
|
||||
Convenience function to avoid having to manually wrap
|
||||
already-generated Device objects into callables.
|
||||
|
||||
Args:
|
||||
const: Pre-generated device object
|
||||
"""
|
||||
self.generators[const.pattern.name] = lambda: const
|
||||
|
||||
def add(
|
||||
self: D,
|
||||
other: D,
|
||||
use_ours: Callable[[str], bool] = lambda name: False,
|
||||
use_theirs: Callable[[str], bool] = lambda name: False,
|
||||
) -> D:
|
||||
"""
|
||||
Add keys from another library into this one.
|
||||
|
||||
There must be no conflicting keys.
|
||||
|
||||
Args:
|
||||
other: The library to insert keys from
|
||||
use_ours: Decision function for name conflicts. Will be called with duplicate cell names.
|
||||
Should return `True` if the value from `self` should be used.
|
||||
use_theirs: Decision function for name conflicts. Same format as `use_ours`.
|
||||
Should return `True` if the value from `other` should be used.
|
||||
`use_ours` takes priority over `use_theirs`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
duplicates = set(self.keys()) & set(other.keys())
|
||||
keep_ours = set(name for name in duplicates if use_ours(name))
|
||||
keep_theirs = set(name for name in duplicates - keep_ours if use_theirs(name))
|
||||
conflicts = duplicates - keep_ours - keep_theirs
|
||||
if conflicts:
|
||||
raise DeviceLibraryError('Duplicate keys encountered in DeviceLibrary merge: '
|
||||
+ pformat(conflicts))
|
||||
|
||||
for name in set(other.generators.keys()) - keep_ours:
|
||||
self.generators[name] = other.generators[name]
|
||||
if name in other.cache:
|
||||
self.cache[name] = other.cache[name]
|
||||
return self
|
||||
|
||||
def clear_cache(self: D) -> D:
|
||||
"""
|
||||
Clear the cache of this library.
|
||||
This is usually used before modifying or deleting cells, e.g. when merging
|
||||
with another library.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.cache = {}
|
||||
return self
|
||||
|
||||
def add_device(
|
||||
self,
|
||||
name: str,
|
||||
fn: Callable[[], Device],
|
||||
dev2pat: Callable[[Device], Pattern],
|
||||
prefix: str = '',
|
||||
) -> None:
|
||||
"""
|
||||
Convenience function for adding a device to the library.
|
||||
|
||||
- The device is generated with the provided `fn()`
|
||||
- Port info is written to the pattern using the provied dev2pat
|
||||
- The pattern is renamed to match the provided `prefix + name`
|
||||
- If `prefix` is non-empty, a wrapped copy is also added, named
|
||||
`name` (no prefix). See `wrap_device()` for details.
|
||||
|
||||
Adding devices with this function helps to
|
||||
- Make sure Pattern names are reflective of what the devices are named
|
||||
- Ensure port info is written into the `Pattern`, so that the `Device`
|
||||
can be reconstituted from the layout.
|
||||
- Simplify adding a prefix to all device names, to make it easier to
|
||||
track their provenance and purpose, while also allowing for
|
||||
generic device names which can later be swapped out with different
|
||||
underlying implementations.
|
||||
|
||||
Args:
|
||||
name: Base name for the device. If a prefix is used, this is the
|
||||
"generic" name (e.g. "L3_cavity" vs "2022_02_02_L3_cavity").
|
||||
fn: Function which is called to generate the device.
|
||||
dev2pat: Post-processing function which is called to add the port
|
||||
info into the device's pattern.
|
||||
prefix: If present, the actual device is named `prefix + name`, and
|
||||
a second device with name `name` is also added (containing only
|
||||
this one).
|
||||
"""
|
||||
def build_dev() -> Device:
|
||||
dev = fn()
|
||||
dev.pattern = dev2pat(dev)
|
||||
dev.pattern.rename(prefix + name)
|
||||
return dev
|
||||
|
||||
self[prefix + name] = build_dev
|
||||
if prefix:
|
||||
self.wrap_device(name, prefix + name)
|
||||
|
||||
def wrap_device(
|
||||
self,
|
||||
name: str,
|
||||
old_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new device which simply contains an instance of an already-existing device.
|
||||
|
||||
This is useful for assigning an alternate name to a device, while still keeping
|
||||
the original name available for traceability.
|
||||
|
||||
Args:
|
||||
name: Name for the wrapped device.
|
||||
old_name: Name of the existing device to wrap.
|
||||
"""
|
||||
|
||||
def build_wrapped_dev() -> Device:
|
||||
old_dev = self[old_name]
|
||||
wrapper = Pattern(name=name)
|
||||
wrapper.addsp(old_dev.pattern)
|
||||
return Device(wrapper, old_dev.ports)
|
||||
|
||||
self[name] = build_wrapped_dev
|
||||
|
||||
|
||||
class LibDeviceLibrary(DeviceLibrary):
|
||||
"""
|
||||
Extends `DeviceLibrary`, enabling it to ingest `Library` objects
|
||||
(e.g. obtained by loading a GDS file).
|
||||
|
||||
Each `Library` object must be accompanied by a `pat2dev` function,
|
||||
which takes in the `Pattern` and returns a full `Device` (including
|
||||
port info). This is usually accomplished by scanning the `Pattern` for
|
||||
port-related geometry, but could also bake in external info.
|
||||
|
||||
`Library` objects are ingested into `underlying`, which is a
|
||||
`Library` which is kept in sync with the `DeviceLibrary` when
|
||||
devices are removed (or new libraries added via `add_library()`).
|
||||
"""
|
||||
underlying: Library
|
||||
|
||||
def __init__(self) -> None:
|
||||
DeviceLibrary.__init__(self)
|
||||
self.underlying = Library()
|
||||
|
||||
def __setitem__(self, key: str, value: Callable[[], Device]) -> None:
|
||||
self.generators[key] = value
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
# If any `Library` that has been (or will be) added has an entry for `key`,
|
||||
# it will be added to `self.underlying` and then returned by it during subpattern
|
||||
# resolution for other entries, and will conflict with the name for our
|
||||
# wrapped device. To avoid that, we need to set ourselves as the "true" source of
|
||||
# the `Pattern` named `key`.
|
||||
if key in self.underlying:
|
||||
raise DeviceLibraryError(f'Device name {key} already exists in underlying Library!'
|
||||
' Demote or delete it first.')
|
||||
|
||||
# NOTE that this means the `Device` may be cached without the `Pattern` being in
|
||||
# the `underlying` cache yet!
|
||||
self.underlying.set_value(key, '__DeviceLibrary', lambda: self[key].pattern)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
DeviceLibrary.__delitem__(self, key)
|
||||
if key in self.underlying:
|
||||
del self.underlying[key]
|
||||
|
||||
def add_library(
|
||||
self: L,
|
||||
lib: Library,
|
||||
pat2dev: Callable[[Pattern], Device],
|
||||
use_ours: Callable[[Union[str, Tuple[str, str]]], bool] = lambda name: False,
|
||||
use_theirs: Callable[[Union[str, Tuple[str, str]]], bool] = lambda name: False,
|
||||
) -> L:
|
||||
"""
|
||||
Add a pattern `Library` into this `LibDeviceLibrary`.
|
||||
|
||||
This requires a `pat2dev` function which can transform each `Pattern`
|
||||
into a `Device`. For example, this can be accomplished by scanning
|
||||
the `Pattern` data for port location info or by looking up port info
|
||||
based on the pattern name or other characteristics in a hardcoded or
|
||||
user-supplied dictionary.
|
||||
|
||||
Args:
|
||||
lib: Pattern library to add.
|
||||
pat2dev: Function for transforming each `Pattern` object from `lib`
|
||||
into a `Device` which will be returned by this device library.
|
||||
use_ours: Decision function for name conflicts. Will be called with
|
||||
duplicate cell names, and (name, tag) tuples from the underlying library.
|
||||
Should return `True` if the value from `self` should be used.
|
||||
use_theirs: Decision function for name conflicts. Same format as `use_ours`.
|
||||
Should return `True` if the value from `other` should be used.
|
||||
`use_ours` takes priority over `use_theirs`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
duplicates = set(lib.keys()) & set(self.keys())
|
||||
keep_ours = set(name for name in duplicates if use_ours(name))
|
||||
keep_theirs = set(name for name in duplicates - keep_ours if use_theirs(name))
|
||||
bad_duplicates = duplicates - keep_ours - keep_theirs
|
||||
if bad_duplicates:
|
||||
raise DeviceLibraryError('Duplicate devices (no action specified): ' + pformat(bad_duplicates))
|
||||
|
||||
# No 'bad' duplicates, so all duplicates should be overwritten
|
||||
for name in keep_theirs:
|
||||
self.underlying.demote(name)
|
||||
|
||||
self.underlying.add(lib, use_ours, use_theirs)
|
||||
|
||||
for name in lib:
|
||||
self.generators[name] = lambda name=name: pat2dev(self.underlying[name])
|
||||
return self
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
"""Closure-backed lazy library implementation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, Self, cast
|
||||
import logging
|
||||
|
||||
from ..error import LibraryError
|
||||
from .base import ILibrary
|
||||
from .capabilities import IMaterializable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LazyLibrary(ILibrary, IMaterializable):
|
||||
"""
|
||||
This class is usually used to create a library of Patterns by mapping names to
|
||||
functions which generate or load the relevant `Pattern` object as-needed.
|
||||
|
||||
TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid?
|
||||
"""
|
||||
mapping: dict[str, Callable[[], Pattern]]
|
||||
cache: dict[str, Pattern]
|
||||
_lookups_in_progress: list[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.mapping = {}
|
||||
self.cache = {}
|
||||
self._lookups_in_progress = []
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self.mapping:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
|
||||
if callable(value):
|
||||
value_func = value
|
||||
else:
|
||||
value_func = lambda: cast('Pattern', value) # noqa: E731
|
||||
|
||||
self.mapping[key] = value_func
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.mapping[key]
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.materialize(key, persist=True)
|
||||
|
||||
def materialize(self, key: str, *, persist: bool = True) -> Pattern:
|
||||
logger.debug(f'loading {key}')
|
||||
if key in self.cache:
|
||||
logger.debug(f'found {key} in cache')
|
||||
return self.cache[key]
|
||||
|
||||
if key in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [key])
|
||||
raise LibraryError(
|
||||
f'Detected circular reference or recursive lookup of "{key}".\n'
|
||||
f'Lookup chain: {chain}\n'
|
||||
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
||||
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
|
||||
)
|
||||
|
||||
self._lookups_in_progress.append(key)
|
||||
try:
|
||||
func = self.mapping[key]
|
||||
pat = func()
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
if persist:
|
||||
self.cache[key] = pat
|
||||
return pat
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def referenced_patterns(
|
||||
self,
|
||||
tops: str | Sequence[str] | None = None,
|
||||
skip: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
# Closure-backed cells do not have hierarchy metadata. Preserve laziness
|
||||
# by loading only patterns reached from the requested roots.
|
||||
return self._referenced_patterns_by_lookup(tops=tops, skip=skip)
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
if isinstance(other, LazyLibrary):
|
||||
self.mapping[key_self] = other.mapping[key_other]
|
||||
if key_other in other.cache:
|
||||
self.cache[key_self] = other.cache[key_other]
|
||||
else:
|
||||
self[key_self] = other[key_other]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<LazyLibrary with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
def rename(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
move_references: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Rename a pattern.
|
||||
|
||||
Args:
|
||||
old_name: Current name for the pattern
|
||||
new_name: New name for the pattern
|
||||
move_references: Whether to scan all refs in the pattern and
|
||||
move them to point to `new_name` as necessary.
|
||||
Default `False`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if old_name not in self.mapping:
|
||||
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||
if old_name == new_name:
|
||||
return self
|
||||
|
||||
self[new_name] = self.mapping[old_name] # copy over function
|
||||
if old_name in self.cache:
|
||||
self.cache[new_name] = self.cache[old_name]
|
||||
del self[old_name]
|
||||
|
||||
if move_references:
|
||||
self.move_references(old_name, new_name)
|
||||
|
||||
return self
|
||||
|
||||
def move_references(self, old_target: str, new_target: str) -> Self:
|
||||
"""
|
||||
Change all references pointing at `old_target` into references pointing at `new_target`.
|
||||
|
||||
Args:
|
||||
old_target: Current reference target
|
||||
new_target: New target for the reference
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if old_target == new_target:
|
||||
return self
|
||||
|
||||
self.precache()
|
||||
for pattern in self.cache.values():
|
||||
if old_target in pattern.refs:
|
||||
pattern.refs[new_target].extend(pattern.refs[old_target])
|
||||
del pattern.refs[old_target]
|
||||
return self
|
||||
|
||||
def precache(self) -> Self:
|
||||
"""
|
||||
Force all patterns into the cache
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for key in self.mapping:
|
||||
_ = self[key] # want to trigger our own __getitem__
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> LazyLibrary:
|
||||
raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)')
|
||||
355
masque/library/library.py
Normal file
355
masque/library/library.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"""
|
||||
Library class for managing unique name->pattern mappings and
|
||||
deferred loading or creation.
|
||||
"""
|
||||
from typing import Dict, Callable, TypeVar, TYPE_CHECKING
|
||||
from typing import Any, Tuple, Union, Iterator
|
||||
import logging
|
||||
from pprint import pformat
|
||||
from dataclasses import dataclass
|
||||
import copy
|
||||
|
||||
from ..error import LibraryError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternGenerator:
|
||||
__slots__ = ('tag', 'gen')
|
||||
tag: str
|
||||
""" Unique identifier for the source """
|
||||
|
||||
gen: Callable[[], 'Pattern']
|
||||
""" Function which generates a pattern when called """
|
||||
|
||||
|
||||
L = TypeVar('L', bound='Library')
|
||||
|
||||
|
||||
class Library:
|
||||
"""
|
||||
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.
|
||||
|
||||
Generated/loaded patterns can have "symbolic" references, where a SubPattern
|
||||
object `sp` has a `None`-valued `sp.pattern` attribute, in which case the
|
||||
Library expects `sp.identifier[0]` to contain a string which specifies the
|
||||
referenced pattern's name.
|
||||
|
||||
Patterns can either be "primary" (default) or "secondary". Both get the
|
||||
same deferred-load behavior, but "secondary" patterns may have conflicting
|
||||
names and are not accessible through basic []-indexing. They are only used
|
||||
to fill symbolic references in cases where there is no "primary" pattern
|
||||
available, and only if both the referencing and referenced pattern-generators'
|
||||
`tag` values match (i.e., only if they came from the same source).
|
||||
|
||||
Primary patterns can be turned into secondary patterns with the `demote`
|
||||
method, `promote` performs the reverse (secondary -> primary) operation.
|
||||
|
||||
The `set_const` and `set_value` methods provide an easy way to transparently
|
||||
construct PatternGenerator objects and directly set create "secondary"
|
||||
patterns.
|
||||
|
||||
The cache can be disabled by setting the `enable_cache` attribute to `False`.
|
||||
"""
|
||||
primary: Dict[str, PatternGenerator]
|
||||
secondary: Dict[Tuple[str, str], PatternGenerator]
|
||||
cache: Dict[Union[str, Tuple[str, str]], 'Pattern']
|
||||
enable_cache: bool = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.primary = {}
|
||||
self.secondary = {}
|
||||
self.cache = {}
|
||||
|
||||
def __setitem__(self, key: str, value: PatternGenerator) -> None:
|
||||
self.primary[key] = value
|
||||
if key in self.cache:
|
||||
logger.warning(f'Replaced library item "{key}" & existing cache entry.'
|
||||
' Previously-generated Pattern will *not* be updated!')
|
||||
del self.cache[key]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
if isinstance(key, str):
|
||||
del self.primary[key]
|
||||
elif isinstance(key, tuple):
|
||||
del self.secondary[key]
|
||||
|
||||
if key in self.cache:
|
||||
logger.warning(f'Deleting library item "{key}" & existing cache entry.'
|
||||
' Previously-generated Pattern may remain in the wild!')
|
||||
del self.cache[key]
|
||||
|
||||
def __getitem__(self, key: str) -> 'Pattern':
|
||||
return self.get_primary(key)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self.primary
|
||||
|
||||
def get_primary(self, key: str) -> 'Pattern':
|
||||
if self.enable_cache and key in self.cache:
|
||||
logger.debug(f'found {key} in cache')
|
||||
return self.cache[key]
|
||||
|
||||
logger.debug(f'loading {key}')
|
||||
pg = self.primary[key]
|
||||
pat = pg.gen()
|
||||
self.resolve_subpatterns(pat, pg.tag)
|
||||
self.cache[key] = pat
|
||||
return pat
|
||||
|
||||
def get_secondary(self, key: str, tag: str) -> 'Pattern':
|
||||
logger.debug(f'get_secondary({key}, {tag})')
|
||||
key2 = (key, tag)
|
||||
if self.enable_cache and key2 in self.cache:
|
||||
return self.cache[key2]
|
||||
|
||||
pg = self.secondary[key2]
|
||||
pat = pg.gen()
|
||||
self.resolve_subpatterns(pat, pg.tag)
|
||||
self.cache[key2] = pat
|
||||
return pat
|
||||
|
||||
def set_secondary(self, key: str, tag: str, value: PatternGenerator) -> None:
|
||||
self.secondary[(key, tag)] = value
|
||||
if (key, tag) in self.cache:
|
||||
logger.warning(f'Replaced library item "{key}" & existing cache entry.'
|
||||
' Previously-generated Pattern will *not* be updated!')
|
||||
del self.cache[(key, tag)]
|
||||
|
||||
def resolve_subpatterns(self, pat: 'Pattern', tag: str) -> 'Pattern':
|
||||
logger.debug(f'Resolving subpatterns in {pat.name}')
|
||||
for sp in pat.subpatterns:
|
||||
if sp.pattern is not None:
|
||||
continue
|
||||
|
||||
key = sp.identifier[0]
|
||||
if key in self.primary:
|
||||
sp.pattern = self.get_primary(key)
|
||||
continue
|
||||
|
||||
if (key, tag) in self.secondary:
|
||||
sp.pattern = self.get_secondary(key, tag)
|
||||
continue
|
||||
|
||||
raise LibraryError(f'Broken reference to {key} (tag {tag})')
|
||||
return pat
|
||||
|
||||
def keys(self) -> Iterator[str]:
|
||||
return iter(self.primary.keys())
|
||||
|
||||
def values(self) -> Iterator['Pattern']:
|
||||
return iter(self[key] for key in self.keys())
|
||||
|
||||
def items(self) -> Iterator[Tuple[str, 'Pattern']]:
|
||||
return iter((key, self[key]) for key in self.keys())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<Library with keys ' + repr(list(self.primary.keys())) + '>'
|
||||
|
||||
def set_const(
|
||||
self,
|
||||
key: str,
|
||||
tag: Any,
|
||||
const: 'Pattern',
|
||||
secondary: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Convenience function to avoid having to manually wrap
|
||||
constant values into callables.
|
||||
|
||||
Args:
|
||||
key: Lookup key, usually the cell/pattern name
|
||||
tag: Unique tag for the source, used to disambiguate secondary patterns
|
||||
const: Pattern object to return
|
||||
secondary: If True, this pattern is not accessible for normal lookup, and is
|
||||
only used as a sub-component of other patterns if no non-secondary
|
||||
equivalent is available.
|
||||
"""
|
||||
pg = PatternGenerator(tag=tag, gen=lambda: const)
|
||||
if secondary:
|
||||
self.secondary[(key, tag)] = pg
|
||||
else:
|
||||
self.primary[key] = pg
|
||||
|
||||
def set_value(
|
||||
self,
|
||||
key: str,
|
||||
tag: str,
|
||||
value: Callable[[], 'Pattern'],
|
||||
secondary: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Convenience function to automatically build a PatternGenerator.
|
||||
|
||||
Args:
|
||||
key: Lookup key, usually the cell/pattern name
|
||||
tag: Unique tag for the source, used to disambiguate secondary patterns
|
||||
value: Callable which takes no arguments and generates the `Pattern` object
|
||||
secondary: If True, this pattern is not accessible for normal lookup, and is
|
||||
only used as a sub-component of other patterns if no non-secondary
|
||||
equivalent is available.
|
||||
"""
|
||||
pg = PatternGenerator(tag=tag, gen=value)
|
||||
if secondary:
|
||||
self.secondary[(key, tag)] = pg
|
||||
else:
|
||||
self.primary[key] = pg
|
||||
|
||||
def precache(self: L) -> L:
|
||||
"""
|
||||
Force all patterns into the cache
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
for key in self.primary:
|
||||
_ = self.get_primary(key)
|
||||
for key2 in self.secondary:
|
||||
_ = self.get_secondary(*key2)
|
||||
return self
|
||||
|
||||
def add(
|
||||
self: L,
|
||||
other: L,
|
||||
use_ours: Callable[[Union[str, Tuple[str, str]]], bool] = lambda name: False,
|
||||
use_theirs: Callable[[Union[str, Tuple[str, str]]], bool] = lambda name: False,
|
||||
) -> L:
|
||||
"""
|
||||
Add keys from another library into this one.
|
||||
|
||||
Args:
|
||||
other: The library to insert keys from
|
||||
use_ours: Decision function for name conflicts.
|
||||
May be called with cell names and (name, tag) tuples for primary or
|
||||
secondary cells, respectively.
|
||||
Should return `True` if the value from `self` should be used.
|
||||
use_theirs: Decision function for name conflicts. Same format as `use_ours`.
|
||||
Should return `True` if the value from `other` should be used.
|
||||
`use_ours` takes priority over `use_theirs`.
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
duplicates1 = set(self.primary.keys()) & set(other.primary.keys())
|
||||
duplicates2 = set(self.secondary.keys()) & set(other.secondary.keys())
|
||||
keep_ours1 = set(name for name in duplicates1 if use_ours(name))
|
||||
keep_ours2 = set(name for name in duplicates2 if use_ours(name))
|
||||
keep_theirs1 = set(name for name in duplicates1 - keep_ours1 if use_theirs(name))
|
||||
keep_theirs2 = set(name for name in duplicates2 - keep_ours2 if use_theirs(name))
|
||||
conflicts1 = duplicates1 - keep_ours1 - keep_theirs1
|
||||
conflicts2 = duplicates2 - keep_ours2 - keep_theirs2
|
||||
|
||||
if conflicts1:
|
||||
raise LibraryError('Unresolved duplicate keys encountered in library merge: ' + pformat(conflicts1))
|
||||
|
||||
if conflicts2:
|
||||
raise LibraryError('Unresolved duplicate secondary keys encountered in library merge: ' + pformat(conflicts2))
|
||||
|
||||
for key1 in set(other.primary.keys()) - keep_ours1:
|
||||
self[key1] = other.primary[key1]
|
||||
if key1 in other.cache:
|
||||
self.cache[key1] = other.cache[key1]
|
||||
|
||||
for key2 in set(other.secondary.keys()) - keep_ours2:
|
||||
self.set_secondary(*key2, other.secondary[key2])
|
||||
if key2 in other.cache:
|
||||
self.cache[key2] = other.cache[key2]
|
||||
|
||||
return self
|
||||
|
||||
def demote(self, key: str) -> None:
|
||||
"""
|
||||
Turn a primary pattern into a secondary one.
|
||||
It will no longer be accessible through [] indexing and will only be used to
|
||||
when referenced by other patterns from the same source, and only if no primary
|
||||
pattern with the same name exists.
|
||||
|
||||
Args:
|
||||
key: Lookup key, usually the cell/pattern name
|
||||
"""
|
||||
pg = self.primary[key]
|
||||
key2 = (key, pg.tag)
|
||||
self.secondary[key2] = pg
|
||||
if key in self.cache:
|
||||
self.cache[key2] = self.cache[key]
|
||||
del self[key]
|
||||
|
||||
def promote(self, key: str, tag: str) -> None:
|
||||
"""
|
||||
Turn a secondary pattern into a primary one.
|
||||
It will become accessible through [] indexing and will be used to satisfy any
|
||||
reference to a pattern with its key, regardless of tag.
|
||||
|
||||
Args:
|
||||
key: Lookup key, usually the cell/pattern name
|
||||
tag: Unique tag for identifying the pattern's source, used to disambiguate
|
||||
secondary patterns
|
||||
"""
|
||||
if key in self.primary:
|
||||
raise LibraryError(f'Promoting ({key}, {tag}), but {key} already exists in primary!')
|
||||
|
||||
key2 = (key, tag)
|
||||
pg = self.secondary[key2]
|
||||
self.primary[key] = pg
|
||||
if key2 in self.cache:
|
||||
self.cache[key] = self.cache[key2]
|
||||
del self.secondary[key2]
|
||||
del self.cache[key2]
|
||||
|
||||
def copy(self, preserve_cache: bool = False) -> 'Library':
|
||||
"""
|
||||
Create a copy of this `Library`.
|
||||
|
||||
A shallow copy is made of the contained dicts.
|
||||
Note that you should probably clear the cache (with `clear_cache()`) after copying.
|
||||
|
||||
Returns:
|
||||
A copy of self
|
||||
"""
|
||||
new = Library()
|
||||
new.primary.update(self.primary)
|
||||
new.secondary.update(self.secondary)
|
||||
new.cache.update(self.cache)
|
||||
return new
|
||||
|
||||
def clear_cache(self: L) -> L:
|
||||
"""
|
||||
Clear the cache of this library.
|
||||
This is usually used before modifying or deleting cells, e.g. when merging
|
||||
with another library.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.cache = {}
|
||||
return self
|
||||
|
||||
|
||||
r"""
|
||||
# Add a filter for names which aren't added
|
||||
|
||||
- Registration:
|
||||
- scanned files (tag=filename, gen_fn[stream, {name: pos}])
|
||||
- generator functions (tag='fn?', gen_fn[params])
|
||||
- merge decision function (based on tag and cell name, can be "neither") ??? neither=keep both, load using same tag!
|
||||
- Load process:
|
||||
- file:
|
||||
- read single cell
|
||||
- check subpat identifiers, and load stuff recursively based on those. If not present, load from same file??
|
||||
- function:
|
||||
- generate cell
|
||||
- traverse and check if we should load any subcells from elsewhere. replace if so.
|
||||
* should fn generate subcells at all, or register those separately and have us control flow? maybe ask us and generate itself if not present?
|
||||
|
||||
- Scan all GDS files, save name -> (file, position). Keep the streams handy.
|
||||
- Merge all names. This requires subcell merge because we don't know hierarchy.
|
||||
- possibly include a "neither" option during merge, to deal with subcells. Means: just use parent's file.
|
||||
"""
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
"""Concrete mapping-backed library implementations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, Self
|
||||
|
||||
from ..error import LibraryError
|
||||
from .base import ILibrary, ILibraryView
|
||||
from .capabilities import IBorrowing, IMaterializable
|
||||
from .utils import dangling_mode_t, _validate_dangling_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
class LibraryView(ILibraryView):
|
||||
"""
|
||||
Default implementation for a read-only library.
|
||||
|
||||
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
|
||||
This library is backed by an arbitrary python object which implements the `Mapping` interface.
|
||||
"""
|
||||
mapping: Mapping[str, Pattern]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: Mapping[str, Pattern],
|
||||
) -> None:
|
||||
self.mapping = mapping
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<LibraryView ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
|
||||
class _SubtreeLibraryView(ILibraryView, IMaterializable, IBorrowing):
|
||||
"""Borrowed subtree view with snapshotted membership and hierarchy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ILibraryView,
|
||||
*,
|
||||
names: set[str],
|
||||
child_graph: Mapping[str, set[str]],
|
||||
) -> None:
|
||||
self._source = source
|
||||
source_order = source.source_order()
|
||||
ordered = list(dict.fromkeys(name for name in source_order if name in names))
|
||||
seen = set(ordered)
|
||||
ordered.extend(name for name in source if name in names and name not in seen)
|
||||
self._order = tuple(ordered)
|
||||
self._names = frozenset(self._order)
|
||||
self._child_graph = {
|
||||
name: set(child_graph.get(name, set()))
|
||||
for name in self._order
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
if key not in self._names:
|
||||
raise KeyError(key)
|
||||
return self._source[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._order)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._order)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._names
|
||||
|
||||
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
|
||||
return (self._source,)
|
||||
|
||||
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
|
||||
if name not in self._names:
|
||||
return None
|
||||
return self._source, name
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return self._order
|
||||
|
||||
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
|
||||
if name not in self._names:
|
||||
raise KeyError(name)
|
||||
if isinstance(self._source, IMaterializable):
|
||||
return self._source.materialize(name, persist=persist)
|
||||
return self._source[name]
|
||||
|
||||
def materialize_detached(self, name: str) -> Pattern:
|
||||
if name not in self._names:
|
||||
raise KeyError(name)
|
||||
if isinstance(self._source, IMaterializable):
|
||||
return self._source.materialize_detached(name)
|
||||
return self._source[name].deepcopy()
|
||||
|
||||
def materialize_many_detached(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
) -> LibraryView:
|
||||
ordered_names = tuple(dict.fromkeys(names))
|
||||
missing = next((name for name in ordered_names if name not in self._names), None)
|
||||
if missing is not None:
|
||||
raise KeyError(missing)
|
||||
if isinstance(self._source, IMaterializable):
|
||||
return self._source.materialize_many_detached(ordered_names)
|
||||
return LibraryView({name: self._source[name].deepcopy() for name in ordered_names})
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
_validate_dangling_mode(dangling)
|
||||
graph = {name: set(children) for name, children in self._child_graph.items()}
|
||||
existing = set(graph)
|
||||
dangling_refs = set().union(*(children - existing for children in graph.values())) if graph else set()
|
||||
if dangling == 'error':
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(dangling_refs, 'building child graph')
|
||||
return graph
|
||||
if dangling == 'ignore':
|
||||
return {
|
||||
name: {child for child in children if child in existing}
|
||||
for name, children in graph.items()
|
||||
}
|
||||
for target in dangling_refs:
|
||||
graph.setdefault(target, set())
|
||||
return graph
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
_validate_dangling_mode(dangling)
|
||||
if parent_graph is None:
|
||||
graph_mode: dangling_mode_t = 'ignore' if dangling == 'ignore' else 'include'
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
refs = self._source.find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
|
||||
return {parent: transforms for parent, transforms in refs.items() if parent in self._names}
|
||||
|
||||
|
||||
class Library(ILibrary):
|
||||
"""
|
||||
Default implementation for a writeable library.
|
||||
|
||||
A library is a mapping from unique names (str) to collections of geometry (`Pattern`).
|
||||
This library is backed by an arbitrary python object which implements the `MutableMapping` interface.
|
||||
"""
|
||||
mapping: MutableMapping[str, Pattern]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: MutableMapping[str, Pattern] | None = None,
|
||||
) -> None:
|
||||
if mapping is None:
|
||||
self.mapping = {}
|
||||
else:
|
||||
self.mapping = mapping
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self.mapping:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
|
||||
value = value() if callable(value) else value
|
||||
self.mapping[key] = value
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self.mapping[key]
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
self[key_self] = other[key_other]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Library ({type(self.mapping)}) with keys\n' + pformat(list(self.keys())) + '>'
|
||||
|
||||
@classmethod
|
||||
def mktree(cls: type[Self], name: str) -> tuple[Self, Pattern]:
|
||||
"""
|
||||
Create a new Library and immediately add a pattern
|
||||
|
||||
Args:
|
||||
name: The name for the new pattern (usually the name of the topcell).
|
||||
|
||||
Returns:
|
||||
The newly created `Library` and the newly created `Pattern`
|
||||
"""
|
||||
from ..pattern import Pattern # noqa: PLC0415
|
||||
tree = cls()
|
||||
pat = Pattern()
|
||||
tree[name] = pat
|
||||
return tree, pat
|
||||
|
|
@ -1,561 +0,0 @@
|
|||
"""Overlay and lazily processed library views."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal, Self, cast
|
||||
import copy
|
||||
|
||||
from ..error import LibraryError
|
||||
from ..pattern import Pattern, map_layers, map_targets
|
||||
from .base import ILibrary, ILibraryView
|
||||
from .capabilities import IBorrowing, IMaterializable
|
||||
from .utils import (
|
||||
INameView,
|
||||
dangling_mode_t,
|
||||
_plan_source_names,
|
||||
_rename_patterns,
|
||||
_source_rename_map,
|
||||
_validate_dangling_mode,
|
||||
)
|
||||
from .mapping import LibraryView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ..ports import Port
|
||||
from ..utils import layer_t
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SourceLayer:
|
||||
""" One imported source layer tracked by an `OverlayLibrary`. """
|
||||
library: ILibraryView
|
||||
source_target_map: dict[str, str]
|
||||
child_graph: dict[str, set[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SourceEntry:
|
||||
""" Reference to a single visible source-backed cell in an overlay. """
|
||||
layer_index: int
|
||||
source_name: str
|
||||
|
||||
|
||||
def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern:
|
||||
if isinstance(view, IMaterializable):
|
||||
return view.materialize_detached(name)
|
||||
return view[name].deepcopy()
|
||||
|
||||
|
||||
class _ProcessedLibraryView(ILibraryView, IMaterializable, IBorrowing):
|
||||
"""Shared detached-materialization behavior for read-only processing views."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ILibraryView,
|
||||
*,
|
||||
copy_through: bool,
|
||||
) -> None:
|
||||
self._source = source
|
||||
self._copy_through = copy_through
|
||||
self._cache: dict[str, Pattern] = {}
|
||||
self._lookups_in_progress: list[str] = []
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.materialize(key, persist=True)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._source)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._source)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._source
|
||||
|
||||
def _process_pattern(self, name: str, pattern: Pattern) -> Pattern:
|
||||
"""Apply this view's processing to one detached source pattern."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _materialize_uncached_detached(self, name: str) -> Pattern:
|
||||
if name in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||
raise LibraryError(
|
||||
f'Detected circular reference or recursive lookup of "{name}".\n'
|
||||
f'Lookup chain: {chain}\n'
|
||||
'This may be caused by an invalid (cyclical) reference, or buggy code.'
|
||||
)
|
||||
|
||||
self._lookups_in_progress.append(name)
|
||||
try:
|
||||
pattern = _materialize_detached_pattern(self._source, name)
|
||||
pattern = self._process_pattern(name, pattern)
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
|
||||
return pattern
|
||||
|
||||
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
|
||||
if name in self._cache:
|
||||
return self._cache[name]
|
||||
|
||||
pattern = self._materialize_uncached_detached(name)
|
||||
|
||||
if persist:
|
||||
self._cache[name] = pattern
|
||||
return pattern
|
||||
|
||||
def materialize_detached(self, name: str) -> Pattern:
|
||||
if name in self._cache:
|
||||
return self._cache[name].deepcopy()
|
||||
return self._materialize_uncached_detached(name)
|
||||
|
||||
def materialize_many_detached(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
) -> LibraryView:
|
||||
ordered_names = tuple(dict.fromkeys(names))
|
||||
result: dict[str, Pattern] = {}
|
||||
uncached = [name for name in ordered_names if name not in self._cache]
|
||||
|
||||
for name in ordered_names:
|
||||
if name in self._cache:
|
||||
result[name] = self._cache[name].deepcopy()
|
||||
|
||||
if uncached:
|
||||
if isinstance(self._source, IMaterializable):
|
||||
source_patterns = self._source.materialize_many_detached(uncached)
|
||||
else:
|
||||
source_patterns = LibraryView({name: self._source[name].deepcopy() for name in uncached})
|
||||
for name in uncached:
|
||||
if name in self._lookups_in_progress:
|
||||
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||
raise LibraryError(f'Detected circular reference or recursive lookup of "{name}".\nLookup chain: {chain}')
|
||||
self._lookups_in_progress.append(name)
|
||||
try:
|
||||
result[name] = self._process_pattern(name, source_patterns[name])
|
||||
finally:
|
||||
self._lookups_in_progress.pop()
|
||||
|
||||
return LibraryView({name: result[name] for name in ordered_names})
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return self._source.source_order()
|
||||
|
||||
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
|
||||
return (self._source,)
|
||||
|
||||
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
|
||||
if not self._copy_through or name not in self._source or name in self._cache:
|
||||
return None
|
||||
return self._source, name
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
_validate_dangling_mode(dangling)
|
||||
return self._source.child_graph(dangling=dangling)
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
_validate_dangling_mode(dangling)
|
||||
finder = getattr(self._source, 'find_refs_local', None)
|
||||
if callable(finder):
|
||||
return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling))
|
||||
return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
|
||||
|
||||
|
||||
class PortLoadView(_ProcessedLibraryView):
|
||||
"""
|
||||
Read-only view which loads or applies ports on first materialization.
|
||||
|
||||
The wrapped source remains untouched; this view owns a separate processed
|
||||
cache so direct-copy workflows can continue to use the raw source view.
|
||||
The view borrows its source: callers must keep the source open for the
|
||||
lifetime of the view and close the source themselves.
|
||||
|
||||
Graph queries and source ordering are delegated to the wrapped source,
|
||||
while `source_cell()` exposes unchanged layout provenance and `__getitem__`
|
||||
and `materialize_many()` return port-imported patterns.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ILibraryView,
|
||||
*,
|
||||
layers: Sequence[layer_t] = (),
|
||||
max_depth: int = 0,
|
||||
skip_subcells: bool = True,
|
||||
ports: Mapping[str, Mapping[str, Port]] | None = None,
|
||||
replace: bool = False,
|
||||
) -> None:
|
||||
super().__init__(source, copy_through=True)
|
||||
self._layers = tuple(layers)
|
||||
self._max_depth = max_depth
|
||||
self._skip_subcells = skip_subcells
|
||||
self._ports = {
|
||||
name: copy.deepcopy(dict(cell_ports))
|
||||
for name, cell_ports in (ports or {}).items()
|
||||
}
|
||||
self._replace = replace
|
||||
|
||||
def _process_pattern(self, name: str, pattern: Pattern) -> Pattern:
|
||||
from ..utils.ports2data import data_to_ports # noqa: PLC0415
|
||||
|
||||
if self._layers:
|
||||
pattern = data_to_ports(
|
||||
layers=self._layers,
|
||||
library=self,
|
||||
pattern=pattern,
|
||||
name=name,
|
||||
max_depth=self._max_depth,
|
||||
skip_subcells=self._skip_subcells,
|
||||
)
|
||||
if name in self._ports:
|
||||
ports = copy.deepcopy(self._ports[name])
|
||||
if self._replace:
|
||||
pattern.ports = ports
|
||||
else:
|
||||
pattern.ports.update(ports)
|
||||
return pattern
|
||||
|
||||
|
||||
class LayerMappedView(_ProcessedLibraryView):
|
||||
"""
|
||||
Read-only view which remaps shape and label layers on materialization.
|
||||
|
||||
The wrapped source remains untouched. By default, source-aware writers
|
||||
must materialize and serialize every mapped cell. With `copy_through=True`,
|
||||
unmaterialized cells may instead be copied unchanged from their source;
|
||||
persistent access maps and caches a cell, disabling copy-through for it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ILibraryView,
|
||||
map_layer: Callable[[layer_t], layer_t],
|
||||
*,
|
||||
copy_through: bool = False,
|
||||
) -> None:
|
||||
super().__init__(source, copy_through=copy_through)
|
||||
self._map_layer = map_layer
|
||||
|
||||
def _process_pattern(self, name: str, pattern: Pattern) -> Pattern:
|
||||
_ = name
|
||||
pattern.shapes = map_layers(pattern.shapes, self._map_layer)
|
||||
pattern.labels = map_layers(pattern.labels, self._map_layer)
|
||||
return pattern
|
||||
|
||||
|
||||
class OverlayLibrary(ILibrary, IMaterializable, IBorrowing):
|
||||
"""
|
||||
Mutable overlay over one or more source libraries.
|
||||
|
||||
Source-backed cells remain lazy until accessed through `__getitem__`, which
|
||||
persistently materializes a detached, overlay-owned `Pattern`.
|
||||
|
||||
Source libraries must remain open and must not be mutated after they are
|
||||
added. The overlay borrows each source and snapshots its names, hierarchy,
|
||||
and initial visible-name mapping while retaining the source itself for lazy
|
||||
pattern materialization.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._layers: list[_SourceLayer] = []
|
||||
self._entries: dict[str, Pattern | _SourceEntry] = {}
|
||||
self._order: list[str] = []
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return (name for name in self._order if name in self._entries)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._entries
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.materialize(key, persist=True)
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key: str,
|
||||
value: Pattern | Callable[[], Pattern],
|
||||
) -> None:
|
||||
if key in self._entries:
|
||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||
pattern = value() if callable(value) else value
|
||||
self._entries[key] = pattern
|
||||
if key not in self._order:
|
||||
self._order.append(key)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
if key not in self._entries:
|
||||
raise KeyError(key)
|
||||
del self._entries[key]
|
||||
|
||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||
self[key_self] = copy.deepcopy(other[key_other])
|
||||
|
||||
def add_source(
|
||||
self,
|
||||
source: Mapping[str, Pattern] | ILibraryView,
|
||||
*,
|
||||
rename_theirs: Callable[[INameView, str], str] | None = _rename_patterns,
|
||||
rename_when: Literal['conflict', 'always'] = 'conflict',
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Add a source-backed library layer.
|
||||
|
||||
The source must remain open, and its names, hierarchy, and pattern
|
||||
contents must remain unchanged for the lifetime of this overlay.
|
||||
|
||||
Args:
|
||||
rename_theirs: Function used to choose visible names for imported
|
||||
source cells. Its `INameView` argument contains existing and
|
||||
previously reserved names, but does not support pattern lookup.
|
||||
By default, conflicting single-use names are made unique;
|
||||
pass `None` to reject every conflict.
|
||||
rename_when: If `'conflict'`, only conflicting names are renamed.
|
||||
If `'always'`, every imported source name is passed through
|
||||
`rename_theirs`.
|
||||
"""
|
||||
view = source if isinstance(source, ILibraryView) else LibraryView(source)
|
||||
source_order = list(view.source_order())
|
||||
child_graph = view.child_graph(dangling='include')
|
||||
|
||||
source_to_visible = _plan_source_names(
|
||||
self,
|
||||
source_order,
|
||||
rename_theirs = rename_theirs,
|
||||
rename_when = rename_when,
|
||||
)
|
||||
layer = _SourceLayer(
|
||||
library=view,
|
||||
source_target_map=dict(source_to_visible),
|
||||
child_graph=child_graph,
|
||||
)
|
||||
# Include dangling targets so each source tracks current names directly.
|
||||
for children in child_graph.values():
|
||||
for child in children:
|
||||
layer.source_target_map.setdefault(child, child)
|
||||
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
|
||||
|
||||
self._order = [name for name in self._order if name != new_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 move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
|
||||
if old_target == new_target:
|
||||
return self
|
||||
for layer in self._layers:
|
||||
for source_target, current_target in layer.source_target_map.items():
|
||||
if current_target == old_target:
|
||||
layer.source_target_map[source_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:
|
||||
return layer.source_target_map.get(target, target)
|
||||
|
||||
def _remap_source_pattern(self, layer: _SourceLayer, source_pat: Pattern) -> Pattern:
|
||||
def remap(target: str | None) -> str | None:
|
||||
return None if target is None else self._effective_target(layer, target)
|
||||
|
||||
if source_pat.refs:
|
||||
source_pat.refs = map_targets(source_pat.refs, remap)
|
||||
return source_pat
|
||||
|
||||
def materialize(self, name: str, *, persist: bool = True) -> Pattern:
|
||||
if name not in self._entries:
|
||||
raise KeyError(name)
|
||||
entry = self._entries[name]
|
||||
if isinstance(entry, Pattern):
|
||||
return entry
|
||||
|
||||
layer = self._layers[entry.layer_index]
|
||||
source_pat = _materialize_detached_pattern(layer.library, entry.source_name)
|
||||
pat = self._remap_source_pattern(layer, source_pat)
|
||||
if persist:
|
||||
self._entries[name] = pat
|
||||
return pat
|
||||
|
||||
def materialize_detached(self, name: str) -> Pattern:
|
||||
if name not in self._entries:
|
||||
raise KeyError(name)
|
||||
entry = self._entries[name]
|
||||
if isinstance(entry, Pattern):
|
||||
return entry.deepcopy()
|
||||
layer = self._layers[entry.layer_index]
|
||||
source_pat = _materialize_detached_pattern(layer.library, entry.source_name)
|
||||
return self._remap_source_pattern(layer, source_pat)
|
||||
|
||||
def materialize_many_detached(
|
||||
self,
|
||||
names: Sequence[str],
|
||||
) -> LibraryView:
|
||||
ordered_names = tuple(dict.fromkeys(names))
|
||||
missing = next((name for name in ordered_names if name not in self._entries), None)
|
||||
if missing is not None:
|
||||
raise KeyError(missing)
|
||||
|
||||
result: dict[str, Pattern] = {}
|
||||
grouped: dict[int, list[tuple[str, str]]] = defaultdict(list)
|
||||
for name in ordered_names:
|
||||
entry = self._entries[name]
|
||||
if isinstance(entry, Pattern):
|
||||
result[name] = entry.deepcopy()
|
||||
else:
|
||||
grouped[entry.layer_index].append((name, entry.source_name))
|
||||
|
||||
for layer_index, cells in grouped.items():
|
||||
layer = self._layers[layer_index]
|
||||
source_names = [source_name for _name, source_name in cells]
|
||||
if isinstance(layer.library, IMaterializable):
|
||||
source_patterns = layer.library.materialize_many_detached(source_names)
|
||||
else:
|
||||
source_patterns = LibraryView({
|
||||
source_name: layer.library[source_name].deepcopy()
|
||||
for source_name in source_names
|
||||
})
|
||||
for name, source_name in cells:
|
||||
result[name] = self._remap_source_pattern(layer, source_patterns[source_name])
|
||||
|
||||
return LibraryView({name: result[name] for name in ordered_names})
|
||||
|
||||
def child_graph(
|
||||
self,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, set[str]]:
|
||||
_validate_dangling_mode(dangling)
|
||||
graph: dict[str, set[str]] = {}
|
||||
for name in self._order:
|
||||
if name not in self._entries:
|
||||
continue
|
||||
entry = self._entries[name]
|
||||
if isinstance(entry, Pattern):
|
||||
graph[name] = {child for child, refs in entry.refs.items() if child is not None and refs}
|
||||
continue
|
||||
layer = self._layers[entry.layer_index]
|
||||
children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())}
|
||||
graph[name] = children
|
||||
|
||||
existing = set(graph)
|
||||
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
||||
if dangling == 'error':
|
||||
if dangling_refs:
|
||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
||||
return graph
|
||||
if dangling == 'ignore':
|
||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||
|
||||
for child in dangling_refs:
|
||||
graph.setdefault(cast('str', child), set())
|
||||
return graph
|
||||
|
||||
def subtree(
|
||||
self,
|
||||
tops: str | Sequence[str],
|
||||
) -> Self:
|
||||
if isinstance(tops, str):
|
||||
tops = (tops,)
|
||||
|
||||
graph = self.child_graph(dangling='include')
|
||||
keep = self._referenced_patterns_from_graph(graph, tops=tops)
|
||||
keep &= set(self)
|
||||
keep |= set(tops)
|
||||
|
||||
new = type(self)()
|
||||
new._layers = [
|
||||
_SourceLayer(
|
||||
library=layer.library,
|
||||
source_target_map=dict(layer.source_target_map),
|
||||
child_graph={name: set(children) for name, children in layer.child_graph.items()},
|
||||
)
|
||||
for layer in self._layers
|
||||
]
|
||||
new._order = [name for name in self._order if name in keep and name in self._entries]
|
||||
new._entries = {name: self._entries[name] for name in new._order}
|
||||
return new
|
||||
|
||||
def find_refs_local(
|
||||
self,
|
||||
name: str,
|
||||
parent_graph: dict[str, set[str]] | None = None,
|
||||
dangling: dangling_mode_t = 'error',
|
||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||
_validate_dangling_mode(dangling)
|
||||
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
||||
if parent_graph is None:
|
||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||
|
||||
if name not in self:
|
||||
if name not in parent_graph:
|
||||
return instances
|
||||
if dangling == 'error':
|
||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||
if dangling == 'ignore':
|
||||
return instances
|
||||
|
||||
for parent in parent_graph.get(name, set()):
|
||||
pat = self.materialize(parent, persist=False)
|
||||
for ref in pat.refs.get(name, []):
|
||||
instances[parent].append(ref.as_transforms())
|
||||
return instances
|
||||
|
||||
def source_order(self) -> tuple[str, ...]:
|
||||
return tuple(name for name in self._order if name in self._entries)
|
||||
|
||||
def borrowed_sources(self) -> tuple[ILibraryView, ...]:
|
||||
return tuple(layer.library for layer in self._layers)
|
||||
|
||||
def source_cell(self, name: str) -> tuple[ILibraryView, str] | None:
|
||||
entry = self._entries.get(name)
|
||||
if not isinstance(entry, _SourceEntry):
|
||||
return None
|
||||
layer = self._layers[entry.layer_index]
|
||||
children = layer.child_graph.get(entry.source_name, set())
|
||||
if any(self._effective_target(layer, child) != child for child in children):
|
||||
return None
|
||||
return layer.library, entry.source_name
|
||||
|
|
@ -1,223 +1,48 @@
|
|||
"""Shared types and helpers for library implementations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
|
||||
from collections.abc import Callable, Collection, Iterator, Mapping, MutableMapping, Sequence
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..error import LibraryError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ..pattern import Pattern
|
||||
from typing import Callable, TypeVar, Generic
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLE_USE_PREFIX = '_'
|
||||
"""
|
||||
Names starting with this prefix are assumed to refer to single-use patterns,
|
||||
which may be renamed automatically by `ILibrary.add()` (via
|
||||
`rename_theirs=_rename_patterns()` )
|
||||
"""
|
||||
# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
|
||||
Key = TypeVar('Key')
|
||||
Value = TypeVar('Value')
|
||||
|
||||
|
||||
class INameView(Collection[str], ABC):
|
||||
class DeferredDict(dict, Generic[Key, Value]):
|
||||
"""
|
||||
Read-only collection of reserved names with a shared name allocator.
|
||||
This is a modified `dict` which is used to defer loading/generating
|
||||
values until they are accessed.
|
||||
|
||||
Name views support membership, iteration, `len()`, and `get_name()`. They
|
||||
do not provide pattern lookup or the other operations of a library mapping.
|
||||
```
|
||||
bignum = my_slow_function() # slow function call, would like to defer this
|
||||
numbers = DeferredDict()
|
||||
numbers['big'] = my_slow_function # no slow function call here
|
||||
assert(bignum == numbers['big']) # first access is slow (function called)
|
||||
assert(bignum == numbers['big']) # second access is fast (result is cached)
|
||||
```
|
||||
|
||||
The `set_const` method is provided for convenience;
|
||||
`numbers['a'] = lambda: 10` is equivalent to `numbers.set_const('a', 10)`.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
dict.__init__(self)
|
||||
self.update(*args, **kwargs)
|
||||
|
||||
def get_name(
|
||||
self,
|
||||
name: str = SINGLE_USE_PREFIX * 2,
|
||||
sanitize: bool = True,
|
||||
max_length: int = 32,
|
||||
quiet: bool | None = None,
|
||||
) -> str:
|
||||
def __setitem__(self, key: Key, value: Callable[[], Value]) -> None:
|
||||
cached_fn = lru_cache(maxsize=1)(value)
|
||||
dict.__setitem__(self, key, cached_fn)
|
||||
|
||||
def __getitem__(self, key: Key) -> Value:
|
||||
return dict.__getitem__(self, key)()
|
||||
|
||||
def update(self, *args, **kwargs) -> None:
|
||||
for k, v in dict(*args, **kwargs).items():
|
||||
self[k] = v
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<DeferredDict with keys ' + repr(set(self.keys())) + '>'
|
||||
|
||||
def set_const(self, key: Key, value: Value) -> None:
|
||||
"""
|
||||
Find a unique name.
|
||||
|
||||
This function may be overridden in a subclass or monkey-patched to fit
|
||||
the caller's requirements.
|
||||
|
||||
Args:
|
||||
name: Preferred name. Default is `SINGLE_USE_PREFIX * 2`.
|
||||
sanitize: Allow only alphanumeric characters and _?$, replacing
|
||||
invalid characters with underscores.
|
||||
max_length: Truncate names longer than this.
|
||||
quiet: Suppress log messages when `True`. The default suppresses
|
||||
messages only when `name` starts with `SINGLE_USE_PREFIX`.
|
||||
|
||||
Returns:
|
||||
A name unique within this view.
|
||||
Convenience function to avoid having to manually wrap
|
||||
constant values into callables.
|
||||
"""
|
||||
if quiet is None:
|
||||
quiet = name.startswith(SINGLE_USE_PREFIX)
|
||||
|
||||
if sanitize:
|
||||
sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', name)
|
||||
else:
|
||||
sanitized_name = name
|
||||
|
||||
suffixed_name = sanitized_name
|
||||
if sanitized_name in self:
|
||||
ii = sum(1 for nn in self if nn.startswith(sanitized_name))
|
||||
else:
|
||||
ii = 0
|
||||
while suffixed_name in self or suffixed_name == '':
|
||||
suffixed_name = sanitized_name + b64suffix(ii)
|
||||
ii += 1
|
||||
|
||||
if len(suffixed_name) > max_length:
|
||||
if name == '':
|
||||
raise LibraryError(f'No valid pattern names remaining within the specified {max_length=}')
|
||||
|
||||
cropped_name = self.get_name(sanitized_name[:-1], sanitize=sanitize, max_length=max_length, quiet=True)
|
||||
else:
|
||||
cropped_name = suffixed_name
|
||||
|
||||
if not quiet:
|
||||
logger.info(f'Requested name "{name}" changed to "{cropped_name}"')
|
||||
|
||||
return cropped_name
|
||||
|
||||
|
||||
class visitor_function_t(Protocol):
|
||||
""" Signature for `Library.dfs()` visitor functions. """
|
||||
def __call__(
|
||||
self,
|
||||
pattern: Pattern,
|
||||
hierarchy: tuple[str | None, ...],
|
||||
memo: dict,
|
||||
transform: NDArray[numpy.float64] | Literal[False],
|
||||
) -> Pattern:
|
||||
...
|
||||
|
||||
|
||||
TreeView: TypeAlias = Mapping[str, 'Pattern']
|
||||
""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """
|
||||
|
||||
Tree: TypeAlias = MutableMapping[str, 'Pattern']
|
||||
""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """
|
||||
|
||||
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
||||
""" How helpers should handle refs whose targets are not present in the library. """
|
||||
|
||||
|
||||
def _rename_patterns(lib: INameView, name: str) -> str:
|
||||
"""
|
||||
The default `rename_theirs` function for `ILibrary.add`.
|
||||
|
||||
Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as
|
||||
"one-offs" for which name conflicts should be automatically resolved.
|
||||
Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)`
|
||||
where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`.
|
||||
Names lacking the prefix are directly returned (not renamed).
|
||||
|
||||
Args:
|
||||
lib: The library into which `name` is to be added (but is presumed to conflict)
|
||||
name: The original name, to be modified
|
||||
|
||||
Returns:
|
||||
The new name, not guaranteed to be conflict-free!
|
||||
"""
|
||||
if not name.startswith(SINGLE_USE_PREFIX):
|
||||
return name
|
||||
|
||||
stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]
|
||||
return lib.get_name(SINGLE_USE_PREFIX + stem)
|
||||
|
||||
|
||||
def _validate_dangling_mode(dangling: dangling_mode_t) -> None:
|
||||
if dangling not in ('error', 'ignore', 'include'):
|
||||
raise ValueError(
|
||||
f'Unknown dangling-reference mode {dangling!r}; '
|
||||
'expected one of "error", "ignore", or "include"'
|
||||
)
|
||||
|
||||
|
||||
class _ProspectiveNames(INameView):
|
||||
"""Target names plus names reserved earlier in an addition plan."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: INameView,
|
||||
reserved: set[str],
|
||||
) -> None:
|
||||
self._target = target
|
||||
self._reserved = reserved
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
yield from self._target
|
||||
yield from self._reserved
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._target) + len(self._reserved)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self._reserved or key in self._target
|
||||
|
||||
|
||||
def _plan_source_names(
|
||||
target: INameView,
|
||||
source_order: Sequence[str],
|
||||
*,
|
||||
rename_theirs: Callable[[INameView, str], str] | None = None,
|
||||
rename_when: Literal['conflict', 'always'] = 'conflict',
|
||||
) -> dict[str, str]:
|
||||
if rename_when not in ('conflict', 'always'):
|
||||
raise ValueError(f'Unknown source rename mode: {rename_when!r}')
|
||||
if rename_when == 'always' and rename_theirs is None:
|
||||
raise TypeError('rename_theirs is required when rename_when="always"')
|
||||
|
||||
source_to_visible: dict[str, str] = {}
|
||||
reserved: set[str] = set()
|
||||
prospective = _ProspectiveNames(target, reserved)
|
||||
|
||||
for name in source_order:
|
||||
visible = name
|
||||
if rename_when == 'always':
|
||||
assert rename_theirs is not None
|
||||
visible = rename_theirs(prospective, name)
|
||||
elif visible in prospective:
|
||||
if rename_theirs is None:
|
||||
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
||||
visible = rename_theirs(prospective, name)
|
||||
if visible in prospective:
|
||||
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
||||
source_to_visible[name] = visible
|
||||
reserved.add(visible)
|
||||
|
||||
return source_to_visible
|
||||
|
||||
|
||||
def _source_rename_map(source_to_visible: Mapping[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
source_name: visible_name
|
||||
for source_name, visible_name in source_to_visible.items()
|
||||
if source_name != visible_name
|
||||
}
|
||||
|
||||
def b64suffix(ii: int) -> str:
|
||||
"""
|
||||
Turn an integer into a base64-equivalent suffix.
|
||||
|
||||
This could be done with base64.b64encode, but this way is faster for many small `ii`.
|
||||
"""
|
||||
def i2a(nn: int) -> str:
|
||||
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn]
|
||||
|
||||
parts = ['$', i2a(ii % 64)]
|
||||
ii >>= 6
|
||||
while ii:
|
||||
parts.append(i2a(ii % 64))
|
||||
ii >>= 6
|
||||
return ''.join(parts)
|
||||
self[key] = lambda: value
|
||||
|
|
|
|||
2230
masque/pattern.py
2230
masque/pattern.py
File diff suppressed because it is too large
Load diff
765
masque/ports.py
765
masque/ports.py
|
|
@ -1,765 +0,0 @@
|
|||
from typing import overload, Self, NoReturn, Any
|
||||
from collections.abc import Iterable, KeysView, ValuesView, Mapping
|
||||
import logging
|
||||
import functools
|
||||
import copy
|
||||
from collections import Counter
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from itertools import chain
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable
|
||||
from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d
|
||||
from .error import PortError, format_stacktrace
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
port_logger = logging.getLogger('masque.ports')
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable):
|
||||
"""
|
||||
A point at which a `Device` can be snapped to another `Device`.
|
||||
|
||||
Each port has an `offset` ((x, y) position) and may also have a
|
||||
`rotation` (orientation) and a `ptype` (port type).
|
||||
|
||||
The `rotation` is an angle, in radians, measured counterclockwise
|
||||
from the +x axis, pointing inwards into the device which owns the port.
|
||||
The rotation may be set to `None`, indicating that any orientation is
|
||||
allowed (e.g. for a DC electrical port). It is stored modulo 2pi.
|
||||
|
||||
The `ptype` is an arbitrary string, default of `unk` (unknown).
|
||||
"""
|
||||
__slots__ = (
|
||||
'ptype', '_rotation',
|
||||
# inherited:
|
||||
'_offset',
|
||||
)
|
||||
|
||||
_rotation: float | None
|
||||
""" radians counterclockwise from +x, pointing into device body.
|
||||
Can be `None` to signify undirected port """
|
||||
|
||||
ptype: str
|
||||
""" Port types must match to be plugged together if both are non-zero """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
offset: ArrayLike,
|
||||
rotation: float | None,
|
||||
ptype: str = 'unk',
|
||||
) -> None:
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.ptype = ptype
|
||||
|
||||
@property
|
||||
def rotation(self) -> float | None:
|
||||
""" Rotation, radians counterclockwise, pointing into device body. Can be None. """
|
||||
return self._rotation
|
||||
|
||||
@rotation.setter
|
||||
def rotation(self, val: float | None) -> None:
|
||||
if val is None:
|
||||
self._rotation = None
|
||||
else:
|
||||
if not numpy.size(val) == 1:
|
||||
raise PortError('Rotation must be a scalar')
|
||||
self._rotation = val % (2 * pi)
|
||||
|
||||
@property
|
||||
def x(self) -> float:
|
||||
""" Alias for offset[0] """
|
||||
return self.offset[0]
|
||||
|
||||
@x.setter
|
||||
def x(self, val: float) -> None:
|
||||
self.offset[0] = val
|
||||
|
||||
@property
|
||||
def y(self) -> float:
|
||||
""" Alias for offset[1] """
|
||||
return self.offset[1]
|
||||
|
||||
@y.setter
|
||||
def y(self, val: float) -> None:
|
||||
self.offset[1] = val
|
||||
|
||||
def copy(self) -> Self:
|
||||
return self.deepcopy()
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
return new
|
||||
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
return numpy.vstack((self.offset, self.offset))
|
||||
|
||||
def set_ptype(self, ptype: str) -> Self:
|
||||
""" Chainable setter for `ptype` """
|
||||
self.ptype = ptype
|
||||
return self
|
||||
|
||||
def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self:
|
||||
"""
|
||||
Mirror the object across a line in the container's coordinate system.
|
||||
|
||||
Note this operation is performed relative to the pattern's origin and modifies the port's offset.
|
||||
|
||||
Args:
|
||||
axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0.
|
||||
x: Vertical line x=val to mirror across.
|
||||
y: Horizontal line y=val to mirror across.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
axis, pivot = self._check_flip_args(axis=axis, x=x, y=y)
|
||||
self.translate(-pivot)
|
||||
self.mirror(axis)
|
||||
self.offset[1 - axis] *= -1
|
||||
self.translate(+pivot)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
if self.rotation is not None:
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
return self
|
||||
|
||||
def rotate(self, rotation: float) -> Self:
|
||||
if self.rotation is not None:
|
||||
self.rotation += rotation
|
||||
return self
|
||||
|
||||
def set_rotation(self, rotation: float | None) -> Self:
|
||||
self.rotation = rotation
|
||||
return self
|
||||
|
||||
def describe(self) -> str:
|
||||
"""
|
||||
Returns a human-readable description of the port's state including cardinal directions.
|
||||
"""
|
||||
deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None
|
||||
|
||||
cardinal = ""
|
||||
travel_dir = ""
|
||||
|
||||
if self.rotation is not None:
|
||||
dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"}
|
||||
# normalize to [0, 360)
|
||||
deg_norm = deg % 360
|
||||
|
||||
# Find closest cardinal
|
||||
closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180))
|
||||
if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3):
|
||||
cardinal = f" ({dirs[closest]})"
|
||||
|
||||
# Travel direction (rotation + 180)
|
||||
t_deg = (deg_norm + 180) % 360
|
||||
closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180))
|
||||
if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3):
|
||||
travel_dir = f" (Travel -> {dirs[closest_t]})"
|
||||
|
||||
deg_text = 'any' if deg is None else f'{deg:g}'
|
||||
return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.rotation is None:
|
||||
rot = 'any'
|
||||
else:
|
||||
rot = str(numpy.rad2deg(self.rotation))
|
||||
return f'<{self.offset}, {rot}, [{self.ptype}]>'
|
||||
|
||||
def __lt__(self, other: 'Port') -> bool:
|
||||
if self.ptype != other.ptype:
|
||||
return self.ptype < other.ptype
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.rotation != other.rotation:
|
||||
if self.rotation is None:
|
||||
return True
|
||||
if other.rotation is None:
|
||||
return False
|
||||
return self.rotation < other.rotation
|
||||
return False
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and self.ptype == other.ptype
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
and self.rotation == other.rotation
|
||||
)
|
||||
|
||||
def measure_travel(self, destination: 'Port') -> tuple[NDArray[numpy.float64], float | None]:
|
||||
"""
|
||||
Find the (travel, jog) distances and rotation angle from the current port to the provided
|
||||
`destination` port.
|
||||
|
||||
Travel is along the source port's axis (into the device interior), and jog is perpendicular,
|
||||
with left of the travel direction corresponding to a positive jog.
|
||||
|
||||
Args:
|
||||
(self): Source `Port`
|
||||
destination: Destination `Port`
|
||||
|
||||
Returns
|
||||
[travel, jog], rotation
|
||||
"""
|
||||
angle_in = self.rotation
|
||||
angle_out = destination.rotation
|
||||
assert angle_in is not None
|
||||
dxy = rotation_matrix_2d(-angle_in) @ (destination.offset - self.offset)
|
||||
angle = ((angle_out - angle_in) % (2 * pi)) if angle_out is not None else None
|
||||
return dxy, angle
|
||||
|
||||
|
||||
class PortList(metaclass=ABCMeta):
|
||||
__slots__ = () # Allow subclasses to use __slots__
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def ports(self) -> dict[str, Port]:
|
||||
""" Uniquely-named ports which can be used to snap to other Device instances"""
|
||||
pass
|
||||
|
||||
@ports.setter
|
||||
@abstractmethod
|
||||
def ports(self, value: dict[str, Port]) -> None:
|
||||
pass
|
||||
|
||||
def _log_port_update(self, name: str) -> None:
|
||||
""" Log the current state of the named port """
|
||||
port_logger.debug("Port %s: %s", name, self.ports[name].describe())
|
||||
|
||||
def _log_port_removal(self, name: str) -> None:
|
||||
""" Log that the named port has been removed """
|
||||
port_logger.debug("Port %s: removed", name)
|
||||
|
||||
def _log_bulk_update(self, label: str) -> None:
|
||||
""" Log all current ports at DEBUG level """
|
||||
for name, port in self.ports.items():
|
||||
port_logger.debug("%s: Port %s: %s", label, name, port)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: str) -> Port:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: list[str] | tuple[str, ...] | KeysView[str] | ValuesView[str]) -> dict[str, Port]:
|
||||
pass
|
||||
|
||||
def __getitem__(self, key: str | Iterable[str]) -> Port | dict[str, Port]:
|
||||
"""
|
||||
For convenience, ports can be read out using square brackets:
|
||||
- `pattern['A'] == Port((0, 0), 0)`
|
||||
- ```
|
||||
pattern[['A', 'B']] == {
|
||||
'A': Port((0, 0), 0),
|
||||
'B': Port((0, 0), pi),
|
||||
}
|
||||
```
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
return self.ports[key]
|
||||
else: # noqa: RET505
|
||||
return {k: self.ports[k] for k in key}
|
||||
|
||||
def measure_travel(self, src: str, dst: str) -> tuple[NDArray[numpy.float64], float | None]:
|
||||
"""
|
||||
Convenience wrapper for measuring travel between two named ports.
|
||||
"""
|
||||
return self[src].measure_travel(self[dst])
|
||||
|
||||
def __contains__(self, key: str) -> NoReturn:
|
||||
raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.')
|
||||
|
||||
# NOTE: Didn't add keys(), items(), values(), __contains__(), etc.
|
||||
# because it's weird on stuff like Pattern that contains other lists
|
||||
# and because you can just grab .ports and use that instead
|
||||
|
||||
def mkport(
|
||||
self,
|
||||
name: str,
|
||||
value: Port,
|
||||
) -> Self:
|
||||
"""
|
||||
Create a port, raising a `PortError` if a port with the same name already exists.
|
||||
|
||||
Args:
|
||||
name: Name for the port. A port with this name should not already exist.
|
||||
value: The `Port` object to which `name` will refer.
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`PortError` if the name already exists.
|
||||
"""
|
||||
if name in self.ports:
|
||||
raise PortError(f'Port {name} already exists.')
|
||||
assert name not in self.ports
|
||||
self.ports[name] = value
|
||||
self._log_port_update(name)
|
||||
return self
|
||||
|
||||
def rename_ports(
|
||||
self,
|
||||
mapping: dict[str, str | None],
|
||||
overwrite: bool = False,
|
||||
) -> Self:
|
||||
"""
|
||||
Renames ports as specified by `mapping`.
|
||||
Ports can be explicitly deleted by mapping them to `None`.
|
||||
|
||||
Args:
|
||||
mapping: dict of `{'old_name': 'new_name'}` pairs. Names can be mapped
|
||||
to `None` to perform an explicit deletion. `'new_name'` can also
|
||||
overwrite an existing non-renamed port to implicitly delete it if
|
||||
`overwrite` is set to `True`.
|
||||
overwrite: Allows implicit deletion of ports if set to `True`; see `mapping`.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self._rename_ports_impl(mapping, overwrite=overwrite)
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _normalize_target_mapping(
|
||||
ordered_targets: Iterable[tuple[str, str | None]],
|
||||
explicit_map: Mapping[str, str | None] | None = None,
|
||||
) -> dict[str, str | None]:
|
||||
ordered_targets = list(ordered_targets)
|
||||
normalized = {} if explicit_map is None else copy.deepcopy(dict(explicit_map))
|
||||
winners = {
|
||||
target: source
|
||||
for source, target in ordered_targets
|
||||
if target is not None
|
||||
}
|
||||
for source, target in ordered_targets:
|
||||
if target is not None and winners[target] != source:
|
||||
normalized[source] = None
|
||||
return normalized
|
||||
|
||||
def _resolve_insert_mapping(
|
||||
self,
|
||||
other_names: Iterable[str],
|
||||
map_in: Mapping[str, str] | None = None,
|
||||
map_out: Mapping[str, str | None] | None = None,
|
||||
*,
|
||||
allow_conflicts: bool = False,
|
||||
) -> tuple[dict[str, str | None], set[str]]:
|
||||
if map_in is None:
|
||||
map_in = {}
|
||||
|
||||
normalized_map_out = {} if map_out is None else copy.deepcopy(dict(map_out))
|
||||
other_names = list(other_names)
|
||||
other = set(other_names)
|
||||
|
||||
missing_inkeys = set(map_in.keys()) - set(self.ports.keys())
|
||||
if missing_inkeys:
|
||||
raise PortError(f'`map_in` keys not present in device: {missing_inkeys}')
|
||||
|
||||
missing_invals = set(map_in.values()) - other
|
||||
if missing_invals:
|
||||
raise PortError(f'`map_in` values not present in other device: {missing_invals}')
|
||||
|
||||
map_in_counts = Counter(map_in.values())
|
||||
conflicts_in = {kk for kk, vv in map_in_counts.items() if vv > 1}
|
||||
if conflicts_in:
|
||||
raise PortError(f'Duplicate values in `map_in`: {conflicts_in}')
|
||||
|
||||
missing_outkeys = set(normalized_map_out.keys()) - other
|
||||
if missing_outkeys:
|
||||
raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}')
|
||||
|
||||
connected_outkeys = set(normalized_map_out.keys()) & set(map_in.values())
|
||||
if connected_outkeys:
|
||||
raise PortError(f'`map_out` keys conflict with connected ports: {connected_outkeys}')
|
||||
|
||||
orig_remaining = set(self.ports.keys()) - set(map_in.keys())
|
||||
connected = set(map_in.values())
|
||||
if allow_conflicts:
|
||||
ordered_targets = [
|
||||
(name, normalized_map_out.get(name, name))
|
||||
for name in other_names
|
||||
if name not in connected
|
||||
]
|
||||
normalized_map_out = self._normalize_target_mapping(ordered_targets, normalized_map_out)
|
||||
final_targets = {
|
||||
normalized_map_out.get(name, name)
|
||||
for name in other_names
|
||||
if name not in connected and normalized_map_out.get(name, name) is not None
|
||||
}
|
||||
overwrite_targets = {target for target in final_targets if target in orig_remaining}
|
||||
return normalized_map_out, overwrite_targets
|
||||
|
||||
other_remaining = other - set(normalized_map_out.keys()) - connected
|
||||
mapped_vals = set(normalized_map_out.values())
|
||||
mapped_vals.discard(None)
|
||||
|
||||
conflicts_final = orig_remaining & (other_remaining | mapped_vals)
|
||||
if conflicts_final:
|
||||
raise PortError(f'Device ports conflict with existing ports: {conflicts_final}')
|
||||
|
||||
conflicts_partial = other_remaining & mapped_vals
|
||||
if conflicts_partial:
|
||||
raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}')
|
||||
|
||||
map_out_counts = Counter(normalized_map_out.values())
|
||||
map_out_counts[None] = 0
|
||||
conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1}
|
||||
if conflicts_out:
|
||||
raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}')
|
||||
return normalized_map_out, set()
|
||||
|
||||
def _rename_ports_impl(
|
||||
self,
|
||||
mapping: Mapping[str, str | None],
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
allow_collisions: bool = False,
|
||||
) -> dict[str, str]:
|
||||
if not overwrite:
|
||||
duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values())
|
||||
if duplicates:
|
||||
raise PortError(f'Unrenamed ports would be overwritten: {duplicates}')
|
||||
missing = set(mapping) - set(self.ports)
|
||||
if missing:
|
||||
raise PortError(f'Ports to rename were not found: {missing}')
|
||||
renamed_targets = [vv for vv in mapping.values() if vv is not None]
|
||||
if not allow_collisions:
|
||||
duplicate_targets = {vv for vv in renamed_targets if renamed_targets.count(vv) > 1}
|
||||
if duplicate_targets:
|
||||
raise PortError(f'Renamed ports would collide: {duplicate_targets}')
|
||||
|
||||
winners = {
|
||||
target: source
|
||||
for source, target in mapping.items()
|
||||
if target is not None
|
||||
}
|
||||
overwritten = {
|
||||
target
|
||||
for target, source in winners.items()
|
||||
if target in self.ports and target not in mapping and target != source
|
||||
}
|
||||
|
||||
for kk, vv in mapping.items():
|
||||
if vv is None or vv != kk:
|
||||
self._log_port_removal(kk)
|
||||
|
||||
source_ports = {kk: self.ports.pop(kk) for kk in mapping}
|
||||
for target in overwritten:
|
||||
self.ports.pop(target, None)
|
||||
|
||||
renamed = {
|
||||
vv: source_ports[kk]
|
||||
for kk, vv in mapping.items()
|
||||
if vv is not None and winners[vv] == kk
|
||||
}
|
||||
self.ports.update(renamed) # type: ignore
|
||||
|
||||
for vv in winners:
|
||||
self._log_port_update(vv)
|
||||
return winners
|
||||
|
||||
def add_port_pair(
|
||||
self,
|
||||
offset: ArrayLike = (0, 0),
|
||||
rotation: float = 0.0,
|
||||
names: tuple[str, str] = ('A', 'B'),
|
||||
ptype: str = 'unk',
|
||||
) -> Self:
|
||||
"""
|
||||
Add a pair of ports with opposing directions at the specified location.
|
||||
|
||||
Args:
|
||||
offset: Location at which to add the ports
|
||||
rotation: Orientation of the first port. Radians, counterclockwise.
|
||||
Default 0.
|
||||
names: Names for the two ports. Default 'A' and 'B'
|
||||
ptype: Sets the port type for both ports.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
if names[0] == names[1]:
|
||||
raise PortError(f'Port names must be distinct: {names[0]!r}')
|
||||
new_ports = {
|
||||
names[0]: Port(offset, rotation=rotation, ptype=ptype),
|
||||
names[1]: Port(offset, rotation=rotation + pi, ptype=ptype),
|
||||
}
|
||||
self.check_ports(names)
|
||||
self.ports.update(new_ports)
|
||||
self._log_port_update(names[0])
|
||||
self._log_port_update(names[1])
|
||||
return self
|
||||
|
||||
def plugged(
|
||||
self,
|
||||
connections: dict[str, str],
|
||||
) -> Self:
|
||||
"""
|
||||
Verify that the ports specified by `connections` are coincident and have opposing
|
||||
rotations, then remove the ports.
|
||||
|
||||
This is used when ports have been "manually" aligned as part of some other routing,
|
||||
but for whatever reason were not eliminated via `plug()`.
|
||||
|
||||
Args:
|
||||
connections: Pairs of ports which "plug" each other (same offset, opposing directions)
|
||||
|
||||
Returns:
|
||||
self
|
||||
|
||||
Raises:
|
||||
`PortError` if the ports are not properly aligned.
|
||||
"""
|
||||
if not connections:
|
||||
raise PortError('Must provide at least one port connection')
|
||||
missing_a = set(connections) - set(self.ports)
|
||||
if missing_a:
|
||||
raise PortError(f'Connection source ports were not found: {missing_a}')
|
||||
missing_b = set(connections.values()) - set(self.ports)
|
||||
if missing_b:
|
||||
raise PortError(f'Connection destination ports were not found: {missing_b}')
|
||||
a_names, b_names = list(zip(*connections.items(), strict=True))
|
||||
used_names = list(chain(a_names, b_names))
|
||||
duplicate_names = {name for name in used_names if used_names.count(name) > 1}
|
||||
if duplicate_names:
|
||||
raise PortError(f'Each port may appear in at most one connection: {duplicate_names}')
|
||||
a_ports = [self.ports[pp] for pp in a_names]
|
||||
b_ports = [self.ports[pp] for pp in b_names]
|
||||
|
||||
a_types = [pp.ptype for pp in a_ports]
|
||||
b_types = [pp.ptype for pp in b_ports]
|
||||
type_conflicts = numpy.array([not ptypes_compatible(at, bt)
|
||||
for at, bt in zip(a_types, b_types, strict=True)])
|
||||
|
||||
if type_conflicts.any():
|
||||
msg = 'Ports have conflicting types:\n'
|
||||
for nn, (kk, vv) in enumerate(connections.items()):
|
||||
if type_conflicts[nn]:
|
||||
msg += f'{kk} | {a_types[nn]}:{b_types[nn]} | {vv}\n'
|
||||
msg += '\nStack trace:\n' + format_stacktrace()
|
||||
logger.warning(msg)
|
||||
|
||||
a_offsets = numpy.array([pp.offset for pp in a_ports])
|
||||
b_offsets = numpy.array([pp.offset for pp in b_ports])
|
||||
a_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in a_ports])
|
||||
b_rotations = numpy.array([pp.rotation if pp.rotation is not None else 0 for pp in b_ports])
|
||||
a_has_rot = numpy.array([pp.rotation is not None for pp in a_ports], dtype=bool)
|
||||
b_has_rot = numpy.array([pp.rotation is not None for pp in b_ports], dtype=bool)
|
||||
has_rot = a_has_rot & b_has_rot
|
||||
|
||||
if has_rot.any():
|
||||
rotations = numpy.mod(a_rotations - b_rotations - pi, 2 * pi)
|
||||
rotations[~has_rot] = rotations[has_rot][0]
|
||||
|
||||
if not numpy.allclose(rotations, 0):
|
||||
rot_deg = numpy.rad2deg(rotations)
|
||||
msg = 'Port orientations do not match:\n'
|
||||
for nn, (kk, vv) in enumerate(connections.items()):
|
||||
if not numpy.isclose(rot_deg[nn], 0):
|
||||
msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n'
|
||||
raise PortError(msg)
|
||||
|
||||
translations = a_offsets - b_offsets
|
||||
if not numpy.allclose(a_offsets, b_offsets):
|
||||
msg = 'Port translations do not match:\n'
|
||||
for nn, (kk, vv) in enumerate(connections.items()):
|
||||
if not numpy.allclose(a_offsets[nn], b_offsets[nn]):
|
||||
msg += f'{kk} | {translations[nn]} | {vv}\n'
|
||||
raise PortError(msg)
|
||||
|
||||
for pp in chain(a_names, b_names):
|
||||
del self.ports[pp]
|
||||
self._log_port_removal(pp)
|
||||
return self
|
||||
|
||||
def check_ports(
|
||||
self,
|
||||
other_names: Iterable[str],
|
||||
map_in: dict[str, str] | None = None,
|
||||
map_out: dict[str, str | None] | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Given the provided port mappings, check that:
|
||||
- All of the ports specified in the mappings exist
|
||||
- There are no duplicate port names after all the mappings are performed
|
||||
|
||||
Args:
|
||||
other_names: List of port names being considered for inclusion into
|
||||
`self.ports` (before mapping)
|
||||
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 unconnected `other_names` ports.
|
||||
|
||||
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.
|
||||
"""
|
||||
self._resolve_insert_mapping(other_names, map_in, map_out)
|
||||
return self
|
||||
|
||||
def find_transform(
|
||||
self,
|
||||
other: 'PortList',
|
||||
map_in: dict[str, str],
|
||||
*,
|
||||
mirrored: bool = False,
|
||||
set_rotation: bool | None = None,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
|
||||
"""
|
||||
Given a device `other` and a mapping `map_in` specifying port connections,
|
||||
find the transform which will correctly align the specified ports.
|
||||
|
||||
Args:
|
||||
other: a device
|
||||
map_in: dict of `{'self_port': 'other_port'}` mappings, specifying
|
||||
port connections between the two devices.
|
||||
mirrored: Mirrors `other` across the x axis prior to
|
||||
connecting any ports.
|
||||
set_rotation: If the necessary rotation cannot be determined from
|
||||
the ports being connected (i.e. all pairs have at least one
|
||||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `other` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
ok_connections: Set of additional allowed ptype combinations.
|
||||
Ptypes accepted by the shared compatibility policy are always
|
||||
allowed. Non-allowed ptype connections will log a warning.
|
||||
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
- The rotation (radians, counterclockwise)
|
||||
- The (x, y) pivot point for the rotation
|
||||
|
||||
The rotation should be performed before the translation.
|
||||
"""
|
||||
if not map_in:
|
||||
raise PortError('Must provide at least one port connection')
|
||||
s_ports = self[map_in.keys()]
|
||||
o_ports = other[map_in.values()]
|
||||
return self.find_port_transform(
|
||||
s_ports = s_ports,
|
||||
o_ports = o_ports,
|
||||
map_in = map_in,
|
||||
mirrored = mirrored,
|
||||
set_rotation = set_rotation,
|
||||
ok_connections = ok_connections,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def find_port_transform(
|
||||
s_ports: Mapping[str, Port],
|
||||
o_ports: Mapping[str, Port],
|
||||
map_in: dict[str, str],
|
||||
*,
|
||||
mirrored: bool = False,
|
||||
set_rotation: bool | None = None,
|
||||
ok_connections: Iterable[tuple[str, str]] = (),
|
||||
) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]:
|
||||
"""
|
||||
Given two sets of ports (s_ports and o_ports) and a mapping `map_in`
|
||||
specifying port connections, find the transform which will correctly
|
||||
align the specified o_ports onto their respective s_ports.
|
||||
|
||||
Args:
|
||||
s_ports: A list of stationary ports
|
||||
o_ports: A list of ports which are to be moved/mirrored.
|
||||
map_in: dict of `{'s_port': 'o_port'}` mappings, specifying
|
||||
port connections.
|
||||
mirrored: Mirrors `o_ports` across the x axis prior to
|
||||
connecting any ports.
|
||||
set_rotation: If the necessary rotation cannot be determined from
|
||||
the ports being connected (i.e. all pairs have at least one
|
||||
port with `rotation=None`), `set_rotation` must be provided
|
||||
to indicate how much `o_ports` should be rotated. Otherwise,
|
||||
`set_rotation` must remain `None`.
|
||||
ok_connections: Set of additional allowed ptype combinations.
|
||||
Ptypes accepted by the shared compatibility policy are always
|
||||
allowed. Non-allowed ptype connections will log a warning.
|
||||
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
|
||||
|
||||
Returns:
|
||||
- The (x, y) translation (performed last)
|
||||
- The rotation (radians, counterclockwise)
|
||||
- The (x, y) pivot point for the rotation
|
||||
|
||||
The rotation should be performed before the translation.
|
||||
"""
|
||||
if not map_in:
|
||||
raise PortError('Must provide at least one port connection')
|
||||
s_offsets = numpy.array([p.offset for p in s_ports.values()])
|
||||
o_offsets = numpy.array([p.offset for p in o_ports.values()])
|
||||
s_types = [p.ptype for p in s_ports.values()]
|
||||
o_types = [p.ptype for p in o_ports.values()]
|
||||
|
||||
s_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in s_ports.values()])
|
||||
o_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in o_ports.values()])
|
||||
s_has_rot = numpy.array([p.rotation is not None for p in s_ports.values()], dtype=bool)
|
||||
o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool)
|
||||
has_rot = s_has_rot & o_has_rot
|
||||
|
||||
if mirrored:
|
||||
o_offsets[:, 1] *= -1
|
||||
o_rotations *= -1
|
||||
|
||||
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
|
||||
type_conflicts = numpy.array([
|
||||
not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs
|
||||
for st, ot in zip(s_types, o_types, strict=True)
|
||||
])
|
||||
if type_conflicts.any():
|
||||
msg = 'Ports have conflicting types:\n'
|
||||
for nn, (kk, vv) in enumerate(map_in.items()):
|
||||
if type_conflicts[nn]:
|
||||
msg += f'{kk} | {s_types[nn]}:{o_types[nn]} | {vv}\n'
|
||||
msg += '\nStack trace:\n' + format_stacktrace()
|
||||
logger.warning(msg)
|
||||
|
||||
rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi)
|
||||
if not has_rot.any():
|
||||
if set_rotation is None:
|
||||
raise PortError('Must provide set_rotation if rotation is indeterminate')
|
||||
rotations[:] = set_rotation
|
||||
else:
|
||||
rotations[~has_rot] = rotations[has_rot][0]
|
||||
|
||||
if not numpy.allclose(rotations[:1], rotations):
|
||||
rot_deg = numpy.rad2deg(rotations)
|
||||
msg = 'Port orientations do not match:\n'
|
||||
for nn, (kk, vv) in enumerate(map_in.items()):
|
||||
msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n'
|
||||
raise PortError(msg)
|
||||
|
||||
pivot = o_offsets[0].copy()
|
||||
rotate_offsets_around(o_offsets, pivot, rotations[0])
|
||||
translations = s_offsets - o_offsets
|
||||
if not numpy.allclose(translations[:1], translations):
|
||||
msg = 'Port translations do not match:\n'
|
||||
common_translation = numpy.min(translations, axis=0)
|
||||
msg += f'Common: {common_translation} \n'
|
||||
msg += 'Deltas:\n'
|
||||
for nn, (kk, vv) in enumerate(map_in.items()):
|
||||
msg += f'{kk} | {translations[nn] - common_translation} | {vv}\n'
|
||||
raise PortError(msg)
|
||||
|
||||
return translations[0], rotations[0], o_offsets[0]
|
||||
268
masque/ref.py
268
masque/ref.py
|
|
@ -1,268 +0,0 @@
|
|||
"""
|
||||
Ref provides basic support for nesting Pattern objects within each other.
|
||||
It carries offset, rotation, mirroring, and scaling data for each individual instance.
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Self, Any
|
||||
from collections.abc import Mapping
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotations_lt, rep2key, SupportsBool
|
||||
from .repetition import Repetition
|
||||
from .traits import (
|
||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||
PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl,
|
||||
FlippableImpl,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Pattern
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Ref(
|
||||
FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
PositionableImpl, RotatableImpl, ScalableImpl,
|
||||
Copyable,
|
||||
):
|
||||
"""
|
||||
`Ref` provides basic support for nesting Pattern objects within each other.
|
||||
|
||||
It containts the transformation (mirror, rotation, scale, offset, repetition)
|
||||
and annotations for a single instantiation of a `Pattern`.
|
||||
|
||||
Note that the target (i.e. which pattern a `Ref` instantiates) is not stored within the
|
||||
`Ref` itself, but is specified by the containing `Pattern`.
|
||||
|
||||
Order of operations is (mirror, rotate, scale, translate, repeat).
|
||||
"""
|
||||
__slots__ = (
|
||||
'_mirrored',
|
||||
# inherited
|
||||
'_offset', '_rotation', '_scale', '_repetition', '_annotations',
|
||||
)
|
||||
|
||||
_mirrored: bool
|
||||
""" Whether to mirror the instance across the x axis (new_y = -old_y)ubefore rotating. """
|
||||
|
||||
# Mirrored property
|
||||
@property
|
||||
def mirrored(self) -> bool:
|
||||
return self._mirrored
|
||||
|
||||
@mirrored.setter
|
||||
def mirrored(self, val: SupportsBool) -> None:
|
||||
self._mirrored = bool(val)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
mirrored: bool = False,
|
||||
scale: float = 1.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Note: Order is (mirror, rotate, scale, translate, repeat)
|
||||
|
||||
Args:
|
||||
offset: (x, y) offset applied to the referenced pattern. Not affected by rotation etc.
|
||||
rotation: Rotation (radians, counterclockwise) relative to the referenced pattern's (0, 0).
|
||||
mirrored: Whether to mirror the referenced pattern across its x axis before rotating.
|
||||
scale: Scaling factor applied to the pattern's geometry.
|
||||
repetition: `Repetition` object, default `None`
|
||||
"""
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.scale = scale
|
||||
self.mirrored = mirrored
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
offset: NDArray[numpy.float64],
|
||||
rotation: float,
|
||||
mirrored: bool,
|
||||
scale: float,
|
||||
repetition: Repetition | None,
|
||||
annotations: annotations_t | None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._offset = offset
|
||||
new._rotation = rotation % (2 * pi)
|
||||
new._scale = scale
|
||||
new._mirrored = mirrored
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __copy__(self) -> 'Ref':
|
||||
new = Ref(
|
||||
offset=self.offset.copy(),
|
||||
rotation=self.rotation,
|
||||
scale=self.scale,
|
||||
mirrored=self.mirrored,
|
||||
repetition=self.repetition,
|
||||
annotations=self.annotations,
|
||||
)
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> 'Ref':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._offset = self._offset.copy()
|
||||
new.repetition = copy.deepcopy(self.repetition, memo)
|
||||
new.annotations = copy.deepcopy(self.annotations, memo)
|
||||
return new
|
||||
|
||||
def copy(self) -> 'Ref':
|
||||
return self.deepcopy()
|
||||
|
||||
def __lt__(self, other: 'Ref') -> bool:
|
||||
if (self.offset != other.offset).any():
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.mirrored != other.mirrored:
|
||||
return self.mirrored < other.mirrored
|
||||
if self.rotation != other.rotation:
|
||||
return self.rotation < other.rotation
|
||||
if self.scale != other.scale:
|
||||
return self.scale < other.scale
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (
|
||||
numpy.array_equal(self.offset, other.offset)
|
||||
and self.mirrored == other.mirrored
|
||||
and self.rotation == other.rotation
|
||||
and self.scale == other.scale
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def as_pattern(
|
||||
self,
|
||||
pattern: 'Pattern',
|
||||
) -> 'Pattern':
|
||||
"""
|
||||
Args:
|
||||
pattern: Pattern object to transform
|
||||
|
||||
Returns:
|
||||
A copy of the referenced Pattern which has been scaled, rotated, etc.
|
||||
according to this `Ref`'s properties.
|
||||
"""
|
||||
pattern = pattern.deepcopy()
|
||||
|
||||
if self.scale != 1:
|
||||
pattern.scale_by(self.scale)
|
||||
if self.mirrored:
|
||||
pattern.mirror()
|
||||
if self.rotation % (2 * pi) != 0:
|
||||
pattern.rotate_around((0.0, 0.0), self.rotation)
|
||||
if numpy.any(self.offset):
|
||||
pattern.translate_elements(self.offset)
|
||||
|
||||
if self.repetition is not None:
|
||||
combined = type(pattern)()
|
||||
for dd in self.repetition.displacements:
|
||||
temp_pat = pattern.deepcopy()
|
||||
temp_pat.ports = {}
|
||||
temp_pat.translate_elements(dd)
|
||||
combined.append(temp_pat)
|
||||
pattern = combined
|
||||
|
||||
return pattern
|
||||
|
||||
def rotate(self, rotation: float) -> Self:
|
||||
"""
|
||||
Intrinsic transformation: Rotate the target pattern relative to this Ref's
|
||||
origin. This does NOT affect the repetition grid.
|
||||
"""
|
||||
self.rotation += rotation
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self.mirror_target(axis)
|
||||
self.rotation *= -1
|
||||
return self
|
||||
|
||||
def mirror_target(self, axis: int = 0) -> Self:
|
||||
self.mirrored = not self.mirrored
|
||||
self.rotation += axis * pi
|
||||
return self
|
||||
|
||||
def mirror2d_target(self, across_x: bool = False, across_y: bool = False) -> Self:
|
||||
self.mirrored = bool((self.mirrored + across_x + across_y) % 2)
|
||||
if across_y:
|
||||
self.rotation += pi
|
||||
return self
|
||||
|
||||
def as_transforms(self) -> NDArray[numpy.float64]:
|
||||
xys = self.offset[None, :]
|
||||
if self.repetition is not None:
|
||||
xys = xys + self.repetition.displacements
|
||||
transforms = numpy.empty((xys.shape[0], 5))
|
||||
transforms[:, :2] = xys
|
||||
transforms[:, 2] = self.rotation
|
||||
transforms[:, 3] = self.mirrored
|
||||
transforms[:, 4] = self.scale
|
||||
return transforms
|
||||
|
||||
def get_bounds_single(
|
||||
self,
|
||||
pattern: 'Pattern',
|
||||
*,
|
||||
library: Mapping[str, 'Pattern'] | None = None,
|
||||
) -> NDArray[numpy.float64] | None:
|
||||
"""
|
||||
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
|
||||
extent of the `Ref` in each dimension.
|
||||
Returns `None` if the contained `Pattern` is empty.
|
||||
|
||||
Args:
|
||||
library: Name-to-Pattern mapping for resul
|
||||
|
||||
Returns:
|
||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
||||
"""
|
||||
if pattern.is_empty():
|
||||
# no need to run as_pattern()
|
||||
return None
|
||||
|
||||
# if rotation is manhattan, can take pattern's bounds and transform them
|
||||
if numpy.isclose(self.rotation % (pi / 2), 0):
|
||||
unrot_bounds = pattern.get_bounds(library)
|
||||
if unrot_bounds is None:
|
||||
return None
|
||||
|
||||
if self.mirrored:
|
||||
unrot_bounds[:, 1] *= -1
|
||||
|
||||
corners = (rotation_matrix_2d(self.rotation) @ unrot_bounds.T).T
|
||||
bounds = numpy.vstack((numpy.min(corners, axis=0),
|
||||
numpy.max(corners, axis=0))) * self.scale + [self.offset]
|
||||
return bounds
|
||||
|
||||
single_ref = self.deepcopy()
|
||||
single_ref.repetition = None
|
||||
return single_ref.as_pattern(pattern=pattern).get_bounds(library)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||
scale = f' d{self.scale:g}' if self.scale != 1 else ''
|
||||
mirrored = ' m' if self.mirrored else ''
|
||||
return f'<Ref {self.offset}{rotation}{scale}{mirrored}>'
|
||||
|
|
@ -2,28 +2,24 @@
|
|||
Repetitions provide support for efficiently representing multiple identical
|
||||
instances of an object .
|
||||
"""
|
||||
from typing import Any, Self, TypeVar, cast
|
||||
|
||||
from typing import Union, Dict, Optional, Sequence, Any, Type
|
||||
import copy
|
||||
import functools
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from .traits import Copyable, Scalable, Rotatable, Mirrorable, Bounded
|
||||
from .error import PatternError
|
||||
from .utils import rotation_matrix_2d
|
||||
from .utils import rotation_matrix_2d, AutoSlots
|
||||
from .traits import LockableImpl, Copyable, Scalable, Rotatable, Mirrorable
|
||||
|
||||
|
||||
GG = TypeVar('GG', bound='Grid')
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=ABCMeta):
|
||||
class Repetition(Copyable, Rotatable, Mirrorable, Scalable, metaclass=ABCMeta):
|
||||
"""
|
||||
Interface common to all objects which specify repetitions
|
||||
"""
|
||||
__slots__ = () # Allow subclasses to use __slots__
|
||||
__slots__ = ()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
|
|
@ -33,16 +29,8 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A
|
|||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __lt__(self, other: 'Repetition') -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
class Grid(Repetition):
|
||||
class Grid(LockableImpl, Repetition, metaclass=AutoSlots):
|
||||
"""
|
||||
`Grid` describes a 2D grid formed by two basis vectors and two 'counts' (sizes).
|
||||
|
||||
|
|
@ -51,10 +39,10 @@ class Grid(Repetition):
|
|||
|
||||
Note that the offsets in either the 2D or 1D grids do not have to be axis-aligned.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_a_vector', '_b_vector',
|
||||
'_a_count', '_b_count',
|
||||
)
|
||||
__slots__ = ('_a_vector',
|
||||
'_b_vector',
|
||||
'_a_count',
|
||||
'_b_count')
|
||||
|
||||
_a_vector: NDArray[numpy.float64]
|
||||
""" Vector `[x, y]` specifying the first lattice vector of the grid.
|
||||
|
|
@ -64,7 +52,7 @@ class Grid(Repetition):
|
|||
_a_count: int
|
||||
""" Number of instances along the direction specified by the `a_vector` """
|
||||
|
||||
_b_vector: NDArray[numpy.float64]
|
||||
_b_vector: Optional[NDArray[numpy.float64]]
|
||||
""" Vector `[x, y]` specifying a second lattice vector for the grid.
|
||||
Specifies center-to-center spacing between adjacent elements.
|
||||
Can be `None` for a 1D array.
|
||||
|
|
@ -77,8 +65,9 @@ class Grid(Repetition):
|
|||
self,
|
||||
a_vector: ArrayLike,
|
||||
a_count: int,
|
||||
b_vector: ArrayLike | None = None,
|
||||
b_count: int | None = 1,
|
||||
b_vector: Optional[ArrayLike] = None,
|
||||
b_count: Optional[int] = 1,
|
||||
locked: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
|
|
@ -90,6 +79,7 @@ class Grid(Repetition):
|
|||
Can be omitted when specifying a 1D array.
|
||||
b_count: Number of elements in the `b_vector` direction.
|
||||
Should be omitted if `b_vector` was omitted.
|
||||
locked: Whether the `Grid` is locked after initialization.
|
||||
|
||||
Raises:
|
||||
PatternError if `b_*` inputs conflict with each other
|
||||
|
|
@ -101,6 +91,7 @@ class Grid(Repetition):
|
|||
if b_vector is None:
|
||||
if b_count > 1:
|
||||
raise PatternError('Repetition has b_count > 1 but no b_vector')
|
||||
else:
|
||||
b_vector = numpy.array([0.0, 0.0])
|
||||
|
||||
if a_count < 1:
|
||||
|
|
@ -108,35 +99,21 @@ class Grid(Repetition):
|
|||
if b_count < 1:
|
||||
raise PatternError(f'Repetition has too-small b_count: {b_count}')
|
||||
|
||||
object.__setattr__(self, 'locked', False)
|
||||
self.a_vector = a_vector # type: ignore # setter handles type conversion
|
||||
self.b_vector = b_vector # type: ignore # setter handles type conversion
|
||||
self.a_count = a_count
|
||||
self.b_count = b_count
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls: type[GG],
|
||||
*,
|
||||
a_vector: NDArray[numpy.float64],
|
||||
a_count: int,
|
||||
b_vector: NDArray[numpy.float64],
|
||||
b_count: int,
|
||||
) -> GG:
|
||||
new = cls.__new__(cls)
|
||||
new._a_vector = a_vector
|
||||
new._b_vector = b_vector
|
||||
new._a_count = int(a_count)
|
||||
new._b_count = int(b_count)
|
||||
return new
|
||||
self.locked = locked
|
||||
|
||||
@classmethod
|
||||
def aligned(
|
||||
cls: type[GG],
|
||||
cls: Type,
|
||||
x: float,
|
||||
y: float,
|
||||
x_count: int,
|
||||
y_count: int,
|
||||
) -> GG:
|
||||
) -> 'Grid':
|
||||
"""
|
||||
Simple constructor for an axis-aligned 2D grid
|
||||
|
||||
|
|
@ -152,17 +129,18 @@ class Grid(Repetition):
|
|||
return cls(a_vector=(x, 0), b_vector=(0, y), a_count=x_count, b_count=y_count)
|
||||
|
||||
def __copy__(self) -> 'Grid':
|
||||
new = Grid(
|
||||
a_vector=self.a_vector.copy(),
|
||||
new = Grid(a_vector=self.a_vector.copy(),
|
||||
b_vector=copy.copy(self.b_vector),
|
||||
a_count=self.a_count,
|
||||
b_count=self.b_count,
|
||||
)
|
||||
locked=self.locked)
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
def __deepcopy__(self, memo: Dict = None) -> 'Grid':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
LocakbleImpl.unlock(new)
|
||||
new.locked = self.locked
|
||||
return new
|
||||
|
||||
# a_vector property
|
||||
|
|
@ -172,20 +150,22 @@ class Grid(Repetition):
|
|||
|
||||
@a_vector.setter
|
||||
def a_vector(self, val: ArrayLike) -> None:
|
||||
if not isinstance(val, numpy.ndarray):
|
||||
val = numpy.array(val, dtype=float)
|
||||
|
||||
if val.size != 2:
|
||||
raise PatternError('a_vector must be convertible to size-2 ndarray')
|
||||
self._a_vector = val.flatten()
|
||||
self._a_vector = val.flatten().astype(float)
|
||||
|
||||
# b_vector property
|
||||
@property
|
||||
def b_vector(self) -> NDArray[numpy.float64] | None:
|
||||
def b_vector(self) -> Optional[NDArray[numpy.float64]]:
|
||||
return self._b_vector
|
||||
|
||||
@b_vector.setter
|
||||
def b_vector(self, val: ArrayLike) -> None:
|
||||
val = numpy.array(val, dtype=float)
|
||||
if not isinstance(val, numpy.ndarray):
|
||||
val = numpy.array(val, dtype=float, copy=True)
|
||||
|
||||
if val.size != 2:
|
||||
raise PatternError('b_vector must be convertible to size-2 ndarray')
|
||||
|
|
@ -200,8 +180,6 @@ class Grid(Repetition):
|
|||
def a_count(self, val: int) -> None:
|
||||
if val != int(val):
|
||||
raise PatternError('a_count must be convertable to an int!')
|
||||
if int(val) < 1:
|
||||
raise PatternError(f'Repetition has too-small a_count: {val}')
|
||||
self._a_count = int(val)
|
||||
|
||||
# b_count property
|
||||
|
|
@ -213,17 +191,18 @@ class Grid(Repetition):
|
|||
def b_count(self, val: int) -> None:
|
||||
if val != int(val):
|
||||
raise PatternError('b_count must be convertable to an int!')
|
||||
if int(val) < 1:
|
||||
raise PatternError(f'Repetition has too-small b_count: {val}')
|
||||
self._b_count = int(val)
|
||||
|
||||
@property
|
||||
def displacements(self) -> NDArray[numpy.float64]:
|
||||
if self.b_vector is None:
|
||||
return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :]
|
||||
|
||||
aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij')
|
||||
return (aa.flatten()[:, None] * self.a_vector[None, :]
|
||||
+ bb.flatten()[:, None] * self.b_vector[None, :]) # noqa
|
||||
|
||||
def rotate(self, rotation: float) -> Self:
|
||||
def rotate(self, rotation: float) -> 'Grid':
|
||||
"""
|
||||
Rotate lattice vectors (around (0, 0))
|
||||
|
||||
|
|
@ -238,7 +217,7 @@ class Grid(Repetition):
|
|||
self.b_vector = numpy.dot(rotation_matrix_2d(rotation), self.b_vector)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
def mirror(self, axis: int) -> 'Grid':
|
||||
"""
|
||||
Mirror the Grid across an axis.
|
||||
|
||||
|
|
@ -254,7 +233,7 @@ class Grid(Repetition):
|
|||
self.b_vector[1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
||||
def get_bounds(self) -> Optional[NDArray[numpy.float64]]:
|
||||
"""
|
||||
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
|
||||
extent of the `Grid` in each dimension.
|
||||
|
|
@ -262,19 +241,15 @@ class Grid(Repetition):
|
|||
Returns:
|
||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
||||
"""
|
||||
a_extent = self.a_vector * (self.a_count - 1)
|
||||
if self.b_count is None:
|
||||
b_extent = numpy.zeros(2)
|
||||
else:
|
||||
assert self.b_vector is not None
|
||||
b_extent = self.b_vector * (self.b_count - 1)
|
||||
a_extent = self.a_vector * self.a_count
|
||||
b_extent = self.b_vector * self.b_count if (self.b_vector is not None) else 0 # type: Union[NDArray[numpy.float64], float]
|
||||
|
||||
corners = numpy.stack(((0, 0), a_extent, b_extent, a_extent + b_extent))
|
||||
corners = ((0, 0), a_extent, b_extent, a_extent + b_extent)
|
||||
xy_min = numpy.min(corners, axis=0)
|
||||
xy_max = numpy.max(corners, axis=0)
|
||||
return numpy.array((xy_min, xy_max))
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
def scale_by(self, c: float) -> 'Grid':
|
||||
"""
|
||||
Scale the Grid by a factor
|
||||
|
||||
|
|
@ -289,12 +264,39 @@ class Grid(Repetition):
|
|||
self.b_vector *= c
|
||||
return self
|
||||
|
||||
def lock(self) -> 'Grid':
|
||||
"""
|
||||
Lock the `Grid`, disallowing changes.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.a_vector.flags.writeable = False
|
||||
if self.b_vector is not None:
|
||||
self.b_vector.flags.writeable = False
|
||||
LockableImpl.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self) -> 'Grid':
|
||||
"""
|
||||
Unlock the `Grid`
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.a_vector.flags.writeable = True
|
||||
if self.b_vector is not None:
|
||||
self.b_vector.flags.writeable = True
|
||||
LockableImpl.unlock(self)
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
locked = ' L' if self.locked else ''
|
||||
bv = f', {self.b_vector}' if self.b_vector is not None else ''
|
||||
return (f'<Grid {self.a_count}x{self.b_count} ({self.a_vector}{bv})>')
|
||||
return (f'<Grid {self.a_count}x{self.b_count} ({self.a_vector}{bv}){locked}>')
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(other) is not type(self):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
if self.a_count != other.a_count or self.b_count != other.b_count:
|
||||
return False
|
||||
|
|
@ -304,26 +306,14 @@ class Grid(Repetition):
|
|||
return True
|
||||
if self.b_vector is None or other.b_vector is None:
|
||||
return False
|
||||
if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)): # noqa: SIM103
|
||||
if any(self.b_vector[ii] != other.b_vector[ii] for ii in range(2)):
|
||||
return False
|
||||
if self.locked != other.locked:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __lt__(self, other: Repetition) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
other = cast('Grid', other)
|
||||
if self.a_count != other.a_count:
|
||||
return self.a_count < other.a_count
|
||||
if self.b_count != other.b_count:
|
||||
return self.b_count < other.b_count
|
||||
if not numpy.array_equal(self.a_vector, other.a_vector):
|
||||
return tuple(self.a_vector) < tuple(other.a_vector)
|
||||
if not numpy.array_equal(self.b_vector, other.b_vector):
|
||||
return tuple(self.b_vector) < tuple(other.b_vector)
|
||||
return False
|
||||
|
||||
|
||||
class Arbitrary(Repetition):
|
||||
class Arbitrary(LockableImpl, Repetition, metaclass=AutoSlots):
|
||||
"""
|
||||
`Arbitrary` is a simple list of (absolute) displacements for instances.
|
||||
|
||||
|
|
@ -340,62 +330,63 @@ class Arbitrary(Repetition):
|
|||
"""
|
||||
|
||||
@property
|
||||
def displacements(self) -> NDArray[numpy.float64]:
|
||||
def displacements(self) -> Any: # TODO: mypy#3004 NDArray[numpy.float64]:
|
||||
return self._displacements
|
||||
|
||||
@displacements.setter
|
||||
def displacements(self, val: ArrayLike) -> None:
|
||||
try:
|
||||
vala = numpy.array(val, dtype=float)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PatternError('displacements must be convertible to an Nx2 ndarray') from exc
|
||||
|
||||
if vala.size == 0:
|
||||
self._displacements = numpy.empty((0, 2), dtype=float)
|
||||
return
|
||||
|
||||
if vala.ndim == 1:
|
||||
if vala.size != 2:
|
||||
raise PatternError('displacements must be convertible to an Nx2 ndarray')
|
||||
vala = vala.reshape(1, 2)
|
||||
elif vala.ndim != 2 or vala.shape[1] != 2:
|
||||
raise PatternError('displacements must be convertible to an Nx2 ndarray')
|
||||
|
||||
order = numpy.lexsort(vala.T[::-1]) # sortrows
|
||||
self._displacements = vala[order]
|
||||
vala: NDArray[numpy.float64] = numpy.array(val, dtype=float)
|
||||
vala = numpy.sort(vala.view([('', vala.dtype)] * vala.shape[1]), 0).view(vala.dtype) # sort rows
|
||||
self._displacements = vala
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
displacements: ArrayLike,
|
||||
locked: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
displacements: List of vectors (Nx2 ndarray) specifying displacements.
|
||||
locked: Whether the object is locked after initialization.
|
||||
"""
|
||||
object.__setattr__(self, 'locked', False)
|
||||
self.displacements = displacements
|
||||
self.locked = locked
|
||||
|
||||
def lock(self) -> 'Arbitrary':
|
||||
"""
|
||||
Lock the object, disallowing changes.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self._displacements.flags.writeable = False
|
||||
LockableImpl.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self) -> 'Arbitrary':
|
||||
"""
|
||||
Unlock the object
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self._displacements.flags.writeable = True
|
||||
LockableImpl.unlock(self)
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f'<Arbitrary {len(self.displacements)}pts >')
|
||||
locked = ' L' if self.locked else ''
|
||||
return (f'<Arbitrary {len(self.displacements)}pts {locked}>')
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(other) is not type(self):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
if self.locked != other.locked:
|
||||
return False
|
||||
return numpy.array_equal(self.displacements, other.displacements)
|
||||
|
||||
def __lt__(self, other: Repetition) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
other = cast('Arbitrary', other)
|
||||
if self.displacements.size != other.displacements.size:
|
||||
return self.displacements.size < other.displacements.size
|
||||
|
||||
neq = (self.displacements != other.displacements)
|
||||
if neq.any():
|
||||
return self.displacements[neq][0] < other.displacements[neq][0]
|
||||
|
||||
return False
|
||||
|
||||
def rotate(self, rotation: float) -> Self:
|
||||
def rotate(self, rotation: float) -> 'Arbitrary':
|
||||
"""
|
||||
Rotate dispacements (around (0, 0))
|
||||
|
||||
|
|
@ -408,7 +399,7 @@ class Arbitrary(Repetition):
|
|||
self.displacements = numpy.dot(rotation_matrix_2d(rotation), self.displacements.T).T
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
def mirror(self, axis: int) -> 'Arbitrary':
|
||||
"""
|
||||
Mirror the displacements across an axis.
|
||||
|
||||
|
|
@ -419,12 +410,10 @@ class Arbitrary(Repetition):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
new_displacements = self.displacements.copy()
|
||||
new_displacements[:, 1 - axis] *= -1
|
||||
self.displacements = new_displacements
|
||||
self.displacements[1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def get_bounds(self) -> NDArray[numpy.float64] | None:
|
||||
def get_bounds(self) -> Optional[NDArray[numpy.float64]]:
|
||||
"""
|
||||
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
|
||||
extent of the `displacements` in each dimension.
|
||||
|
|
@ -432,13 +421,11 @@ class Arbitrary(Repetition):
|
|||
Returns:
|
||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
||||
"""
|
||||
if self.displacements.size == 0:
|
||||
return None
|
||||
xy_min = numpy.min(self.displacements, axis=0)
|
||||
xy_max = numpy.max(self.displacements, axis=0)
|
||||
return numpy.array((xy_min, xy_max))
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
def scale_by(self, c: float) -> 'Arbitrary':
|
||||
"""
|
||||
Scale the displacements by a factor
|
||||
|
||||
|
|
@ -448,5 +435,6 @@ class Arbitrary(Repetition):
|
|||
Returns:
|
||||
self
|
||||
"""
|
||||
self.displacements = self.displacements * c
|
||||
self.displacements *= c
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,11 @@ Shapes for use with the Pattern class, as well as the Shape abstract class from
|
|||
which they are derived.
|
||||
"""
|
||||
|
||||
from .shape import (
|
||||
Shape as Shape,
|
||||
normalized_shape_tuple as normalized_shape_tuple,
|
||||
DEFAULT_POLY_NUM_VERTICES as DEFAULT_POLY_NUM_VERTICES,
|
||||
)
|
||||
from .shape import Shape, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
|
||||
|
||||
from .polygon import Polygon as Polygon
|
||||
from .poly_collection import PolyCollection as PolyCollection
|
||||
from .rect_collection import RectCollection as RectCollection
|
||||
from .circle import Circle as Circle
|
||||
from .ellipse import Ellipse as Ellipse
|
||||
from .arc import Arc as Arc
|
||||
from .text import Text as Text
|
||||
from .path import Path as Path
|
||||
from .polygon import Polygon
|
||||
from .circle import Circle
|
||||
from .ellipse import Ellipse
|
||||
from .arc import Arc
|
||||
from .text import Text
|
||||
from .path import Path
|
||||
|
|
|
|||
|
|
@ -1,53 +1,29 @@
|
|||
from typing import Any, cast
|
||||
from typing import List, Dict, Optional, Sequence, Any
|
||||
import copy
|
||||
import functools
|
||||
from enum import Enum
|
||||
import math
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
|
||||
from ..error import PatternError
|
||||
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
|
||||
from .. import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
|
||||
from ..traits import PositionableImpl
|
||||
from ..utils import is_scalar, layer_t, AutoSlots, annotations_t
|
||||
from ..traits import LockableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class ArcAngleRef(Enum):
|
||||
Center = 'center'
|
||||
FocusPos = 'focus_pos'
|
||||
FocusNeg = 'focus_neg'
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
if self.__class__ is not other.__class__:
|
||||
return self.__class__.__name__ < other.__class__.__name__
|
||||
order = {
|
||||
ArcAngleRef.Center: 0,
|
||||
ArcAngleRef.FocusPos: 1,
|
||||
ArcAngleRef.FocusNeg: 2,
|
||||
}
|
||||
return order[self] < order[other]
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Arc(PositionableImpl, Shape):
|
||||
class Arc(Shape, metaclass=AutoSlots):
|
||||
"""
|
||||
An elliptical arc, formed by cutting off an elliptical ring with two rays.
|
||||
By default the rays exit from its center, but they can optionally exit from one of the
|
||||
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
|
||||
a rotation, and a width.
|
||||
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
|
||||
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
|
||||
|
||||
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
||||
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
||||
The start and stop angle are measured counterclockwise from the first (x) radius.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations',
|
||||
)
|
||||
__slots__ = ('_radii', '_angles', '_width', '_rotation',
|
||||
'poly_num_points', 'poly_max_arclen')
|
||||
|
||||
_radii: NDArray[numpy.float64]
|
||||
""" Two radii for defining an ellipse """
|
||||
|
|
@ -61,14 +37,15 @@ class Arc(PositionableImpl, Shape):
|
|||
_width: float
|
||||
""" Width of the arc """
|
||||
|
||||
_angle_ref: ArcAngleRef
|
||||
""" Origin used by start/stop rays """
|
||||
poly_num_points: Optional[int]
|
||||
""" Sets the default number of points for `.polygonize()` """
|
||||
|
||||
AngleRef = ArcAngleRef
|
||||
poly_max_arclen: Optional[float]
|
||||
""" Sets the default max segement length for `.polygonize()` """
|
||||
|
||||
# radius properties
|
||||
@property
|
||||
def radii(self) -> NDArray[numpy.float64]:
|
||||
def radii(self) -> Any: #TODO mypy#3004 NDArray[numpy.float64]:
|
||||
"""
|
||||
Return the radii `[rx, ry]`
|
||||
"""
|
||||
|
|
@ -79,8 +56,8 @@ class Arc(PositionableImpl, Shape):
|
|||
val = numpy.array(val, dtype=float).flatten()
|
||||
if not val.size == 2:
|
||||
raise PatternError('Radii must have length 2')
|
||||
if not val.min() > 0:
|
||||
raise PatternError('Radii must be positive')
|
||||
if not val.min() >= 0:
|
||||
raise PatternError('Radii must be non-negative')
|
||||
self._radii = val
|
||||
|
||||
@property
|
||||
|
|
@ -89,8 +66,8 @@ class Arc(PositionableImpl, Shape):
|
|||
|
||||
@radius_x.setter
|
||||
def radius_x(self, val: float) -> None:
|
||||
if not val > 0:
|
||||
raise PatternError('Radius must be positive')
|
||||
if not val >= 0:
|
||||
raise PatternError('Radius must be non-negative')
|
||||
self._radii[0] = val
|
||||
|
||||
@property
|
||||
|
|
@ -99,13 +76,13 @@ class Arc(PositionableImpl, Shape):
|
|||
|
||||
@radius_y.setter
|
||||
def radius_y(self, val: float) -> None:
|
||||
if not val > 0:
|
||||
raise PatternError('Radius must be positive')
|
||||
if not val >= 0:
|
||||
raise PatternError('Radius must be non-negative')
|
||||
self._radii[1] = val
|
||||
|
||||
# arc start/stop angle properties
|
||||
@property
|
||||
def angles(self) -> NDArray[numpy.float64]:
|
||||
def angles(self) -> Any: #TODO mypy#3004 NDArray[numpy.float64]:
|
||||
"""
|
||||
Return the start and stop angles `[a_start, a_stop]`.
|
||||
Angles are measured from x-axis after rotation
|
||||
|
|
@ -138,18 +115,6 @@ class Arc(PositionableImpl, Shape):
|
|||
def stop_angle(self, val: float) -> None:
|
||||
self.angles = (self.angles[0], val)
|
||||
|
||||
# Angle reference property
|
||||
@property
|
||||
def angle_ref(self) -> ArcAngleRef:
|
||||
"""
|
||||
Origin used to interpret start and stop angle rays.
|
||||
"""
|
||||
return self._angle_ref
|
||||
|
||||
@angle_ref.setter
|
||||
def angle_ref(self, val: ArcAngleRef | str) -> None:
|
||||
self._angle_ref = ArcAngleRef(val)
|
||||
|
||||
# Rotation property
|
||||
@property
|
||||
def rotation(self) -> float:
|
||||
|
|
@ -192,172 +157,99 @@ class Arc(PositionableImpl, Shape):
|
|||
angles: ArrayLike,
|
||||
width: float,
|
||||
*,
|
||||
poly_num_points: Optional[int] = DEFAULT_POLY_NUM_POINTS,
|
||||
poly_max_arclen: Optional[float] = None,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||
mirrored: Sequence[bool] = (False, False),
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = ()
|
||||
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 if annotations is not None else {}
|
||||
self._layer = layer
|
||||
self._dose = dose
|
||||
else:
|
||||
self.radii = radii
|
||||
self.angles = angles
|
||||
self.width = width
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.angle_ref = angle_ref
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.layer = layer
|
||||
self.dose = dose
|
||||
self.poly_num_points = poly_num_points
|
||||
self.poly_max_arclen = poly_max_arclen
|
||||
[self.mirror(a) for a, do in enumerate(mirrored) if do]
|
||||
self.set_locked(locked)
|
||||
|
||||
@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) -> 'Arc':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
Shape.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._radii = self._radii.copy()
|
||||
new._angles = self._angles.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
and numpy.array_equal(self.radii, other.radii)
|
||||
and numpy.array_equal(self.angles, other.angles)
|
||||
and self.width == other.width
|
||||
and self.rotation == other.rotation
|
||||
and self.angle_ref == other.angle_ref
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
other = cast('Arc', other)
|
||||
if self.width != other.width:
|
||||
return self.width < other.width
|
||||
if not numpy.array_equal(self.radii, other.radii):
|
||||
return tuple(self.radii) < tuple(other.radii)
|
||||
if not numpy.array_equal(self.angles, other.angles):
|
||||
return tuple(self.angles) < tuple(other.angles)
|
||||
if self.angle_ref != other.angle_ref:
|
||||
return self.angle_ref < other.angle_ref
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.rotation != other.rotation:
|
||||
return self.rotation < other.rotation
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
|
||||
max_arclen: float | None = None,
|
||||
) -> list[Polygon]:
|
||||
if (num_vertices is None) and (max_arclen is None):
|
||||
poly_num_points: Optional[int] = None,
|
||||
poly_max_arclen: Optional[float] = None,
|
||||
) -> List[Polygon]:
|
||||
if poly_num_points is None:
|
||||
poly_num_points = self.poly_num_points
|
||||
if poly_max_arclen is None:
|
||||
poly_max_arclen = self.poly_max_arclen
|
||||
|
||||
if (poly_num_points is None) and (poly_max_arclen is None):
|
||||
raise PatternError('Max number of points and arclength left unspecified'
|
||||
+ ' (default was also overridden)')
|
||||
if max_arclen is not None and (numpy.isnan(max_arclen) or max_arclen <= 0):
|
||||
raise PatternError('Max arclength must be positive and not NaN')
|
||||
|
||||
r0, r1 = self.radii
|
||||
|
||||
# Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
|
||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
||||
a_ranges = self._angles_to_parameters()
|
||||
|
||||
# Approximate perimeter via numerical integration
|
||||
# Approximate perimeter
|
||||
# Ramanujan, S., "Modular Equations and Approximations to ,"
|
||||
# Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372
|
||||
a0, a1 = a_ranges[1] # use outer arc
|
||||
h = ((r1 - r0) / (r1 + r0)) ** 2
|
||||
ellipse_perimeter = pi * (r1 + r0) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
|
||||
perimeter = abs(a0 - a1) / (2 * pi) * ellipse_perimeter # TODO: make this more accurate
|
||||
|
||||
#perimeter1 = numpy.trapz(numpy.sqrt(r0sin * r0sin + r1cos * r1cos), dx=dt)
|
||||
#from scipy.special import ellipeinc
|
||||
#m = 1 - (r1 / r0) ** 2
|
||||
#t1 = ellipeinc(a1 - pi / 2, m)
|
||||
#t0 = ellipeinc(a0 - pi / 2, m)
|
||||
#perimeter2 = r0 * (t1 - t0)
|
||||
|
||||
def get_arclens(n_pts: int, a0: float, a1: float, dr: float) -> tuple[NDArray[numpy.float64], NDArray[numpy.float64]]:
|
||||
""" Get `n_pts` arclengths """
|
||||
tt, dt = numpy.linspace(a0, a1, n_pts, retstep=True) # NOTE: could probably use an adaptive number of points
|
||||
r0sin = (r0 + dr) * numpy.sin(tt)
|
||||
r1cos = (r1 + dr) * numpy.cos(tt)
|
||||
arc_dl = numpy.sqrt(r0sin * r0sin + r1cos * r1cos)
|
||||
#arc_lengths = numpy.diff(tt) * (arc_dl[1:] + arc_dl[:-1]) / 2
|
||||
arc_lengths = (arc_dl[1:] + arc_dl[:-1]) * numpy.abs(dt) / 2
|
||||
return arc_lengths, tt
|
||||
n = []
|
||||
if poly_num_points is not None:
|
||||
n += [poly_num_points]
|
||||
if poly_max_arclen is not None:
|
||||
n += [perimeter / poly_max_arclen]
|
||||
num_points = int(round(max(n)))
|
||||
|
||||
wh = self.width / 2.0
|
||||
arclen_limits: list[float] = []
|
||||
if max_arclen is not None:
|
||||
arclen_limits.append(max_arclen)
|
||||
if num_vertices is not None:
|
||||
n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int)
|
||||
perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum()
|
||||
perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum()
|
||||
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
|
||||
if not (numpy.isnan(implied_arclen) or implied_arclen <= 0):
|
||||
arclen_limits.append(implied_arclen)
|
||||
if not arclen_limits:
|
||||
raise PatternError('Arc polygonization could not determine a valid max_arclen')
|
||||
max_arclen = min(arclen_limits)
|
||||
|
||||
def get_thetas(inner: bool) -> NDArray[numpy.float64]:
|
||||
""" Figure out the parameter values at which we should place vertices to meet the arclength constraint"""
|
||||
dr = -wh if inner else wh
|
||||
|
||||
n_pts = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen)))
|
||||
arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr)
|
||||
|
||||
keep = [0]
|
||||
start = 0
|
||||
while start < arc_lengths.size:
|
||||
removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen)
|
||||
if not removable.any():
|
||||
next_to_keep = start + 1
|
||||
else:
|
||||
next_to_keep = start + numpy.where(removable)[0][-1] + 1
|
||||
keep.append(next_to_keep)
|
||||
start = next_to_keep
|
||||
|
||||
if keep[-1] != thetas.size - 1:
|
||||
keep.append(thetas.size - 1)
|
||||
|
||||
thetas = thetas[keep]
|
||||
if inner:
|
||||
thetas = thetas[::-1]
|
||||
return thetas
|
||||
|
||||
thetas_inner: NDArray[numpy.float64]
|
||||
if wh in (r0, r1):
|
||||
if wh == r0 or wh == r1:
|
||||
thetas_inner = numpy.zeros(1) # Don't generate multiple vertices if we're at the origin
|
||||
else:
|
||||
thetas_inner = get_thetas(inner=True)
|
||||
thetas_outer = get_thetas(inner=False)
|
||||
thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], num_points, endpoint=True)
|
||||
thetas_outer = numpy.linspace(a_ranges[1][0], a_ranges[1][1], num_points, endpoint=True)
|
||||
|
||||
sin_th_i, cos_th_i = (numpy.sin(thetas_inner), numpy.cos(thetas_inner))
|
||||
sin_th_o, cos_th_o = (numpy.sin(thetas_outer), numpy.cos(thetas_outer))
|
||||
|
|
@ -371,62 +263,85 @@ class Arc(PositionableImpl, Shape):
|
|||
ys = numpy.hstack((ys1, ys2))
|
||||
xys = numpy.vstack((xs, ys)).T
|
||||
|
||||
poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
|
||||
poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset, rotation=self.rotation)
|
||||
return [poly]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
'''
|
||||
Equation for rotated ellipse is
|
||||
`x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)`
|
||||
`y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)`
|
||||
where `t` is our parameter.
|
||||
|
||||
def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
|
||||
return numpy.array((
|
||||
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
|
||||
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
|
||||
))
|
||||
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.
|
||||
|
||||
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))
|
||||
If the extrema are innaccessible due to arc constraints, check the arc endpoints instead.
|
||||
'''
|
||||
a_ranges = self._angles_to_parameters()
|
||||
|
||||
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):
|
||||
mins = []
|
||||
maxs = []
|
||||
for a, sgn in zip(a_ranges, (-1, +1)):
|
||||
wh = sgn * self.width / 2
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
if rx == 0 or ry == 0:
|
||||
pts.append(numpy.zeros(2))
|
||||
continue
|
||||
pts.extend(points_in_interval(rx, ry, aa[0], aa[1]))
|
||||
|
||||
all_pts = numpy.asarray(pts) + self.offset
|
||||
return numpy.vstack((numpy.min(all_pts, axis=0),
|
||||
numpy.max(all_pts, axis=0)))
|
||||
if rx == 0 or ry == 0:
|
||||
# Single point, at origin
|
||||
mins.append([0, 0])
|
||||
maxs.append([0, 0])
|
||||
continue
|
||||
|
||||
a0, a1 = a
|
||||
a0_offset = a0 - (a0 % (2 * pi))
|
||||
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
sin_a = numpy.sin(a)
|
||||
cos_a = numpy.cos(a)
|
||||
|
||||
# Cutoff angles
|
||||
xpt = (-self.rotation) % (2 * pi) + a0_offset
|
||||
ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset
|
||||
xnt = (xpt - pi) % (2 * pi) + a0_offset
|
||||
ynt = (ypt - pi) % (2 * pi) + a0_offset
|
||||
|
||||
# Points along coordinate axes
|
||||
rx2_inv = 1 / (rx * rx)
|
||||
ry2_inv = 1 / (ry * ry)
|
||||
xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5
|
||||
yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5
|
||||
|
||||
# Arc endpoints
|
||||
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
|
||||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
||||
|
||||
# If our arc subtends a coordinate axis, use the extremum along that axis
|
||||
if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1:
|
||||
xp = xr
|
||||
|
||||
if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1:
|
||||
xn = -xr
|
||||
|
||||
if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1:
|
||||
yp = yr
|
||||
|
||||
if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1:
|
||||
yn = -yr
|
||||
|
||||
mins.append([xn, yn])
|
||||
maxs.append([xp, yp])
|
||||
return numpy.vstack((numpy.min(mins, axis=0) + self.offset,
|
||||
numpy.max(maxs, axis=0) + self.offset))
|
||||
|
||||
def rotate(self, theta: float) -> 'Arc':
|
||||
self.rotation += theta
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Arc':
|
||||
# Both external reflections use a local Y reflection; the extra pi
|
||||
# rotation below accounts for the external axis.
|
||||
if self.angle_ref != ArcAngleRef.Center and self.radius_y > self.radius_x:
|
||||
self._swap_focus_ref()
|
||||
def mirror(self, axis: int) -> 'Arc':
|
||||
self.offset[axis - 1] *= -1
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
self.angles *= -1
|
||||
|
|
@ -438,7 +353,6 @@ class Arc(PositionableImpl, Shape):
|
|||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
angle_ref = self.angle_ref
|
||||
if self.radius_x < self.radius_y:
|
||||
radii = self.radii / self.radius_x
|
||||
scale = self.radius_x
|
||||
|
|
@ -449,161 +363,87 @@ class Arc(PositionableImpl, Shape):
|
|||
scale = self.radius_y
|
||||
rotation = self.rotation + pi / 2
|
||||
angles = self.angles - pi / 2
|
||||
angle_ref = _swapped_focus_ref(angle_ref)
|
||||
|
||||
delta_angle = angles[1] - angles[0]
|
||||
start_angle = angles[0] % (2 * pi)
|
||||
if start_angle >= pi:
|
||||
start_angle -= pi
|
||||
rotation += pi
|
||||
angle_ref = _swapped_focus_ref(angle_ref)
|
||||
|
||||
norm_angles = (start_angle, start_angle + delta_angle)
|
||||
angles = (start_angle, start_angle + delta_angle)
|
||||
rotation %= 2 * pi
|
||||
width = self.width
|
||||
|
||||
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
|
||||
(self.offset, scale / norm_value, rotation, False),
|
||||
lambda: Arc(
|
||||
radii=radii * norm_value,
|
||||
angles=norm_angles,
|
||||
width=width * norm_value,
|
||||
angle_ref=angle_ref,
|
||||
))
|
||||
return ((type(self), radii, angles, width / norm_value, self.layer),
|
||||
(self.offset, scale / norm_value, rotation, False, self.dose),
|
||||
lambda: Arc(radii=radii * norm_value, angles=angles, width=width * norm_value, layer=self.layer))
|
||||
|
||||
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
'''
|
||||
Returns:
|
||||
```
|
||||
[[[x0, y0], [x1, y1]], array of 4 points, specifying the two cuts which
|
||||
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
||||
```
|
||||
"""
|
||||
'''
|
||||
a_ranges = self._angles_to_parameters()
|
||||
|
||||
cuts = []
|
||||
for index in range(2):
|
||||
edge = []
|
||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||
mins = []
|
||||
maxs = []
|
||||
for a, sgn in zip(a_ranges, (-1, +1)):
|
||||
wh = sgn * self.width / 2
|
||||
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
|
||||
cuts.append(edge)
|
||||
return numpy.array(cuts) + self.offset
|
||||
|
||||
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation)
|
||||
|
||||
Returns:
|
||||
"Eccentric anomaly" parameter ranges for the inner and outer edges, in the form
|
||||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||
"""
|
||||
aa = []
|
||||
d_angle = self.angles[1] - self.angles[0]
|
||||
if abs(d_angle) >= 2 * pi:
|
||||
# Full ring
|
||||
return numpy.tile([0, 2 * pi], (2, 1)).astype(float)
|
||||
|
||||
for sgn in (-1, +1):
|
||||
wh = sgn * self.width / 2.0
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
|
||||
sign = numpy.sign(d_angle)
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
sin_a = numpy.sin(a)
|
||||
cos_a = numpy.cos(a)
|
||||
|
||||
# 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]:
|
||||
'''
|
||||
Returns:
|
||||
"Eccentric anomaly" parameter ranges for the inner and outer edges, in the form
|
||||
`[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]`
|
||||
'''
|
||||
a = []
|
||||
for sgn in (-1, +1):
|
||||
wh = sgn * self.width / 2
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
# create paremeter 'a' for parametrized ellipse
|
||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(a), ry * numpy.cos(a)) for a in self.angles)
|
||||
sign = numpy.sign(self.angles[1] - self.angles[0])
|
||||
if sign != numpy.sign(a1 - a0):
|
||||
a1 += sign * 2 * pi
|
||||
|
||||
aa.append((a0, a1))
|
||||
return numpy.array(aa, dtype=float)
|
||||
a.append((a0, a1))
|
||||
return numpy.array(a)
|
||||
|
||||
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.
|
||||
def lock(self) -> 'Arc':
|
||||
self.radii.flags.writeable = False
|
||||
self.angles.flags.writeable = False
|
||||
Shape.lock(self)
|
||||
return self
|
||||
|
||||
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 unlock(self) -> 'Arc':
|
||||
Shape.unlock(self)
|
||||
self.radii.flags.writeable = True
|
||||
self.angles.flags.writeable = True
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
angles = f' a°{numpy.rad2deg(self.angles)}'
|
||||
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
|
||||
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
|
||||
|
||||
|
||||
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
|
||||
"""
|
||||
Return the opposite focus reference, or center for center-referenced arcs.
|
||||
"""
|
||||
if angle_ref == ArcAngleRef.FocusPos:
|
||||
return ArcAngleRef.FocusNeg
|
||||
if angle_ref == ArcAngleRef.FocusNeg:
|
||||
return ArcAngleRef.FocusPos
|
||||
return angle_ref
|
||||
|
||||
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<Arc l{self.layer} o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{dose}{locked}>'
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
from typing import Any, cast
|
||||
from typing import List, Dict, Optional
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
|
||||
from ..error import PatternError
|
||||
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
|
||||
from .. import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, rep2key
|
||||
from ..traits import PositionableImpl
|
||||
from ..utils import is_scalar, layer_t, AutoSlots, annotations_t
|
||||
from ..traits import LockableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Circle(PositionableImpl, Shape):
|
||||
class Circle(Shape, metaclass=AutoSlots):
|
||||
"""
|
||||
A circle, which has a position and radius.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_radius',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations',
|
||||
)
|
||||
__slots__ = ('_radius', 'poly_num_points', 'poly_max_arclen')
|
||||
|
||||
_radius: float
|
||||
""" Circle radius """
|
||||
|
||||
poly_num_points: Optional[int]
|
||||
""" Sets the default number of points for `.polygonize()` """
|
||||
|
||||
poly_max_arclen: Optional[float]
|
||||
""" Sets the default max segement length for `.polygonize()` """
|
||||
|
||||
# radius property
|
||||
@property
|
||||
def radius(self) -> float:
|
||||
|
|
@ -47,104 +47,96 @@ class Circle(PositionableImpl, Shape):
|
|||
self,
|
||||
radius: float,
|
||||
*,
|
||||
poly_num_points: Optional[int] = DEFAULT_POLY_NUM_POINTS,
|
||||
poly_max_arclen: Optional[float] = None,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = ()
|
||||
if raw:
|
||||
assert(isinstance(offset, numpy.ndarray))
|
||||
self._radius = radius
|
||||
self._offset = offset
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations if annotations is not None else {}
|
||||
self._layer = layer
|
||||
self._dose = dose
|
||||
else:
|
||||
self.radius = radius
|
||||
self.offset = offset
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.layer = layer
|
||||
self.dose = dose
|
||||
self.poly_num_points = poly_num_points
|
||||
self.poly_max_arclen = poly_max_arclen
|
||||
self.set_locked(locked)
|
||||
|
||||
@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) -> 'Circle':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
Shape.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
and self.radius == other.radius
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
other = cast('Circle', other)
|
||||
if not self.radius == other.radius:
|
||||
return self.radius < other.radius
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
|
||||
max_arclen: float | None = None,
|
||||
) -> list[Polygon]:
|
||||
if (num_vertices is None) and (max_arclen is None):
|
||||
poly_num_points: Optional[int] = None,
|
||||
poly_max_arclen: Optional[float] = None,
|
||||
) -> List[Polygon]:
|
||||
if poly_num_points is None:
|
||||
poly_num_points = self.poly_num_points
|
||||
if poly_max_arclen is None:
|
||||
poly_max_arclen = self.poly_max_arclen
|
||||
|
||||
if (poly_num_points is None) and (poly_max_arclen is None):
|
||||
raise PatternError('Number of points and arclength left '
|
||||
'unspecified (default was also overridden)')
|
||||
|
||||
n: list[float] = []
|
||||
if num_vertices is not None:
|
||||
n += [num_vertices]
|
||||
if max_arclen is not None:
|
||||
n += [2 * pi * self.radius / max_arclen]
|
||||
num_vertices = max(3, int(round(max(n))))
|
||||
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
||||
n: List[float] = []
|
||||
if poly_num_points is not None:
|
||||
n += [poly_num_points]
|
||||
if poly_max_arclen is not None:
|
||||
n += [2 * pi * self.radius / poly_max_arclen]
|
||||
num_points = int(round(max(n)))
|
||||
thetas = numpy.linspace(2 * pi, 0, num_points, endpoint=False)
|
||||
xs = numpy.cos(thetas) * self.radius
|
||||
ys = numpy.sin(thetas) * self.radius
|
||||
xys = numpy.vstack((xs, ys)).T
|
||||
|
||||
return [Polygon(xys, offset=self.offset)]
|
||||
return [Polygon(xys, offset=self.offset, dose=self.dose, layer=self.layer)]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
return numpy.vstack((self.offset - self.radius,
|
||||
self.offset + self.radius))
|
||||
|
||||
def rotate(self, theta: float) -> 'Circle': # noqa: ARG002 (theta unused)
|
||||
def rotate(self, theta: float) -> 'Circle':
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused)
|
||||
def mirror(self, axis: int) -> 'Circle':
|
||||
self.offset *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Circle':
|
||||
self.radius *= c
|
||||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
def normalized_form(self, norm_value) -> normalized_shape_tuple:
|
||||
rotation = 0.0
|
||||
magnitude = self.radius / norm_value
|
||||
return ((type(self),),
|
||||
(self.offset, magnitude, rotation, False),
|
||||
lambda: Circle(radius=norm_value))
|
||||
return ((type(self), self.layer),
|
||||
(self.offset, magnitude, rotation, False, self.dose),
|
||||
lambda: Circle(radius=norm_value, layer=self.layer))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Circle o{self.offset} r{self.radius:g}>'
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<Circle l{self.layer} o{self.offset} r{self.radius:g}{dose}{locked}>'
|
||||
|
|
|
|||
|
|
@ -1,30 +1,25 @@
|
|||
from typing import Any, Self, cast
|
||||
from typing import List, Dict, Sequence, Optional, Any
|
||||
import copy
|
||||
import math
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_VERTICES
|
||||
from ..error import PatternError
|
||||
from . import Shape, Polygon, normalized_shape_tuple, DEFAULT_POLY_NUM_POINTS
|
||||
from .. import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import is_scalar, rotation_matrix_2d, annotations_t, annotations_lt, annotations_eq, rep2key
|
||||
from ..traits import PositionableImpl
|
||||
from ..utils import is_scalar, rotation_matrix_2d, layer_t, AutoSlots, annotations_t
|
||||
from ..traits import LockableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Ellipse(PositionableImpl, Shape):
|
||||
class Ellipse(Shape, metaclass=AutoSlots):
|
||||
"""
|
||||
An ellipse, which has a position, two radii, and a rotation.
|
||||
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_radii', '_rotation',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations',
|
||||
)
|
||||
__slots__ = ('_radii', '_rotation',
|
||||
'poly_num_points', 'poly_max_arclen')
|
||||
|
||||
_radii: NDArray[numpy.float64]
|
||||
""" Ellipse radii """
|
||||
|
|
@ -32,9 +27,15 @@ class Ellipse(PositionableImpl, Shape):
|
|||
_rotation: float
|
||||
""" Angle from x-axis to first radius (ccw, radians) """
|
||||
|
||||
poly_num_points: Optional[int]
|
||||
""" Sets the default number of points for `.polygonize()` """
|
||||
|
||||
poly_max_arclen: Optional[float]
|
||||
""" Sets the default max segement length for `.polygonize()` """
|
||||
|
||||
# radius properties
|
||||
@property
|
||||
def radii(self) -> NDArray[numpy.float64]:
|
||||
def radii(self) -> Any: #TODO mypy#3004 NDArray[numpy.float64]:
|
||||
"""
|
||||
Return the radii `[rx, ry]`
|
||||
"""
|
||||
|
|
@ -42,7 +43,7 @@ class Ellipse(PositionableImpl, Shape):
|
|||
|
||||
@radii.setter
|
||||
def radii(self, val: ArrayLike) -> None:
|
||||
val = numpy.array(val, dtype=float).flatten()
|
||||
val = numpy.array(val).flatten()
|
||||
if not val.size == 2:
|
||||
raise PatternError('Radii must have length 2')
|
||||
if not val.min() >= 0:
|
||||
|
|
@ -91,76 +92,64 @@ class Ellipse(PositionableImpl, Shape):
|
|||
self,
|
||||
radii: ArrayLike,
|
||||
*,
|
||||
poly_num_points: Optional[int] = DEFAULT_POLY_NUM_POINTS,
|
||||
poly_max_arclen: Optional[float] = None,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
mirrored: Sequence[bool] = (False, False),
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = ()
|
||||
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 if annotations is not None else {}
|
||||
self._layer = layer
|
||||
self._dose = dose
|
||||
else:
|
||||
self.radii = radii
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.layer = layer
|
||||
self.dose = dose
|
||||
[self.mirror(a) for a, do in enumerate(mirrored) if do]
|
||||
self.poly_num_points = poly_num_points
|
||||
self.poly_max_arclen = poly_max_arclen
|
||||
self.set_locked(locked)
|
||||
|
||||
@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) -> 'Ellipse':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
Shape.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._radii = self._radii.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
and numpy.array_equal(self.radii, other.radii)
|
||||
and self.rotation == other.rotation
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
other = cast('Ellipse', other)
|
||||
if not numpy.array_equal(self.radii, other.radii):
|
||||
return tuple(self.radii) < tuple(other.radii)
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.rotation != other.rotation:
|
||||
return self.rotation < other.rotation
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = DEFAULT_POLY_NUM_VERTICES,
|
||||
max_arclen: float | None = None,
|
||||
) -> list[Polygon]:
|
||||
if (num_vertices is None) and (max_arclen is None):
|
||||
poly_num_points: Optional[int] = None,
|
||||
poly_max_arclen: Optional[float] = None,
|
||||
) -> List[Polygon]:
|
||||
if poly_num_points is None:
|
||||
poly_num_points = self.poly_num_points
|
||||
if poly_max_arclen is None:
|
||||
poly_max_arclen = self.poly_max_arclen
|
||||
|
||||
if (poly_num_points is None) and (poly_max_arclen is None):
|
||||
raise PatternError('Number of points and arclength left unspecified'
|
||||
' (default was also overridden)')
|
||||
|
||||
|
|
@ -173,40 +162,37 @@ class Ellipse(PositionableImpl, Shape):
|
|||
perimeter = pi * (r1 + r0) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
|
||||
|
||||
n = []
|
||||
if num_vertices is not None:
|
||||
n += [num_vertices]
|
||||
if max_arclen is not None:
|
||||
n += [perimeter / max_arclen]
|
||||
num_vertices = max(3, int(round(max(n))))
|
||||
thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False)
|
||||
if poly_num_points is not None:
|
||||
n += [poly_num_points]
|
||||
if poly_max_arclen is not None:
|
||||
n += [perimeter / poly_max_arclen]
|
||||
num_points = int(round(max(n)))
|
||||
thetas = numpy.linspace(2 * pi, 0, num_points, endpoint=False)
|
||||
|
||||
sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas))
|
||||
xs = r0 * cos_th
|
||||
ys = r1 * sin_th
|
||||
xys = numpy.vstack((xs, ys)).T
|
||||
|
||||
poly = Polygon(xys, offset=self.offset, rotation=self.rotation)
|
||||
poly = Polygon(xys, dose=self.dose, layer=self.layer, offset=self.offset, rotation=self.rotation)
|
||||
return [poly]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
cos_r = numpy.cos(self.rotation)
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2)
|
||||
y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2)
|
||||
extents = numpy.array((x_extent, y_extent))
|
||||
return numpy.vstack((self.offset - extents,
|
||||
self.offset + extents))
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii)
|
||||
return numpy.vstack((self.offset - rot_radii[0],
|
||||
self.offset + rot_radii[1]))
|
||||
|
||||
def rotate(self, theta: float) -> Self:
|
||||
def rotate(self, theta: float) -> 'Ellipse':
|
||||
self.rotation += theta
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
def mirror(self, axis: int) -> 'Ellipse':
|
||||
self.offset[axis - 1] *= -1
|
||||
self.rotation *= -1
|
||||
self.rotation += axis * pi
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
def scale_by(self, c: float) -> 'Ellipse':
|
||||
self.radii *= c
|
||||
return self
|
||||
|
||||
|
|
@ -219,10 +205,22 @@ class Ellipse(PositionableImpl, Shape):
|
|||
radii = self.radii[::-1] / self.radius_y
|
||||
scale = self.radius_y
|
||||
angle = (self.rotation + pi / 2) % pi
|
||||
return ((type(self), tuple(radii.tolist())),
|
||||
(self.offset, scale / norm_value, angle, False),
|
||||
lambda: Ellipse(radii=radii * norm_value))
|
||||
return ((type(self), radii, self.layer),
|
||||
(self.offset, scale / norm_value, angle, False, self.dose),
|
||||
lambda: Ellipse(radii=radii * norm_value, layer=self.layer))
|
||||
|
||||
def lock(self) -> 'Ellipse':
|
||||
self.radii.flags.writeable = False
|
||||
Shape.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self) -> 'Ellipse':
|
||||
Shape.unlock(self)
|
||||
self.radii.flags.writeable = True
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||
return f'<Ellipse o{self.offset} r{self.radii}{rotation}>'
|
||||
rotation = f' r{self.rotation*180/pi:g}' if self.rotation != 0 else ''
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<Ellipse l{self.layer} o{self.offset} r{self.radii}{rotation}{dose}{locked}>'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
from typing import Any, cast, Self
|
||||
from collections.abc import Sequence
|
||||
from typing import List, Tuple, Dict, Optional, Sequence, Any
|
||||
import copy
|
||||
import functools
|
||||
from enum import Enum
|
||||
|
||||
import numpy
|
||||
|
|
@ -9,13 +7,13 @@ from numpy import pi, inf
|
|||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, normalized_shape_tuple, Polygon, Circle
|
||||
from ..error import PatternError
|
||||
from .. import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
|
||||
from ..utils import is_scalar, rotation_matrix_2d, layer_t, AutoSlots
|
||||
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
|
||||
from ..traits import LockableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class PathCap(Enum):
|
||||
Flush = 0 # Path ends at final vertices
|
||||
Circle = 1 # Path extends past final vertices with a semicircle of radius width/2
|
||||
|
|
@ -23,37 +21,19 @@ class PathCap(Enum):
|
|||
SquareCustom = 4 # Path extends past final vertices with a rectangle of length
|
||||
# # defined by path.cap_extensions
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
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
|
||||
class Path(Shape):
|
||||
class Path(Shape, metaclass=AutoSlots):
|
||||
"""
|
||||
A path, consisting of a bunch of vertices (Nx2 ndarray), a width, and an end-cap shape.
|
||||
|
||||
Note that the setter for `Path.vertices` will create a copy of the passed vertex coordinates.
|
||||
A path, consisting of a bunch of vertices (Nx2 ndarray), a width, an end-cap shape,
|
||||
and an offset.
|
||||
|
||||
A normalized_form(...) is available, but can be quite slow with lots of vertices.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_vertices', '_width', '_cap', '_cap_extensions',
|
||||
# Inherited
|
||||
'_repetition', '_annotations',
|
||||
)
|
||||
__slots__ = ('_vertices', '_width', '_cap', '_cap_extensions')
|
||||
_vertices: NDArray[numpy.float64]
|
||||
_width: float
|
||||
_cap: PathCap
|
||||
_cap_extensions: NDArray[numpy.float64] | None
|
||||
_cap_extensions: Optional[NDArray[numpy.float64]]
|
||||
|
||||
Cap = PathCap
|
||||
|
||||
|
|
@ -78,60 +58,53 @@ class Path(Shape):
|
|||
def cap(self) -> PathCap:
|
||||
"""
|
||||
Path end-cap
|
||||
|
||||
Note that `cap_extensions` will be reset to default values if
|
||||
`cap` is changed away from `PathCap.SquareCustom`.
|
||||
"""
|
||||
return self._cap
|
||||
|
||||
@cap.setter
|
||||
def cap(self, val: PathCap) -> None:
|
||||
# TODO: Document that setting cap can change cap_extensions
|
||||
self._cap = PathCap(val)
|
||||
if self.cap != PathCap.SquareCustom:
|
||||
self._cap_extensions = None
|
||||
elif self._cap_extensions is None:
|
||||
self.cap_extensions = None
|
||||
elif self.cap_extensions is None:
|
||||
# just got set to SquareCustom
|
||||
self._cap_extensions = numpy.zeros(2)
|
||||
self.cap_extensions = numpy.zeros(2)
|
||||
|
||||
# cap_extensions property
|
||||
@property
|
||||
def cap_extensions(self) -> NDArray[numpy.float64] | None:
|
||||
def cap_extensions(self) -> Optional[Any]: #TODO mypy#3004 NDArray[numpy.float64]]:
|
||||
"""
|
||||
Path end-cap extension
|
||||
|
||||
Note that `cap_extensions` will be reset to default values if
|
||||
`cap` is changed away from `PathCap.SquareCustom`.
|
||||
|
||||
Returns:
|
||||
2-element ndarray or `None`
|
||||
"""
|
||||
return self._cap_extensions
|
||||
|
||||
@cap_extensions.setter
|
||||
def cap_extensions(self, vals: ArrayLike | None) -> None:
|
||||
def cap_extensions(self, vals: Optional[ArrayLike]) -> None:
|
||||
custom_caps = (PathCap.SquareCustom,)
|
||||
if self.cap in custom_caps:
|
||||
if vals is None:
|
||||
raise PatternError('Tried to set cap extensions to None on path with custom cap type')
|
||||
raise Exception('Tried to set cap extensions to None on path with custom cap type')
|
||||
self._cap_extensions = numpy.array(vals, dtype=float)
|
||||
else:
|
||||
if vals is not None:
|
||||
raise PatternError('Tried to set custom cap extensions on path with non-custom cap type')
|
||||
raise Exception('Tried to set custom cap extensions on path with non-custom cap type')
|
||||
self._cap_extensions = vals
|
||||
|
||||
# vertices property
|
||||
@property
|
||||
def vertices(self) -> NDArray[numpy.float64]:
|
||||
def vertices(self) -> Any: #TODO mypy#3004 NDArray[numpy.float64]]:
|
||||
"""
|
||||
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`
|
||||
|
||||
When setting, note that a copy of the provided vertices will be made.
|
||||
Vertices of the path (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
|
||||
"""
|
||||
return self._vertices
|
||||
|
||||
@vertices.setter
|
||||
def vertices(self, val: ArrayLike) -> None:
|
||||
val = numpy.array(val, dtype=float)
|
||||
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
|
||||
if len(val.shape) < 2 or val.shape[1] != 2:
|
||||
raise PatternError('Vertices must be an Nx2 array')
|
||||
if val.shape[0] < 2:
|
||||
|
|
@ -168,133 +141,77 @@ class Path(Shape):
|
|||
raise PatternError('Wrong number of vertices')
|
||||
self.vertices[:, 1] = val
|
||||
|
||||
# Offset property for `Positionable`
|
||||
@property
|
||||
def offset(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
[x, y] offset
|
||||
"""
|
||||
return numpy.zeros(2)
|
||||
|
||||
@offset.setter
|
||||
def offset(self, val: ArrayLike) -> None:
|
||||
if numpy.any(val):
|
||||
raise PatternError('Path offset is forced to (0, 0)')
|
||||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('Path offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
self._vertices += numpy.atleast_2d(offset)
|
||||
return self
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vertices: ArrayLike,
|
||||
width: float = 0.0,
|
||||
*,
|
||||
cap: PathCap = PathCap.Flush,
|
||||
cap_extensions: ArrayLike | None = None,
|
||||
cap_extensions: Optional[ArrayLike] = None,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
mirrored: Sequence[bool] = (False, False),
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self._cap_extensions = None # Since .cap setter might access it
|
||||
|
||||
self.vertices = vertices
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.identifier = ()
|
||||
if raw:
|
||||
assert(isinstance(vertices, numpy.ndarray))
|
||||
assert(isinstance(offset, numpy.ndarray))
|
||||
assert(isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None)
|
||||
self._vertices = vertices
|
||||
self._offset = offset
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations if annotations is not None else {}
|
||||
self._layer = layer
|
||||
self._dose = dose
|
||||
self._width = width
|
||||
self._cap = cap
|
||||
if cap == PathCap.SquareCustom and cap_extensions is None:
|
||||
self._cap_extensions = numpy.zeros(2)
|
||||
self._cap_extensions = cap_extensions
|
||||
else:
|
||||
self.cap_extensions = cap_extensions
|
||||
self.vertices = vertices
|
||||
self.offset = offset
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.layer = layer
|
||||
self.dose = dose
|
||||
self.width = width
|
||||
if rotation:
|
||||
self.cap = cap
|
||||
self.cap_extensions = cap_extensions
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
[self.mirror(a) for a, do in enumerate(mirrored) if do]
|
||||
self.set_locked(locked)
|
||||
|
||||
@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) -> 'Path':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
Shape.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._vertices = self._vertices.copy()
|
||||
new._cap = copy.deepcopy(self._cap, memo)
|
||||
new._cap_extensions = copy.deepcopy(self._cap_extensions, memo)
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self.vertices, other.vertices)
|
||||
and self.width == other.width
|
||||
and self.cap == other.cap
|
||||
and numpy.array_equal(self.cap_extensions, other.cap_extensions) # type: ignore
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
other = cast('Path', other)
|
||||
if self.width != other.width:
|
||||
return self.width < other.width
|
||||
if self.cap != other.cap:
|
||||
return self.cap < other.cap
|
||||
if not numpy.array_equal(self.cap_extensions, other.cap_extensions): # type: ignore
|
||||
if other.cap_extensions is None:
|
||||
return False
|
||||
if self.cap_extensions is None:
|
||||
return True
|
||||
return tuple(self.cap_extensions) < tuple(other.cap_extensions)
|
||||
if not numpy.array_equal(self.vertices, other.vertices):
|
||||
min_len = min(self.vertices.shape[0], other.vertices.shape[0])
|
||||
eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
|
||||
eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
|
||||
eq_lt_masked = eq_lt[eq_mask]
|
||||
if eq_lt_masked.size > 0:
|
||||
return eq_lt_masked.flat[0]
|
||||
return self.vertices.shape[0] < other.vertices.shape[0]
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
@staticmethod
|
||||
def travel(
|
||||
travel_pairs: Sequence[tuple[float, float]],
|
||||
travel_pairs: Sequence[Tuple[float, float]],
|
||||
width: float = 0.0,
|
||||
cap: PathCap = PathCap.Flush,
|
||||
cap_extensions: tuple[float, float] | None = None,
|
||||
cap_extensions: Optional[Tuple[float, float]] = None,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0,
|
||||
mirrored: Sequence[bool] = (False, False),
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
) -> 'Path':
|
||||
"""
|
||||
Build a path by specifying the turn angles and travel distances
|
||||
|
|
@ -311,56 +228,41 @@ class Path(Shape):
|
|||
Default `(0, 0)` or `None`, depending on cap type
|
||||
offset: Offset, default `(0, 0)`
|
||||
rotation: Rotation counterclockwise, in radians. Default `0`
|
||||
mirrored: Whether to mirror across the x or y axes. For example,
|
||||
`mirrored=(True, False)` results in a reflection across the x-axis,
|
||||
multiplying the path's y-coordinates by -1. Default `(False, False)`
|
||||
layer: Layer, default `0`
|
||||
dose: Dose, default `1.0`
|
||||
|
||||
Returns:
|
||||
The resulting Path object
|
||||
"""
|
||||
# TODO: Path.travel() needs testing
|
||||
#TODO: needs testing
|
||||
direction = numpy.array([1, 0])
|
||||
|
||||
verts: list[NDArray[numpy.float64]] = [numpy.zeros(2)]
|
||||
verts = [numpy.zeros(2)]
|
||||
for angle, distance in travel_pairs:
|
||||
direction = numpy.dot(rotation_matrix_2d(angle), direction.T).T
|
||||
verts.append(verts[-1] + direction * distance)
|
||||
|
||||
return Path(vertices=verts, width=width, cap=cap, cap_extensions=cap_extensions,
|
||||
offset=offset, rotation=rotation)
|
||||
offset=offset, rotation=rotation, mirrored=mirrored,
|
||||
layer=layer, dose=dose)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = None,
|
||||
max_arclen: float | None = None,
|
||||
) -> list['Polygon']:
|
||||
poly_num_points: int = None,
|
||||
poly_max_arclen: float = None,
|
||||
) -> List['Polygon']:
|
||||
extensions = self._calculate_cap_extensions()
|
||||
|
||||
v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
||||
v = remove_colinear_vertices(self.vertices, closed_path=False)
|
||||
dv = numpy.diff(v, axis=0)
|
||||
norms = numpy.sqrt((dv * dv).sum(axis=1))
|
||||
|
||||
# Filter out zero-length segments if any remained after remove_colinear_vertices
|
||||
valid = (norms > 1e-18)
|
||||
if not numpy.all(valid):
|
||||
# This shouldn't happen much if remove_colinear_vertices is working
|
||||
v = v[numpy.append(valid, True)]
|
||||
dv = numpy.diff(v, axis=0)
|
||||
norms = norms[valid]
|
||||
|
||||
if dv.shape[0] == 0:
|
||||
# All vertices were the same. It's a point.
|
||||
if self.width == 0:
|
||||
return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate
|
||||
if self.cap == PathCap.Circle:
|
||||
return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
|
||||
if self.cap == PathCap.Square:
|
||||
return [Polygon.square(side_length=self.width, offset=v[0])]
|
||||
# Flush or CustomSquare
|
||||
return [Polygon(vertices=numpy.zeros((3, 2)))]
|
||||
|
||||
dvdir = dv / norms[:, None]
|
||||
dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None]
|
||||
|
||||
if self.width == 0:
|
||||
verts = numpy.vstack((v, v[::-1]))
|
||||
return [Polygon(vertices=verts)]
|
||||
return [Polygon(offset=self.offset, vertices=verts, dose=self.dose, layer=self.layer)]
|
||||
|
||||
perp = dvdir[:, ::-1] * [[1, -1]] * self.width / 2
|
||||
|
||||
|
|
@ -375,21 +277,11 @@ class Path(Shape):
|
|||
bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1]
|
||||
ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1]
|
||||
|
||||
try:
|
||||
# Vectorized solve for all intersections
|
||||
# solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1)
|
||||
rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0]
|
||||
rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0]
|
||||
except numpy.linalg.LinAlgError:
|
||||
# Fallback to slower lstsq if some segments are parallel (singular matrix)
|
||||
rp = numpy.zeros(As.shape[0])
|
||||
rn = numpy.zeros(As.shape[0])
|
||||
for ii in range(As.shape[0]):
|
||||
rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0]
|
||||
rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0]
|
||||
rp = numpy.linalg.solve(As, bs)[:, 0, None]
|
||||
rn = numpy.linalg.solve(As, ds)[:, 0, None]
|
||||
|
||||
intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1]
|
||||
intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1]
|
||||
intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1]
|
||||
intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1]
|
||||
|
||||
towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp?
|
||||
# straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight
|
||||
|
|
@ -421,33 +313,31 @@ class Path(Shape):
|
|||
o1.append(v[-1] - perp[-1])
|
||||
verts = numpy.vstack((o0, o1[::-1]))
|
||||
|
||||
polys = [Polygon(vertices=verts)]
|
||||
polys = [Polygon(offset=self.offset, vertices=verts, dose=self.dose, layer=self.layer)]
|
||||
|
||||
if self.cap == PathCap.Circle:
|
||||
#for vert in v: # not sure if every vertex, or just ends?
|
||||
for vert in [v[0], v[-1]]:
|
||||
circ = Circle(offset=vert, radius=self.width / 2)
|
||||
polys += circ.to_polygons(num_vertices=num_vertices, max_arclen=max_arclen)
|
||||
circ = Circle(offset=vert, radius=self.width / 2, dose=self.dose, layer=self.layer)
|
||||
polys += circ.to_polygons(poly_num_points=poly_num_points, poly_max_arclen=poly_max_arclen)
|
||||
|
||||
return polys
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
if self.cap == PathCap.Circle:
|
||||
bounds = numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2,
|
||||
bounds = self.offset + numpy.vstack((numpy.min(self.vertices, axis=0) - self.width / 2,
|
||||
numpy.max(self.vertices, axis=0) + self.width / 2))
|
||||
elif self.cap in (
|
||||
PathCap.Flush,
|
||||
elif self.cap in (PathCap.Flush,
|
||||
PathCap.Square,
|
||||
PathCap.SquareCustom,
|
||||
):
|
||||
PathCap.SquareCustom):
|
||||
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
|
||||
polys = self.to_polygons()
|
||||
for poly in polys:
|
||||
poly_bounds = poly.get_bounds_single_nonempty()
|
||||
poly_bounds = poly.get_bounds_nonempty()
|
||||
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
|
||||
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
|
||||
else:
|
||||
raise PatternError(f'get_bounds_single() not implemented for endcaps: {self.cap}')
|
||||
raise PatternError(f'get_bounds() not implemented for endcaps: {self.cap}')
|
||||
|
||||
return bounds
|
||||
|
||||
|
|
@ -456,21 +346,19 @@ class Path(Shape):
|
|||
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Path':
|
||||
self.vertices[:, 1 - axis] *= -1
|
||||
def mirror(self, axis: int) -> 'Path':
|
||||
self.vertices[:, axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Path':
|
||||
self.vertices *= c
|
||||
self.width *= c
|
||||
if self.cap_extensions is not None:
|
||||
self.cap_extensions *= c
|
||||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
# Note: this function is going to be pretty slow for many-vertexed paths, relative to
|
||||
# other shapes
|
||||
offset = self.vertices.mean(axis=0)
|
||||
offset = self.vertices.mean(axis=0) + self.offset
|
||||
zeroed_vertices = self.vertices - offset
|
||||
|
||||
scale = zeroed_vertices.std()
|
||||
|
|
@ -481,23 +369,19 @@ class Path(Shape):
|
|||
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
|
||||
for v in normed_vertices])
|
||||
|
||||
# Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller
|
||||
if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat):
|
||||
reordered_vertices = rotated_vertices[::-1]
|
||||
else:
|
||||
reordered_vertices = rotated_vertices
|
||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||
x_min = rotated_vertices[:, 0].argmin()
|
||||
if not is_scalar(x_min):
|
||||
y_min = rotated_vertices[x_min, 1].argmin()
|
||||
x_min = x_min[y_min]
|
||||
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||
|
||||
width0 = self.width / norm_value
|
||||
cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions)
|
||||
|
||||
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0),
|
||||
(offset, scale / norm_value, rotation, False),
|
||||
lambda: Path(
|
||||
reordered_vertices * norm_value,
|
||||
width=width0 * norm_value,
|
||||
cap=self.cap,
|
||||
cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0),
|
||||
))
|
||||
return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, self.layer),
|
||||
(offset, scale / norm_value, rotation, False, self.dose),
|
||||
lambda: Path(reordered_vertices * norm_value, width=self.width * norm_value,
|
||||
cap=self.cap, layer=self.layer))
|
||||
|
||||
def clean_vertices(self) -> 'Path':
|
||||
"""
|
||||
|
|
@ -510,36 +394,52 @@ class Path(Shape):
|
|||
return self
|
||||
|
||||
def remove_duplicate_vertices(self) -> 'Path':
|
||||
"""
|
||||
'''
|
||||
Removes all consecutive duplicate (repeated) vertices.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
'''
|
||||
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=False)
|
||||
return self
|
||||
|
||||
def remove_colinear_vertices(self) -> 'Path':
|
||||
"""
|
||||
'''
|
||||
Removes consecutive co-linear vertices.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True)
|
||||
'''
|
||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=False)
|
||||
return self
|
||||
|
||||
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:
|
||||
if self.cap == PathCap.Square:
|
||||
extensions = numpy.full(2, self.width / 2)
|
||||
elif self.cap == PathCap.SquareCustom:
|
||||
assert isinstance(self.cap_extensions, numpy.ndarray)
|
||||
assert(isinstance(self.cap_extensions, numpy.ndarray))
|
||||
extensions = self.cap_extensions
|
||||
else:
|
||||
# Flush or Circle
|
||||
extensions = numpy.zeros(2)
|
||||
return extensions
|
||||
|
||||
def lock(self) -> 'Path':
|
||||
self.vertices.flags.writeable = False
|
||||
if self.cap_extensions is not None:
|
||||
self.cap_extensions.flags.writeable = False
|
||||
Shape.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self) -> 'Path':
|
||||
Shape.unlock(self)
|
||||
self.vertices.flags.writeable = True
|
||||
if self.cap_extensions is not None:
|
||||
self.cap_extensions.flags.writeable = True
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
centroid = self.vertices.mean(axis=0)
|
||||
return f'<Path centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}>'
|
||||
centroid = self.offset + self.vertices.mean(axis=0)
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<Path l{self.layer} centroid {centroid} v{len(self.vertices)} w{self.width} c{self.cap}{dose}{locked}>'
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
from typing import Any, cast, Self
|
||||
from collections.abc import Iterator
|
||||
import copy
|
||||
import functools
|
||||
from itertools import chain
|
||||
|
||||
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 rotation_matrix_2d, annotations_lt, annotations_eq, rep2key, annotations_t
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class PolyCollection(Shape):
|
||||
"""
|
||||
A collection of polygons, consisting of concatenated vertex arrays (N_m x 2 ndarray) which specify
|
||||
implicitly-closed boundaries, and an array of offets specifying the first vertex of each
|
||||
successive polygon.
|
||||
|
||||
A `normalized_form(...)` is available, but is untested and probably fairly slow.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_vertex_lists',
|
||||
'_vertex_offsets',
|
||||
# Inherited
|
||||
'_repetition', '_annotations',
|
||||
)
|
||||
|
||||
_vertex_lists: NDArray[numpy.float64]
|
||||
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
|
||||
|
||||
_vertex_offsets: NDArray[numpy.integer[Any]]
|
||||
""" 1D NDArray specifying the starting offset for each polygon """
|
||||
|
||||
@property
|
||||
def vertex_lists(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Vertices of the polygons, ((N+M+...) x 2). Use with `vertex_offsets`.
|
||||
"""
|
||||
return self._vertex_lists
|
||||
|
||||
@property
|
||||
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
|
||||
"""
|
||||
Starting offset (in `vertex_lists`) for each polygon
|
||||
"""
|
||||
return self._vertex_offsets
|
||||
|
||||
@property
|
||||
def vertex_slices(self) -> Iterator[slice]:
|
||||
"""
|
||||
Iterator which provides slices which index vertex_lists
|
||||
"""
|
||||
if self._vertex_offsets.size == 0:
|
||||
return
|
||||
for ii, ff in zip(
|
||||
self._vertex_offsets,
|
||||
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
|
||||
strict=True,
|
||||
):
|
||||
yield slice(int(ii), int(ff))
|
||||
|
||||
@property
|
||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||
for slc in self.vertex_slices:
|
||||
yield self._vertex_lists[slc]
|
||||
|
||||
# Offset property for `Positionable`
|
||||
@property
|
||||
def offset(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
[x, y] offset
|
||||
"""
|
||||
return numpy.zeros(2)
|
||||
|
||||
@offset.setter
|
||||
def offset(self, _val: ArrayLike) -> None:
|
||||
raise PatternError('PolyCollection offset is forced to (0, 0)')
|
||||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('PolyCollection offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
self._vertex_lists += numpy.atleast_2d(offset)
|
||||
return self
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vertex_lists: ArrayLike,
|
||||
vertex_offsets: ArrayLike,
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
) -> None:
|
||||
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
|
||||
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if rotation:
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
vertex_lists: NDArray[numpy.float64],
|
||||
vertex_offsets: NDArray[numpy.integer[Any]],
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._vertex_lists = vertex_lists
|
||||
new._vertex_offsets = vertex_offsets
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._vertex_lists = self._vertex_lists.copy()
|
||||
new._vertex_offsets = self._vertex_offsets.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
|
||||
and numpy.array_equal(self.vertex_offsets, other.vertex_offsets)
|
||||
and 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('PolyCollection', other)
|
||||
|
||||
for vv, oo in zip(self.polygon_vertices, other.polygon_vertices, strict=False):
|
||||
if not numpy.array_equal(vv, oo):
|
||||
min_len = min(vv.shape[0], oo.shape[0])
|
||||
eq_mask = vv[:min_len] != oo[:min_len]
|
||||
eq_lt = vv[:min_len] < oo[:min_len]
|
||||
eq_lt_masked = eq_lt[eq_mask]
|
||||
if eq_lt_masked.size > 0:
|
||||
return eq_lt_masked.flat[0]
|
||||
return vv.shape[0] < oo.shape[0]
|
||||
if len(self.vertex_lists) != len(other.vertex_lists):
|
||||
return len(self.vertex_lists) < len(other.vertex_lists)
|
||||
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 = vv,
|
||||
repetition = copy.deepcopy(self.repetition),
|
||||
annotations = copy.deepcopy(self.annotations),
|
||||
) for vv in self.polygon_vertices]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition
|
||||
if self._vertex_lists.size == 0:
|
||||
return None
|
||||
return numpy.vstack((numpy.min(self._vertex_lists, axis=0),
|
||||
numpy.max(self._vertex_lists, axis=0)))
|
||||
|
||||
def rotate(self, theta: float) -> Self:
|
||||
if theta != 0:
|
||||
rot = rotation_matrix_2d(theta)
|
||||
self._vertex_lists = numpy.einsum('ij,kj->ki', rot, self._vertex_lists)
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self._vertex_lists[:, 1 - axis] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
self._vertex_lists *= c
|
||||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
# Note: this function is going to be pretty slow for many-vertexed polygons, relative to
|
||||
# other shapes
|
||||
meanv = self._vertex_lists.mean(axis=0)
|
||||
zeroed_vertices = self._vertex_lists - [meanv]
|
||||
offset = meanv
|
||||
|
||||
scale = zeroed_vertices.std()
|
||||
normed_vertices = zeroed_vertices / scale
|
||||
|
||||
_, _, vertex_axis = numpy.linalg.svd(zeroed_vertices)
|
||||
rotation = numpy.arctan2(vertex_axis[0][1], vertex_axis[0][0]) % (2 * pi)
|
||||
rotated_vertices = numpy.einsum('ij,kj->ki', rotation_matrix_2d(-rotation), normed_vertices)
|
||||
|
||||
# TODO consider how to reorder vertices for polycollection
|
||||
## Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||
#x_min = rotated_vertices[:, 0].argmin()
|
||||
#if not is_scalar(x_min):
|
||||
# y_min = rotated_vertices[x_min, 1].argmin()
|
||||
# x_min = cast('Sequence', x_min)[y_min]
|
||||
#reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||
|
||||
# TODO: normalize mirroring?
|
||||
|
||||
return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()),
|
||||
(offset, scale / norm_value, rotation, False),
|
||||
lambda: PolyCollection(
|
||||
vertex_lists=rotated_vertices * norm_value,
|
||||
vertex_offsets=self.vertex_offsets.copy(),
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
centroid = self.vertex_lists.mean(axis=0)
|
||||
return f'<PolyCollection centroid {centroid} p{len(self.vertex_offsets)}>'
|
||||
|
|
@ -1,54 +1,41 @@
|
|||
from typing import Any, cast, TYPE_CHECKING, Self, Literal
|
||||
from typing import List, Dict, Optional, Sequence, Any
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, normalized_shape_tuple
|
||||
from ..error import PatternError
|
||||
from .. import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import is_scalar, rotation_matrix_2d, annotations_lt, annotations_eq, rep2key
|
||||
from ..utils import is_scalar, rotation_matrix_2d, layer_t, AutoSlots
|
||||
from ..utils import remove_colinear_vertices, remove_duplicate_vertices, annotations_t
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from ..traits import LockableImpl
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Polygon(Shape):
|
||||
class Polygon(Shape, metaclass=AutoSlots):
|
||||
"""
|
||||
A polygon, consisting of a bunch of vertices (Nx2 ndarray) which specify an
|
||||
implicitly-closed boundary.
|
||||
|
||||
Note that the setter for `Polygon.vertices` creates a copy of the
|
||||
passed vertex coordinates.
|
||||
implicitly-closed boundary, and an offset.
|
||||
|
||||
A `normalized_form(...)` is available, but can be quite slow with lots of vertices.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_vertices',
|
||||
# Inherited
|
||||
'_repetition', '_annotations',
|
||||
)
|
||||
__slots__ = ('_vertices',)
|
||||
|
||||
_vertices: NDArray[numpy.float64]
|
||||
""" Nx2 ndarray of vertices `[[x0, y0], [x1, y1], ...]` """
|
||||
|
||||
# vertices property
|
||||
@property
|
||||
def vertices(self) -> NDArray[numpy.float64]:
|
||||
def vertices(self) -> Any: #TODO mypy#3004 NDArray[numpy.float64]:
|
||||
"""
|
||||
Vertices of the polygon (Nx2 ndarray: `[[x0, y0], [x1, y1], ...]`)
|
||||
|
||||
When setting, note that a copy of the provided vertices will be made,
|
||||
"""
|
||||
return self._vertices
|
||||
|
||||
@vertices.setter
|
||||
def vertices(self, val: ArrayLike) -> None:
|
||||
val = numpy.array(val, dtype=float)
|
||||
val = numpy.array(val, dtype=float) # TODO document that these might not be copied
|
||||
if len(val.shape) < 2 or val.shape[1] != 2:
|
||||
raise PatternError('Vertices must be an Nx2 array')
|
||||
if val.shape[0] < 3:
|
||||
|
|
@ -85,100 +72,61 @@ class Polygon(Shape):
|
|||
raise PatternError('Wrong number of vertices')
|
||||
self.vertices[:, 1] = val
|
||||
|
||||
# Offset property for `Positionable`
|
||||
@property
|
||||
def offset(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
[x, y] offset
|
||||
"""
|
||||
return numpy.zeros(2)
|
||||
|
||||
@offset.setter
|
||||
def offset(self, val: ArrayLike) -> None:
|
||||
if numpy.any(val):
|
||||
raise PatternError('Polygon offset is forced to (0, 0)')
|
||||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('Polygon offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
self._vertices += numpy.atleast_2d(offset)
|
||||
return self
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vertices: ArrayLike,
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
mirrored: Sequence[bool] = (False, False),
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = ()
|
||||
if raw:
|
||||
assert(isinstance(vertices, numpy.ndarray))
|
||||
assert(isinstance(offset, numpy.ndarray))
|
||||
self._vertices = vertices
|
||||
self._offset = offset
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations if annotations is not None else {}
|
||||
self._layer = layer
|
||||
self._dose = dose
|
||||
else:
|
||||
self.vertices = vertices
|
||||
self.offset = offset
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if rotation:
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.layer = layer
|
||||
self.dose = dose
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
[self.mirror(a) for a, do in enumerate(mirrored) if do]
|
||||
self.set_locked(locked)
|
||||
|
||||
@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: Optional[Dict] = None) -> 'Polygon':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
Shape.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._vertices = self._vertices.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self.vertices, other.vertices)
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
other = cast('Polygon', other)
|
||||
if not numpy.array_equal(self.vertices, other.vertices):
|
||||
min_len = min(self.vertices.shape[0], other.vertices.shape[0])
|
||||
eq_mask = self.vertices[:min_len] != other.vertices[:min_len]
|
||||
eq_lt = self.vertices[:min_len] < other.vertices[:min_len]
|
||||
eq_lt_masked = eq_lt[eq_mask]
|
||||
if eq_lt_masked.size > 0:
|
||||
return eq_lt_masked.flat[0]
|
||||
return self.vertices.shape[0] < other.vertices.shape[0]
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
@staticmethod
|
||||
def square(
|
||||
side_length: float,
|
||||
*,
|
||||
rotation: float = 0.0,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
repetition: Repetition | None = None,
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
) -> 'Polygon':
|
||||
"""
|
||||
Draw a square given side_length, centered on the origin.
|
||||
|
|
@ -187,6 +135,8 @@ class Polygon(Shape):
|
|||
side_length: Length of one side
|
||||
rotation: Rotation counterclockwise, in radians
|
||||
offset: Offset, default `(0, 0)`
|
||||
layer: Layer, default `0`
|
||||
dose: Dose, default `1.0`
|
||||
repetition: `Repetition` object, default `None`
|
||||
|
||||
Returns:
|
||||
|
|
@ -197,7 +147,8 @@ class Polygon(Shape):
|
|||
[+1, +1],
|
||||
[+1, -1]], dtype=float)
|
||||
vertices = 0.5 * side_length * norm_square
|
||||
poly = Polygon(vertices, offset=offset, repetition=repetition)
|
||||
poly = Polygon(vertices, offset=offset, layer=layer, dose=dose,
|
||||
repetition=repetition)
|
||||
poly.rotate(rotation)
|
||||
return poly
|
||||
|
||||
|
|
@ -208,7 +159,9 @@ class Polygon(Shape):
|
|||
*,
|
||||
rotation: float = 0,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
repetition: Repetition | None = None,
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
) -> 'Polygon':
|
||||
"""
|
||||
Draw a rectangle with side lengths lx and ly, centered on the origin.
|
||||
|
|
@ -218,6 +171,8 @@ class Polygon(Shape):
|
|||
ly: Length along y (before rotation)
|
||||
rotation: Rotation counterclockwise, in radians
|
||||
offset: Offset, default `(0, 0)`
|
||||
layer: Layer, default `0`
|
||||
dose: Dose, default `1.0`
|
||||
repetition: `Repetition` object, default `None`
|
||||
|
||||
Returns:
|
||||
|
|
@ -227,22 +182,25 @@ class Polygon(Shape):
|
|||
[-lx, +ly],
|
||||
[+lx, +ly],
|
||||
[+lx, -ly]], dtype=float)
|
||||
poly = Polygon(vertices, offset=offset, repetition=repetition)
|
||||
poly = Polygon(vertices, offset=offset, layer=layer, dose=dose,
|
||||
repetition=repetition)
|
||||
poly.rotate(rotation)
|
||||
return poly
|
||||
|
||||
@staticmethod
|
||||
def rect(
|
||||
*,
|
||||
xmin: float | None = None,
|
||||
xctr: float | None = None,
|
||||
xmax: float | None = None,
|
||||
lx: float | None = None,
|
||||
ymin: float | None = None,
|
||||
yctr: float | None = None,
|
||||
ymax: float | None = None,
|
||||
ly: float | None = None,
|
||||
repetition: Repetition | None = None,
|
||||
xmin: Optional[float] = None,
|
||||
xctr: Optional[float] = None,
|
||||
xmax: Optional[float] = None,
|
||||
lx: Optional[float] = None,
|
||||
ymin: Optional[float] = None,
|
||||
yctr: Optional[float] = None,
|
||||
ymax: Optional[float] = None,
|
||||
ly: Optional[float] = None,
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
) -> 'Polygon':
|
||||
"""
|
||||
Draw a rectangle by specifying side/center positions.
|
||||
|
|
@ -259,88 +217,88 @@ class Polygon(Shape):
|
|||
yctr: Center y coordinate
|
||||
ymax: Maximum y coordinate
|
||||
ly: Length along y direction
|
||||
layer: Layer, default `0`
|
||||
dose: Dose, default `1.0`
|
||||
repetition: `Repetition` object, default `None`
|
||||
|
||||
Returns:
|
||||
A Polygon object containing the requested rectangle
|
||||
"""
|
||||
if sum(int(pp is None) for pp in (xmin, xmax, xctr, lx)) != 2:
|
||||
raise PatternError('Exactly two of xmin, xctr, xmax, lx must be provided!')
|
||||
if sum(int(pp is None) for pp in (ymin, ymax, yctr, ly)) != 2:
|
||||
raise PatternError('Exactly two of ymin, yctr, ymax, ly must be provided!')
|
||||
|
||||
if lx is None:
|
||||
if xctr is None:
|
||||
assert xmin is not None
|
||||
assert xmax is not None
|
||||
assert(xmin is not None)
|
||||
assert(xmax is not None)
|
||||
xctr = 0.5 * (xmax + xmin)
|
||||
lx = xmax - xmin
|
||||
elif xmax is None:
|
||||
assert xmin is not None
|
||||
assert xctr is not None
|
||||
lx = 2.0 * (xctr - xmin)
|
||||
assert(xmin is not None)
|
||||
assert(xctr is not None)
|
||||
lx = 2 * (xctr - xmin)
|
||||
elif xmin is None:
|
||||
assert xctr is not None
|
||||
assert xmax is not None
|
||||
lx = 2.0 * (xmax - xctr)
|
||||
assert(xctr is not None)
|
||||
assert(xmax is not None)
|
||||
lx = 2 * (xmax - xctr)
|
||||
else:
|
||||
raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
|
||||
else: # noqa: PLR5501
|
||||
else:
|
||||
if xctr is not None:
|
||||
pass
|
||||
elif xmax is None:
|
||||
assert xmin is not None
|
||||
assert lx is not None
|
||||
assert(xmin is not None)
|
||||
assert(lx is not None)
|
||||
xctr = xmin + 0.5 * lx
|
||||
elif xmin is None:
|
||||
assert xmax is not None
|
||||
assert lx is not None
|
||||
assert(xmax is not None)
|
||||
assert(lx is not None)
|
||||
xctr = xmax - 0.5 * lx
|
||||
else:
|
||||
raise PatternError('Two of xmin, xctr, xmax, lx must be None!')
|
||||
|
||||
if ly is None:
|
||||
if yctr is None:
|
||||
assert ymin is not None
|
||||
assert ymax is not None
|
||||
assert(ymin is not None)
|
||||
assert(ymax is not None)
|
||||
yctr = 0.5 * (ymax + ymin)
|
||||
ly = ymax - ymin
|
||||
elif ymax is None:
|
||||
assert ymin is not None
|
||||
assert yctr is not None
|
||||
ly = 2.0 * (yctr - ymin)
|
||||
assert(ymin is not None)
|
||||
assert(yctr is not None)
|
||||
ly = 2 * (yctr - ymin)
|
||||
elif ymin is None:
|
||||
assert yctr is not None
|
||||
assert ymax is not None
|
||||
ly = 2.0 * (ymax - yctr)
|
||||
assert(yctr is not None)
|
||||
assert(ymax is not None)
|
||||
ly = 2 * (ymax - yctr)
|
||||
else:
|
||||
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
||||
else: # noqa: PLR5501
|
||||
else:
|
||||
if yctr is not None:
|
||||
pass
|
||||
elif ymax is None:
|
||||
assert ymin is not None
|
||||
assert ly is not None
|
||||
assert(ymin is not None)
|
||||
assert(ly is not None)
|
||||
yctr = ymin + 0.5 * ly
|
||||
elif ymin is None:
|
||||
assert ly is not None
|
||||
assert ymax is not None
|
||||
assert(ly is not None)
|
||||
assert(ymax is not None)
|
||||
yctr = ymax - 0.5 * ly
|
||||
else:
|
||||
raise PatternError('Two of ymin, yctr, ymax, ly must be None!')
|
||||
|
||||
poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition)
|
||||
poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr),
|
||||
layer=layer, dose=dose, repetition=repetition)
|
||||
return poly
|
||||
|
||||
@staticmethod
|
||||
def octagon(
|
||||
*,
|
||||
side_length: float | None = None,
|
||||
inner_radius: float | None = None,
|
||||
side_length: Optional[float] = None,
|
||||
inner_radius: Optional[float] = None,
|
||||
regular: bool = True,
|
||||
center: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
) -> 'Polygon':
|
||||
"""
|
||||
Draw an octagon given one of (side length, inradius, circumradius).
|
||||
|
|
@ -358,12 +316,17 @@ class Polygon(Shape):
|
|||
rotation: Rotation counterclockwise, in radians.
|
||||
`0` results in four axis-aligned sides (the long sides of the
|
||||
irregular octagon).
|
||||
layer: Layer, default `0`
|
||||
dose: Dose, default `1.0`
|
||||
repetition: `Repetition` object, default `None`
|
||||
|
||||
Returns:
|
||||
A Polygon object containing the requested octagon
|
||||
"""
|
||||
s = (1 + numpy.sqrt(2)) if regular else 2
|
||||
if regular:
|
||||
s = 1 + numpy.sqrt(2)
|
||||
else:
|
||||
s = 2
|
||||
|
||||
norm_oct = numpy.array([
|
||||
[-1, -s],
|
||||
|
|
@ -381,28 +344,29 @@ class Polygon(Shape):
|
|||
side_length = 2 * inner_radius / s
|
||||
|
||||
vertices = 0.5 * side_length * norm_oct
|
||||
poly = Polygon(vertices, offset=center, repetition=repetition)
|
||||
poly = Polygon(vertices, offset=center, layer=layer, dose=dose, repetition=repetition)
|
||||
poly.rotate(rotation)
|
||||
return poly
|
||||
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = None, # unused # noqa: ARG002
|
||||
max_arclen: float | None = None, # unused # noqa: ARG002
|
||||
) -> list['Polygon']:
|
||||
poly_num_points: int = None, # unused
|
||||
poly_max_arclen: float = None, # unused
|
||||
) -> List['Polygon']:
|
||||
return [copy.deepcopy(self)]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition
|
||||
return numpy.vstack((numpy.min(self.vertices, axis=0),
|
||||
numpy.max(self.vertices, axis=0)))
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
return numpy.vstack((self.offset + numpy.min(self.vertices, axis=0),
|
||||
self.offset + numpy.max(self.vertices, axis=0)))
|
||||
|
||||
def rotate(self, theta: float) -> 'Polygon':
|
||||
if theta != 0:
|
||||
self.vertices = numpy.dot(rotation_matrix_2d(theta), self.vertices.T).T
|
||||
return self
|
||||
|
||||
def mirror(self, axis: int = 0) -> 'Polygon':
|
||||
self.vertices[:, 1 - axis] *= -1
|
||||
def mirror(self, axis: int) -> 'Polygon':
|
||||
self.vertices[:, axis - 1] *= -1
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> 'Polygon':
|
||||
|
|
@ -412,9 +376,8 @@ class Polygon(Shape):
|
|||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
# Note: this function is going to be pretty slow for many-vertexed polygons, relative to
|
||||
# other shapes
|
||||
meanv = self.vertices.mean(axis=0)
|
||||
zeroed_vertices = self.vertices - meanv
|
||||
offset = meanv
|
||||
offset = self.vertices.mean(axis=0) + self.offset
|
||||
zeroed_vertices = self.vertices - offset
|
||||
|
||||
scale = zeroed_vertices.std()
|
||||
normed_vertices = zeroed_vertices / scale
|
||||
|
|
@ -425,21 +388,17 @@ class Polygon(Shape):
|
|||
for v in normed_vertices])
|
||||
|
||||
# Reorder the vertices so that the one with lowest x, then y, comes first.
|
||||
x_min_val = rotated_vertices[:, 0].min()
|
||||
x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0]
|
||||
if x_min_inds.size > 1:
|
||||
y_min_val = rotated_vertices[x_min_inds, 1].min()
|
||||
tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0]
|
||||
start_ind = x_min_inds[tie_breaker]
|
||||
else:
|
||||
start_ind = x_min_inds[0]
|
||||
reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0)
|
||||
x_min = rotated_vertices[:, 0].argmin()
|
||||
if not is_scalar(x_min):
|
||||
y_min = rotated_vertices[x_min, 1].argmin()
|
||||
x_min = x_min[y_min]
|
||||
reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0)
|
||||
|
||||
# TODO: normalize mirroring?
|
||||
|
||||
return ((type(self), reordered_vertices.data.tobytes()),
|
||||
(offset, scale / norm_value, rotation, False),
|
||||
lambda: Polygon(reordered_vertices * norm_value))
|
||||
return ((type(self), reordered_vertices.data.tobytes(), self.layer),
|
||||
(offset, scale / norm_value, rotation, False, self.dose),
|
||||
lambda: Polygon(reordered_vertices * norm_value, layer=self.layer))
|
||||
|
||||
def clean_vertices(self) -> 'Polygon':
|
||||
"""
|
||||
|
|
@ -452,45 +411,37 @@ class Polygon(Shape):
|
|||
return self
|
||||
|
||||
def remove_duplicate_vertices(self) -> 'Polygon':
|
||||
"""
|
||||
'''
|
||||
Removes all consecutive duplicate (repeated) vertices.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
'''
|
||||
self.vertices = remove_duplicate_vertices(self.vertices, closed_path=True)
|
||||
return self
|
||||
|
||||
def remove_colinear_vertices(self) -> 'Polygon':
|
||||
"""
|
||||
'''
|
||||
Removes consecutive co-linear vertices.
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
'''
|
||||
self.vertices = remove_colinear_vertices(self.vertices, closed_path=True)
|
||||
return self
|
||||
|
||||
def lock(self) -> 'Polygon':
|
||||
self.vertices.flags.writeable = False
|
||||
Shape.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self) -> 'Polygon':
|
||||
Shape.unlock(self)
|
||||
self.vertices.flags.writeable = True
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
centroid = self.vertices.mean(axis=0)
|
||||
return f'<Polygon centroid {centroid} v{len(self.vertices)}>'
|
||||
|
||||
def boolean(
|
||||
self,
|
||||
other: Any,
|
||||
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
|
||||
scale: float = 1e6,
|
||||
) -> list['Polygon']:
|
||||
"""
|
||||
Perform a boolean operation using this polygon as the subject.
|
||||
|
||||
Args:
|
||||
other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP.
|
||||
operation: 'union', 'intersection', 'difference', 'xor'.
|
||||
scale: Scaling factor for integer conversion.
|
||||
|
||||
Returns:
|
||||
A list of resulting Polygons.
|
||||
"""
|
||||
from ..utils.boolean import boolean #noqa: PLC0415
|
||||
return boolean([self], other, operation=operation, scale=scale)
|
||||
centroid = self.offset + self.vertices.mean(axis=0)
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<Polygon l{self.layer} centroid {centroid} v{len(self.vertices)}{dose}{locked}>'
|
||||
|
|
|
|||
|
|
@ -1,254 +0,0 @@
|
|||
from typing import Any, cast, Self
|
||||
from collections.abc import Iterator
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, normalized_shape_tuple
|
||||
from .polygon import Polygon
|
||||
from ..error import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t
|
||||
|
||||
|
||||
def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]:
|
||||
arr = numpy.asarray(rects, dtype=float)
|
||||
if arr.ndim != 2 or arr.shape[1] != 4:
|
||||
raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]')
|
||||
if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]):
|
||||
raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax')
|
||||
if arr.shape[0] <= 1:
|
||||
return arr
|
||||
order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0]))
|
||||
return arr[order]
|
||||
|
||||
|
||||
def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None:
|
||||
x0 = numpy.minimum(rects[:, 0], rects[:, 2])
|
||||
x1 = numpy.maximum(rects[:, 0], rects[:, 2])
|
||||
y0 = numpy.minimum(rects[:, 1], rects[:, 3])
|
||||
y1 = numpy.maximum(rects[:, 1], rects[:, 3])
|
||||
rects[:, 0] = x0
|
||||
rects[:, 1] = y0
|
||||
rects[:, 2] = x1
|
||||
rects[:, 3] = y1
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class RectCollection(Shape):
|
||||
"""
|
||||
A collection of axis-aligned rectangles, stored as an Nx4 array of
|
||||
`[xmin, ymin, xmax, ymax]` rows.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_rects',
|
||||
'_repetition', '_annotations',
|
||||
)
|
||||
|
||||
_rects: NDArray[numpy.float64]
|
||||
|
||||
@property
|
||||
def rects(self) -> NDArray[numpy.float64]:
|
||||
return self._rects
|
||||
|
||||
@rects.setter
|
||||
def rects(self, val: ArrayLike) -> None:
|
||||
self._rects = _normalize_rects(val)
|
||||
|
||||
@property
|
||||
def offset(self) -> NDArray[numpy.float64]:
|
||||
return numpy.zeros(2)
|
||||
|
||||
@offset.setter
|
||||
def offset(self, val: ArrayLike) -> None:
|
||||
if numpy.any(val):
|
||||
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||
|
||||
def set_offset(self, val: ArrayLike) -> Self:
|
||||
if numpy.any(val):
|
||||
raise PatternError('RectCollection offset is forced to (0, 0)')
|
||||
return self
|
||||
|
||||
def translate(self, offset: ArrayLike) -> Self:
|
||||
delta = numpy.asarray(offset, dtype=float).reshape(2)
|
||||
self._rects[:, [0, 2]] += delta[0]
|
||||
self._rects[:, [1, 3]] += delta[1]
|
||||
return self
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rects: ArrayLike,
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
) -> None:
|
||||
self.rects = rects
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
if rotation:
|
||||
self.rotate(rotation)
|
||||
if numpy.any(offset):
|
||||
self.translate(offset)
|
||||
|
||||
@classmethod
|
||||
def _from_raw(
|
||||
cls,
|
||||
*,
|
||||
rects: NDArray[numpy.float64],
|
||||
annotations: annotations_t = None,
|
||||
repetition: Repetition | None = None,
|
||||
) -> Self:
|
||||
new = cls.__new__(cls)
|
||||
new._rects = rects
|
||||
new._repetition = repetition
|
||||
new._annotations = annotations
|
||||
return new
|
||||
|
||||
@property
|
||||
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
|
||||
for rect in self._rects:
|
||||
xmin, ymin, xmax, ymax = rect
|
||||
yield numpy.array([
|
||||
[xmin, ymin],
|
||||
[xmin, ymax],
|
||||
[xmax, ymax],
|
||||
[xmax, ymin],
|
||||
], dtype=float)
|
||||
|
||||
def __deepcopy__(self, memo: dict | None = None) -> Self:
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
new._rects = self._rects.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
return new
|
||||
|
||||
def _sorted_rects(self) -> NDArray[numpy.float64]:
|
||||
if self._rects.shape[0] <= 1:
|
||||
return self._rects
|
||||
order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0]))
|
||||
return self._rects[order]
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self._sorted_rects(), other._sorted_rects())
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
|
||||
other = cast('RectCollection', other)
|
||||
self_rects = self._sorted_rects()
|
||||
other_rects = other._sorted_rects()
|
||||
if not numpy.array_equal(self_rects, other_rects):
|
||||
min_len = min(self_rects.shape[0], other_rects.shape[0])
|
||||
eq_mask = self_rects[:min_len] != other_rects[:min_len]
|
||||
eq_lt = self_rects[:min_len] < other_rects[:min_len]
|
||||
eq_lt_masked = eq_lt[eq_mask]
|
||||
if eq_lt_masked.size > 0:
|
||||
return bool(eq_lt_masked.flat[0])
|
||||
return self_rects.shape[0] < other_rects.shape[0]
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = None, # unused # noqa: ARG002
|
||||
max_arclen: float | None = None, # unused # noqa: ARG002
|
||||
) -> list[Polygon]:
|
||||
return [
|
||||
Polygon(
|
||||
vertices=vertices,
|
||||
repetition=copy.deepcopy(self.repetition),
|
||||
annotations=copy.deepcopy(self.annotations),
|
||||
)
|
||||
for vertices in self.polygon_vertices
|
||||
]
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||
if self._rects.size == 0:
|
||||
return None
|
||||
mins = self._rects[:, :2].min(axis=0)
|
||||
maxs = self._rects[:, 2:].max(axis=0)
|
||||
return numpy.vstack((mins, maxs))
|
||||
|
||||
def rotate(self, theta: float) -> Self:
|
||||
quarter_turns = int(numpy.rint(theta / (pi / 2)))
|
||||
if not numpy.isclose(theta, quarter_turns * (pi / 2)):
|
||||
raise PatternError(
|
||||
f'RectCollection cannot rotate by {theta!r} radians; only Manhattan rotations (multiples of pi/2) are supported. '
|
||||
'Explicitly replace the collection with to_polygons(), or call Pattern.polygonize() '
|
||||
'on the pattern containing it (including referenced child patterns) before transforming, '
|
||||
'flattening, or computing hierarchical bounds.'
|
||||
)
|
||||
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]}>'
|
||||
|
|
@ -1,63 +1,57 @@
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Callable
|
||||
from typing import List, Tuple, Callable, TypeVar, Optional, TYPE_CHECKING
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import numpy
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from ..traits import (
|
||||
Copyable, Scalable, FlippableImpl,
|
||||
PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
)
|
||||
from ..traits import (PositionableImpl, LayerableImpl, DoseableImpl,
|
||||
Rotatable, Mirrorable, Copyable, Scalable,
|
||||
PivotableImpl, LockableImpl, RepeatableImpl,
|
||||
AnnotatableImpl)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Polygon
|
||||
|
||||
|
||||
# Type definitions
|
||||
normalized_shape_tuple = tuple[
|
||||
tuple,
|
||||
tuple[NDArray[numpy.float64], float, float, bool],
|
||||
Callable[[], 'Shape'],
|
||||
]
|
||||
normalized_shape_tuple = Tuple[Tuple,
|
||||
Tuple[NDArray[numpy.float64], float, float, bool, float],
|
||||
Callable[[], 'Shape']]
|
||||
|
||||
# ## Module-wide defaults
|
||||
# Default number of points per polygon for shapes
|
||||
DEFAULT_POLY_NUM_VERTICES = 24
|
||||
DEFAULT_POLY_NUM_POINTS = 24
|
||||
|
||||
|
||||
class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
||||
Copyable, Scalable,
|
||||
metaclass=ABCMeta):
|
||||
T = TypeVar('T', bound='Shape')
|
||||
|
||||
|
||||
class Shape(PositionableImpl, LayerableImpl, DoseableImpl, Rotatable, Mirrorable, Copyable, Scalable,
|
||||
PivotableImpl, RepeatableImpl, LockableImpl, AnnotatableImpl, metaclass=ABCMeta):
|
||||
"""
|
||||
Class specifying functions common to all shapes.
|
||||
Abstract class specifying functions common to all shapes.
|
||||
"""
|
||||
__slots__ = () # Children should use AutoSlots or set slots themselves
|
||||
__slots__ = () # Children should use AutoSlots
|
||||
|
||||
#def __copy__(self) -> Self:
|
||||
# cls = self.__class__
|
||||
# new = cls.__new__(cls)
|
||||
# for name in self.__slots__: # type: str
|
||||
# object.__setattr__(new, name, getattr(self, name))
|
||||
# return new
|
||||
identifier: Tuple
|
||||
""" An arbitrary identifier for the shape, usually empty but used by `Pattern.flatten()` """
|
||||
|
||||
#
|
||||
# Methods (abstract)
|
||||
#
|
||||
@abstractmethod
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __lt__(self, other: 'Shape') -> bool:
|
||||
pass
|
||||
def __copy__(self) -> 'Shape':
|
||||
cls = self.__class__
|
||||
new = cls.__new__(cls)
|
||||
for name in self.__slots__: # type: str
|
||||
object.__setattr__(new, name, getattr(self, name))
|
||||
return new
|
||||
|
||||
'''
|
||||
--- Abstract methods
|
||||
'''
|
||||
@abstractmethod
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = None,
|
||||
max_arclen: float | None = None,
|
||||
) -> list['Polygon']:
|
||||
num_vertices: Optional[int] = None,
|
||||
max_arclen: Optional[float] = None,
|
||||
) -> List['Polygon']:
|
||||
"""
|
||||
Returns a list of polygons which approximate the shape.
|
||||
|
||||
|
|
@ -74,9 +68,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
def normalized_form(self: T, norm_value: int) -> 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, rotation, and dose
|
||||
information separated out from the remaining values.
|
||||
|
||||
Args:
|
||||
|
|
@ -91,20 +85,20 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
`(intrinsic, extrinsic, constructor)`. These are further broken down as:
|
||||
`intrinsic`: A tuple of basic types containing all information about the instance that
|
||||
is not contained in 'extrinsic'. Usually, `intrinsic[0] == type(self)`.
|
||||
`extrinsic`: `([x_offset, y_offset], scale, rotation, mirror_across_x_axis)`
|
||||
`extrinsic`: `([x_offset, y_offset], scale, rotation, mirror_across_x_axis, dose)`
|
||||
`constructor`: A callable (no arguments) which returns an instance of `type(self)` with
|
||||
internal state equivalent to `intrinsic`.
|
||||
"""
|
||||
pass
|
||||
|
||||
#
|
||||
# Non-abstract methods
|
||||
#
|
||||
'''
|
||||
---- Non-abstract methods
|
||||
'''
|
||||
def manhattanize_fast(
|
||||
self,
|
||||
grid_x: ArrayLike,
|
||||
grid_y: ArrayLike,
|
||||
) -> list['Polygon']:
|
||||
) -> List['Polygon']:
|
||||
"""
|
||||
Returns a list of polygons with grid-aligned ("Manhattan") edges approximating the shape.
|
||||
|
||||
|
|
@ -121,42 +115,40 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
Returns:
|
||||
List of `Polygon` objects with grid-aligned edges.
|
||||
"""
|
||||
from . import Polygon #noqa: PLC0415
|
||||
from . import Polygon
|
||||
|
||||
gx = numpy.unique(grid_x)
|
||||
gy = numpy.unique(grid_y)
|
||||
|
||||
polygon_contours = []
|
||||
for polygon in self.to_polygons():
|
||||
bounds = polygon.get_bounds_single()
|
||||
bounds = polygon.get_bounds()
|
||||
if bounds is None:
|
||||
continue
|
||||
|
||||
mins, maxs = bounds
|
||||
|
||||
vertex_lists = []
|
||||
p_verts = polygon.vertices
|
||||
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True):
|
||||
p_verts = polygon.vertices + polygon.offset
|
||||
for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0)):
|
||||
dv = v_next - v
|
||||
|
||||
# Find x-index bounds for the line
|
||||
# Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape
|
||||
gxi_range = numpy.digitize([v[0], v_next[0]], gx)
|
||||
gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1))
|
||||
gxi_max = int(numpy.max(gxi_range).clip(0, len(gx)))
|
||||
gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1)
|
||||
gxi_max = numpy.max(gxi_range).clip(0, len(gx))
|
||||
|
||||
if gxi_min < len(gx) - 1:
|
||||
err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min])
|
||||
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
||||
|
||||
if err_xmin >= 0.5:
|
||||
gxi_min += 1
|
||||
|
||||
if gxi_max > 0 and gxi_max < len(gx):
|
||||
err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1])
|
||||
if err_xmax >= 0.5:
|
||||
gxi_max += 1
|
||||
|
||||
if abs(dv[0]) < 1e-20:
|
||||
# Vertical line, don't calculate slope
|
||||
xi = [gxi_min, max(gxi_min, gxi_max - 1)]
|
||||
xi = [gxi_min, gxi_max - 1]
|
||||
ys = numpy.array([v[1], v_next[1]])
|
||||
yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1)
|
||||
err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1])
|
||||
|
|
@ -168,7 +160,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
|
||||
m = dv[1] / dv[0]
|
||||
|
||||
def get_grid_inds(xes: ArrayLike, m: float = m, v: NDArray = v) -> NDArray[numpy.float64]:
|
||||
def get_grid_inds(xes: ArrayLike) -> NDArray[numpy.float64]:
|
||||
ys = m * (xes - v[0]) + v[1]
|
||||
|
||||
# (inds - 1) is the index of the y-grid line below the edge's intersection with the x-grid
|
||||
|
|
@ -183,14 +175,14 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
return inds
|
||||
|
||||
# Find the y indices on all x gridlines
|
||||
xs = gx[int(gxi_min):int(gxi_max)]
|
||||
xs = gx[gxi_min:gxi_max]
|
||||
inds = get_grid_inds(xs)
|
||||
|
||||
# Find y-intersections for x-midpoints
|
||||
xs2 = (xs[:-1] + xs[1:]) / 2
|
||||
inds2 = get_grid_inds(xs2)
|
||||
|
||||
xinds = numpy.rint(numpy.arange(gxi_min, gxi_max - 0.99, 1 / 3)).astype(numpy.int64)
|
||||
xinds = numpy.rint(numpy.arange(gxi_min, gxi_max - 0.99, 1 / 3), dtype=numpy.int64, casting='unsafe')
|
||||
|
||||
# interleave the results
|
||||
yinds = xinds.copy()
|
||||
|
|
@ -205,7 +197,12 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
vertex_lists.append(vlist)
|
||||
polygon_contours.append(numpy.vstack(vertex_lists))
|
||||
|
||||
manhattan_polygons = [Polygon(vertices=contour) for contour in polygon_contours]
|
||||
manhattan_polygons = []
|
||||
for contour in polygon_contours:
|
||||
manhattan_polygons.append(Polygon(
|
||||
vertices=contour,
|
||||
layer=self.layer,
|
||||
dose=self.dose))
|
||||
|
||||
return manhattan_polygons
|
||||
|
||||
|
|
@ -213,7 +210,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
self,
|
||||
grid_x: ArrayLike,
|
||||
grid_y: ArrayLike,
|
||||
) -> list['Polygon']:
|
||||
) -> List['Polygon']:
|
||||
"""
|
||||
Returns a list of polygons with grid-aligned ("Manhattan") edges approximating the shape.
|
||||
|
||||
|
|
@ -252,9 +249,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
Returns:
|
||||
List of `Polygon` objects with grid-aligned edges.
|
||||
"""
|
||||
from . import Polygon #noqa: PLC0415
|
||||
import skimage.measure #noqa: PLC0415
|
||||
import float_raster #noqa: PLC0415
|
||||
from . import Polygon
|
||||
import skimage.measure # type: ignore
|
||||
import float_raster
|
||||
|
||||
grx = numpy.unique(grid_x)
|
||||
gry = numpy.unique(grid_y)
|
||||
|
|
@ -262,19 +259,18 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
polygon_contours = []
|
||||
for polygon in self.to_polygons():
|
||||
# Get rid of unused gridlines (anything not within 2 lines of the polygon bounds)
|
||||
bounds = polygon.get_bounds_single()
|
||||
bounds = polygon.get_bounds()
|
||||
if bounds is None:
|
||||
continue
|
||||
|
||||
mins, maxs = bounds
|
||||
keep_x = numpy.logical_and(grx > mins[0], grx < maxs[0])
|
||||
keep_y = numpy.logical_and(gry > mins[1], gry < maxs[1])
|
||||
# Flood left & rightwards by 2 cells
|
||||
for kk in (keep_x, keep_y):
|
||||
for ss in (1, 2):
|
||||
kk[ss:] += kk[:-ss]
|
||||
kk[:-ss] += kk[ss:]
|
||||
kk[:] = kk > 0
|
||||
for k in (keep_x, keep_y):
|
||||
for s in (1, 2):
|
||||
k[s:] += k[:-s]
|
||||
k[:-s] += k[s:]
|
||||
k = k > 0
|
||||
|
||||
gx = grx[keep_x]
|
||||
gy = gry[keep_y]
|
||||
|
|
@ -285,7 +281,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
offset = (numpy.where(keep_x)[0][0],
|
||||
numpy.where(keep_y)[0][0])
|
||||
|
||||
rastered = float_raster.raster((polygon.vertices).T, gx, gy)
|
||||
rastered = float_raster.raster((polygon.vertices + polygon.offset).T, gx, gy)
|
||||
binary_rastered = (numpy.abs(rastered) >= 0.5)
|
||||
supersampled = binary_rastered.repeat(2, axis=0).repeat(2, axis=1)
|
||||
|
||||
|
|
@ -297,10 +293,23 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl,
|
|||
for contour in contours:
|
||||
# /2 deals with supersampling
|
||||
# +.5 deals with the fact that our 0-edge becomes -.5 in the super-sampled contour output
|
||||
snapped_contour = numpy.rint((contour + .5) / 2).astype(numpy.int64)
|
||||
snapped_contour = numpy.rint((contour + .5) / 2, dtype=numpy.int64, casting='unsafe')
|
||||
vertices = numpy.hstack((grx[snapped_contour[:, None, 0] + offset_i[0]],
|
||||
gry[snapped_contour[:, None, 1] + offset_i[1]]))
|
||||
|
||||
manhattan_polygons.append(Polygon(vertices=vertices))
|
||||
manhattan_polygons.append(Polygon(
|
||||
vertices=vertices,
|
||||
layer=self.layer,
|
||||
dose=self.dose))
|
||||
|
||||
return manhattan_polygons
|
||||
|
||||
def lock(self: T) -> T:
|
||||
PositionableImpl._lock(self)
|
||||
LockableImpl.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self: T) -> T:
|
||||
LockableImpl.unlock(self)
|
||||
PositionableImpl._unlock(self)
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -1,37 +1,33 @@
|
|||
from typing import Self, Any, cast
|
||||
from typing import List, Tuple, Dict, Sequence, Optional, Any
|
||||
import copy
|
||||
import functools
|
||||
|
||||
import numpy
|
||||
from numpy import pi, nan
|
||||
from numpy import pi, inf
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from . import Shape, Polygon, normalized_shape_tuple
|
||||
from ..error import PatternError
|
||||
from .. import PatternError
|
||||
from ..repetition import Repetition
|
||||
from ..traits import PositionableImpl, RotatableImpl
|
||||
from ..utils import is_scalar, get_bit, annotations_t, annotations_lt, annotations_eq, rep2key, SupportsBool
|
||||
from ..traits import RotatableImpl
|
||||
from ..utils import is_scalar, get_bit, normalize_mirror, layer_t, AutoSlots
|
||||
from ..utils import annotations_t
|
||||
from ..traits import LockableImpl
|
||||
|
||||
# Loaded on use:
|
||||
# from freetype import Face
|
||||
# from matplotlib.path import Path
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Text(PositionableImpl, RotatableImpl, Shape):
|
||||
class Text(RotatableImpl, Shape, metaclass=AutoSlots):
|
||||
"""
|
||||
Text (to be printed e.g. as a set of polygons).
|
||||
This is distinct from non-printed Label objects.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_string', '_height', '_mirrored', 'font_path',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations', '_rotation',
|
||||
)
|
||||
__slots__ = ('_string', '_height', '_mirrored', 'font_path')
|
||||
|
||||
_string: str
|
||||
_height: float
|
||||
_mirrored: bool
|
||||
_mirrored: NDArray[numpy.bool_]
|
||||
font_path: str
|
||||
|
||||
# vertices property
|
||||
|
|
@ -54,13 +50,16 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
raise PatternError('Height must be a scalar')
|
||||
self._height = val
|
||||
|
||||
# Mirrored property
|
||||
@property
|
||||
def mirrored(self) -> bool:
|
||||
def mirrored(self) -> Any: #TODO mypy#3004 NDArray[numpy.bool_]:
|
||||
return self._mirrored
|
||||
|
||||
@mirrored.setter
|
||||
def mirrored(self, val: SupportsBool) -> None:
|
||||
self._mirrored = bool(val)
|
||||
def mirrored(self, val: Sequence[bool]) -> None:
|
||||
if is_scalar(val):
|
||||
raise PatternError('Mirrored must be a 2-element list of booleans')
|
||||
self._mirrored = numpy.array(val, dtype=bool, copy=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -70,91 +69,56 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
mirrored: bool = False,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
mirrored: ArrayLike = (False, False),
|
||||
layer: layer_t = 0,
|
||||
dose: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = ()
|
||||
if raw:
|
||||
assert(isinstance(offset, numpy.ndarray))
|
||||
assert(isinstance(mirrored, numpy.ndarray))
|
||||
self._offset = offset
|
||||
self._layer = layer
|
||||
self._dose = dose
|
||||
self._string = string
|
||||
self._height = height
|
||||
self._rotation = rotation
|
||||
self._mirrored = mirrored
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations if annotations is not None else {}
|
||||
else:
|
||||
self.offset = offset
|
||||
self.layer = layer
|
||||
self.dose = dose
|
||||
self.string = string
|
||||
self.height = height
|
||||
self.rotation = rotation
|
||||
self.mirrored = mirrored
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.font_path = font_path
|
||||
self.set_locked(locked)
|
||||
|
||||
@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) -> 'Text':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
Shape.unlock(new)
|
||||
new._offset = self._offset.copy()
|
||||
new._repetition = copy.deepcopy(self._repetition, memo)
|
||||
new._mirrored = copy.deepcopy(self._mirrored, memo)
|
||||
new._annotations = copy.deepcopy(self._annotations)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return (
|
||||
type(self) is type(other)
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
and self.string == other.string
|
||||
and self.height == other.height
|
||||
and self.font_path == other.font_path
|
||||
and self.mirrored == other.mirrored
|
||||
and self.rotation == other.rotation
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
||||
def __lt__(self, other: Shape) -> bool:
|
||||
if type(self) is not type(other):
|
||||
if repr(type(self)) != repr(type(other)):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
return id(type(self)) < id(type(other))
|
||||
other = cast('Text', other)
|
||||
if not self.height == other.height:
|
||||
return self.height < other.height
|
||||
if not self.string == other.string:
|
||||
return self.string < other.string
|
||||
if not self.font_path == other.font_path:
|
||||
return self.font_path < other.font_path
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.mirrored != other.mirrored:
|
||||
return self.mirrored < other.mirrored
|
||||
if self.rotation != other.rotation:
|
||||
return self.rotation < other.rotation
|
||||
if self.repetition != other.repetition:
|
||||
return rep2key(self.repetition) < rep2key(other.repetition)
|
||||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def to_polygons(
|
||||
self,
|
||||
num_vertices: int | None = None, # unused # noqa: ARG002
|
||||
max_arclen: float | None = None, # unused # noqa: ARG002
|
||||
) -> list[Polygon]:
|
||||
poly_num_points: Optional[int] = None, # unused
|
||||
poly_max_arclen: Optional[float] = None, # unused
|
||||
) -> List[Polygon]:
|
||||
all_polygons = []
|
||||
total_advance = 0.0
|
||||
for char in self.string:
|
||||
|
|
@ -162,11 +126,10 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
|
||||
# Move these polygons to the right of the previous letter
|
||||
for xys in raw_polys:
|
||||
poly = Polygon(xys)
|
||||
if self.mirrored:
|
||||
poly.mirror()
|
||||
poly = Polygon(xys, dose=self.dose, layer=self.layer)
|
||||
poly.mirror2d(self.mirrored)
|
||||
poly.scale_by(self.height)
|
||||
poly.translate(self.offset + [total_advance, 0])
|
||||
poly.offset = self.offset + [total_advance, 0]
|
||||
poly.rotate_around(self.offset, self.rotation)
|
||||
all_polygons += [poly]
|
||||
|
||||
|
|
@ -175,58 +138,47 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
|
||||
return all_polygons
|
||||
|
||||
def mirror(self, axis: int = 0) -> Self:
|
||||
self.mirrored = not self.mirrored
|
||||
if axis == 1:
|
||||
self.rotation += pi
|
||||
def mirror(self, axis: int) -> 'Text':
|
||||
self.mirrored[axis] = not self.mirrored[axis]
|
||||
return self
|
||||
|
||||
def scale_by(self, c: float) -> Self:
|
||||
def scale_by(self, c: float) -> 'Text':
|
||||
self.height *= c
|
||||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
rotation = self.rotation % (2 * pi)
|
||||
return ((type(self), self.string, self.font_path),
|
||||
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
|
||||
lambda: Text(
|
||||
string=self.string,
|
||||
height=norm_value,
|
||||
mirror_x, rotation = normalize_mirror(self.mirrored)
|
||||
rotation += self.rotation
|
||||
rotation %= 2 * pi
|
||||
return ((type(self), self.string, self.font_path, self.layer),
|
||||
(self.offset, self.height / norm_value, rotation, mirror_x, self.dose),
|
||||
lambda: Text(string=self.string,
|
||||
height=self.height * norm_value,
|
||||
font_path=self.font_path,
|
||||
rotation=rotation,
|
||||
).mirror2d(across_x=self.mirrored),
|
||||
)
|
||||
mirrored=(mirror_x, False),
|
||||
layer=self.layer))
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||
def get_bounds(self) -> NDArray[numpy.float64]:
|
||||
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
|
||||
# just convert to polygons instead
|
||||
bounds = numpy.array([[+inf, +inf], [-inf, -inf]])
|
||||
polys = self.to_polygons()
|
||||
if not polys:
|
||||
return None
|
||||
|
||||
pbounds = numpy.full((len(polys), 2, 2), nan)
|
||||
for pp, poly in enumerate(polys):
|
||||
pbounds[pp] = poly.get_bounds_nonempty()
|
||||
bounds = numpy.vstack((
|
||||
numpy.min(pbounds[:, 0, :], axis=0),
|
||||
numpy.max(pbounds[:, 1, :], axis=0),
|
||||
))
|
||||
for poly in polys:
|
||||
poly_bounds = poly.get_bounds()
|
||||
bounds[0, :] = numpy.minimum(bounds[0, :], poly_bounds[0, :])
|
||||
bounds[1, :] = numpy.maximum(bounds[1, :], poly_bounds[1, :])
|
||||
|
||||
return bounds
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||
mirrored = ' m{:d}' if self.mirrored else ''
|
||||
return f'<TextShape "{self.string}" o{self.offset} h{self.height:g}{rotation}{mirrored}>'
|
||||
|
||||
|
||||
def get_char_as_polygons(
|
||||
font_path: str,
|
||||
char: str,
|
||||
resolution: float = 48 * 64,
|
||||
) -> tuple[list[NDArray[numpy.float64]], float]:
|
||||
from freetype import Face # type: ignore #noqa: PLC0415
|
||||
from matplotlib.path import Path # type: ignore #noqa: PLC0415
|
||||
) -> Tuple[List[List[List[float]]], float]:
|
||||
from freetype import Face # type: ignore
|
||||
from matplotlib.path import Path # type: ignore
|
||||
|
||||
"""
|
||||
Get a list of polygons representing a single character.
|
||||
|
|
@ -244,7 +196,7 @@ def get_char_as_polygons(
|
|||
'advance' distance (distance from the start of this glyph to the start of the next one)
|
||||
"""
|
||||
if len(char) != 1:
|
||||
raise PatternError('get_char_as_polygons called with non-char')
|
||||
raise Exception('get_char_as_polygons called with non-char')
|
||||
|
||||
face = Face(font_path)
|
||||
face.set_char_size(resolution)
|
||||
|
|
@ -253,8 +205,7 @@ def get_char_as_polygons(
|
|||
outline = slot.outline
|
||||
|
||||
start = 0
|
||||
all_verts_list = []
|
||||
all_codes = []
|
||||
all_verts_list, all_codes = [], []
|
||||
for end in outline.contours:
|
||||
points = outline.points[start:end + 1]
|
||||
points.append(points[0])
|
||||
|
|
@ -262,7 +213,7 @@ def get_char_as_polygons(
|
|||
tags = outline.tags[start:end + 1]
|
||||
tags.append(tags[0])
|
||||
|
||||
segments: list[list[list[float]]] = []
|
||||
segments: List[List[List[float]]] = []
|
||||
for j, point in enumerate(points):
|
||||
# If we already have a segment, add this point to it
|
||||
if j > 0:
|
||||
|
|
@ -299,12 +250,28 @@ def get_char_as_polygons(
|
|||
|
||||
advance = slot.advance.x / resolution
|
||||
|
||||
polygons: list[NDArray[numpy.float64]]
|
||||
if len(all_verts) == 0:
|
||||
polygons = []
|
||||
else:
|
||||
path = Path(all_verts, all_codes)
|
||||
path.should_simplify = False
|
||||
polygons = [numpy.asarray(poly) for poly in path.to_polygons()]
|
||||
polygons = path.to_polygons()
|
||||
|
||||
return polygons, advance
|
||||
|
||||
def lock(self) -> 'Text':
|
||||
self.mirrored.flags.writeable = False
|
||||
Shape.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self) -> 'Text':
|
||||
Shape.unlock(self)
|
||||
self.mirrored.flags.writeable = True
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rotation = f' r°{self.rotation*180/pi:g}' if self.rotation != 0 else ''
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
mirrored = ' m{:d}{:d}'.format(*self.mirrored) if self.mirrored.any() else ''
|
||||
return f'<TextShape "{self.string}" l{self.layer} o{self.offset} h{self.height:g}{rotation}{mirrored}{dose}{locked}>'
|
||||
|
|
|
|||
248
masque/subpattern.py
Normal file
248
masque/subpattern.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""
|
||||
SubPattern provides basic support for nesting Pattern objects within each other, by adding
|
||||
offset, rotation, scaling, and other such properties to the reference.
|
||||
"""
|
||||
#TODO more top-level documentation
|
||||
|
||||
from typing import Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any, TypeVar
|
||||
import copy
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray, ArrayLike
|
||||
|
||||
from .error import PatternError
|
||||
from .utils import is_scalar, AutoSlots, annotations_t
|
||||
from .repetition import Repetition
|
||||
from .traits import (PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl,
|
||||
Mirrorable, PivotableImpl, Copyable, LockableImpl, RepeatableImpl,
|
||||
AnnotatableImpl)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Pattern
|
||||
|
||||
|
||||
S = TypeVar('S', bound='SubPattern')
|
||||
|
||||
|
||||
class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mirrorable,
|
||||
PivotableImpl, Copyable, RepeatableImpl, LockableImpl, AnnotatableImpl,
|
||||
metaclass=AutoSlots):
|
||||
"""
|
||||
SubPattern provides basic support for nesting Pattern objects within each other, by adding
|
||||
offset, rotation, scaling, and associated methods.
|
||||
"""
|
||||
__slots__ = ('_pattern',
|
||||
'_mirrored',
|
||||
'identifier',
|
||||
)
|
||||
|
||||
_pattern: Optional['Pattern']
|
||||
""" The `Pattern` being instanced """
|
||||
|
||||
_mirrored: NDArray[numpy.bool_]
|
||||
""" Whether to mirror the instance across the x and/or y axes. """
|
||||
|
||||
identifier: Tuple[Any, ...]
|
||||
""" Arbitrary identifier, used internally by some `masque` functions. """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pattern: Optional['Pattern'],
|
||||
*,
|
||||
offset: ArrayLike = (0.0, 0.0),
|
||||
rotation: float = 0.0,
|
||||
mirrored: Optional[Sequence[bool]] = None,
|
||||
dose: float = 1.0,
|
||||
scale: float = 1.0,
|
||||
repetition: Optional[Repetition] = None,
|
||||
annotations: Optional[annotations_t] = None,
|
||||
locked: bool = False,
|
||||
identifier: Tuple[Any, ...] = (),
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
pattern: Pattern to reference.
|
||||
offset: (x, y) offset applied to the referenced pattern. Not affected by rotation etc.
|
||||
rotation: Rotation (radians, counterclockwise) relative to the referenced pattern's (0, 0).
|
||||
mirrored: Whether to mirror the referenced pattern across its x and y axes.
|
||||
dose: Scaling factor applied to the dose.
|
||||
scale: Scaling factor applied to the pattern's geometry.
|
||||
repetition: TODO
|
||||
locked: Whether the `SubPattern` is locked after initialization.
|
||||
identifier: Arbitrary tuple, used internally by some `masque` functions.
|
||||
"""
|
||||
LockableImpl.unlock(self)
|
||||
self.identifier = identifier
|
||||
self.pattern = pattern
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.dose = dose
|
||||
self.scale = scale
|
||||
if mirrored is None:
|
||||
mirrored = (False, False)
|
||||
self.mirrored = mirrored
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations if annotations is not None else {}
|
||||
self.set_locked(locked)
|
||||
|
||||
def __copy__(self) -> 'SubPattern':
|
||||
new = SubPattern(pattern=self.pattern,
|
||||
offset=self.offset.copy(),
|
||||
rotation=self.rotation,
|
||||
dose=self.dose,
|
||||
scale=self.scale,
|
||||
mirrored=self.mirrored.copy(),
|
||||
repetition=copy.deepcopy(self.repetition),
|
||||
annotations=copy.deepcopy(self.annotations),
|
||||
locked=self.locked)
|
||||
return new
|
||||
|
||||
def __deepcopy__(self, memo: Dict = None) -> 'SubPattern':
|
||||
memo = {} if memo is None else memo
|
||||
new = copy.copy(self)
|
||||
LockableImpl.unlock(new)
|
||||
new.pattern = copy.deepcopy(self.pattern, memo)
|
||||
new.repetition = copy.deepcopy(self.repetition, memo)
|
||||
new.annotations = copy.deepcopy(self.annotations, memo)
|
||||
new.set_locked(self.locked)
|
||||
return new
|
||||
|
||||
# pattern property
|
||||
@property
|
||||
def pattern(self) -> Optional['Pattern']:
|
||||
return self._pattern
|
||||
|
||||
@pattern.setter
|
||||
def pattern(self, val: Optional['Pattern']) -> None:
|
||||
from .pattern import Pattern
|
||||
if val is not None and not isinstance(val, Pattern):
|
||||
raise PatternError(f'Provided pattern {val} is not a Pattern object or None!')
|
||||
self._pattern = val
|
||||
|
||||
# Mirrored property
|
||||
@property
|
||||
def mirrored(self) -> Any: #TODO mypy#3004 NDArray[numpy.bool_]:
|
||||
return self._mirrored
|
||||
|
||||
@mirrored.setter
|
||||
def mirrored(self, val: ArrayLike) -> None:
|
||||
if is_scalar(val):
|
||||
raise PatternError('Mirrored must be a 2-element list of booleans')
|
||||
self._mirrored = numpy.array(val, dtype=bool, copy=True)
|
||||
|
||||
def as_pattern(self) -> 'Pattern':
|
||||
"""
|
||||
Returns:
|
||||
A copy of self.pattern which has been scaled, rotated, etc. according to this
|
||||
`SubPattern`'s properties.
|
||||
"""
|
||||
assert(self.pattern is not None)
|
||||
pattern = self.pattern.deepcopy().deepunlock()
|
||||
if self.scale != 1:
|
||||
pattern.scale_by(self.scale)
|
||||
if numpy.any(self.mirrored):
|
||||
pattern.mirror2d(self.mirrored)
|
||||
if self.rotation % (2 * pi) != 0:
|
||||
pattern.rotate_around((0.0, 0.0), self.rotation)
|
||||
if numpy.any(self.offset):
|
||||
pattern.translate_elements(self.offset)
|
||||
if self.dose != 1:
|
||||
pattern.scale_element_doses(self.dose)
|
||||
|
||||
if self.repetition is not None:
|
||||
combined = type(pattern)(name='__repetition__')
|
||||
for dd in self.repetition.displacements:
|
||||
temp_pat = pattern.deepcopy()
|
||||
temp_pat.translate_elements(dd)
|
||||
combined.append(temp_pat)
|
||||
pattern = combined
|
||||
|
||||
return pattern
|
||||
|
||||
def rotate(self: S, rotation: float) -> S:
|
||||
self.rotation += rotation
|
||||
if self.repetition is not None:
|
||||
self.repetition.rotate(rotation)
|
||||
return self
|
||||
|
||||
def mirror(self: S, axis: int) -> S:
|
||||
self.mirrored[axis] = not self.mirrored[axis]
|
||||
self.rotation *= -1
|
||||
if self.repetition is not None:
|
||||
self.repetition.mirror(axis)
|
||||
return self
|
||||
|
||||
def get_bounds(self) -> Optional[NDArray[numpy.float64]]:
|
||||
"""
|
||||
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
|
||||
extent of the `SubPattern` in each dimension.
|
||||
Returns `None` if the contained `Pattern` is empty.
|
||||
|
||||
Returns:
|
||||
`[[x_min, y_min], [x_max, y_max]]` or `None`
|
||||
"""
|
||||
if self.pattern is None:
|
||||
return None
|
||||
return self.as_pattern().get_bounds()
|
||||
|
||||
def lock(self: S) -> S:
|
||||
"""
|
||||
Lock the SubPattern, disallowing changes
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
self.mirrored.flags.writeable = False
|
||||
PositionableImpl._lock(self)
|
||||
LockableImpl.lock(self)
|
||||
return self
|
||||
|
||||
def unlock(self: S) -> S:
|
||||
"""
|
||||
Unlock the SubPattern
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
LockableImpl.unlock(self)
|
||||
PositionableImpl._unlock(self)
|
||||
self.mirrored.flags.writeable = True
|
||||
return self
|
||||
|
||||
def deeplock(self: S) -> S:
|
||||
"""
|
||||
Recursively lock the SubPattern and its contained pattern
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
assert(self.pattern is not None)
|
||||
self.lock()
|
||||
self.pattern.deeplock()
|
||||
return self
|
||||
|
||||
def deepunlock(self: S) -> S:
|
||||
"""
|
||||
Recursively unlock the SubPattern and its contained pattern
|
||||
|
||||
This is dangerous unless you have just performed a deepcopy, since
|
||||
the subpattern and its components may be used in more than one once!
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
assert(self.pattern is not None)
|
||||
self.unlock()
|
||||
self.pattern.deepunlock()
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name = self.pattern.name if self.pattern is not None else None
|
||||
rotation = f' r{self.rotation*180/pi:g}' if self.rotation != 0 else ''
|
||||
scale = f' d{self.scale:g}' if self.scale != 1 else ''
|
||||
mirrored = ' m{:d}{:d}'.format(*self.mirrored) if self.mirrored.any() else ''
|
||||
dose = f' d{self.dose:g}' if self.dose != 1 else ''
|
||||
locked = ' L' if self.locked else ''
|
||||
return f'<SubPattern "{name}" at {self.offset}{rotation}{scale}{mirrored}{dose}{locked}>'
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
|
||||
"""
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
"""
|
||||
|
||||
Test fixtures
|
||||
|
||||
"""
|
||||
|
||||
# ruff: noqa: ARG001
|
||||
from typing import Any
|
||||
import numpy
|
||||
|
||||
|
||||
FixtureRequest = Any
|
||||
PRNG = numpy.random.RandomState(12345)
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from masque import Pather, Port
|
||||
from masque.builder.tools import RenderStep
|
||||
|
||||
|
||||
def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
Return lengths for each edge of an implicitly closed vertex loop.
|
||||
"""
|
||||
vv = numpy.asarray(vertices, dtype=float)
|
||||
return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1))
|
||||
|
||||
|
||||
def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None:
|
||||
"""
|
||||
Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`.
|
||||
"""
|
||||
assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol)
|
||||
|
||||
|
||||
def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None:
|
||||
"""
|
||||
Assert that an object's single-shape bounds match `expected`.
|
||||
"""
|
||||
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
|
||||
|
||||
|
||||
def normalized_route_data(data: Any) -> Any:
|
||||
"""
|
||||
Return a deterministic, comparison-friendly representation of route data.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0])))
|
||||
if isinstance(data, list | tuple):
|
||||
return tuple(normalized_route_data(value) for value in data)
|
||||
if isinstance(data, numpy.ndarray):
|
||||
return tuple(normalized_route_data(value) for value in data.tolist())
|
||||
if isinstance(data, numpy.generic):
|
||||
return data.item()
|
||||
try:
|
||||
hash(data)
|
||||
except TypeError:
|
||||
return repr(data)
|
||||
return data
|
||||
|
||||
|
||||
def route_step_signature(step: RenderStep) -> tuple[Any, ...]:
|
||||
"""
|
||||
Return the stable planning-relevant portion of one rendered route step.
|
||||
"""
|
||||
return (
|
||||
step.opcode,
|
||||
tuple(round(float(value), 9) for value in step.start_port.offset),
|
||||
None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9),
|
||||
step.start_port.ptype,
|
||||
tuple(round(float(value), 9) for value in step.end_port.offset),
|
||||
None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9),
|
||||
step.end_port.ptype,
|
||||
normalized_route_data(step.data),
|
||||
)
|
||||
|
||||
|
||||
def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]:
|
||||
"""
|
||||
Return a deterministic signature for a pather route.
|
||||
"""
|
||||
return tuple(route_step_signature(step) for step in pather._paths[portspec])
|
||||
|
||||
|
||||
def route_endpoint(pather: Pather, portspec: str) -> Port:
|
||||
"""
|
||||
Return the endpoint of a routed port, falling back to the live port for empty routes.
|
||||
"""
|
||||
steps = pather._paths[portspec]
|
||||
if not steps:
|
||||
return pather.pattern[portspec]
|
||||
return steps[-1].end_port
|
||||
|
||||
|
||||
def assert_route_endpoint(
|
||||
pather: Pather,
|
||||
portspec: str,
|
||||
expected: Port,
|
||||
*,
|
||||
atol: float = 1e-8,
|
||||
) -> None:
|
||||
"""
|
||||
Assert that a route endpoint matches an expected port pose and ptype.
|
||||
"""
|
||||
actual = route_endpoint(pather, portspec)
|
||||
assert_allclose(actual.offset, expected.offset, atol=atol)
|
||||
if expected.rotation is None:
|
||||
assert actual.rotation is None
|
||||
else:
|
||||
assert actual.rotation is not None
|
||||
assert numpy.isclose(actual.rotation, expected.rotation, atol=atol)
|
||||
assert actual.ptype == expected.ptype
|
||||
|
||||
|
||||
def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None:
|
||||
"""
|
||||
Assert a simple render-step bend budget for route signatures.
|
||||
"""
|
||||
bend_count = sum(1 for step in pather._paths[portspec] if step.kind == 'bend')
|
||||
assert bend_count <= max_bends
|
||||
|
||||
|
||||
def assert_route_deterministic(
|
||||
make_pather: Callable[[], Pather],
|
||||
route: Callable[[Pather], None],
|
||||
portspec: str,
|
||||
) -> None:
|
||||
"""
|
||||
Assert that the same route operation produces the same route signature twice.
|
||||
"""
|
||||
first = make_pather()
|
||||
route(first)
|
||||
first_signature = route_signature(first, portspec)
|
||||
|
||||
second = make_pather()
|
||||
route(second)
|
||||
assert route_signature(second, portspec) == first_signature
|
||||
|
||||
|
||||
def assert_route_failure_does_not_mutate(
|
||||
pather: Pather,
|
||||
route: Callable[[], None],
|
||||
expected_exception: type[BaseException],
|
||||
) -> BaseException:
|
||||
"""
|
||||
Assert that a failing route operation leaves pending route steps untouched.
|
||||
"""
|
||||
before = deepcopy(dict(pather._paths))
|
||||
try:
|
||||
route()
|
||||
except expected_exception as err:
|
||||
assert dict(pather._paths) == before
|
||||
return err
|
||||
raise AssertionError(f'Expected {expected_exception.__name__}')
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
from numpy.testing import assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..abstract import Abstract
|
||||
from ..ports import Port
|
||||
from ..ref import Ref
|
||||
|
||||
|
||||
def test_abstract_init() -> None:
|
||||
ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)}
|
||||
abs_obj = Abstract("test", ports)
|
||||
assert abs_obj.name == "test"
|
||||
assert len(abs_obj.ports) == 2
|
||||
assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied
|
||||
|
||||
|
||||
def test_abstract_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||
# Rotate 90 deg around (0,0)
|
||||
abs_obj.rotate_around((0, 0), pi / 2)
|
||||
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
||||
assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
# Mirror across x axis (axis 0): flips y-offset
|
||||
abs_obj.mirror(0)
|
||||
# (0, 10) mirrored(0) -> (0, -10)
|
||||
# rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2
|
||||
assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_ref_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||
|
||||
# Apply ref transform
|
||||
abs_obj.apply_ref_transform(ref)
|
||||
# Ref order: mirror, rotate, scale, translate
|
||||
|
||||
# 1. mirror (across x: y -> -y)
|
||||
# (10, 0) rot 0 -> (10, 0) rot 0
|
||||
|
||||
# 2. rotate pi/2 around (0,0)
|
||||
# (10, 0) rot 0 -> (0, 10) rot pi/2
|
||||
|
||||
# 3. translate (100, 100)
|
||||
# (0, 10) -> (100, 110)
|
||||
|
||||
assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_ref_transform_scales_offsets() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((10, 0), 0)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
||||
|
||||
abs_obj.apply_ref_transform(ref)
|
||||
|
||||
assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_undo_transform() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True)
|
||||
|
||||
abs_obj.undo_ref_transform(ref)
|
||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_abstract_undo_transform_scales_offsets() -> None:
|
||||
abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)})
|
||||
ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2)
|
||||
|
||||
abs_obj.undo_ref_transform(ref)
|
||||
assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10)
|
||||
assert abs_obj.ports["A"].rotation is not None
|
||||
assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10)
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..error import PatternError
|
||||
from ..shapes import Arc
|
||||
from .helpers import assert_closed_edges_within
|
||||
|
||||
|
||||
def test_arc_init() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
||||
assert_equal(a.radii, [10, 10])
|
||||
assert_equal(a.angles, [0, pi / 2])
|
||||
assert a.width == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('axis', [0, 1])
|
||||
@pytest.mark.parametrize('radii', [(10, 6), (6, 10), (10, 10)])
|
||||
@pytest.mark.parametrize('angle_ref', list(Arc.AngleRef))
|
||||
@pytest.mark.parametrize('rotation', [0, pi / 5])
|
||||
def test_arc_reflection_preserves_caps_and_bounds(axis: int, radii: tuple, angle_ref: Arc.AngleRef, rotation: float) -> None:
|
||||
arc = Arc(radii=radii, angles=(-0.3, 1.1), width=1, angle_ref=angle_ref, rotation=rotation)
|
||||
reflected = arc.deepcopy().mirror(axis)
|
||||
signs = numpy.ones(2)
|
||||
signs[1 - axis] = -1
|
||||
assert_allclose(reflected.get_cap_edges(), arc.get_cap_edges() * signs, atol=1e-12)
|
||||
expected = arc.get_bounds_single() * signs
|
||||
assert_allclose(reflected.get_bounds_single(), numpy.sort(expected, axis=0), atol=1e-12)
|
||||
assert_allclose(reflected.mirror(axis).get_cap_edges(), arc.get_cap_edges(), atol=1e-12)
|
||||
|
||||
def test_arc_to_polygons() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
polys = a.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
|
||||
# Quarter-circle ring section with outer radius 11 and inner radius 9.
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
||||
|
||||
def test_arc_focus_to_polygons() -> None:
|
||||
a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos)
|
||||
polys = a.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
|
||||
focus = numpy.array([8.0, 0.0])
|
||||
cuts = a.get_cap_edges()
|
||||
for angle, cut in zip(a.angles, cuts, strict=True):
|
||||
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||
for point in cut:
|
||||
delta = point - focus
|
||||
assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10)
|
||||
assert numpy.dot(direction, delta) > 0
|
||||
|
||||
def test_arc_circle_focus_matches_center() -> None:
|
||||
center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||
|
||||
assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices,
|
||||
center.to_polygons(num_vertices=32)[0].vertices,
|
||||
atol=1e-10)
|
||||
|
||||
def test_arc_edge_cases() -> None:
|
||||
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
||||
a.to_polygons(num_vertices=64)
|
||||
bounds = a.get_bounds_single()
|
||||
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
||||
|
||||
def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
||||
arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200))
|
||||
bounds = arc.get_bounds_single()
|
||||
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||
|
||||
def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None:
|
||||
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4,
|
||||
offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos)
|
||||
bounds = arc.get_bounds_single()
|
||||
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||
|
||||
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
||||
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
|
||||
with pytest.raises(PatternError, match='valid max_arclen'):
|
||||
arc.to_polygons(num_vertices=24)
|
||||
|
||||
def test_focus_arc_rejects_focus_outside_inner_boundary() -> None:
|
||||
arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos)
|
||||
with pytest.raises(PatternError, match='inside both arc boundary ellipses'):
|
||||
arc.to_polygons(num_vertices=24)
|
||||
|
||||
def test_focus_arc_max_arclen_limits_segments() -> None:
|
||||
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg)
|
||||
assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2)
|
||||
|
||||
def test_arc_rejects_zero_radii_up_front() -> None:
|
||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||
Arc(radii=(0, 5), angles=(0, 1), width=1)
|
||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||
Arc(radii=(5, 0), angles=(0, 1), width=1)
|
||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||
Arc(radii=(0, 0), angles=(0, 1), width=1)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,276 +0,0 @@
|
|||
# ruff: noqa: PLC0415
|
||||
import pytest
|
||||
import numpy
|
||||
from numpy.testing import assert_allclose
|
||||
from masque.pattern import Pattern
|
||||
from masque.shapes.polygon import Polygon
|
||||
from masque.repetition import Grid
|
||||
from masque.library import Library
|
||||
from masque.error import PatternError
|
||||
|
||||
|
||||
def _poly_area(poly: Polygon) -> float:
|
||||
verts = poly.vertices
|
||||
x = verts[:, 0]
|
||||
y = verts[:, 1]
|
||||
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('repeated_clip', [False, True])
|
||||
@pytest.mark.parametrize('nested', [False, True])
|
||||
def test_boolean_expands_repetitions(repeated_clip: bool, nested: bool) -> None:
|
||||
from masque import boolean
|
||||
from masque.repetition import Arbitrary
|
||||
from masque.shapes import RectCollection
|
||||
|
||||
repeated = RectCollection([[0, 0, 2, 2]], repetition=Arbitrary([[0, 0], [10, 0]]))
|
||||
clip = Polygon([[10, 0], [12, 0], [12, 2], [10, 2]])
|
||||
subject, other = (clip, repeated) if repeated_clip else (repeated, clip)
|
||||
result = boolean([[subject]] if nested else subject, [other], operation='intersection')
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[10, 0], [12, 2]])
|
||||
assert _poly_area(result[0]) == 4
|
||||
assert result[0].repetition is None
|
||||
assert_allclose(repeated.rects, [[0, 0, 2, 2]])
|
||||
assert_allclose(repeated.repetition.displacements, [[0, 0], [10, 0]])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['union', 'difference', 'xor'])
|
||||
def test_boolean_single_set_normalizes_overlaps(operation: str) -> None:
|
||||
from masque import boolean
|
||||
|
||||
subject = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]], repetition=Grid(a_vector=(1, 0), a_count=2))
|
||||
for clips in (None, []):
|
||||
result = boolean(subject, clips, operation=operation)
|
||||
assert len(result) == 1
|
||||
assert _poly_area(result[0]) == 6
|
||||
if operation != 'difference':
|
||||
result = boolean([], subject, operation=operation)
|
||||
assert len(result) == 1
|
||||
assert _poly_area(result[0]) == 6
|
||||
|
||||
def test_layer_as_polygons_basic() -> None:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||
assert len(polys) == 1
|
||||
assert isinstance(polys[0], Polygon)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
def test_layer_as_polygons_repetition() -> None:
|
||||
pat = Pattern()
|
||||
rep = Grid(a_vector=(2, 0), a_count=2)
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep)
|
||||
|
||||
polys = pat.layer_as_polygons((1, 0), flatten=False)
|
||||
assert len(polys) == 2
|
||||
# First polygon at (0,0)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
# Second polygon at (2,0)
|
||||
assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]])
|
||||
|
||||
def test_layer_as_polygons_flatten() -> None:
|
||||
lib = Library()
|
||||
|
||||
child = Pattern()
|
||||
child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]])
|
||||
lib['child'] = child
|
||||
|
||||
parent = Pattern()
|
||||
parent.ref('child', offset=(10, 10), rotation=numpy.pi/2)
|
||||
|
||||
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
||||
assert len(polys) == 1
|
||||
# Child vertices are rotated by the ref and then translated by the ref offset.
|
||||
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
||||
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
||||
|
||||
def test_boolean_import_error() -> None:
|
||||
from masque import boolean
|
||||
# If pyclipper is not installed, this should raise ImportError
|
||||
try:
|
||||
import pyclipper # noqa: F401
|
||||
pytest.skip("pyclipper is installed, cannot test ImportError")
|
||||
except ImportError:
|
||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||
boolean([], [], operation='union')
|
||||
|
||||
def test_polygon_boolean_shortcut() -> None:
|
||||
poly = Polygon([[0, 0], [1, 0], [1, 1]])
|
||||
# This should also raise ImportError if pyclipper is missing
|
||||
try:
|
||||
import pyclipper # noqa: F401
|
||||
pytest.skip("pyclipper is installed")
|
||||
except ImportError:
|
||||
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
|
||||
poly.boolean(poly)
|
||||
|
||||
|
||||
def test_boolean_intersection_with_pyclipper() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
result = boolean(
|
||||
[Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])],
|
||||
[Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])],
|
||||
operation='intersection',
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
|
||||
|
||||
def test_polygon_boolean_shortcut_with_pyclipper() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
|
||||
poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||
result = poly.boolean(
|
||||
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
operation='intersection',
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_union_difference_and_xor_with_pyclipper() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
|
||||
rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
|
||||
|
||||
union = boolean([rect_a], [rect_b], operation='union')
|
||||
assert len(union) == 1
|
||||
assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10)
|
||||
assert_allclose(_poly_area(union[0]), 7, atol=1e-10)
|
||||
|
||||
difference = boolean([rect_a], [rect_b], operation='difference')
|
||||
assert len(difference) == 1
|
||||
assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10)
|
||||
assert_allclose(_poly_area(difference[0]), 3, atol=1e-10)
|
||||
|
||||
xor = boolean([rect_a], [rect_b], operation='xor')
|
||||
assert len(xor) == 2
|
||||
assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10)
|
||||
xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor)
|
||||
assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))]
|
||||
|
||||
|
||||
def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
raw_result = boolean(
|
||||
[numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])],
|
||||
numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
operation='intersection',
|
||||
)
|
||||
assert len(raw_result) == 1
|
||||
assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10)
|
||||
|
||||
single_shape_result = boolean(
|
||||
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
|
||||
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
operation='intersection',
|
||||
)
|
||||
assert len(single_shape_result) == 1
|
||||
assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_handles_multi_polygon_inputs() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
result = boolean(
|
||||
[
|
||||
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
|
||||
Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]),
|
||||
],
|
||||
[
|
||||
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
|
||||
Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]),
|
||||
],
|
||||
operation='intersection',
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10)
|
||||
result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result)
|
||||
assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))]
|
||||
|
||||
|
||||
def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
|
||||
result = boolean([outer], [hole], operation='difference')
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
|
||||
assert_allclose(_poly_area(result[0]), 64, atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_nested_hole_and_island_case() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
|
||||
island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]])
|
||||
|
||||
result = boolean([outer, island], [hole], operation='union')
|
||||
|
||||
assert len(result) == 1
|
||||
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
|
||||
assert_allclose(_poly_area(result[0]), 100, atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_empty_inputs_follow_set_semantics() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
|
||||
|
||||
union = boolean([], [rect], operation='union')
|
||||
assert len(union) == 1
|
||||
assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
intersection = boolean([], [rect], operation='intersection')
|
||||
assert intersection == []
|
||||
|
||||
difference = boolean([], [rect], operation='difference')
|
||||
assert difference == []
|
||||
|
||||
xor = boolean([], [rect], operation='xor')
|
||||
assert len(xor) == 1
|
||||
assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
clip_empty_union = boolean([rect], [], operation='union')
|
||||
assert len(clip_empty_union) == 1
|
||||
assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
clip_empty_intersection = boolean([rect], [], operation='intersection')
|
||||
assert clip_empty_intersection == []
|
||||
|
||||
clip_empty_difference = boolean([rect], [], operation='difference')
|
||||
assert len(clip_empty_difference) == 1
|
||||
assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
clip_empty_xor = boolean([rect], [], operation='xor')
|
||||
assert len(clip_empty_xor) == 1
|
||||
assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
|
||||
|
||||
|
||||
def test_boolean_invalid_inputs_raise_pattern_error() -> None:
|
||||
pytest.importorskip("pyclipper")
|
||||
from masque.utils.boolean import boolean
|
||||
|
||||
rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
||||
for bad in (123, object(), [123]):
|
||||
with pytest.raises(PatternError, match='Unsupported type'):
|
||||
boolean([rect], bad, operation='intersection')
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,254 +0,0 @@
|
|||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..builder import MinimumStatus, Pather, RouteFailureDetails
|
||||
from ..builder.utils import ell
|
||||
from ..error import BuildError
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
|
||||
|
||||
def test_builder_public_imports() -> None:
|
||||
from masque import PortPather as TopPortPather
|
||||
from masque import RenderStep as TopRenderStep
|
||||
from masque import RouteError as TopRouteError
|
||||
from masque import RouteFailureDetails as TopRouteFailureDetails
|
||||
from masque import RouteFailurePolicy as TopRouteFailurePolicy
|
||||
from masque import MinimumStatus as TopMinimumStatus
|
||||
from masque.builder import PortPather as BuilderPortPather
|
||||
from masque.builder import RenderStep as BuilderRenderStep
|
||||
from masque.builder import RouteError as BuilderRouteError
|
||||
from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails
|
||||
from masque.builder import RouteFailurePolicy as BuilderRouteFailurePolicy
|
||||
from masque.builder import MinimumStatus as BuilderMinimumStatus
|
||||
|
||||
assert TopPortPather is BuilderPortPather
|
||||
assert TopRenderStep is BuilderRenderStep
|
||||
assert TopRouteError is BuilderRouteError
|
||||
assert TopRouteFailureDetails is BuilderRouteFailureDetails
|
||||
assert TopRouteFailurePolicy is BuilderRouteFailurePolicy
|
||||
assert TopMinimumStatus is BuilderMinimumStatus
|
||||
|
||||
|
||||
def test_route_failure_details_enforces_minimum_status_invariants() -> None:
|
||||
common = {
|
||||
'operation': 'trace',
|
||||
'portspec': 'A',
|
||||
'in_ptype': 'wire',
|
||||
'out_ptype': 'wide',
|
||||
'request': {},
|
||||
'resolved_length': 1,
|
||||
'resolved_jog': None,
|
||||
'cause': 'no route',
|
||||
}
|
||||
|
||||
with pytest.raises(BuildError, match='FOUND requires minimum_length'):
|
||||
RouteFailureDetails(
|
||||
**common,
|
||||
minimum_length=None,
|
||||
minimum_status=MinimumStatus.FOUND,
|
||||
)
|
||||
|
||||
with pytest.raises(BuildError, match='requires minimum_length=None'):
|
||||
RouteFailureDetails(
|
||||
**common,
|
||||
minimum_length=2,
|
||||
minimum_status=MinimumStatus.NO_ROUTE,
|
||||
)
|
||||
|
||||
|
||||
def test_plain_build_error_does_not_include_stacktrace() -> None:
|
||||
assert str(BuildError('plain builder failure')) == 'plain builder failure'
|
||||
|
||||
|
||||
def test_builder_init() -> None:
|
||||
lib = Library()
|
||||
b = Pather(lib, name="mypat")
|
||||
assert b.pattern is lib["mypat"]
|
||||
assert b.library is lib
|
||||
|
||||
|
||||
def test_builder_place() -> None:
|
||||
lib = Library()
|
||||
child = Pattern()
|
||||
child.ports["A"] = Port((0, 0), 0)
|
||||
lib["child"] = child
|
||||
|
||||
b = Pather(lib)
|
||||
b.place("child", offset=(10, 20), port_map={"A": "child_A"})
|
||||
|
||||
assert "child_A" in b.ports
|
||||
assert_equal(b.ports["child_A"].offset, [10, 20])
|
||||
assert "child" in b.pattern.refs
|
||||
|
||||
|
||||
def test_builder_plug() -> None:
|
||||
lib = Library()
|
||||
|
||||
wire = Pattern()
|
||||
wire.ports["in"] = Port((0, 0), 0)
|
||||
wire.ports["out"] = Port((10, 0), pi)
|
||||
lib["wire"] = wire
|
||||
|
||||
b = Pather(lib)
|
||||
b.ports["start"] = Port((100, 100), 0)
|
||||
|
||||
# Plug wire's "in" port into builder's "start" port
|
||||
# Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports
|
||||
# builder start: (100, 100) rotation 0
|
||||
# wire in: (0, 0) rotation 0
|
||||
# wire out: (10, 0) rotation pi
|
||||
# Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg)
|
||||
# so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100)
|
||||
b.plug("wire", map_in={"start": "in"})
|
||||
|
||||
assert "start" in b.ports
|
||||
assert_equal(b.ports["start"].offset, [90, 100])
|
||||
assert b.ports["start"].rotation is not None
|
||||
assert_allclose(b.ports["start"].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_builder_interface() -> None:
|
||||
lib = Library()
|
||||
source = Pattern()
|
||||
source.ports["P1"] = Port((0, 0), 0)
|
||||
lib["source"] = source
|
||||
|
||||
b = Pather.interface("source", library=lib, name="iface")
|
||||
assert "in_P1" in b.ports
|
||||
assert "P1" in b.ports
|
||||
assert b.pattern is lib["iface"]
|
||||
|
||||
|
||||
def test_builder_set_dead() -> None:
|
||||
lib = Library()
|
||||
lib["sub"] = Pattern()
|
||||
b = Pather(lib)
|
||||
b.set_dead()
|
||||
|
||||
b.place("sub")
|
||||
assert not b.pattern.has_refs()
|
||||
|
||||
|
||||
def test_builder_dead_ports() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.ports['A'] = Port((0, 0), 0)
|
||||
b = Pather(lib, pattern=pat)
|
||||
b.set_dead()
|
||||
|
||||
# Attempt to plug a device where ports don't line up
|
||||
# A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference).
|
||||
other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)})
|
||||
|
||||
# This should NOT raise PortError because b is dead
|
||||
b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'})
|
||||
|
||||
# Port A should be removed, and Port B (renamed from D) should be added
|
||||
assert 'A' not in b.ports
|
||||
assert 'B' in b.ports
|
||||
|
||||
# Verify geometry was not added
|
||||
assert not b.pattern.has_refs()
|
||||
assert not b.pattern.has_shapes()
|
||||
|
||||
|
||||
def test_dead_plug_best_effort() -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.ports['A'] = Port((0, 0), 0)
|
||||
b = Pather(lib, pattern=pat)
|
||||
b.set_dead()
|
||||
|
||||
# Device with multiple ports, none of which line up correctly
|
||||
other = Pattern(ports={
|
||||
'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi)
|
||||
'P2': Port((20, 20), pi) # Correct rotation but wrong offset
|
||||
})
|
||||
|
||||
# Try to plug. find_transform will fail.
|
||||
# It should fall back to aligning the first pair ('A' and 'P1').
|
||||
b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'})
|
||||
|
||||
assert 'A' not in b.ports
|
||||
assert 'B' in b.ports
|
||||
|
||||
# Dummy transform aligns A (0,0) with P1 (10,10)
|
||||
# A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi
|
||||
# P2 (20,20) rotation pi:
|
||||
# 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10)
|
||||
# 2. Rotate (10,10) by -pi: (-10,-10)
|
||||
# 3. Translate by s_port.offset (0,0): (-10,-10)
|
||||
assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10)
|
||||
# P2 rot pi + transform rot -pi = 0
|
||||
assert b.ports['B'].rotation is not None
|
||||
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10)
|
||||
|
||||
|
||||
def test_ell_validates_spacing_length() -> None:
|
||||
ports = {
|
||||
'A': Port((0, 0), 0),
|
||||
'B': Port((0, 1), 0),
|
||||
'C': Port((0, 2), 0),
|
||||
}
|
||||
|
||||
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
|
||||
ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3])
|
||||
|
||||
with pytest.raises(BuildError, match='spacing must be scalar or have length 2'):
|
||||
ell(ports, True, 'min_extension', 5, spacing=[])
|
||||
|
||||
|
||||
def test_ell_handles_array_spacing_when_ccw_none() -> None:
|
||||
ports = {
|
||||
'A': Port((0, 0), 0),
|
||||
'B': Port((0, 1), 0),
|
||||
}
|
||||
|
||||
scalar = ell(ports, None, 'min_extension', 5, spacing=0)
|
||||
array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0]))
|
||||
assert scalar == array_zero
|
||||
|
||||
with pytest.raises(BuildError, match='Spacing must be 0 or None'):
|
||||
ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bound_type', ['emin', 'emax', 'min_past_furthest'])
|
||||
@pytest.mark.parametrize(
|
||||
('rotation', 'expected'),
|
||||
[
|
||||
(0, 5),
|
||||
(pi / 2, 7),
|
||||
(pi / 6, 5),
|
||||
(pi / 3, 7),
|
||||
(pi / 4, 5),
|
||||
],
|
||||
)
|
||||
def test_ell_extension_vector_selects_dominant_route_axis(
|
||||
bound_type: str,
|
||||
rotation: float,
|
||||
expected: float,
|
||||
) -> None:
|
||||
result = ell({'A': Port((0, 0), rotation)}, None, bound_type, (5, 7), spacing=0)
|
||||
|
||||
assert_allclose(result['A'], expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bound', [(-1, 2), (1, -2), (numpy.nan, 2), (1, numpy.inf), (1, 2, 3)])
|
||||
def test_ell_rejects_invalid_extension_vector(bound: tuple[float, ...]) -> None:
|
||||
with pytest.raises(BuildError, match='bound|negative'):
|
||||
ell({'A': Port((0, 0), 0)}, None, 'emin', bound, spacing=0)
|
||||
|
||||
|
||||
def test_ell_position_vector_still_projects_onto_route_direction() -> None:
|
||||
result = ell({'A': Port((0, 0), pi)}, None, 'pmax', (5, 7), spacing=0)
|
||||
|
||||
assert_allclose(result['A'], 5)
|
||||
|
||||
|
||||
def test_ell_rejects_invalid_bound_type() -> None:
|
||||
with pytest.raises(BuildError, match='Invalid bound type'):
|
||||
ell({'A': Port((0, 0), 0)}, None, 'nearest', 5, spacing=0)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..shapes import Circle, Polygon
|
||||
|
||||
|
||||
def test_circle_init() -> None:
|
||||
c = Circle(radius=10, offset=(5, 5))
|
||||
assert c.radius == 10
|
||||
assert_equal(c.offset, [5, 5])
|
||||
|
||||
def test_circle_to_polygons() -> None:
|
||||
c = Circle(radius=10)
|
||||
polys = c.to_polygons(num_vertices=32)
|
||||
assert len(polys) == 1
|
||||
assert isinstance(polys[0], Polygon)
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from numpy import pi
|
||||
|
||||
from ..shapes import Arc, Circle, Ellipse
|
||||
from .helpers import assert_closed_edges_within
|
||||
|
||||
|
||||
def test_shape_arclen() -> None:
|
||||
e = Ellipse(radii=(10, 5))
|
||||
polys = e.to_polygons(max_arclen=5)
|
||||
v = polys[0].vertices
|
||||
assert_closed_edges_within(v, 5)
|
||||
assert len(v) > 10
|
||||
|
||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||
polys = a.to_polygons(max_arclen=2)
|
||||
assert_closed_edges_within(polys[0].vertices, 2)
|
||||
|
||||
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
||||
for shape in (
|
||||
Circle(radius=10),
|
||||
Ellipse(radii=(10, 20)),
|
||||
Arc(radii=(10, 20), angles=(0, 1), width=2),
|
||||
):
|
||||
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
|
||||
assert len(polys) == 1
|
||||
assert len(polys[0].vertices) >= 3
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import io
|
||||
import numpy
|
||||
import ezdxf
|
||||
from numpy.testing import assert_allclose
|
||||
from pathlib import Path
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath, Polygon
|
||||
from ..repetition import Grid
|
||||
from ..file import dxf
|
||||
|
||||
|
||||
def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
|
||||
return bool(
|
||||
numpy.allclose(actual, expected)
|
||||
or numpy.allclose(actual, expected[::-1])
|
||||
)
|
||||
|
||||
|
||||
def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool:
|
||||
return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()}
|
||||
|
||||
|
||||
def test_dxf_roundtrip(tmp_path: Path):
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
|
||||
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
pat.polygon("1", vertices=poly_verts)
|
||||
|
||||
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
|
||||
pat.path("2", vertices=path_verts, width=2)
|
||||
|
||||
# Two-point paths remain paths rather than being polygonized.
|
||||
path2_verts = numpy.array([[40, 0], [50, 10]])
|
||||
pat.path("3", vertices=path2_verts, width=0)
|
||||
|
||||
subpat = Pattern()
|
||||
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
lib["sub"] = subpat
|
||||
|
||||
pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3))
|
||||
|
||||
lib["top"] = pat
|
||||
|
||||
dxf_file = tmp_path / "test.dxf"
|
||||
dxf.writefile(lib, "top", dxf_file)
|
||||
|
||||
read_lib, _ = dxf.readfile(dxf_file)
|
||||
|
||||
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||
|
||||
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
|
||||
assert len(polys) >= 1
|
||||
poly_read = polys[0]
|
||||
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
|
||||
|
||||
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
|
||||
assert len(paths) >= 1
|
||||
path_read = paths[0]
|
||||
assert _matches_open_path(path_read.vertices, path_verts)
|
||||
assert path_read.width == 2
|
||||
|
||||
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
|
||||
assert len(paths2) >= 1
|
||||
path2_read = paths2[0]
|
||||
assert _matches_open_path(path2_read.vertices, path2_verts)
|
||||
assert path2_read.width == 0
|
||||
|
||||
assert "sub" in read_lib
|
||||
|
||||
found_grid = False
|
||||
for target, reflist in top_pat.refs.items():
|
||||
if target.upper() == "SUB":
|
||||
for ref in reflist:
|
||||
if isinstance(ref.repetition, Grid):
|
||||
assert ref.repetition.a_count == 2
|
||||
assert ref.repetition.b_count == 3
|
||||
assert_allclose(ref.repetition.a_vector, (10, 0))
|
||||
assert_allclose(ref.repetition.b_vector, (0, 10))
|
||||
found_grid = True
|
||||
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
|
||||
|
||||
def test_dxf_manhattan_precision(tmp_path: Path):
|
||||
lib = Library()
|
||||
sub = Pattern()
|
||||
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
lib["sub"] = sub
|
||||
|
||||
top = Pattern()
|
||||
angle = numpy.pi / 2 # 90 degrees
|
||||
top.ref("sub", offset=(0, 0), rotation=angle,
|
||||
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
|
||||
|
||||
lib["top"] = top
|
||||
|
||||
dxf_file = tmp_path / "precision.dxf"
|
||||
dxf.writefile(lib, "top", dxf_file)
|
||||
|
||||
# Near-integer rotated basis vectors round-trip as a Manhattan Grid.
|
||||
read_lib, _ = dxf.readfile(dxf_file)
|
||||
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||
|
||||
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
||||
ref = read_top.refs[target_name][0]
|
||||
assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation"
|
||||
|
||||
|
||||
def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path):
|
||||
lib = Library()
|
||||
sub = Pattern()
|
||||
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||
lib["sub"] = sub
|
||||
|
||||
top = Pattern()
|
||||
top.ref(
|
||||
"sub",
|
||||
offset=(0, 0),
|
||||
rotation=numpy.pi / 2,
|
||||
repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2),
|
||||
)
|
||||
lib["top"] = top
|
||||
|
||||
dxf_file = tmp_path / "rotated_grid.dxf"
|
||||
dxf.writefile(lib, "top", dxf_file)
|
||||
|
||||
read_lib, _ = dxf.readfile(dxf_file)
|
||||
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||
|
||||
target_name = next(k for k in read_top.refs if k.upper() == "SUB")
|
||||
ref = read_top.refs[target_name][0]
|
||||
assert isinstance(ref.repetition, Grid)
|
||||
actual = ref.repetition.displacements
|
||||
expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements
|
||||
assert_allclose(
|
||||
actual[numpy.lexsort((actual[:, 1], actual[:, 0]))],
|
||||
expected[numpy.lexsort((expected[:, 1], expected[:, 0]))],
|
||||
)
|
||||
|
||||
|
||||
def test_dxf_read_legacy_polyline() -> None:
|
||||
doc = ezdxf.new()
|
||||
msp = doc.modelspace()
|
||||
msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True)
|
||||
|
||||
stream = io.StringIO()
|
||||
doc.write(stream)
|
||||
stream.seek(0)
|
||||
|
||||
read_lib, _ = dxf.read(stream)
|
||||
top_pat = read_lib.get("Model") or list(read_lib.values())[0]
|
||||
|
||||
polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)]
|
||||
assert len(polys) == 1
|
||||
assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]]))
|
||||
|
||||
|
||||
def test_dxf_read_ignores_unreferenced_setup_blocks() -> None:
|
||||
lib = Library({"top": Pattern()})
|
||||
stream = io.StringIO()
|
||||
|
||||
dxf.write(lib, "top", stream)
|
||||
stream.seek(0)
|
||||
|
||||
read_lib, _ = dxf.read(stream)
|
||||
|
||||
assert set(read_lib) == {"Model"}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
"""DXF geometry checks independent of masque's writer/reader round trips."""
|
||||
import io
|
||||
|
||||
import ezdxf
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..file import dxf
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..repetition import Grid
|
||||
from ..shapes import Path, Polygon
|
||||
|
||||
|
||||
def _read(doc: ezdxf.document.Drawing, mode: int = 2, accuracy: float = 0.0) -> Library:
|
||||
stream = io.StringIO()
|
||||
doc.write(stream)
|
||||
stream.seek(0)
|
||||
return dxf.read(stream, polyline_mode=mode, contour_accuracy=accuracy)[0]
|
||||
|
||||
|
||||
def _area(shapes: list) -> float:
|
||||
total = 0.0
|
||||
for shape in shapes:
|
||||
if isinstance(shape, Polygon):
|
||||
xx, yy = shape.vertices.T
|
||||
total += abs(numpy.dot(xx, numpy.roll(yy, 1)) - numpy.dot(yy, numpy.roll(xx, 1))) / 2
|
||||
return total
|
||||
|
||||
|
||||
def _origins(rows: numpy.ndarray) -> numpy.ndarray:
|
||||
rounded = numpy.round(rows, 8)
|
||||
return rounded[numpy.lexsort((rounded[:, 1], rounded[:, 0]))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('legacy', [False, True])
|
||||
@pytest.mark.parametrize('flagged', [False, True])
|
||||
@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4])
|
||||
def test_closed_polylines(legacy: bool, flagged: bool, mode: int) -> None:
|
||||
doc = ezdxf.new()
|
||||
points = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
||||
if not flagged:
|
||||
points.append(points[0])
|
||||
msp = doc.modelspace()
|
||||
if legacy:
|
||||
msp.add_polyline2d(points).close(flagged)
|
||||
else:
|
||||
msp.add_lwpolyline(points, close=flagged)
|
||||
shapes = _read(doc, mode)['Model'].shapes['0']
|
||||
assert len(shapes) == 1
|
||||
assert isinstance(shapes[0], Path if mode == 1 else Polygon)
|
||||
assert _area(shapes) == (0 if mode == 1 else 100)
|
||||
if mode == 1:
|
||||
assert_allclose(shapes[0].vertices[0], shapes[0].vertices[-1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4])
|
||||
@pytest.mark.parametrize('closed', [False, True])
|
||||
def test_join_shuffled_lines(mode: int, closed: bool) -> None:
|
||||
doc = ezdxf.new()
|
||||
segments = [((10, 10), (10, 0)), ((0, 0), (10, 0)), ((0, 10), (10, 10))]
|
||||
if closed:
|
||||
segments.append(((0, 0), (0, 10)))
|
||||
for start, end in segments:
|
||||
doc.modelspace().add_line(start, end)
|
||||
shapes = _read(doc, mode)['Model'].shapes['0']
|
||||
filled = mode == 4 or (closed and mode in (0, 3))
|
||||
assert _area(shapes) == (100 if filled else 0)
|
||||
assert len(shapes) == (len(segments) if mode in (1, 2) else 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('entity', ['SOLID', 'HATCH'])
|
||||
def test_auto_detects_solids_in_child_blocks(entity: str) -> None:
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_lwpolyline([(0, 0), (4, 0), (4, 4)], close=True)
|
||||
block = doc.blocks.new('child')
|
||||
if entity == 'SOLID':
|
||||
block.add_solid([(0, 0), (1, 0), (1, 1)])
|
||||
else:
|
||||
block.add_hatch()
|
||||
doc.modelspace().add_blockref('child', (0, 0))
|
||||
assert isinstance(_read(doc, 0)['Model'].shapes['0'][0], Path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(('accuracy', 'expected'), [(0, 0), (0.005, 0), (0.02, 100)])
|
||||
def test_contour_tolerance(accuracy: float, expected: float) -> None:
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0.01)])
|
||||
shapes = _read(doc, 3, accuracy)['Model'].shapes['0']
|
||||
assert _area(shapes) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [3, 4])
|
||||
def test_nested_contours_resolve_holes_and_islands(mode: int) -> None:
|
||||
doc = ezdxf.new()
|
||||
for lo, hi in [(0, 10), (2, 8), (4, 6)]:
|
||||
doc.modelspace().add_lwpolyline([(lo, lo), (hi, lo), (hi, hi), (lo, hi)], close=True)
|
||||
shapes = _read(doc, mode)['Model'].shapes['0']
|
||||
assert len(shapes) == 2
|
||||
assert _area(shapes) == 68
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [3, 4])
|
||||
def test_layers_and_blocks_are_not_joined(mode: int) -> None:
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_line((0, 0), (10, 0), dxfattribs={'layer': 'a'})
|
||||
doc.modelspace().add_line((10, 0), (0, 10), dxfattribs={'layer': 'b'})
|
||||
doc.blocks.new('child').add_line((0, 10), (0, 0), dxfattribs={'layer': 'a'})
|
||||
lib = _read(doc, mode)
|
||||
assert all(isinstance(shape, Path) for pat in lib.values() for shapes in pat.shapes.values() for shape in shapes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [0, 1, 2, 3, 4])
|
||||
@pytest.mark.parametrize('legacy', [False, True])
|
||||
def test_width_and_degenerate_paths_are_preserved(mode: int, legacy: bool) -> None:
|
||||
doc = ezdxf.new()
|
||||
if legacy:
|
||||
doc.modelspace().add_polyline2d([(0, 0), (2, 0), (2, 2)], dxfattribs={
|
||||
'default_start_width': 2, 'default_end_width': 2,
|
||||
}).close(True)
|
||||
else:
|
||||
doc.modelspace().add_lwpolyline([(0, 0), (2, 0), (2, 2)], close=True, dxfattribs={'const_width': 2})
|
||||
doc.modelspace().add_lwpolyline([(10, 0), (10, 0)])
|
||||
doc.modelspace().add_lwpolyline([(20, 0), (21, 0), (22, 0)])
|
||||
shapes = _read(doc, mode)['Model'].shapes['0']
|
||||
assert len(shapes) == 3
|
||||
assert all(isinstance(shape, Path) for shape in shapes)
|
||||
assert sorted(shape.width for shape in shapes) == [0, 0, 2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [3, 4])
|
||||
@pytest.mark.parametrize('spur', [((10, 0), (12, 0)), ((-2, 0), (0, 0)), ((0, -2), (0, 0))])
|
||||
def test_branch_and_shuffling_preserve_square(mode: int, spur: tuple) -> None:
|
||||
segments = [((0, 0), (10, 0)), ((10, 0), (10, 10)), ((10, 10), (0, 10)),
|
||||
((0, 10), (0, 0)), spur]
|
||||
for order in (segments, [(b, a) for a, b in segments[::-1]]):
|
||||
doc = ezdxf.new()
|
||||
for start, end in order:
|
||||
doc.modelspace().add_line(start, end)
|
||||
shapes = _read(doc, mode)['Model'].shapes['0']
|
||||
assert _area(shapes) == 100
|
||||
assert len(shapes) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [-1, 5, 'closed'])
|
||||
def test_invalid_polyline_mode(mode: int) -> None:
|
||||
with pytest.raises(ValueError, match='polyline_mode'):
|
||||
_read(ezdxf.new(), mode)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [3, 4])
|
||||
def test_merge_uses_even_odd_filling_for_overlapping_contours(mode: int) -> None:
|
||||
doc = ezdxf.new()
|
||||
for xx in (0, 5):
|
||||
doc.modelspace().add_lwpolyline([(xx, 0), (xx + 10, 0), (xx + 10, 10), (xx, 10)], close=True)
|
||||
assert _area(_read(doc, mode)['Model'].shapes['0']) == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [3, 4])
|
||||
def test_contours_below_clipping_precision_remain_paths(mode: int) -> None:
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_lwpolyline([(0, 0), (1e-7, 0), (0, 1e-7)], close=True)
|
||||
shapes = _read(doc, mode)['Model'].shapes['0']
|
||||
assert len(shapes) == 1
|
||||
assert isinstance(shapes[0], Path)
|
||||
assert_allclose(shapes[0].get_bounds_single(), [[0, 0], [1e-7, 1e-7]], atol=1e-15)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('accuracy', [-1, numpy.nan, numpy.inf])
|
||||
def test_invalid_contour_accuracy(accuracy: float) -> None:
|
||||
with pytest.raises(ValueError, match='contour_accuracy'):
|
||||
_read(ezdxf.new(), 3, accuracy)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('angle', [0, numpy.pi / 2, numpy.pi, numpy.pi / 4])
|
||||
@pytest.mark.parametrize('local_grid', [False, True])
|
||||
@pytest.mark.parametrize('mirrored', [False, True])
|
||||
def test_exported_insert_origins(angle: float, local_grid: bool, mirrored: bool) -> None:
|
||||
lib = Library({'leaf': Pattern(), 'top': Pattern()})
|
||||
lib['leaf'].polygon('1', vertices=[(0, 0), (4, 0), (0, 2)])
|
||||
rep = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2)
|
||||
if local_grid:
|
||||
rep.rotate(angle)
|
||||
lib['top'].ref('leaf', offset=(5, 7), rotation=angle, scale=2, mirrored=mirrored, repetition=rep)
|
||||
stream = io.StringIO()
|
||||
dxf.write(lib, 'top', stream)
|
||||
stream.seek(0)
|
||||
inserts = ezdxf.read(stream).modelspace().query('INSERT')
|
||||
origins = [instance.dxf.insert.xyz[:2] for ins in inserts for instance in ins.multi_insert()]
|
||||
assert_allclose(_origins(numpy.asarray(origins)), _origins(rep.displacements + (5, 7)), atol=1e-7)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scales', [(1, 1), (-1, 1), (1, -1), (-1, -1), (2, 2)])
|
||||
@pytest.mark.parametrize('angle', [0, 30, 90])
|
||||
def test_imported_insert_origins(scales: tuple[float, float], angle: float) -> None:
|
||||
doc = ezdxf.new()
|
||||
doc.blocks.new('leaf')
|
||||
insert = doc.modelspace().add_blockref('leaf', (5, 7), dxfattribs={
|
||||
'rotation': angle, 'xscale': scales[0], 'yscale': scales[1],
|
||||
'column_count': 3, 'row_count': 2, 'column_spacing': 20, 'row_spacing': -10,
|
||||
})
|
||||
expected = numpy.asarray([ins.dxf.insert.xyz[:2] for ins in insert.multi_insert()])
|
||||
ref = _read(doc)['Model'].refs['leaf'][0]
|
||||
assert_allclose(_origins(ref.as_transforms()[:, :2]), _origins(expected), atol=1e-7)
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
from numpy import pi
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..shapes import Ellipse
|
||||
|
||||
|
||||
def test_ellipse_init() -> None:
|
||||
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
||||
assert_equal(e.radii, [10, 5])
|
||||
assert_equal(e.offset, [1, 2])
|
||||
assert e.rotation == pi / 4
|
||||
|
||||
def test_ellipse_to_polygons() -> None:
|
||||
e = Ellipse(radii=(10, 5))
|
||||
polys = e.to_polygons(num_vertices=64)
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
||||
|
||||
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
|
||||
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
|
||||
bounds = ellipse.get_bounds_single()
|
||||
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||
|
||||
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
||||
ellipse = Ellipse(radii=(10, 20))
|
||||
ellipse.scale_by(0.5)
|
||||
assert_allclose(ellipse.radii, [5, 10])
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# ruff: noqa
|
||||
# ruff: noqa: ARG001
|
||||
|
||||
|
||||
import dataclasses
|
||||
import pytest # type: ignore
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.typing import NDArray
|
||||
# from numpy.testing import assert_allclose, assert_array_equal
|
||||
|
||||
from .. import Pattern, Arc, Circle
|
||||
|
||||
|
||||
def test_circle_mirror():
|
||||
cc = Circle(radius=4, offset=(10, 20))
|
||||
cc.flip_across(axis=0) # flip across y=0
|
||||
assert cc.offset[0] == 10
|
||||
assert cc.offset[1] == -20
|
||||
assert cc.radius == 4
|
||||
cc.flip_across(axis=1) # flip across x=0
|
||||
assert cc.offset[0] == -10
|
||||
assert cc.offset[1] == -20
|
||||
assert cc.radius == 4
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
from pathlib import Path
|
||||
from typing import cast
|
||||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
|
||||
from ..repetition import Grid, Arbitrary
|
||||
|
||||
def create_test_library(for_gds: bool = False) -> Library:
|
||||
lib = Library()
|
||||
|
||||
pat_poly = Pattern()
|
||||
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
|
||||
lib["polygons"] = pat_poly
|
||||
|
||||
pat_paths = Pattern()
|
||||
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
|
||||
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
|
||||
if for_gds:
|
||||
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
|
||||
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
|
||||
lib["paths"] = pat_paths
|
||||
|
||||
pat_circles = Pattern()
|
||||
if for_gds:
|
||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
|
||||
else:
|
||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
|
||||
lib["circles"] = pat_circles
|
||||
|
||||
pat_refs = Pattern()
|
||||
pat_refs.ref("polygons", offset=(0, 0))
|
||||
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
|
||||
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
|
||||
lib["refs"] = pat_refs
|
||||
|
||||
pat_rep_shapes = Pattern()
|
||||
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
|
||||
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
|
||||
lib["rep_shapes"] = pat_rep_shapes
|
||||
|
||||
if for_gds:
|
||||
lib.wrap_repeated_shapes()
|
||||
|
||||
return lib
|
||||
|
||||
def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
|
||||
from ..file import gdsii
|
||||
lib = create_test_library(for_gds=True)
|
||||
gds_file = tmp_path / "full_test.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
|
||||
for name in lib:
|
||||
assert name in read_lib
|
||||
|
||||
read_paths = read_lib["paths"]
|
||||
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
|
||||
assert p_flush.cap == MPath.Cap.Flush
|
||||
|
||||
p_square = cast("MPath", read_paths.shapes[(2, 1)][0])
|
||||
assert p_square.cap == MPath.Cap.Square
|
||||
|
||||
p_circle = cast("MPath", read_paths.shapes[(2, 2)][0])
|
||||
assert p_circle.cap == MPath.Cap.Circle
|
||||
|
||||
p_custom = cast("MPath", read_paths.shapes[(2, 3)][0])
|
||||
assert p_custom.cap == MPath.Cap.SquareCustom
|
||||
assert p_custom.cap_extensions is not None
|
||||
assert_allclose(p_custom.cap_extensions, (1, 5))
|
||||
|
||||
read_refs = read_lib["refs"]
|
||||
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
|
||||
|
||||
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
|
||||
assert len(arefs) == 1
|
||||
assert isinstance(arefs[0].repetition, Grid)
|
||||
assert arefs[0].repetition.a_count == 3
|
||||
assert arefs[0].repetition.b_count == 2
|
||||
|
||||
# GDS stores repeated shapes through refs created by wrap_repeated_shapes().
|
||||
assert len(read_lib["rep_shapes"].refs) > 0
|
||||
|
||||
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
||||
pytest.importorskip("fatamorgana")
|
||||
from ..file import oasis
|
||||
lib = create_test_library(for_gds=False)
|
||||
oas_file = tmp_path / "full_test.oas"
|
||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||
|
||||
read_lib, _ = oasis.readfile(oas_file)
|
||||
|
||||
for name in lib:
|
||||
assert name in read_lib
|
||||
|
||||
read_circles = read_lib["circles"]
|
||||
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
|
||||
assert read_circles.shapes[(3, 0)][0].radius == 5
|
||||
|
||||
read_paths = read_lib["paths"]
|
||||
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
|
||||
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
|
||||
|
||||
read_rep_shapes = read_lib["rep_shapes"]
|
||||
poly = read_rep_shapes.shapes[(4, 0)][0]
|
||||
assert poly.repetition is not None
|
||||
assert isinstance(poly.repetition, Grid)
|
||||
assert poly.repetition.a_count == 5
|
||||
|
||||
|
||||
def test_gdsii_rect_collection_roundtrip(tmp_path: Path) -> None:
|
||||
from ..file import gdsii
|
||||
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.shapes[(5, 0)].append(
|
||||
RectCollection(
|
||||
rects=[[0, 0, 10, 5], [20, -5, 30, 10]],
|
||||
annotations={'1': ['rects']},
|
||||
)
|
||||
)
|
||||
lib['rects'] = pat
|
||||
|
||||
gds_file = tmp_path / 'rect_collection.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
polys = read_lib['rects'].shapes[(5, 0)]
|
||||
|
||||
assert len(polys) == 2
|
||||
assert all(isinstance(poly, Polygon) for poly in polys)
|
||||
assert_allclose(polys[0].vertices, [[0, 0], [0, 5], [10, 5], [10, 0]])
|
||||
assert_allclose(polys[1].vertices, [[20, -5], [20, 10], [30, 10], [30, -5]])
|
||||
assert polys[0].annotations == {'1': ['rects']}
|
||||
assert polys[1].annotations == {'1': ['rects']}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
from pathlib import Path
|
||||
from typing import cast
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..error import LibraryError
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..file import gdsii
|
||||
from ..shapes import Path as MPath, Polygon
|
||||
|
||||
|
||||
def test_gdsii_roundtrip(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
|
||||
# Simple polygon cell
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["poly_cell"] = pat1
|
||||
|
||||
# Path cell
|
||||
pat2 = Pattern()
|
||||
pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10)
|
||||
lib["path_cell"] = pat2
|
||||
|
||||
# Cell with Ref
|
||||
pat3 = Pattern()
|
||||
pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2)
|
||||
lib["ref_cell"] = pat3
|
||||
|
||||
gds_file = tmp_path / "test.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, info = gdsii.readfile(gds_file)
|
||||
|
||||
assert "poly_cell" in read_lib
|
||||
assert "path_cell" in read_lib
|
||||
assert "ref_cell" in read_lib
|
||||
|
||||
# Check polygon
|
||||
read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0])
|
||||
# GDSII closes polygons, so it might have an extra vertex or different order
|
||||
assert len(read_poly.vertices) >= 4
|
||||
# Check bounds as a proxy for geometry correctness
|
||||
assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]])
|
||||
|
||||
# Check path
|
||||
read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0])
|
||||
assert isinstance(read_path, MPath)
|
||||
assert read_path.width == 10
|
||||
assert_equal(read_path.vertices, [[0, 0], [100, 0]])
|
||||
|
||||
# Check Ref
|
||||
read_ref = read_lib["ref_cell"].refs["poly_cell"][0]
|
||||
assert_equal(read_ref.offset, [50, 50])
|
||||
assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5)
|
||||
|
||||
|
||||
def test_gdsii_annotations(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
# GDS only supports integer keys in range [1, 126] for properties
|
||||
pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]})
|
||||
lib["cell"] = pat
|
||||
|
||||
gds_file = tmp_path / "test_ann.gds"
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
read_lib, _ = gdsii.readfile(gds_file)
|
||||
read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations
|
||||
assert read_ann is not None
|
||||
assert read_ann["1"] == ["hello"]
|
||||
|
||||
|
||||
def test_gdsii_check_valid_names_validates_generator_lengths() -> None:
|
||||
names = (name for name in ("a" * 40,))
|
||||
|
||||
with pytest.raises(LibraryError, match="invalid names"):
|
||||
gdsii.check_valid_names(names)
|
||||
|
||||
|
||||
def test_gdsii_does_not_export_codec_helpers() -> None:
|
||||
assert not hasattr(gdsii, 'read_elements')
|
||||
assert not hasattr(gdsii, 'rint_cast')
|
||||
|
|
@ -1,665 +0,0 @@
|
|||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import klamath
|
||||
import numpy
|
||||
import pytest
|
||||
|
||||
pytest.importorskip('pyarrow')
|
||||
|
||||
from .. import Ref, Label, PatternError
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
from ..repetition import Grid
|
||||
from ..shapes import Path as MPath, Polygon, PolyCollection, RectCollection
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import arrow as gdsii_arrow
|
||||
from tools.generate_gds_perf import write_fixture
|
||||
|
||||
|
||||
if not gdsii_arrow.is_available():
|
||||
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
|
||||
|
||||
|
||||
def test_arrow_materialized_coordinates_are_writable_floats(tmp_path: Path) -> None:
|
||||
original = _make_arrow_test_library()
|
||||
for annotations, layer in [(None, (30, 0)), ({'1': ['prop']}, (31, 0))]:
|
||||
for xx in (0, 10):
|
||||
original['leaf'].polygon(layer, [(xx, 0), (xx + 4, 0), (xx, 3)], annotations=annotations)
|
||||
filename = tmp_path / 'mutable.gds'
|
||||
gdsii.writefile(original, filename, meters_per_unit=1e-9)
|
||||
lib, _ = gdsii_arrow.readfile(filename)
|
||||
arrays = []
|
||||
types = set()
|
||||
for pattern in lib.values():
|
||||
for shapes in pattern.shapes.values():
|
||||
for shape in shapes:
|
||||
types.add(type(shape))
|
||||
if isinstance(shape, RectCollection):
|
||||
coordinates = shape.rects
|
||||
elif isinstance(shape, PolyCollection):
|
||||
coordinates = shape.vertex_lists
|
||||
else:
|
||||
coordinates = shape.vertices
|
||||
arrays.append(coordinates)
|
||||
before = coordinates.copy()
|
||||
shape.translate((0.25, 0.5)).scale_by(1.5)
|
||||
shift = (0.25, 0.5, 0.25, 0.5) if isinstance(shape, RectCollection) else (0.25, 0.5)
|
||||
numpy.testing.assert_allclose(coordinates, (before + shift) * 1.5)
|
||||
if isinstance(shape, MPath) and shape.cap_extensions is not None:
|
||||
arrays.append(shape.cap_extensions)
|
||||
for labels in pattern.labels.values():
|
||||
arrays.extend(label.offset for label in labels)
|
||||
for refs in pattern.refs.values():
|
||||
for ref in refs:
|
||||
arrays.append(ref.offset)
|
||||
ref.translate((0.25, 0.5))
|
||||
if ref.repetition is not None:
|
||||
arrays.extend((ref.repetition.a_vector, ref.repetition.b_vector))
|
||||
ref.repetition.scale_by(1.5)
|
||||
assert {Polygon, PolyCollection, RectCollection, MPath} <= types
|
||||
for array in arrays:
|
||||
assert array.dtype == numpy.float64
|
||||
assert array.flags.writeable
|
||||
|
||||
|
||||
def test_arrow_path_extensions_are_quantized_for_oasis(tmp_path: Path) -> None:
|
||||
pytest.importorskip('fatamorgana')
|
||||
from ..file import oasis # noqa: PLC0415
|
||||
|
||||
source = Library({'top': Pattern().path((1, 0), [(0, 0), (20, 0)], width=4,
|
||||
cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))})
|
||||
gds_path = tmp_path / 'path.gds'
|
||||
gdsii.writefile(source, gds_path, meters_per_unit=1e-9)
|
||||
loaded, _ = gdsii_arrow.readfile(gds_path)
|
||||
path = loaded['top'].shapes[(1, 0)][0]
|
||||
path.scale_by(1.25)
|
||||
exported = oasis.build(loaded, units_per_micron=1000).cells[0].geometry[0]
|
||||
assert exported.extension_start[1] == 1
|
||||
assert exported.extension_end[1] == 6
|
||||
assert isinstance(exported.extension_start[1], int | numpy.integer)
|
||||
assert isinstance(exported.extension_end[1], int | numpy.integer)
|
||||
numpy.testing.assert_allclose(path.cap_extensions, (1.25, 6.25))
|
||||
|
||||
|
||||
def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None:
|
||||
if not annotations:
|
||||
return None
|
||||
return tuple(sorted((key, tuple(values)) for key, values in annotations.items()))
|
||||
|
||||
|
||||
def _coord_key(values: object) -> tuple[int, ...] | tuple[tuple[int, int], ...]:
|
||||
arr = numpy.rint(numpy.asarray(values, dtype=float)).astype(int)
|
||||
if arr.ndim == 1:
|
||||
return tuple(arr.tolist())
|
||||
return tuple(tuple(row.tolist()) for row in arr)
|
||||
|
||||
|
||||
def _canonical_polygon_key(vertices: object) -> tuple[tuple[int, int], ...]:
|
||||
arr = numpy.rint(numpy.asarray(vertices, dtype=float)).astype(int)
|
||||
rows = [tuple(tuple(row.tolist()) for row in numpy.roll(arr, -shift, axis=0)) for shift in range(arr.shape[0])]
|
||||
rev = arr[::-1]
|
||||
rows.extend(tuple(tuple(row.tolist()) for row in numpy.roll(rev, -shift, axis=0)) for shift in range(rev.shape[0]))
|
||||
return min(rows)
|
||||
|
||||
|
||||
def _shape_key(shape: object, layer: tuple[int, int]) -> list[tuple[object, ...]]:
|
||||
if isinstance(shape, MPath):
|
||||
cap_extensions = None if shape.cap_extensions is None else _coord_key(shape.cap_extensions)
|
||||
return [(
|
||||
'path',
|
||||
layer,
|
||||
_coord_key(shape.vertices),
|
||||
_coord_key(shape.offset),
|
||||
int(round(float(shape.width))),
|
||||
shape.cap.name,
|
||||
cap_extensions,
|
||||
_annotations_key(shape.annotations),
|
||||
)]
|
||||
|
||||
keys = []
|
||||
for poly in shape.to_polygons():
|
||||
keys.append((
|
||||
'polygon',
|
||||
layer,
|
||||
_canonical_polygon_key(poly.vertices),
|
||||
_coord_key(poly.offset),
|
||||
_annotations_key(poly.annotations),
|
||||
))
|
||||
return keys
|
||||
|
||||
|
||||
def _ref_keys(target: str, ref: object) -> list[tuple[object, ...]]:
|
||||
keys = []
|
||||
for transform in ref.as_transforms():
|
||||
keys.append((
|
||||
target,
|
||||
_coord_key(transform[:2]),
|
||||
round(float(transform[2]), 8),
|
||||
round(float(transform[4]), 8),
|
||||
bool(int(round(float(transform[3])))),
|
||||
_annotations_key(ref.annotations),
|
||||
))
|
||||
return keys
|
||||
|
||||
|
||||
def _label_key(layer: tuple[int, int], label: object) -> tuple[object, ...]:
|
||||
return (
|
||||
layer,
|
||||
label.string,
|
||||
_coord_key(label.offset),
|
||||
_annotations_key(label.annotations),
|
||||
)
|
||||
|
||||
|
||||
def _pattern_summary(pattern: Pattern) -> dict[str, object]:
|
||||
shape_keys: list[tuple[object, ...]] = []
|
||||
for layer, shapes in pattern.shapes.items():
|
||||
for shape in shapes:
|
||||
shape_keys.extend(_shape_key(shape, layer))
|
||||
|
||||
ref_keys: list[tuple[object, ...]] = []
|
||||
for target, refs in pattern.refs.items():
|
||||
for ref in refs:
|
||||
ref_keys.extend(_ref_keys(target, ref))
|
||||
|
||||
label_keys = [
|
||||
_label_key(layer, label)
|
||||
for layer, labels in pattern.labels.items()
|
||||
for label in labels
|
||||
]
|
||||
|
||||
return {
|
||||
'shapes': sorted(shape_keys),
|
||||
'refs': sorted(ref_keys),
|
||||
'labels': sorted(label_keys),
|
||||
}
|
||||
|
||||
|
||||
def _library_summary(lib: Library) -> dict[str, dict[str, object]]:
|
||||
return {name: _pattern_summary(pattern) for name, pattern in lib.items()}
|
||||
|
||||
|
||||
def _make_arrow_test_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]], annotations={'1': ['leaf-poly']})
|
||||
leaf.polygon((2, 0), vertices=[[40, 0], [50, 0], [50, 10], [40, 10]])
|
||||
leaf.polygon((1, 0), vertices=[[20, 0], [30, 0], [30, 10], [20, 10]])
|
||||
leaf.polygon((1, 0), vertices=[[80, 0], [90, 0], [90, 10], [80, 10]])
|
||||
leaf.polygon((2, 0), vertices=[[60, 0], [70, 0], [70, 10], [60, 10]], annotations={'18': ['leaf-poly-2']})
|
||||
leaf.label((10, 0), string='LEAF', offset=(3, 4), annotations={'10': ['leaf-label']})
|
||||
lib['leaf'] = leaf
|
||||
|
||||
child = Pattern()
|
||||
child.path(
|
||||
(2, 0),
|
||||
vertices=[[0, 0], [15, 5], [30, 5]],
|
||||
width=6,
|
||||
cap=MPath.Cap.SquareCustom,
|
||||
cap_extensions=(2, 4),
|
||||
annotations={'2': ['child-path']},
|
||||
)
|
||||
child.label((11, 0), string='CHILD', offset=(7, 8), annotations={'11': ['child-label']})
|
||||
child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25, annotations={'12': ['child-ref']})
|
||||
lib['child'] = child
|
||||
|
||||
sibling = Pattern()
|
||||
sibling.polygon((3, 0), vertices=[[0, 0], [5, 0], [5, 6], [0, 6]])
|
||||
sibling.label((12, 0), string='SIB', offset=(1, 2), annotations={'13': ['sib-label']})
|
||||
sibling.ref(
|
||||
'leaf',
|
||||
offset=(-50, 60),
|
||||
repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2),
|
||||
annotations={'14': ['sib-ref']},
|
||||
)
|
||||
lib['sibling'] = sibling
|
||||
|
||||
fanout = Pattern()
|
||||
fanout.ref('leaf', offset=(0, 0))
|
||||
fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1)
|
||||
fanout.ref('leaf', offset=(20, 0))
|
||||
fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3))
|
||||
fanout.ref('child', offset=(40, 0), mirrored=True, rotation=numpy.pi / 4, scale=1.2,
|
||||
repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2))
|
||||
fanout.ref('leaf', offset=(50, 0), repetition=Grid(a_vector=(6, 0), a_count=3, b_vector=(0, 8), b_count=2))
|
||||
fanout.ref('leaf', offset=(60, 0), annotations={'19': ['fanout-sref']})
|
||||
fanout.ref('child', offset=(70, 0), repetition=Grid(a_vector=(4, 0), a_count=2, b_vector=(0, 5), b_count=2),
|
||||
annotations={'20': ['fanout-aref']})
|
||||
lib['fanout'] = fanout
|
||||
|
||||
top = Pattern()
|
||||
top.ref('child', offset=(500, 600), annotations={'15': ['top-child-ref']})
|
||||
top.ref('sibling', offset=(-100, 50), rotation=numpy.pi, annotations={'16': ['top-sibling-ref']})
|
||||
top.ref('fanout', offset=(250, -75))
|
||||
top.label((13, 0), string='TOP', offset=(0, 0), annotations={'17': ['top-label']})
|
||||
lib['top'] = top
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def _write_invalid_path_type_fixture(path: Path) -> None:
|
||||
with path.open('wb') as stream:
|
||||
header = klamath.library.FileHeader(
|
||||
name=b'test',
|
||||
user_units_per_db_unit=1.0,
|
||||
meters_per_db_unit=1e-9,
|
||||
)
|
||||
header.write(stream)
|
||||
elem = klamath.elements.Path(
|
||||
layer=(1, 0),
|
||||
path_type=3,
|
||||
width=10,
|
||||
extension=(0, 0),
|
||||
xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32),
|
||||
properties={},
|
||||
)
|
||||
klamath.library.write_struct(stream, name=b'top', elements=[elem])
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
|
||||
|
||||
def test_gdsii_arrow_matches_gdsii_readfile(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_roundtrip.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
canonical_lib, canonical_info = gdsii.readfile(gds_file)
|
||||
arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file)
|
||||
|
||||
assert canonical_info == arrow_info
|
||||
assert _library_summary(canonical_lib) == _library_summary(arrow_lib)
|
||||
|
||||
|
||||
def test_gdsii_arrow_matches_gdsii_readfile_for_gzipped_file(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_roundtrip.gds.gz'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
canonical_lib, canonical_info = gdsii.readfile(gds_file)
|
||||
arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file)
|
||||
|
||||
assert canonical_info == arrow_info
|
||||
assert _library_summary(canonical_lib) == _library_summary(arrow_lib)
|
||||
|
||||
|
||||
def test_gdsii_arrow_readfile_arrow_returns_native_payload(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'many_cells_native.gds'
|
||||
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
|
||||
|
||||
libarr, info = gdsii_arrow.readfile_arrow(gds_file)
|
||||
|
||||
assert info['name'] == manifest.library_name
|
||||
assert libarr['lib_name'].as_py() == manifest.library_name
|
||||
assert len(libarr['cells']) == manifest.cells
|
||||
assert 0 < len(libarr['layers']) <= manifest.layers
|
||||
|
||||
|
||||
def test_gdsii_arrow_readfile_arrow_reads_gzipped_file(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'native_payload.gds.gz'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr, info = gdsii_arrow.readfile_arrow(gds_file)
|
||||
|
||||
assert info['name'] == 'masque-klamath'
|
||||
assert libarr['lib_name'].as_py() == 'masque-klamath'
|
||||
assert len(libarr['cells']) == len(lib)
|
||||
assert len(libarr['layers']) > 0
|
||||
|
||||
|
||||
def test_gdsii_arrow_removed_raw_mode_arg(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'removed_raw_mode.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr, _ = gdsii_arrow.readfile_arrow(gds_file)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
gdsii_arrow.readfile(gds_file, raw_mode=False)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
gdsii_arrow.read_arrow(libarr, raw_mode=False)
|
||||
|
||||
|
||||
def test_gdsii_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid.gds'
|
||||
gds_file.write_bytes(b'not-a-gds')
|
||||
|
||||
script = textwrap.dedent(f"""
|
||||
from masque.file.gdsii import arrow as gdsii_arrow
|
||||
try:
|
||||
gdsii_arrow.readfile({str(gds_file)!r})
|
||||
except Exception as exc:
|
||||
print(type(exc).__module__)
|
||||
print(type(exc).__qualname__)
|
||||
print(exc)
|
||||
else:
|
||||
raise SystemExit('expected gdsii_arrow.readfile() to fail')
|
||||
""")
|
||||
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert 'klamath.basic' in result.stdout
|
||||
assert 'KlamathError' in result.stdout
|
||||
|
||||
|
||||
def test_gdsii_arrow_reads_small_perf_fixture(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'many_cells_smoke.gds'
|
||||
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
|
||||
|
||||
lib, info = gdsii_arrow.readfile(gds_file)
|
||||
|
||||
assert info['name'] == manifest.library_name
|
||||
assert len(lib) == manifest.cells
|
||||
assert 'TOP' in lib
|
||||
assert sum(len(refs) for refs in lib['TOP'].refs.values()) > 0
|
||||
|
||||
|
||||
def test_gdsii_arrow_degenerate_aref_decodes_as_single_transform(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
top = Pattern()
|
||||
top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1))
|
||||
lib['top'] = top
|
||||
|
||||
gds_file = tmp_path / 'degenerate_aref.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
canonical_lib, _ = gdsii.readfile(gds_file)
|
||||
arrow_lib, _ = gdsii_arrow.readfile(gds_file)
|
||||
assert _library_summary(arrow_lib) == _library_summary(canonical_lib)
|
||||
|
||||
decoded_ref = arrow_lib['top'].refs['leaf'][0]
|
||||
assert decoded_ref.repetition is None
|
||||
|
||||
|
||||
def test_gdsii_arrow_plain_srefs_decode_without_arbitrary(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'plain_srefs.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
arrow_lib, _ = gdsii_arrow.readfile(gds_file)
|
||||
fanout = arrow_lib['fanout']
|
||||
|
||||
plain_leaf_refs = [
|
||||
ref
|
||||
for ref in fanout.refs['leaf']
|
||||
if ref.annotations is None and ref.repetition is None
|
||||
]
|
||||
assert len(plain_leaf_refs) == 2
|
||||
assert all(type(ref.repetition) is not Grid for ref in plain_leaf_refs)
|
||||
|
||||
|
||||
def test_gdsii_arrow_degenerate_aref_schema_normalizes_to_sref(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
top = Pattern()
|
||||
top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1))
|
||||
lib['top'] = top
|
||||
|
||||
gds_file = tmp_path / 'degenerate_aref_schema.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top')
|
||||
|
||||
srefs = cells.field('srefs')[top_index].as_py()
|
||||
arefs = cells.field('arefs')[top_index].as_py()
|
||||
|
||||
assert len(srefs) == 1
|
||||
assert len(arefs) == 0
|
||||
assert cell_names[srefs[0]['target']] == 'leaf'
|
||||
|
||||
|
||||
def test_gdsii_arrow_boundary_batch_schema(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_batches.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
layer_table = [
|
||||
((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF)
|
||||
for layer in libarr['layers'].values.to_numpy()
|
||||
]
|
||||
|
||||
leaf_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'leaf')
|
||||
|
||||
rect_batches = cells.field('rect_batches')[leaf_index].as_py()
|
||||
boundary_batches = cells.field('boundary_batches')[leaf_index].as_py()
|
||||
boundary_props = cells.field('boundary_props')[leaf_index].as_py()
|
||||
|
||||
assert len(rect_batches) == 2
|
||||
assert len(boundary_batches) == 0
|
||||
assert len(boundary_props) == 2
|
||||
|
||||
rects_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in rect_batches}
|
||||
assert rects_by_layer[(1, 0)]['rects'] == [20, 0, 30, 10, 80, 0, 90, 10]
|
||||
assert rects_by_layer[(2, 0)]['rects'] == [40, 0, 50, 10]
|
||||
|
||||
props_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in boundary_props}
|
||||
assert sorted(props_by_layer) == [(1, 0), (2, 0)]
|
||||
assert props_by_layer[(1, 0)]['properties'][0]['value'] == 'leaf-poly'
|
||||
assert props_by_layer[(2, 0)]['properties'][0]['value'] == 'leaf-poly-2'
|
||||
|
||||
|
||||
def test_gdsii_arrow_rect_batch_schema_for_mixed_layer(tmp_path: Path) -> None:
|
||||
lib = Library()
|
||||
top = Pattern()
|
||||
top.shapes[(1, 0)].append(RectCollection(rects=[[0, 0, 10, 10], [20, 0, 30, 10], [40, 0, 50, 10], [60, 0, 70, 10]]))
|
||||
top.polygon((1, 0), vertices=[[80, 0], [85, 10], [90, 0]])
|
||||
top.polygon((1, 0), vertices=[[100, 0], [105, 10], [110, 0]])
|
||||
lib['top'] = top
|
||||
|
||||
gds_file = tmp_path / 'arrow_rect_batches.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
layer_table = [
|
||||
((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF)
|
||||
for layer in libarr['layers'].values.to_numpy()
|
||||
]
|
||||
top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top')
|
||||
|
||||
rect_batches = cells.field('rect_batches')[top_index].as_py()
|
||||
boundary_batches = cells.field('boundary_batches')[top_index].as_py()
|
||||
|
||||
assert len(rect_batches) == 1
|
||||
assert tuple(layer_table[rect_batches[0]['layer']]) == (1, 0)
|
||||
assert rect_batches[0]['rects'] == [
|
||||
0, 0, 10, 10,
|
||||
20, 0, 30, 10,
|
||||
40, 0, 50, 10,
|
||||
60, 0, 70, 10,
|
||||
]
|
||||
|
||||
assert len(boundary_batches) == 1
|
||||
assert tuple(layer_table[boundary_batches[0]['layer']]) == (1, 0)
|
||||
assert boundary_batches[0]['vertex_offsets'] == [0, 3]
|
||||
|
||||
|
||||
def test_gdsii_arrow_ref_schema(tmp_path: Path) -> None:
|
||||
lib = _make_arrow_test_library()
|
||||
gds_file = tmp_path / 'arrow_ref_batches.gds'
|
||||
gdsii.writefile(lib, gds_file, meters_per_unit=1e-9)
|
||||
|
||||
libarr = gdsii_arrow._read_to_arrow(gds_file)[0]
|
||||
cells = libarr['cells'].values
|
||||
cell_ids = cells.field('id').to_numpy()
|
||||
cell_names = libarr['cell_names'].as_py()
|
||||
|
||||
fanout_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'fanout')
|
||||
|
||||
srefs = cells.field('srefs')[fanout_index].as_py()
|
||||
arefs = cells.field('arefs')[fanout_index].as_py()
|
||||
sref_props = cells.field('sref_props')[fanout_index].as_py()
|
||||
aref_props = cells.field('aref_props')[fanout_index].as_py()
|
||||
|
||||
sref_target_ids = [entry['target'] for entry in srefs]
|
||||
sref_targets = [cell_names[target] for target in sref_target_ids]
|
||||
assert sorted(sref_targets) == ['child', 'leaf', 'leaf']
|
||||
assert sref_target_ids == sorted(sref_target_ids)
|
||||
sref_by_target = {}
|
||||
for entry in srefs:
|
||||
sref_by_target.setdefault(cell_names[entry['target']], []).append(entry)
|
||||
assert [entry['invert_y'] for entry in sref_by_target['child']] == [True]
|
||||
assert [entry['scale'] for entry in sref_by_target['child']] == pytest.approx([1.1])
|
||||
assert len(sref_by_target['leaf']) == 2
|
||||
|
||||
aref_target_ids = [entry['target'] for entry in arefs]
|
||||
aref_targets = [cell_names[target] for target in aref_target_ids]
|
||||
assert sorted(aref_targets) == ['child', 'leaf', 'leaf']
|
||||
assert aref_target_ids == sorted(aref_target_ids)
|
||||
aref_by_target = {}
|
||||
for entry in arefs:
|
||||
aref_by_target.setdefault(cell_names[entry['target']], []).append(entry)
|
||||
assert [entry['invert_y'] for entry in aref_by_target['child']] == [True]
|
||||
assert [entry['scale'] for entry in aref_by_target['child']] == pytest.approx([1.2])
|
||||
assert len(aref_by_target['leaf']) == 2
|
||||
|
||||
assert len(sref_props) == 1
|
||||
assert cell_names[sref_props[0]['target']] == 'leaf'
|
||||
assert sref_props[0]['properties'][0]['value'] == 'fanout-sref'
|
||||
|
||||
assert len(aref_props) == 1
|
||||
assert cell_names[aref_props[0]['target']] == 'child'
|
||||
assert aref_props[0]['properties'][0]['value'] == 'fanout-aref'
|
||||
|
||||
|
||||
def test_gdsii_arrow_invalid_path_type_matches_gdsii(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid_path_type.gds'
|
||||
_write_invalid_path_type_fixture(gds_file)
|
||||
|
||||
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
|
||||
gdsii.readfile(gds_file)
|
||||
|
||||
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
|
||||
gdsii_arrow.readfile(gds_file)
|
||||
|
||||
|
||||
def test_raw_ref_grid_label_constructors_match_public() -> None:
|
||||
raw_grid = Grid._from_raw(
|
||||
a_vector=numpy.array([20, 0]),
|
||||
a_count=3,
|
||||
b_vector=numpy.array([0, 30]),
|
||||
b_count=2,
|
||||
)
|
||||
public_grid = Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2)
|
||||
assert raw_grid == public_grid
|
||||
|
||||
raw_poly = Polygon._from_raw(
|
||||
vertices=numpy.array([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]),
|
||||
annotations={'1': ['poly']},
|
||||
)
|
||||
public_poly = Polygon(
|
||||
vertices=[[0, 0], [5, 0], [5, 5], [0, 5]],
|
||||
annotations={'1': ['poly']},
|
||||
)
|
||||
assert raw_poly == public_poly
|
||||
|
||||
raw_poly_collection = PolyCollection._from_raw(
|
||||
vertex_lists=numpy.array([
|
||||
[0.0, 0.0], [2.0, 0.0], [2.0, 2.0],
|
||||
[10.0, 10.0], [12.0, 10.0], [12.0, 12.0],
|
||||
]),
|
||||
vertex_offsets=numpy.array([0, 3], dtype=numpy.uint32),
|
||||
annotations={'2': ['pc']},
|
||||
)
|
||||
public_poly_collection = PolyCollection(
|
||||
vertex_lists=[[0, 0], [2, 0], [2, 2], [10, 10], [12, 10], [12, 12]],
|
||||
vertex_offsets=[0, 3],
|
||||
annotations={'2': ['pc']},
|
||||
)
|
||||
assert raw_poly_collection == public_poly_collection
|
||||
assert [tuple(s.indices(len(raw_poly_collection.vertex_lists))) for s in raw_poly_collection.vertex_slices] == [(0, 3, 1), (3, 6, 1)]
|
||||
|
||||
raw_rect_collection = RectCollection._from_raw(
|
||||
rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]),
|
||||
annotations={'3': ['rects']},
|
||||
)
|
||||
public_rect_collection = RectCollection(
|
||||
rects=[[0, 0, 5, 5], [10, 10, 12, 12]],
|
||||
annotations={'3': ['rects']},
|
||||
)
|
||||
assert raw_rect_collection == public_rect_collection
|
||||
|
||||
raw_ref_empty = Ref._from_raw(
|
||||
offset=numpy.array([100, 200]),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=False,
|
||||
scale=1.0,
|
||||
repetition=None,
|
||||
annotations=None,
|
||||
)
|
||||
public_ref_empty = Ref(
|
||||
offset=(100, 200),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=False,
|
||||
scale=1.0,
|
||||
repetition=None,
|
||||
annotations=None,
|
||||
)
|
||||
assert raw_ref_empty.annotations is None
|
||||
assert raw_ref_empty == public_ref_empty
|
||||
|
||||
raw_ref = Ref._from_raw(
|
||||
offset=numpy.array([100, 200]),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=True,
|
||||
scale=1.25,
|
||||
repetition=raw_grid,
|
||||
annotations={'12': ['child-ref']},
|
||||
)
|
||||
public_ref = Ref(
|
||||
offset=(100, 200),
|
||||
rotation=numpy.pi / 2,
|
||||
mirrored=True,
|
||||
scale=1.25,
|
||||
repetition=public_grid,
|
||||
annotations={'12': ['child-ref']},
|
||||
)
|
||||
assert raw_ref == public_ref
|
||||
assert numpy.array_equal(raw_ref.as_transforms(), public_ref.as_transforms())
|
||||
|
||||
raw_label_empty = Label._from_raw(
|
||||
'LEAF',
|
||||
offset=numpy.array([3, 4]),
|
||||
annotations=None,
|
||||
)
|
||||
public_label_empty = Label(
|
||||
'LEAF',
|
||||
offset=(3, 4),
|
||||
annotations=None,
|
||||
)
|
||||
assert raw_label_empty.annotations is None
|
||||
assert raw_label_empty == public_label_empty
|
||||
|
||||
raw_label = Label._from_raw(
|
||||
'LEAF',
|
||||
offset=numpy.array([3, 4]),
|
||||
annotations={'10': ['leaf-label']},
|
||||
)
|
||||
public_label = Label(
|
||||
'LEAF',
|
||||
offset=(3, 4),
|
||||
annotations={'10': ['leaf-label']},
|
||||
)
|
||||
assert raw_label == public_label
|
||||
assert numpy.array_equal(raw_label.get_bounds_single(), public_label.get_bounds_single())
|
||||
|
|
@ -1,580 +0,0 @@
|
|||
from pathlib import Path
|
||||
import gzip
|
||||
import io
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import lazy as gdsii_lazy
|
||||
from ..file.gdsii import writer as gdsii_writer
|
||||
from ..file.utils import preflight_source_aware
|
||||
from ..error import LibraryError
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
from ..library import (
|
||||
IBorrowing, IMaterializable, LayerMappedView, LazyLibrary, Library, LibraryView,
|
||||
OverlayLibrary, PortLoadView,
|
||||
)
|
||||
|
||||
|
||||
def _make_lazy_port_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.label(layer=(10, 0), string='A:type1 0', offset=(5, 0))
|
||||
lib['leaf'] = leaf
|
||||
|
||||
child = Pattern()
|
||||
child.ref('leaf', offset=(10, 20), rotation=numpy.pi / 2)
|
||||
lib['child'] = child
|
||||
|
||||
top = Pattern()
|
||||
top.ref('child', offset=(100, 200))
|
||||
lib['top'] = top
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def test_gdsii_write_requires_units_without_gds_metadata() -> None:
|
||||
lib = Library({'top': Pattern()})
|
||||
lib.library_info = None # type: ignore[attr-defined]
|
||||
|
||||
with pytest.raises(LibraryError, match='meters_per_unit is required'):
|
||||
gdsii.write(lib, io.BytesIO())
|
||||
|
||||
|
||||
def test_gdsii_write_plain_library_defaults() -> None:
|
||||
lib = _make_lazy_port_library()
|
||||
stream = io.BytesIO()
|
||||
gdsii.write(lib, stream, 1e-9)
|
||||
stream.seek(0)
|
||||
_roundtrip, info = gdsii.read(stream)
|
||||
assert info == {
|
||||
'name': 'masque-klamath',
|
||||
'meters_per_unit': 1e-9,
|
||||
'logical_units_per_unit': 1,
|
||||
}
|
||||
|
||||
|
||||
def test_gdsii_lazy_write_materializes_transiently() -> None:
|
||||
lib = LazyLibrary()
|
||||
lib['top'] = Pattern()
|
||||
|
||||
stream = io.BytesIO()
|
||||
gdsii.write(
|
||||
lib,
|
||||
stream,
|
||||
meters_per_unit=1e-9,
|
||||
logical_units_per_unit=1,
|
||||
library_name='transient',
|
||||
)
|
||||
|
||||
assert not lib.cache
|
||||
stream.seek(0)
|
||||
roundtrip, info = gdsii.read(stream)
|
||||
assert set(roundtrip) == {'top'}
|
||||
assert info['name'] == 'transient'
|
||||
|
||||
|
||||
def test_gdsii_raw_copy_provenance_cycle_falls_back() -> None:
|
||||
class SelfBorrowingView(LibraryView, IBorrowing):
|
||||
def borrowed_sources(self) -> tuple['SelfBorrowingView', ...]:
|
||||
return (self,)
|
||||
|
||||
def source_cell(self, name: str) -> tuple['SelfBorrowingView', str] | None:
|
||||
return self, name
|
||||
|
||||
view = SelfBorrowingView({'top': Pattern()})
|
||||
|
||||
assert gdsii_writer._resolve_raw_struct(view, 'top') is None
|
||||
|
||||
|
||||
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-lazy')
|
||||
|
||||
lib, info = gdsii_lazy.readfile(gds_file)
|
||||
|
||||
assert info['name'] == 'classic-lazy'
|
||||
assert lib.source_order() == ('leaf', 'child', 'top')
|
||||
assert lib.child_graph(dangling='ignore') == {
|
||||
'leaf': set(),
|
||||
'child': {'leaf'},
|
||||
'top': {'child'},
|
||||
}
|
||||
assert lib.parent_graph() == {
|
||||
'leaf': {'child'},
|
||||
'child': {'top'},
|
||||
'top': set(),
|
||||
}
|
||||
assert lib.tops() == ['top']
|
||||
global_refs = lib.find_refs_global('leaf')
|
||||
assert_allclose(global_refs[('top', 'child', 'leaf')], [[110, 220, numpy.pi / 2, 0, 1]])
|
||||
assert not lib._cache
|
||||
|
||||
with pytest.raises(ValueError, match='dangling-reference mode'):
|
||||
lib.child_graph(dangling='typo')
|
||||
with pytest.raises(ValueError, match='dangling-reference mode'):
|
||||
lib.find_refs_local('leaf', dangling='typo')
|
||||
|
||||
child = lib['child']
|
||||
assert list(child.refs.keys()) == ['leaf']
|
||||
assert set(lib._cache) == {'child'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_cached_graph.gds'
|
||||
gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9)
|
||||
|
||||
lib, _ = gdsii_lazy.readfile(gds_file)
|
||||
del lib['child'].refs['leaf']
|
||||
|
||||
assert lib.child_graph(dangling='ignore')['child'] == set()
|
||||
assert lib.find_refs_local('leaf') == {}
|
||||
|
||||
|
||||
def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_subtree_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
src['unused'] = Pattern()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-subtree')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
subtree = raw.subtree('top')
|
||||
|
||||
assert isinstance(raw, IMaterializable)
|
||||
assert not isinstance(raw, IBorrowing)
|
||||
assert isinstance(subtree, IMaterializable)
|
||||
assert isinstance(subtree, IBorrowing)
|
||||
assert subtree.source_order() == ('leaf', 'child', 'top')
|
||||
assert not hasattr(subtree, 'library_info')
|
||||
assert not raw._cache
|
||||
|
||||
out_file = tmp_path / 'lazy_subtree_out.gds'
|
||||
gdsii.writefile(subtree, out_file)
|
||||
assert not raw._cache
|
||||
|
||||
roundtrip, info = gdsii.readfile(out_file)
|
||||
assert info['name'] == 'classic-subtree'
|
||||
assert set(roundtrip) == {'leaf', 'child', 'top'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_borrowed_sources_require_matching_units(tmp_path: Path) -> None:
|
||||
gds_a = tmp_path / 'units_a.gds'
|
||||
gds_b = tmp_path / 'units_b.gds'
|
||||
source = Library({'top': Pattern()})
|
||||
gdsii.writefile(source, gds_a, meters_per_unit=1e-9, library_name='units-a')
|
||||
gdsii.writefile(source, gds_b, meters_per_unit=2e-9, library_name='units-b')
|
||||
|
||||
lazy_a, _ = gdsii_lazy.readfile(gds_a)
|
||||
lazy_b, _ = gdsii_lazy.readfile(gds_b)
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lazy_a)
|
||||
overlay.add_source(lazy_b, rename_theirs=lambda lib, name: lib.get_name(name))
|
||||
|
||||
with pytest.raises(LibraryError, match='identical units'):
|
||||
gdsii.write(overlay, io.BytesIO())
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_subtree_preserves_source_laziness(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_overlay_subtree_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
src['unused'] = Pattern()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-subtree')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(raw)
|
||||
subtree = overlay.subtree('top')
|
||||
|
||||
assert isinstance(subtree, OverlayLibrary)
|
||||
assert subtree.borrowed_sources() == (raw,)
|
||||
|
||||
out_file = tmp_path / 'lazy_overlay_subtree_out.gds'
|
||||
gdsii.writefile(subtree, out_file)
|
||||
|
||||
assert not raw._cache
|
||||
roundtrip, info = gdsii.readfile(out_file)
|
||||
assert info['name'] == 'overlay-subtree'
|
||||
assert set(roundtrip) == {'leaf', 'child', 'top'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_load_view_keeps_raw_source_unmodified(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_ports.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
|
||||
assert not hasattr(processed, 'library_info')
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'A'}
|
||||
assert_allclose(top.ports['A'].offset, [110, 225], atol=1e-10)
|
||||
assert not raw._cache
|
||||
|
||||
raw_top = raw['top']
|
||||
assert not raw_top.ports
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_load_view_detaches_previously_cached_source(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_cached_ports.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-cached-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
raw_top = raw['top']
|
||||
processed = PortLoadView(raw, ports={
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
})
|
||||
|
||||
processed_top = processed['top']
|
||||
|
||||
assert processed_top is not raw_top
|
||||
assert not raw_top.ports
|
||||
assert set(processed_top.ports) == {'P'}
|
||||
assert raw['top'] is raw_top
|
||||
assert not hasattr(processed, 'close')
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_port_overrides.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(raw, ports={
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
})
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'P'}
|
||||
assert_allclose(top.ports['P'].offset, [1, 2], atol=1e-10)
|
||||
assert top.ports['P'].rotation == 0
|
||||
assert top.ports['P'].ptype == 'wire'
|
||||
assert not raw._cache
|
||||
|
||||
raw_top = raw['top']
|
||||
assert not raw_top.ports
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_ports_override_extracted.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(
|
||||
raw,
|
||||
layers=[(10, 0)],
|
||||
max_depth=2,
|
||||
ports={
|
||||
'top': {
|
||||
'A': Port((1, 2), rotation=numpy.pi, ptype='manual'),
|
||||
'B': Port((3, 4), rotation=None, ptype=None),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'A', 'B'}
|
||||
assert_allclose(top.ports['A'].offset, [1, 2], atol=1e-10)
|
||||
assert top.ports['A'].rotation == numpy.pi
|
||||
assert top.ports['A'].ptype == 'manual'
|
||||
assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10)
|
||||
assert top.ports['B'].rotation is None
|
||||
assert top.ports['B'].ptype is None
|
||||
assert not raw._cache
|
||||
|
||||
|
||||
def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_ports_replace.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(
|
||||
raw,
|
||||
layers=[(10, 0)],
|
||||
max_depth=2,
|
||||
ports={
|
||||
'top': {
|
||||
'B': Port((3, 4), rotation=None, ptype=None),
|
||||
},
|
||||
},
|
||||
replace=True,
|
||||
)
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'B'}
|
||||
assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10)
|
||||
assert not raw._cache
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_overlay.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(processed)
|
||||
|
||||
assert not raw._cache
|
||||
assert not processed._cache
|
||||
|
||||
abstract = overlay.abstract('top')
|
||||
assert set(abstract.ports) == {'A'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_overlay_override.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(raw, ports={
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
})
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(processed)
|
||||
|
||||
assert not raw._cache
|
||||
assert not processed._cache
|
||||
|
||||
abstract = overlay.abstract('top')
|
||||
assert set(abstract.ports) == {'P'}
|
||||
assert_allclose(abstract.ports['P'].offset, [1, 2], atol=1e-10)
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_can_rename_every_source_cell() -> None:
|
||||
src = _make_lazy_port_library()
|
||||
overlay = OverlayLibrary()
|
||||
|
||||
rename_map = overlay.add_source(
|
||||
src,
|
||||
rename_theirs=lambda _lib, name: f'mapped_{name}',
|
||||
rename_when='always',
|
||||
)
|
||||
|
||||
assert rename_map == {
|
||||
'leaf': 'mapped_leaf',
|
||||
'child': 'mapped_child',
|
||||
'top': 'mapped_top',
|
||||
}
|
||||
assert tuple(overlay.keys()) == ('mapped_leaf', 'mapped_child', 'mapped_top')
|
||||
assert 'mapped_leaf' in overlay['mapped_child'].refs
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None:
|
||||
src = _make_lazy_port_library()
|
||||
|
||||
with pytest.raises(TypeError, match='rename_theirs'):
|
||||
OverlayLibrary().add_source(src, rename_theirs=None, rename_when='always')
|
||||
|
||||
with pytest.raises(ValueError, match='rename mode'):
|
||||
OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_roundtrip.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
|
||||
|
||||
out_file = tmp_path / 'lazy_roundtrip_out.gds'
|
||||
gdsii.writefile(processed, out_file)
|
||||
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_source_aware_write_copies_untouched_structures(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'classic_copy_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-copy')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
ports = PortLoadView(raw, layers=[(10, 0)], max_depth=2)
|
||||
mapped = LayerMappedView(ports, lambda layer: (20, 0) if layer == (10, 0) else layer, copy_through=True)
|
||||
prepared = preflight_source_aware(mapped)
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
def fail_materialize(_name: str, *, persist: bool = True) -> Pattern: # noqa: ARG001
|
||||
raise AssertionError('untouched cells must not be materialized')
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
monkeypatch.setattr(raw, 'materialize', fail_materialize)
|
||||
out_file = tmp_path / 'classic_copy_out.gds'
|
||||
gdsii.writefile(prepared, out_file)
|
||||
|
||||
assert copied == ['leaf', 'child', 'top']
|
||||
assert not raw._cache
|
||||
assert not ports._cache
|
||||
assert not mapped._cache
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('use_mmap', [False, True])
|
||||
def test_gdsii_lazy_raw_copy_stream_backends(tmp_path: Path, use_mmap: bool) -> None:
|
||||
gds_file = tmp_path / 'classic_stream_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-stream')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file, use_mmap=use_mmap)
|
||||
out_file = tmp_path / f'classic_stream_{use_mmap}.gds'
|
||||
gdsii.writefile(raw, out_file)
|
||||
|
||||
assert not raw._cache
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_raw_copy_gzipped_source(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'classic_gzip_source.gds'
|
||||
gz_file = tmp_path / 'classic_gzip_source.gds.gz'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-gzip')
|
||||
with gzip.open(gz_file, 'wb') as stream:
|
||||
stream.write(gds_file.read_bytes())
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gz_file, use_mmap=False)
|
||||
for name in raw.source_order():
|
||||
raw.raw_struct_bytes(name)
|
||||
assert raw._source.stream.tell() == raw._cells[name].struct_end
|
||||
out_file = tmp_path / 'classic_gzip_out.gds'
|
||||
gdsii.writefile(raw, out_file)
|
||||
|
||||
assert not raw._cache
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_cached_source_cell_disables_only_its_raw_copy(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'classic_cached_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-cached')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
assert raw.can_copy_raw_struct('leaf')
|
||||
raw['leaf'].label((30, 0), string='cached', offset=(1, 2))
|
||||
assert not raw.can_copy_raw_struct('leaf')
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
out_file = tmp_path / 'classic_cached_out.gds'
|
||||
gdsii.writefile(raw, out_file)
|
||||
|
||||
assert copied == ['child', 'top']
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert set(roundtrip['leaf'].labels) == {(10, 0), (30, 0)}
|
||||
|
||||
|
||||
def test_gdsii_lazy_detached_processing_copies_only_cached_patterns(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'classic_detached_source.gds'
|
||||
gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9)
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
mapped = LayerMappedView(raw, lambda layer: layer)
|
||||
|
||||
original_deepcopy = Pattern.deepcopy
|
||||
copied: list[Pattern] = []
|
||||
|
||||
def count_deepcopy(pattern: Pattern) -> Pattern:
|
||||
copied.append(pattern)
|
||||
return original_deepcopy(pattern)
|
||||
|
||||
monkeypatch.setattr(Pattern, 'deepcopy', count_deepcopy)
|
||||
fresh = mapped.materialize_detached('top')
|
||||
assert not copied
|
||||
assert not raw._cache
|
||||
assert not mapped._cache
|
||||
|
||||
cached = raw['top']
|
||||
copied.clear()
|
||||
detached = mapped.materialize_detached('top')
|
||||
assert copied == [cached]
|
||||
assert detached is not cached
|
||||
assert detached is not fresh
|
||||
|
||||
|
||||
def test_gdsii_lazy_materialized_cell_disables_only_its_raw_copy(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'classic_partial_copy_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-partial-copy')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
mapped = LayerMappedView(raw, lambda layer: (20, 0) if layer == (10, 0) else layer, copy_through=True)
|
||||
assert set(mapped['leaf'].labels) == {(20, 0)}
|
||||
prepared = preflight_source_aware(mapped)
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
out_file = tmp_path / 'classic_partial_copy_out.gds'
|
||||
gdsii.writefile(prepared, out_file)
|
||||
|
||||
assert copied == ['child', 'top']
|
||||
assert not raw._cache
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert set(roundtrip['leaf'].labels) == {(20, 0)}
|
||||
|
||||
|
||||
def test_gdsii_lazy_layer_mapped_write_materializes_without_source_cache(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_layer_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-layers')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
mapped = LayerMappedView(raw, lambda layer: (20, 0) if layer == (10, 0) else layer)
|
||||
out_file = tmp_path / 'lazy_layer_mapped.gds'
|
||||
gdsii.writefile(mapped, out_file)
|
||||
|
||||
assert not raw._cache
|
||||
roundtrip, info = gdsii.readfile(out_file)
|
||||
assert info['name'] == 'classic-layers'
|
||||
assert set(roundtrip['leaf'].labels) == {(20, 0)}
|
||||
assert (10, 0) not in roundtrip['leaf'].labels
|
||||
|
||||
|
||||
def test_gdsii_removed_closure_based_lazy_loader() -> None:
|
||||
assert not hasattr(gdsii, 'load_library')
|
||||
assert not hasattr(gdsii, 'load_libraryfile')
|
||||
assert not hasattr(gdsii_lazy, 'write')
|
||||
assert not hasattr(gdsii_lazy, 'writefile')
|
||||
|
|
@ -1,601 +0,0 @@
|
|||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import klamath
|
||||
import numpy
|
||||
import pytest
|
||||
|
||||
pytest.importorskip('pyarrow')
|
||||
|
||||
from .. import PatternError, LibraryError
|
||||
from ..library import IBorrowing, IMaterializable, LayerMappedView, Library, OverlayLibrary, PortLoadView
|
||||
from ..pattern import Pattern
|
||||
from ..repetition import Grid
|
||||
from ..file import gdsii
|
||||
from ..file.utils import preflight_source_aware
|
||||
from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow
|
||||
from ..file.gdsii import arrow as gdsii_arrow
|
||||
from ..file.gdsii import writer as gdsii_writer
|
||||
from tools.generate_gds_perf import write_fixture
|
||||
|
||||
|
||||
if not gdsii_arrow.is_available():
|
||||
pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cached', [False, True])
|
||||
def test_arrow_detached_mutation_is_isolated(tmp_path: Path, cached: bool) -> None:
|
||||
filename = tmp_path / 'detached.gds'
|
||||
gdsii.writefile(_make_small_library(), filename, meters_per_unit=1e-9)
|
||||
with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib:
|
||||
if cached:
|
||||
lib['leaf']
|
||||
detached = lib.materialize_detached('leaf')
|
||||
detached.translate_elements((0.25, 0.5))
|
||||
numpy.testing.assert_allclose(detached.get_bounds(), [[0.25, 0.5], [10.25, 5.5]])
|
||||
numpy.testing.assert_allclose(lib.materialize_detached('leaf').get_bounds(), [[0, 0], [10, 5]])
|
||||
assert lib.can_copy_raw_struct('leaf') == (not cached)
|
||||
top = lib.materialize_detached('top').flatten(lib)
|
||||
assert top.get_bounds() is not None
|
||||
|
||||
|
||||
def test_arrow_rect_hierarchy_requires_explicit_polygonization(tmp_path: Path) -> None:
|
||||
original = _make_small_library()
|
||||
original['mid'].refs['leaf'][0].rotation = numpy.pi / 4
|
||||
filename = tmp_path / 'rotated.gds'
|
||||
gdsii.writefile(original, filename, meters_per_unit=1e-9)
|
||||
with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib:
|
||||
for action in ('bounds', 'flatten'):
|
||||
top = lib.materialize_detached('top')
|
||||
with pytest.raises(PatternError, match='Pattern.polygonize'):
|
||||
top.get_bounds(lib) if action == 'bounds' else top.flatten(lib)
|
||||
lib['leaf'].polygonize()
|
||||
numpy.testing.assert_allclose(lib['top'].get_bounds(lib), original['top'].get_bounds(original))
|
||||
assert lib.materialize_detached('top').flatten(lib).get_bounds() is not None
|
||||
|
||||
|
||||
def test_arrow_dangling_ref_queries_are_cache_independent(tmp_path: Path) -> None:
|
||||
original = Library({'parent': Pattern()})
|
||||
original['parent'].ref('missing', offset=(3, 4), rotation=numpy.pi / 3, mirrored=True, scale=2)
|
||||
original['parent'].ref('missing', offset=(10, 20), repetition=Grid(a_vector=(7, 0), a_count=3))
|
||||
filename = tmp_path / 'dangling.gds'
|
||||
gdsii.writefile(original, filename, meters_per_unit=1e-9)
|
||||
with gdsii_lazy_arrow.ArrowLibrary.from_file(filename) as lib:
|
||||
expected = _local_refs_key(original.find_refs_local('missing', dangling='include'))
|
||||
assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected
|
||||
assert lib.can_copy_raw_struct('parent')
|
||||
assert not lib.find_refs_local('missing', dangling='ignore')
|
||||
with pytest.raises(LibraryError, match='missing'):
|
||||
lib.find_refs_local('missing', dangling='error')
|
||||
assert not lib.find_refs_local('unknown', dangling='include')
|
||||
lib['parent']
|
||||
assert _local_refs_key(lib.find_refs_local('missing', dangling='include')) == expected
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_has_reader_only_surface() -> None:
|
||||
assert not hasattr(gdsii_lazy_arrow, 'is_available')
|
||||
assert not hasattr(gdsii_lazy_arrow, 'write')
|
||||
assert not hasattr(gdsii_lazy_arrow, 'writefile')
|
||||
|
||||
|
||||
def _make_small_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 5], [0, 5]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
mid = Pattern()
|
||||
mid.ref('leaf', offset=(10, 20))
|
||||
mid.ref('leaf', offset=(40, 0), repetition=Grid(a_vector=(12, 0), a_count=2, b_vector=(0, 9), b_count=2))
|
||||
lib['mid'] = mid
|
||||
|
||||
top = Pattern()
|
||||
top.ref('mid', offset=(100, 200))
|
||||
lib['top'] = top
|
||||
return lib
|
||||
|
||||
|
||||
def _make_complex_ref_library() -> Library:
|
||||
lib = Library()
|
||||
|
||||
leaf = Pattern()
|
||||
leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib['leaf'] = leaf
|
||||
|
||||
child = Pattern()
|
||||
child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25)
|
||||
lib['child'] = child
|
||||
|
||||
sibling = Pattern()
|
||||
sibling.ref(
|
||||
'leaf',
|
||||
offset=(-50, 60),
|
||||
repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2),
|
||||
)
|
||||
lib['sibling'] = sibling
|
||||
|
||||
fanout = Pattern()
|
||||
fanout.ref('leaf', offset=(0, 0))
|
||||
fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1)
|
||||
fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3))
|
||||
fanout.ref(
|
||||
'child',
|
||||
offset=(40, 0),
|
||||
mirrored=True,
|
||||
rotation=numpy.pi / 4,
|
||||
scale=1.2,
|
||||
repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2),
|
||||
)
|
||||
lib['fanout'] = fanout
|
||||
|
||||
top = Pattern()
|
||||
top.ref('child', offset=(500, 600))
|
||||
top.ref('sibling', offset=(-100, 50), rotation=numpy.pi)
|
||||
top.ref('fanout', offset=(250, -75))
|
||||
lib['top'] = top
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def _write_invalid_path_type_fixture(path: Path) -> None:
|
||||
with path.open('wb') as stream:
|
||||
header = klamath.library.FileHeader(
|
||||
name=b'test',
|
||||
user_units_per_db_unit=1.0,
|
||||
meters_per_db_unit=1e-9,
|
||||
)
|
||||
header.write(stream)
|
||||
elem = klamath.elements.Path(
|
||||
layer=(1, 0),
|
||||
path_type=3,
|
||||
width=10,
|
||||
extension=(0, 0),
|
||||
xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32),
|
||||
properties={},
|
||||
)
|
||||
klamath.library.write_struct(stream, name=b'top', elements=[elem])
|
||||
klamath.records.ENDLIB.write(stream, None)
|
||||
|
||||
|
||||
def _transform_rows_key(values: numpy.ndarray) -> tuple[tuple[object, ...], ...]:
|
||||
arr = numpy.asarray(values, dtype=float)
|
||||
arr = numpy.atleast_2d(arr)
|
||||
rows = [
|
||||
(
|
||||
round(float(row[0]), 8),
|
||||
round(float(row[1]), 8),
|
||||
round(float(row[2]), 8),
|
||||
bool(int(round(float(row[3])))),
|
||||
round(float(row[4]), 8),
|
||||
)
|
||||
for row in arr
|
||||
]
|
||||
return tuple(sorted(rows))
|
||||
|
||||
|
||||
def _local_refs_key(refs: dict[str, list[numpy.ndarray]]) -> dict[str, tuple[tuple[object, ...], ...]]:
|
||||
return {
|
||||
parent: _transform_rows_key(numpy.concatenate(transforms))
|
||||
for parent, transforms in refs.items()
|
||||
}
|
||||
|
||||
|
||||
def _global_refs_key(refs: dict[tuple[str, ...], numpy.ndarray]) -> dict[tuple[str, ...], tuple[tuple[object, ...], ...]]:
|
||||
return {
|
||||
path: _transform_rows_key(transforms)
|
||||
for path, transforms in refs.items()
|
||||
}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_loads_perf_fixture(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'many_cells_lazy.gds'
|
||||
manifest = write_fixture(gds_file, preset='many_cells', scale=0.001)
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
assert info['name'] == manifest.library_name
|
||||
assert len(lib) == manifest.cells
|
||||
assert lib.top() == 'TOP'
|
||||
assert 'TOP' in lib.child_graph(dangling='ignore')
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'refs.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-refs')
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
local = lib.find_refs_local('leaf')
|
||||
assert set(local) == {'mid'}
|
||||
assert sum(arr.shape[0] for arr in local['mid']) == 5
|
||||
|
||||
global_refs = lib.find_refs_global('leaf')
|
||||
assert set(global_refs) == {('top', 'mid', 'leaf')}
|
||||
assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_arrow_cached_graph.gds'
|
||||
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9)
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
del lib['mid'].refs['leaf']
|
||||
|
||||
assert lib.child_graph(dangling='ignore')['mid'] == set()
|
||||
assert lib.find_refs_local('leaf') == {}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'complex_refs.gds'
|
||||
src = _make_complex_ref_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-complex-refs')
|
||||
|
||||
eager, _ = gdsii.readfile(gds_file)
|
||||
lazy, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
for name in ('leaf', 'child'):
|
||||
assert _local_refs_key(lazy.find_refs_local(name)) == _local_refs_key(eager.find_refs_local(name))
|
||||
assert _global_refs_key(lazy.find_refs_global(name)) == _global_refs_key(eager.find_refs_global(name))
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_detached_batch_preserves_native_batching(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'lazy_arrow_detached_batch.gds'
|
||||
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9)
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
mapped = LayerMappedView(raw, lambda layer: layer)
|
||||
|
||||
original_read = gdsii_arrow._read_selected_cells_to_arrow
|
||||
call_count = 0
|
||||
|
||||
def count_read(*args, **kwargs) -> object:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original_read(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gdsii_arrow, '_read_selected_cells_to_arrow', count_read)
|
||||
detached = mapped.materialize_many_detached(('leaf', 'mid', 'leaf'))
|
||||
|
||||
assert tuple(detached) == ('leaf', 'mid')
|
||||
assert call_count == 1
|
||||
assert not raw._cache
|
||||
assert not mapped._cache
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid.gds'
|
||||
gds_file.write_bytes(b'not-a-gds')
|
||||
|
||||
script = textwrap.dedent(f"""
|
||||
from masque.file.gdsii import lazy_arrow as gdsii_lazy_arrow
|
||||
try:
|
||||
gdsii_lazy_arrow.readfile({str(gds_file)!r})
|
||||
except Exception as exc:
|
||||
print(type(exc).__module__)
|
||||
print(type(exc).__qualname__)
|
||||
print(exc)
|
||||
else:
|
||||
raise SystemExit('expected gdsii_lazy_arrow.readfile() to fail')
|
||||
""")
|
||||
result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert 'klamath.basic' in result.stdout
|
||||
assert 'KlamathError' in result.stdout
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'invalid_path_type.gds'
|
||||
_write_invalid_path_type_fixture(gds_file)
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
with pytest.raises(PatternError, match='Unrecognized path type: 3'):
|
||||
lib['top']
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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)
|
||||
|
||||
def forbid_materialization(*_args, **_kwargs) -> None:
|
||||
pytest.fail('Untouched GDS writes must not materialize Arrow coordinates')
|
||||
|
||||
monkeypatch.setattr(gdsii_arrow, 'read_arrow', forbid_materialization)
|
||||
out_file = tmp_path / 'copy_out.gds'
|
||||
gdsii.writefile(
|
||||
lib,
|
||||
out_file,
|
||||
meters_per_unit=info['meters_per_unit'],
|
||||
logical_units_per_unit=info['logical_units_per_unit'],
|
||||
library_name=info['name'],
|
||||
)
|
||||
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_raw_copy_resolves_generic_borrowing_views(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gds_file = tmp_path / 'provenance_source.gds'
|
||||
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='provenance')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
ports = PortLoadView(raw)
|
||||
subtree = ports.subtree('top')
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(subtree)
|
||||
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
out_file = tmp_path / 'provenance_out.gds'
|
||||
gdsii.writefile(overlay, out_file)
|
||||
|
||||
assert copied == ['leaf', 'mid', 'top']
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
renamed = OverlayLibrary()
|
||||
renamed.add_source(raw)
|
||||
renamed.rename('top', 'renamed_top')
|
||||
assert gdsii_writer._resolve_raw_struct(renamed, 'renamed_top') is None
|
||||
|
||||
remapped = OverlayLibrary()
|
||||
remapped.add_source(raw)
|
||||
remapped.rename('leaf', 'renamed_leaf', move_references=True)
|
||||
assert gdsii_writer._resolve_raw_struct(remapped, 'mid') is None
|
||||
|
||||
|
||||
def test_gdsii_layer_mapped_view_controls_raw_copy_through(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'layer_mapped_source.gds'
|
||||
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='layer-mapped')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
def map_layer(layer): # noqa: ANN001,ANN202
|
||||
return (20, 0) if layer == (1, 0) else layer
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
|
||||
mapped = LayerMappedView(raw, map_layer)
|
||||
mapped_file = tmp_path / 'layer_mapped_all.gds'
|
||||
gdsii.writefile(mapped, mapped_file)
|
||||
|
||||
assert copied == []
|
||||
assert not raw._cache
|
||||
roundtrip, info = gdsii.readfile(mapped_file)
|
||||
assert info['name'] == 'layer-mapped'
|
||||
assert set(roundtrip['leaf'].shapes) == {(20, 0)}
|
||||
|
||||
passthrough = LayerMappedView(raw, map_layer, copy_through=True)
|
||||
preflighted = preflight_source_aware(passthrough)
|
||||
assert isinstance(preflighted, OverlayLibrary)
|
||||
copied_file = tmp_path / 'layer_mapped_copied.gds'
|
||||
gdsii.writefile(preflighted, copied_file)
|
||||
|
||||
assert copied == ['leaf', 'mid', 'top']
|
||||
assert copied_file.read_bytes() == gds_file.read_bytes()
|
||||
assert not raw._cache
|
||||
|
||||
copied.clear()
|
||||
assert set(passthrough['leaf'].shapes) == {(20, 0)}
|
||||
preflighted = preflight_source_aware(passthrough)
|
||||
materialized_file = tmp_path / 'layer_mapped_materialized.gds'
|
||||
gdsii.writefile(preflighted, materialized_file)
|
||||
|
||||
assert copied == ['mid', 'top']
|
||||
assert not raw._cache
|
||||
roundtrip, _ = gdsii.readfile(materialized_file)
|
||||
assert set(roundtrip['leaf'].shapes) == {(20, 0)}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'processed_edit_source.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='processed-edit')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
processed = PortLoadView(raw)
|
||||
processed['top'].polygon((7, 0), vertices=[[0, 0], [4, 0], [0, 4]])
|
||||
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
|
||||
out_file = tmp_path / 'processed_edit_out.gds'
|
||||
gdsii.writefile(processed, out_file)
|
||||
|
||||
assert 'top' not in copied
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert len(roundtrip['top'].shapes[(7, 0)]) == 1
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_subtree_preserves_raw_copy_and_ref_queries(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'subtree_copy_source.gds'
|
||||
src = _make_small_library()
|
||||
src['unused'] = Pattern()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='subtree-copy')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
subtree = raw.subtree('top')
|
||||
|
||||
assert isinstance(raw, IMaterializable)
|
||||
assert not isinstance(raw, IBorrowing)
|
||||
assert isinstance(subtree, IMaterializable)
|
||||
assert isinstance(subtree, IBorrowing)
|
||||
assert subtree.source_order() == ('leaf', 'mid', 'top')
|
||||
assert _global_refs_key(subtree.find_refs_global('leaf')) == _global_refs_key(raw.find_refs_global('leaf'))
|
||||
assert not raw._cache
|
||||
|
||||
out_file = tmp_path / 'subtree_copy_out.gds'
|
||||
gdsii.writefile(subtree, out_file)
|
||||
|
||||
assert not raw._cache
|
||||
roundtrip, info = gdsii.readfile(out_file)
|
||||
assert info['name'] == 'subtree-copy'
|
||||
assert set(roundtrip) == {'leaf', 'mid', 'top'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_overlay_subtree_preserves_raw_copy(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'overlay_subtree_source.gds'
|
||||
src = _make_small_library()
|
||||
src['unused'] = Pattern()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-subtree-copy')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(raw)
|
||||
subtree = overlay.subtree('top')
|
||||
|
||||
assert isinstance(subtree, OverlayLibrary)
|
||||
assert subtree.borrowed_sources() == (raw,)
|
||||
assert not raw._cache
|
||||
|
||||
out_file = tmp_path / 'overlay_subtree_out.gds'
|
||||
gdsii.writefile(subtree, out_file)
|
||||
|
||||
assert not raw._cache
|
||||
roundtrip, info = gdsii.readfile(out_file)
|
||||
assert info['name'] == 'overlay-subtree-copy'
|
||||
assert set(roundtrip) == {'leaf', 'mid', 'top'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_gzipped_copy_through(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'copy_source.gds.gz'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through-gz')
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
out_file = tmp_path / 'copy_out.gds.gz'
|
||||
gdsii.writefile(
|
||||
lib,
|
||||
out_file,
|
||||
meters_per_unit=info['meters_per_unit'],
|
||||
logical_units_per_unit=info['logical_units_per_unit'],
|
||||
library_name=info['name'],
|
||||
)
|
||||
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_overlay_merge_and_write(tmp_path: Path) -> None:
|
||||
base_a = Library()
|
||||
leaf_a = Pattern()
|
||||
leaf_a.polygon((1, 0), vertices=[[0, 0], [8, 0], [8, 8], [0, 8]])
|
||||
base_a['leaf'] = leaf_a
|
||||
top_a = Pattern()
|
||||
top_a.ref('leaf', offset=(0, 0))
|
||||
base_a['top_a'] = top_a
|
||||
|
||||
base_b = Library()
|
||||
leaf_b = Pattern()
|
||||
leaf_b.polygon((2, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
base_b['leaf'] = leaf_b
|
||||
top_b = Pattern()
|
||||
top_b.ref('leaf', offset=(20, 30))
|
||||
base_b['top_b'] = top_b
|
||||
|
||||
gds_a = tmp_path / 'a.gds'
|
||||
gds_b = tmp_path / 'b.gds'
|
||||
gdsii.writefile(base_a, gds_a, meters_per_unit=1e-9, library_name='overlay')
|
||||
gdsii.writefile(base_b, gds_b, meters_per_unit=1e-9, library_name='overlay')
|
||||
|
||||
lib_a, _ = gdsii_lazy_arrow.readfile(gds_a)
|
||||
lib_b, _ = gdsii_lazy_arrow.readfile(gds_b)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lib_a)
|
||||
rename_map = overlay.add_source(lib_b, rename_theirs=lambda lib, name: lib.get_name(name))
|
||||
renamed_leaf = rename_map['leaf']
|
||||
|
||||
assert rename_map == {'leaf': renamed_leaf}
|
||||
assert renamed_leaf != 'leaf'
|
||||
assert len(lib_a._cache) == 0
|
||||
assert len(lib_b._cache) == 0
|
||||
|
||||
overlay.move_references('leaf', renamed_leaf)
|
||||
|
||||
out_file = tmp_path / 'overlay_out.gds'
|
||||
gdsii.writefile(overlay, out_file)
|
||||
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert set(roundtrip.keys()) == {'leaf', renamed_leaf, 'top_a', 'top_b'}
|
||||
assert 'top_b' in roundtrip
|
||||
assert list(roundtrip['top_b'].refs.keys()) == [renamed_leaf]
|
||||
|
||||
|
||||
def test_gdsii_writer_accepts_overlay_library(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'overlay_source.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-src')
|
||||
|
||||
lib, info = gdsii_lazy_arrow.readfile(gds_file)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(lib)
|
||||
overlay.rename('leaf', 'leaf_copy', move_references=True)
|
||||
|
||||
out_file = tmp_path / 'overlay_via_eager_writer.gds'
|
||||
gdsii.writefile(
|
||||
overlay,
|
||||
out_file,
|
||||
meters_per_unit=info['meters_per_unit'],
|
||||
logical_units_per_unit=info['logical_units_per_unit'],
|
||||
library_name=info['name'],
|
||||
)
|
||||
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert set(roundtrip.keys()) == {'leaf_copy', 'mid', 'top'}
|
||||
assert list(roundtrip['mid'].refs.keys()) == ['leaf_copy']
|
||||
|
||||
|
||||
def test_svg_writer_uses_detached_materialized_copy(tmp_path: Path) -> None:
|
||||
pytest.importorskip('svgwrite')
|
||||
from ..file import svg
|
||||
from ..shapes import Path as MPath
|
||||
|
||||
gds_file = tmp_path / 'svg_source.gds'
|
||||
src = _make_small_library()
|
||||
src['top'].path((3, 0), vertices=[[0, 0], [0, 20]], width=4)
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='svg-src')
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
top_pat = lib['top']
|
||||
assert list(top_pat.refs.keys()) == ['mid']
|
||||
assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)])
|
||||
|
||||
svg_path = tmp_path / 'lazy.svg'
|
||||
svg.writefile(lib, 'top', str(svg_path))
|
||||
|
||||
assert svg_path.exists()
|
||||
assert list(top_pat.refs.keys()) == ['mid']
|
||||
assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)])
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
from dataclasses import asdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..file import gdsii
|
||||
from tools.generate_gds_perf import fixture_manifest, write_fixture
|
||||
|
||||
|
||||
def test_gdsii_perf_fixture_smoke(tmp_path: Path) -> None:
|
||||
output = tmp_path / 'many_cells.gds'
|
||||
manifest = write_fixture(output, preset='many_cells', scale=0.002)
|
||||
expected = fixture_manifest(output, preset='many_cells', scale=0.002)
|
||||
|
||||
assert output.exists()
|
||||
assert manifest == expected
|
||||
|
||||
sidecar = json.loads(output.with_suffix('.gds.json').read_text())
|
||||
assert sidecar == asdict(manifest)
|
||||
|
||||
read_lib, info = gdsii.readfile(output)
|
||||
assert info['name'] == manifest.library_name
|
||||
assert len(read_lib) == manifest.cells
|
||||
assert 'TOP' in read_lib
|
||||
assert len(read_lib['TOP'].refs) > 0
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import copy
|
||||
from numpy.testing import assert_equal, assert_allclose
|
||||
from numpy import pi
|
||||
|
||||
from ..label import Label
|
||||
from ..repetition import Grid
|
||||
from ..utils import annotations_eq
|
||||
|
||||
|
||||
def test_label_init() -> None:
|
||||
lbl = Label("test", offset=(10, 20))
|
||||
assert lbl.string == "test"
|
||||
assert_equal(lbl.offset, [10, 20])
|
||||
|
||||
|
||||
def test_label_transform() -> None:
|
||||
lbl = Label("test", offset=(10, 0))
|
||||
# Rotate 90 deg CCW around (0,0)
|
||||
lbl.rotate_around((0, 0), pi / 2)
|
||||
assert_allclose(lbl.offset, [0, 10], atol=1e-10)
|
||||
|
||||
# Translate
|
||||
lbl.translate((5, 5))
|
||||
assert_allclose(lbl.offset, [5, 15], atol=1e-10)
|
||||
|
||||
|
||||
def test_label_repetition() -> None:
|
||||
rep = Grid(a_vector=(10, 0), a_count=3)
|
||||
lbl = Label("rep", offset=(0, 0), repetition=rep)
|
||||
assert lbl.repetition is rep
|
||||
assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]])
|
||||
# Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't
|
||||
# seem to automatically include repetition bounds in label.py itself,
|
||||
# it's handled during pattern bounding.
|
||||
|
||||
|
||||
def test_label_copy() -> None:
|
||||
l1 = Label("test", offset=(1, 2), annotations={"a": [1]})
|
||||
l2 = copy.deepcopy(l1)
|
||||
|
||||
print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}")
|
||||
print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}")
|
||||
print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}")
|
||||
|
||||
assert l1 == l2
|
||||
assert l1 is not l2
|
||||
l2.offset[0] = 100
|
||||
assert l1.offset[0] == 1
|
||||
|
||||
|
||||
def test_label_eq_unrelated_objects_is_false() -> None:
|
||||
lbl = Label("test")
|
||||
assert not (lbl == None)
|
||||
assert not (lbl == object())
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
|
||||
from ..shapes import Polygon
|
||||
|
||||
|
||||
def test_manhattanize() -> None:
|
||||
pytest.importorskip("float_raster")
|
||||
pytest.importorskip("skimage.measure")
|
||||
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
||||
grid = numpy.arange(0, 11, 1)
|
||||
|
||||
manhattan_polys = poly.manhattanize(grid, grid)
|
||||
assert len(manhattan_polys) >= 1
|
||||
for mp in manhattan_polys:
|
||||
dv = numpy.diff(mp.vertices, axis=0)
|
||||
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import io
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from numpy.testing import assert_equal
|
||||
|
||||
from ..error import PatternError
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
from ..shapes import Path as MPath
|
||||
|
||||
|
||||
def test_oasis_roundtrip(tmp_path: Path) -> None:
|
||||
# Skip if fatamorgana is not installed
|
||||
pytest.importorskip("fatamorgana")
|
||||
from ..file import oasis
|
||||
|
||||
lib = Library()
|
||||
pat1 = Pattern()
|
||||
pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["cell1"] = pat1
|
||||
|
||||
oas_file = tmp_path / "test.oas"
|
||||
# OASIS needs units_per_micron
|
||||
oasis.writefile(lib, oas_file, units_per_micron=1000)
|
||||
|
||||
read_lib, info = oasis.readfile(oas_file)
|
||||
assert "cell1" in read_lib
|
||||
|
||||
# Check bounds
|
||||
assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]])
|
||||
|
||||
|
||||
def test_oasis_properties_to_annotations_merges_repeated_keys() -> None:
|
||||
pytest.importorskip("fatamorgana")
|
||||
import fatamorgana.records as fatrec
|
||||
from ..file.oasis import properties_to_annotations
|
||||
|
||||
annotations = properties_to_annotations(
|
||||
[
|
||||
fatrec.Property("k", [1], is_standard=False),
|
||||
fatrec.Property("k", [2, 3], is_standard=False),
|
||||
],
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
assert annotations == {"k": [1, 2, 3]}
|
||||
|
||||
|
||||
def test_oasis_write_rejects_circle_path_caps() -> None:
|
||||
pytest.importorskip("fatamorgana")
|
||||
from ..file import oasis
|
||||
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle)
|
||||
lib["cell1"] = pat
|
||||
|
||||
with pytest.raises(PatternError, match="does not support path cap"):
|
||||
oasis.write(lib, io.BytesIO(), units_per_micron=1000)
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns
|
||||
from ..library import Library
|
||||
from ..pattern import Pattern
|
||||
|
||||
|
||||
def test_maxrects_bssf_simple() -> None:
|
||||
# Pack two 10x10 squares into one 20x10 container
|
||||
rects = [[10, 10], [10, 10]]
|
||||
containers = [[0, 0, 20, 10]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers)
|
||||
|
||||
assert not rejects
|
||||
# They should be at (0,0) and (10,0)
|
||||
assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)}
|
||||
|
||||
|
||||
def test_maxrects_bssf_reject() -> None:
|
||||
# Try to pack a too-large rectangle
|
||||
rects = [[10, 10], [30, 30]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True)
|
||||
assert 1 in rejects # Second rect rejected
|
||||
assert 0 not in rejects
|
||||
|
||||
|
||||
def test_maxrects_bssf_exact_fill_rejects_remaining() -> None:
|
||||
rects = [[20, 20], [1, 1]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True)
|
||||
|
||||
assert tuple(locs[0]) == (0.0, 0.0)
|
||||
assert rejects == {1}
|
||||
|
||||
|
||||
def test_maxrects_bssf_presort_reject_mapping() -> None:
|
||||
rects = [[10, 12], [19, 14], [13, 11]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
_locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True)
|
||||
|
||||
assert rejects == {0, 2}
|
||||
|
||||
|
||||
def test_guillotine_bssf_sas_presort_reject_mapping() -> None:
|
||||
rects = [[2, 1], [17, 15], [16, 11]]
|
||||
containers = [[0, 0, 20, 20]]
|
||||
|
||||
_locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True)
|
||||
|
||||
assert rejects == {2}
|
||||
|
||||
|
||||
def test_pack_patterns() -> None:
|
||||
lib = Library()
|
||||
p1 = Pattern()
|
||||
p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
lib["p1"] = p1
|
||||
|
||||
p2 = Pattern()
|
||||
p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]])
|
||||
lib["p2"] = p2
|
||||
|
||||
# Containers: one 20x20
|
||||
containers = [[0, 0, 20, 20]]
|
||||
# 2um spacing
|
||||
pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2))
|
||||
|
||||
assert not rejects
|
||||
assert len(pat.refs) == 2
|
||||
assert "p1" in pat.refs
|
||||
assert "p2" in pat.refs
|
||||
|
||||
# Check that they don't overlap (simple check via bounds)
|
||||
# p1 size 10x10, effectively 12x12
|
||||
# p2 size 5x5, effectively 7x7
|
||||
# Both should fit in 20x20
|
||||
|
||||
|
||||
def test_pack_patterns_reject_names_match_original_patterns() -> None:
|
||||
lib = Library()
|
||||
for name, (lx, ly) in {
|
||||
"p0": (10, 12),
|
||||
"p1": (19, 14),
|
||||
"p2": (13, 11),
|
||||
}.items():
|
||||
pat = Pattern()
|
||||
pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly)
|
||||
lib[name] = pat
|
||||
|
||||
pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0))
|
||||
|
||||
assert set(rejects) == {"p0", "p2"}
|
||||
assert set(pat.refs) == {"p1"}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
from numpy.testing import assert_equal, assert_allclose
|
||||
|
||||
from ..shapes import Path, Path as MPath
|
||||
|
||||
|
||||
def test_path_init() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||
assert_equal(p.vertices, [[0, 0], [10, 0]])
|
||||
assert p.width == 2
|
||||
assert p.cap == Path.Cap.Flush
|
||||
|
||||
|
||||
def test_path_to_polygons_flush() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[0, -1], [10, 1]])
|
||||
|
||||
|
||||
def test_path_to_polygons_square() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||
|
||||
|
||||
def test_path_to_polygons_circle() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
||||
polys = p.to_polygons(num_vertices=32)
|
||||
assert len(polys) >= 3
|
||||
|
||||
bounds = p.get_bounds_single()
|
||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||
|
||||
|
||||
def test_path_custom_cap() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[-5, -1], [20, 1]])
|
||||
|
||||
|
||||
def test_path_bend() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
bounds = polys[0].get_bounds_single()
|
||||
assert_equal(bounds, [[0, -1], [11, 10]])
|
||||
|
||||
|
||||
def test_path_mirror() -> None:
|
||||
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
||||
p.mirror(0)
|
||||
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
||||
|
||||
|
||||
def test_path_scale() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2)
|
||||
p.scale_by(2)
|
||||
assert_equal(p.vertices, [[0, 0], [20, 0]])
|
||||
assert p.width == 4
|
||||
|
||||
|
||||
def test_path_scale_custom_cap_extensions() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||
p.scale_by(3)
|
||||
|
||||
assert_equal(p.vertices, [[0, 0], [30, 0]])
|
||||
assert p.width == 6
|
||||
assert p.cap_extensions is not None
|
||||
assert_allclose(p.cap_extensions, [3, 6])
|
||||
assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]])
|
||||
|
||||
|
||||
def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None:
|
||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||
|
||||
intrinsic, _extrinsic, ctor = p.normalized_form(5)
|
||||
q = ctor()
|
||||
|
||||
assert intrinsic[-1] == (0.2, 0.4)
|
||||
assert q.width == 2
|
||||
assert q.cap_extensions is not None
|
||||
assert_allclose(q.cap_extensions, [1, 2])
|
||||
|
||||
|
||||
def test_path_normalized_form_distinguishes_custom_caps() -> None:
|
||||
p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2))
|
||||
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
|
||||
|
||||
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
|
||||
|
||||
|
||||
def test_path_edge_cases() -> None:
|
||||
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
||||
polys = p.to_polygons()
|
||||
assert len(polys) == 1
|
||||
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import pytest
|
||||
import numpy
|
||||
from numpy import pi
|
||||
from numpy.testing import assert_allclose
|
||||
|
||||
from masque import Pather, Library, Pattern, Port
|
||||
from masque.builder.tools import AutoTool
|
||||
|
||||
|
||||
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["B"] = Port((length, 0), pi, ptype=ptype)
|
||||
return pat
|
||||
|
||||
def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern:
|
||||
pat = Pattern()
|
||||
# Rectangular approximation of a 90 degree bend.
|
||||
if clockwise:
|
||||
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||
pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0)
|
||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype)
|
||||
else:
|
||||
pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width)
|
||||
pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius)
|
||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||
pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype)
|
||||
return pat
|
||||
|
||||
@pytest.fixture
|
||||
def multi_bend_tool() -> tuple[AutoTool, Library]:
|
||||
lib = Library()
|
||||
|
||||
lib["b1"] = make_bend(2, ptype="wire")
|
||||
b1_abs = lib.abstract("b1")
|
||||
lib["b2"] = make_bend(5, ptype="wire")
|
||||
b2_abs = lib.abstract("b2")
|
||||
|
||||
tool = (
|
||||
AutoTool()
|
||||
.add_straight(make_straight, "wire", "A", length_range=(0, 10))
|
||||
.add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8))
|
||||
.add_bend(b1_abs, "A", "B", clockwise=True, mirror=True)
|
||||
.add_bend(b2_abs, "A", "B", clockwise=True, mirror=True)
|
||||
)
|
||||
return tool, lib
|
||||
|
||||
def test_autotool_uturn() -> None:
|
||||
from masque.builder.tools import AutoTool
|
||||
lib = Library()
|
||||
|
||||
def make_straight(length: float) -> Pattern:
|
||||
pat = Pattern()
|
||||
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
|
||||
pat.ports['in'] = Port((0, 0), 0)
|
||||
pat.ports['out'] = Port((length, 0), pi)
|
||||
return pat
|
||||
|
||||
bend_pat = Pattern()
|
||||
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
|
||||
bend_pat.ports['in'] = Port((0, 0), 0)
|
||||
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
||||
lib['bend'] = bend_pat
|
||||
|
||||
tool = (
|
||||
AutoTool()
|
||||
.add_straight(make_straight, 'wire', 'in')
|
||||
.add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True)
|
||||
)
|
||||
|
||||
p = Pather(lib, tools=tool)
|
||||
p.pattern.ports['A'] = Port((0, 0), 0)
|
||||
|
||||
p.at('A').uturn(offset=-2000, length=1000)
|
||||
|
||||
# U-turn plan output is transformed into the port extension frame.
|
||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
|
||||
assert p.pattern.ports['A'].rotation is not None
|
||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||
|
||||
def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||
tool, lib = multi_bend_tool
|
||||
rp = Pather(lib, tools=tool)
|
||||
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||
|
||||
rp.jog("A", 10, length=20)
|
||||
|
||||
assert_allclose(rp.ports["A"].offset, [-20, -10])
|
||||
assert_allclose(rp.ports["A"].rotation, 0)
|
||||
|
||||
rp.render()
|
||||
assert len(rp.pattern.refs) > 0
|
||||
|
||||
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
|
||||
tool, lib = multi_bend_tool
|
||||
|
||||
p = Pather(lib, tools=tool)
|
||||
p.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||
|
||||
p.uturn("A", 10, length=5)
|
||||
|
||||
# Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames,
|
||||
# yielding a global endpoint at (-5, -10).
|
||||
assert_allclose(p.ports["A"].offset, [-5, -10])
|
||||
assert_allclose(p.ports["A"].rotation, pi)
|
||||
File diff suppressed because it is too large
Load diff
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