Compare commits

..

4 commits

156 changed files with 6854 additions and 37847 deletions

View file

@ -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
View file

@ -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

View file

@ -2,33 +2,29 @@
import numpy
from masque.file import gdsii
from masque import Arc, Pattern
import masque
import masque.file.klamath
from masque import shapes
def main() -> 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__':

View file

@ -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

View file

@ -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)

View file

@ -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())

View file

@ -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__':

View file

@ -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`.

View file

@ -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__':

View file

@ -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__':

View file

@ -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
#

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View 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

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View 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)

View file

@ -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

View file

@ -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
offsets += rot_bound.max()
elif bound_type in('emax', 'max_extension'):
offsets += rot_bound - offsets.max()
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

View file

@ -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))

View file

@ -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
View 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)

View file

@ -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

View file

@ -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)

View file

@ -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')

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,2 @@
# FOr backwards compatibility
from .gdsii import *

View file

@ -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,
fatrec.LayerName(nstring=name,
layer_interval=(layer, layer),
type_interval=(data_type, data_type),
is_textlayer = tt,
)
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,
poly = Polygon(vertices=vertices,
layer=element.get_layer_tuple(),
offset=element.get_xy(),
annotations=annotations,
repetition = repetition,
)
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,
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,
)
**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(),
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(),
label = Label(layer=element.get_layer_tuple(),
offset=element.get_xy(),
repetition=repetition,
annotations=annotations,
string = string,
)
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,105 +479,97 @@ 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,
offset = numpy.round(shape.offset + rep_offset).astype(int)
radius = numpy.round(shape.radius).astype(int)
circle = fatrec.Circle(layer=layer,
datatype=datatype,
radius = cast('int', radius),
radius=radius,
x=offset[0],
y=offset[1],
properties=properties,
repetition = repetition,
)
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,
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 = cast('Sequence[Sequence[int]]', deltas),
half_width = cast('int', half_width),
point_list=deltas,
half_width=half_width,
x=xy[0],
y=xy[1],
extension_start=extension_start, # TODO implement multiple cap types?
@ -583,59 +580,81 @@ def _shapes_to_elements(
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,
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 = cast('list[list[int]]', points),
point_list=points,
properties=properties,
repetition = repetition,
))
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,
texts.append(fatrec.Text(layer=layer,
datatype=datatype,
x=xy[0],
y=xy[1],
string=label.string,
properties=properties,
repetition = repetition,
))
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,
mrep = Grid(a_vector=rep.a_vector,
b_vector=rep.b_vector,
a_count=rep.a_count,
b_count = rep.b_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,22 +663,17 @@ 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,
@ -670,19 +684,17 @@ def repetition_masq2fata(
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
View 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)

View file

@ -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

View file

@ -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)

View file

@ -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}>'

View file

@ -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

View file

@ -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

View 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

View file

@ -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
View 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.
"""

View 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

View file

@ -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

View file

@ -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__)
Key = TypeVar('Key')
Value = TypeVar('Value')
SINGLE_USE_PREFIX = '_'
class DeferredDict(dict, Generic[Key, Value]):
"""
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()` )
This is a modified `dict` which is used to defer loading/generating
values until they are accessed.
```
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)`.
"""
# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
def __init__(self, *args, **kwargs) -> None:
dict.__init__(self)
self.update(*args, **kwargs)
def __setitem__(self, key: Key, value: Callable[[], Value]) -> None:
cached_fn = lru_cache(maxsize=1)(value)
dict.__setitem__(self, key, cached_fn)
class INameView(Collection[str], ABC):
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:
"""
Read-only collection of reserved names with a shared name allocator.
Name views support membership, iteration, `len()`, and `get_name()`. They
do not provide pattern lookup or the other operations of a library mapping.
Convenience function to avoid having to manually wrap
constant values into callables.
"""
def get_name(
self,
name: str = SINGLE_USE_PREFIX * 2,
sanitize: bool = True,
max_length: int = 32,
quiet: bool | None = None,
) -> str:
"""
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.
"""
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

File diff suppressed because it is too large Load diff

View file

@ -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]

View file

@ -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}>'

View file

@ -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

View file

@ -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

View file

@ -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}>'

View file

@ -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}>'

View file

@ -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}>'

View file

@ -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}>'

View file

@ -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)}>'

View file

@ -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}>'

View file

@ -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]}>'

View file

@ -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

View file

@ -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
View 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}>'

View file

@ -1,3 +0,0 @@
"""
Tests (run with `python3 -m pytest -rxPXs | tee results.txt`)
"""

View file

@ -1,13 +0,0 @@
"""
Test fixtures
"""
# ruff: noqa: ARG001
from typing import Any
import numpy
FixtureRequest = Any
PRNG = numpy.random.RandomState(12345)

View file

@ -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__}')

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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"}

View file

@ -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)

View file

@ -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])

View file

@ -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

View file

@ -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']}

View file

@ -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')

View file

@ -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())

View file

@ -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')

View file

@ -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)])

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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"}

View file

@ -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]])

View file

@ -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