Compare commits

..
91 changed files with 3832 additions and 22274 deletions

View file

@ -1,8 +1,7 @@
# 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.
This guide covers changes between the git tag `release` and the current tree.
At `release`, `masque.__version__` was `3.3`; the current tree reports `3.4`.
Most downstream changes are in `masque/builder/*`, but there are a few other
API changes that may require code updates.
@ -23,13 +22,13 @@ The biggest migration point is that the old routing verbs were renamed:
| `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)` |
| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` |
| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` |
| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` |
| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` |
| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` |
| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` |
| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` |
There are also new convenience wrappers:
@ -124,107 +123,89 @@ from masque.builder.renderpather import RenderPather
from masque.builder import Pather
builder = Pather(...)
deferred = Pather(..., render='deferred')
deferred = Pather(..., auto_render=False)
```
The new `Pather` remains importable from both `masque` and `masque.builder`.
The removed `Builder` and `RenderPather` names are no longer exported from
either location.
Top-level imports from `masque` also continue to work.
`Pather` now defaults to `render='auto'`, so plain construction replaces the
old `Builder` behavior. Use `Pather(..., render='deferred')` where you
`Pather` now defaults to `auto_render=True`, so plain construction replaces the
old `Builder` behavior. Use `Pather(..., auto_render=False)` where you
previously used `RenderPather`.
## `SimpleTool` was removed and `AutoTool` registration changed
## `BasicTool` was replaced
`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.
`BasicTool` is no longer exported. Use:
### Old `AutoTool`
- `SimpleTool` for the simple "one straight generator + one bend cell" case
- `AutoTool` if you need transitions, multiple candidate straights/bends, or
S-bends/U-bends
### Old `BasicTool`
```python
from masque.builder import AutoTool
from masque.builder.tools import BasicTool
tool = AutoTool(
straights=[
AutoTool.Straight('m1wire', make_straight, 'input', 'output'),
],
bends=[
AutoTool.Bend(lib.abstract('bend'), 'input', 'output'),
],
sbends=[],
tool = BasicTool(
straight=(make_straight, 'input', 'output'),
bend=(lib.abstract('bend'), 'input', 'output'),
transitions={
('m2wire', 'm1wire'): AutoTool.Transition(
lib.abstract('via'), 'top', 'bottom'
),
'm2wire': (lib.abstract('via'), 'top', 'bottom'),
},
default_out_ptype='m1wire',
)
```
### New `AutoTool`
```python
from masque.builder import AutoTool
from masque.builder.tools 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')
tool = AutoTool(
straights=[
AutoTool.Straight(
ptype='m1wire',
fn=make_straight,
in_port_name='input',
out_port_name='output',
),
],
bends=[
AutoTool.Bend(
abstract=lib.abstract('bend'),
in_port_name='input',
out_port_name='output',
clockwise=True,
),
],
sbends=[],
transitions={
('m2wire', 'm1wire'): AutoTool.Transition(
lib.abstract('via'),
'top',
'bottom',
),
},
default_out_ptype='m1wire',
)
```
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
- `BasicTool` -> `SimpleTool` or `AutoTool`
- `straight=(fn, in_name, out_name)` -> `straights=[AutoTool.Straight(...)]`
- `bend=(abstract, in_name, out_name)` -> `bends=[AutoTool.Bend(...)]`
- transition keys are now `(external_ptype, internal_ptype)` tuples
- transitions use `AutoTool.Transition(...)` instead of raw tuples
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`.
If your old `BasicTool` usage did not rely on transitions or multiple routing
options, `SimpleTool` is the closest replacement.
## 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
- `Tool.path(...)` became `Tool.traceL(...)`
- `Tool.traceS(...)` and `Tool.traceU(...)` were added for native S/U routes
- `planL()` / `planS()` / `planU()` remain the planning hooks used by deferred rendering
In practice, a minimal old implementation like:
@ -237,269 +218,14 @@ class MyTool(Tool):
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]):
def traceL(self, ccw, length, **kwargs):
...
```
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)
```
If you do not implement `traceS()` or `traceU()`, the unified pather will
either fall back to the planning hooks or synthesize those routes from simpler
steps where possible.
## Transform semantics changed
@ -536,305 +262,26 @@ Check code that calls:
- `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`
- `PortPather`
- `SimpleTool`
- `AutoTool`
- `boolean`
## Minimal migration checklist
@ -842,15 +289,11 @@ 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.
2. Replace `BasicTool` with `SimpleTool` or `AutoTool`.
3. Fix imports that still reference `masque.builder.builder` or
`masque.builder.renderpather`.
4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`.
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.
routing helpers, you may not need any changes beyond the transform audit and any
stale imports.

View file

@ -275,6 +275,8 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...)
```
## Development
## TODO
Project-level planned work is tracked in [TODO.md](TODO.md).
* PolyCollection & arrow-based read/write
* Bus-to-bus connections?
* tuple / string layer auto-translation

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

@ -20,16 +20,16 @@ Contents
* 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
* Continue from `devices.py` using a lazy library
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
- [pather](pather.py)
* Use `Pather` to route individual wires and wire bundles
* Define a custom `Tool` that exposes primitive routing offers
* Use primitive offers to automatically transition between path types
* Use `AutoTool` to generate paths
* Use `AutoTool` to automatically transition between path types
- [renderpather](renderpather.py)
* Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py),
* Use `Pather(auto_render=False)` 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

View file

@ -1,133 +1,142 @@
"""
Tutorial: authoring a mixed library with `LibraryBuilder`.
Tutorial: using `LazyLibrary` and `Pather.interface()`.
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.
itself, but rather how Masque lets you mix lazily loaded GDS content with
python-generated devices inside one library.
"""
from typing import Any
from pprint import pformat
from masque import ILibrary, LibraryBuilder, Pather, Pattern, PortLoadView, cell
from masque.file.gdsii import writefile
from masque.file.gdsii.lazy import readfile
from masque import Pather, LazyLibrary
from masque.file.gdsii import writefile, load_libraryfile
import basic_shapes
import devices
from devices import data_to_ports
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)(...)
# A `LazyLibrary` delays work until a pattern is actually needed.
# That applies both to GDS cells we load from disk and to python callables
# that generate patterns on demand.
lib = LazyLibrary()
#
# Load some devices from a GDS file
#
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
# imported on first materialization, but the raw source remains untouched
# until we build the final library.
gds_lib, _properties = readfile('circuit.gds')
builder.add_source(PortLoadView(gds_lib, layers=[(3, 0)], max_depth=1))
# Scan circuit.gds and prepare to lazy-load its contents
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
# Add those cells into our lazy library.
# Nothing is read yet; we are only registering how to fetch and postprocess
# each pattern when it is first requested.
lib.add(gds_lib)
print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys())))
#
# Register some new devices, this time from python code rather than GDS.
# Add some new devices to the library, this time from python code rather than GDS
#
cells.triangle = basic_shapes.triangle(devices.RADIUS)
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
opts: dict[str, Any] = dict(
lattice_constant=devices.LATTICE_CONSTANT,
hole='triangle',
)
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,
lattice_constant = devices.LATTICE_CONSTANT,
hole = 'triangle',
)
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder.library)
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
# Triangle-based variants. These lambdas are only recipes for building the
# patterns; they do not execute until someone asks for the cell.
lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts)
lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts)
lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts)
lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts)
lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts)
lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib)
#
# Build the declaration set into a normal library.
# Build a mixed waveguide with an L3 cavity in the middle
#
built, report = builder.build()
print('Built library contains:\n' + pformat(list(built.keys())))
print('Build dependency graph:\n' + pformat(report.dependency_graph))
# Start a new design by copying the ports from an existing library cell.
# This gives `circ2` the same external interface as `tri_l3cav`.
circ2 = Pather(library=lib, ports='tri_l3cav')
# First way to specify what we are plugging in: request an explicit abstract.
# This works with `Pattern` methods directly as well as with `Pather`.
circ2.plug(lib.abstract('wg10'), {'input': 'right'})
# Second way: use an `AbstractView`, which behaves like a mapping of names
# to abstracts.
abstracts = lib.abstract_view()
circ2.plug(abstracts['wg10'], {'output': 'left'})
# Third way: let `Pather` resolve a pattern name through its own library.
# This shorthand is convenient, but it is specific to helpers that already
# carry a library reference.
circ2.plug('tri_wg10', {'input': 'right'})
circ2.plug('tri_wg10', {'output': 'left'})
# Add the circuit to the device library.
lib['mixed_wg_cav'] = circ2.pattern
#
# Continue designing against the built library.
# Build a second device that is explicitly designed to mate with `circ2`.
#
# 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
# `Pather.interface()` makes a new pattern whose ports mirror an existing
# design's external interface. That is useful when you want to design an
# adapter, continuation, or mating structure.
circ3 = Pather.interface(source=circ2)
# Continue routing outward from those inherited ports.
circ3.plug('tri_bend0', {'input': 'right'})
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
circ3.plug('tri_bend0', {'input': 'right'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('bend0', {'output': 'left'})
circ3.plug('tri_wg10', {'input': 'right'})
circ3.plug('tri_wg28', {'input': 'right'})
circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'})
lib['loop_segment'] = circ3.pattern
#
# Write all devices into a GDS file.
# Write all devices into a GDS file
#
print('Writing library to file...')
writefile(built, 'library.gds', **GDS_OPTS)
# 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()
writefile(lib, 'library.gds', **GDS_OPTS)
if __name__ == '__main__':
main()
#
#class prout:
# def place(
# self,
# other: Pattern,
# label_layer: layer_t = 'WATLAYER',
# *,
# port_map: Dict[str, str | None] | None = None,
# **kwargs,
# ) -> 'prout':
#
# Pattern.place(self, other, port_map=port_map, **kwargs)
# name: str | None
# for name in other.ports:
# if port_map:
# assert(name is not None)
# name = port_map.get(name, name)
# if name is None:
# continue
# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer)
# return self
#

View file

@ -1,20 +1,10 @@
"""
Manual wire routing tutorial: Pather and primitive offers
Manual wire routing tutorial: Pather and AutoTool
"""
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.builder.tools import AutoTool, Tool
from masque.file.gdsii import writefile
from masque.library import ILibrary, SINGLE_USE_PREFIX
from basic_shapes import GDS_OPTS
@ -121,256 +111,6 @@ def map_layer(layer: layer_t) -> layer_t:
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
@ -393,46 +133,71 @@ def prepare_tools() -> tuple[Library, Tool, Tool]:
#
# Now, define two tools.
# M1_tool will route on M1, using wires with M1_WIDTH.
# M2_tool will route on M2, using wires with M2_WIDTH.
# M1_tool will route on M1, using wires with M1_WIDTH
# M2_tool will route on M2, using wires with M2_WIDTH
# Both tools are able to automatically transition from the other wire type (with a via)
#
# Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers
# directly: it tells `Pather` about native straight/bend primitives and about
# via adapters that can transition between M1 and M2 port types.
# Note that while we use AutoTool for this tutorial, you can define your own `Tool`
# with arbitrary logic inside -- e.g. with single-use bends, complex transition rules,
# transmission line geometry, or other features.
#
via = library.abstract('v1_via')
via_transitions = (
WireTransitionSpec(via, 'top', 'bottom'),
WireTransitionSpec(via, 'bottom', 'top'),
M1_tool = AutoTool(
# First, we need a function which takes in a length and spits out an M1 wire
straights = [
AutoTool.Straight(
ptype = 'm1wire',
fn = lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length),
in_port_name = 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input
out_port_name = 'output', # and use the port named 'output' as the output
),
],
bends = [
AutoTool.Bend(
abstract = library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier
in_port_name = 'input',
out_port_name = 'output',
clockwise = True,
),
],
transitions = { # We can automate transitions for different (normally incompatible) port types
('m2wire', 'm1wire'): AutoTool.Transition( # For example, when we're attaching to a port with type 'm2wire'
library.abstract('v1_via'), # we can place a V1 via
'top', # using the port named 'top' as the input (i.e. the M2 side of the via)
'bottom', # and using the port named 'bottom' as the output
),
},
sbends = [],
default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1
)
M1_tool = PrimitiveWireTool(
layer = 'M1',
width = M1_WIDTH,
ptype = 'm1wire',
bend = library.abstract('m1_bend'),
transitions = via_transitions,
M2_tool = AutoTool(
straights = [
# Again, we use make_straight_wire, but this time we set parameters for M2
AutoTool.Straight(
ptype = 'm2wire',
fn = lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length),
in_port_name = 'input',
out_port_name = 'output',
),
],
bends = [
# and we use an M2 bend
AutoTool.Bend(
abstract = library.abstract('m2_bend'),
in_port_name = 'input',
out_port_name = 'output',
),
],
transitions = {
('m1wire', 'm2wire'): AutoTool.Transition(
library.abstract('v1_via'), # We still use the same via,
'bottom', # but the input port is now 'bottom'
'top', # and the output port is now 'top'
),
},
sbends = [],
default_out_ptype = 'm2wire', # We default to trying to stay on M2
)
M2_tool = PrimitiveWireTool(
layer = 'M2',
width = M2_WIDTH,
ptype = 'm2wire',
bend = library.abstract('m2_bend'),
transitions = via_transitions,
)
# 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
@ -527,7 +292,7 @@ def main() -> None:
pather.straight('GND', x=-50_000)
# Save the pather's pattern into our library
library['Pather_and_PrimitiveOffers'] = pather.pattern
library['Pather_and_AutoTool'] = pather.pattern
# Convert from text-based layers to numeric layers for GDS, and output the file
library.map_layers(map_layer)

View file

@ -13,7 +13,7 @@ def main() -> None:
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 = Pather(library, tools=M2_tool, auto_render=False)
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'})
@ -157,7 +157,7 @@ def main() -> None:
#
# Rendering and Saving
#
# Since routing is deferred, we must call .render() to generate the geometry.
# Since we deferred auto-rendering, we must call .render() to generate the geometry.
rpather.render()
library['PortPather_Tutorial'] = rpather.pattern

View file

@ -2,7 +2,7 @@
Manual wire routing tutorial: deferred Pather and PathTool
"""
from masque import Pather, Library
from masque.builder import PathTool
from masque.builder.tools import PathTool
from masque.file.gdsii import writefile
from basic_shapes import GDS_OPTS
@ -13,7 +13,7 @@ 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
# but when used with `Pather(auto_render=False)`, 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`
@ -39,7 +39,7 @@ def main() -> None:
# and what port type to present.
M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire')
M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire')
rpather = Pather(tools=M2_ptool, library=library, render='deferred')
rpather = Pather(tools=M2_ptool, library=library, auto_render=False)
# As in the pather tutorial, we make some pads and labels...
rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'})

View file

@ -42,7 +42,6 @@ from .error import (
from .shapes import (
Shape as Shape,
Polygon as Polygon,
RectCollection as RectCollection,
Path as Path,
Circle as Circle,
Arc as Arc,
@ -59,24 +58,14 @@ from .pattern import (
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,
@ -85,13 +74,9 @@ from .ports import (
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,
SimpleTool as SimpleTool,
AutoTool as AutoTool,
PathTool as PathTool,
PortPather as PortPather,
@ -106,5 +91,5 @@ from .utils import (
__author__ = 'Jan Petykiewicz'
__version__ = '4.0a2'
__version__ = '3.4'
version = __version__ # legacy

View file

@ -1,90 +1,13 @@
"""
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,
RenderStep as RenderStep,
SimpleTool as SimpleTool,
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 .logging import logged_op as logged_op

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

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,13 +1,19 @@
"""Logging helpers for Pather."""
"""
Logging and operation decorators for Pather
"""
from typing import TYPE_CHECKING, Any
from collections.abc import Iterator, Sequence
from collections.abc import Iterator, Sequence, Callable
import logging
from functools import wraps
import inspect
import numpy
from contextlib import contextmanager
if TYPE_CHECKING:
from .pather import Pather
logger = logging.getLogger(__name__)
def _format_log_args(**kwargs) -> str:
arg_strs = []
@ -78,3 +84,37 @@ class PatherLogger:
self.indent -= 1
self.depth -= 1
def logged_op(
portspec_getter: Callable[[dict[str, Any]], str | Sequence[str] | None] | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""
Decorator to wrap Pather methods with logging.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
sig = inspect.signature(func)
@wraps(func)
def wrapper(self: 'Pather', *args: Any, **kwargs: Any) -> Any:
logger_obj = getattr(self, '_logger', None)
if logger_obj is None or not logger_obj.debug:
return func(self, *args, **kwargs)
bound = sig.bind(self, *args, **kwargs)
bound.apply_defaults()
all_args = bound.arguments
# remove 'self' from logged args
logged_args = {k: v for k, v in all_args.items() if k != 'self'}
ps = portspec_getter(all_args) if portspec_getter else None
# Remove portspec from logged_args if it's there to avoid duplicate arg to log_operation
logged_args.pop('portspec', None)
with logger_obj.log_operation(self, func.__name__, ps, **logged_args):
if getattr(self, '_dead', False) and func.__name__ in ('plug', 'place'):
logger.warning(f"Skipping geometry for {func.__name__}() since device is dead")
return func(self, *args, **kwargs)
return wrapper
return decorator

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

@ -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,5 +1,5 @@
from typing import TYPE_CHECKING
from collections.abc import Mapping
from typing import SupportsFloat, cast, TYPE_CHECKING
from collections.abc import Mapping, Sequence
from pprint import pformat
import numpy
@ -8,23 +8,11 @@ from numpy.typing import ArrayLike, NDArray
from ..utils import rotation_matrix_2d, SupportsBool
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
def ell(
ports: Mapping[str, 'Port'],
ccw: SupportsBool | None,
@ -94,18 +82,6 @@ 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):
@ -132,17 +108,9 @@ def ell(
else:
if set_rotation is 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
is_horizontal = numpy.isclose(rotations[0] % pi, 0)
if bound_type in ('ymin', 'ymax') and is_horizontal:
raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!')
if bound_type in ('xmin', 'xmax') and not is_horizontal:
@ -165,11 +133,7 @@ def ell(
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)
steps = numpy.zeros_like(y_order)
if spacing_arr.size == 1:
steps[1:] = spacing_arr[0]
elif spacing_arr.size == len(ports) - 1:
@ -209,34 +173,38 @@ 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])
rot_bound: SupportsFloat
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', 'xmax', 'ymax'):
extension = rot_bound - min_possible.max()
else:
elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'):
extension = rot_bound - min_possible.min()
offsets += extension

View file

@ -6,21 +6,17 @@ Notes:
* ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID
to unique values, so byte-for-byte reproducibility is not achievable for now
"""
from typing import Any, cast, TextIO, IO, Literal
from collections import defaultdict
from collections.abc import Mapping, Callable, Sequence
from typing import Any, cast, TextIO, IO
from collections.abc import Mapping, Callable
import io
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
from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, Label
@ -28,7 +24,6 @@ from ..library import ILibraryView, LibraryView, Library
from ..shapes import Shape, Polygon, Path
from ..repetition import Grid
from ..utils import rotation_matrix_2d, layer_t, normalize_mirror
from ..utils.boolean import _polytree_to_polygons
logger = logging.getLogger(__name__)
@ -178,9 +173,6 @@ 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]]:
"""
Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are
@ -191,30 +183,16 @@ def read(
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.
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()
top_name, top_pat = _read_block(msp)
mlib = Library({top_name: top_pat})
blocks_by_name = {
bb.name: bb
for bb in lib.blocks
@ -241,27 +219,12 @@ def read(
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)
for bb in lib.blocks:
if bb.is_any_layout:
continue
if bb.name.startswith('_') and bb.name not in referenced:
continue
name, pat = _read_block(bb)
mlib[name] = pat
library_info = dict(
@ -271,15 +234,9 @@ def read(
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]:
def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> 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):
@ -290,9 +247,6 @@ def _read_block(
is_closed = element.is_closed
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):
@ -306,44 +260,26 @@ def _read_block(
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 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:
if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])):
verts = numpy.vstack((verts, verts[0]))
is_closed = is_closed or endpoint_closed
shape: Path | Polygon
if width == 0 and polyline_mode >= 3:
contours[layer].append(verts)
continue
if width == 0 and is_closed and polyline_mode == 2 and _is_polygon(verts):
shape = Polygon(vertices=verts[:-1])
if width == 0 and is_closed:
# Use Polygon if it has at least 3 unique vertices
shape_verts = verts[:-1] if len(verts) > 1 else verts
if len(shape_verts) >= 3:
shape = Polygon(vertices=shape_verts)
else:
shape = Path(width=width, vertices=verts)
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)
@ -380,8 +316,7 @@ def _read_block(
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
rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle
offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2]
@ -393,144 +328,64 @@ def _read_block(
rotation=rotation,
)
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)
if 'column_count' in attr:
col_spacing = attr['column_spacing']
row_spacing = attr['row_spacing']
col_count = attr['column_count']
row_count = attr['row_count']
local_x = numpy.array((col_spacing, 0.0))
local_y = numpy.array((0.0, row_spacing))
# 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,
inv_rot = rotation_matrix_2d(-rotation)
candidates = (
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
)
repetition = None
for a_vector, b_vector, a_count, b_count in candidates:
rotated_a = rotation_matrix_2d(rotation) @ a_vector
rotated_b = rotation_matrix_2d(rotation) @ b_vector
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
and a_count == col_count
and b_count == row_count):
repetition = Grid(
a_vector=a_vector,
b_vector=b_vector,
a_count=a_count,
b_count=b_count,
)
break
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
and b_count == col_count
and a_count == row_count):
repetition = Grid(
a_vector=a_vector,
b_vector=b_vector,
a_count=a_count,
b_count=b_count,
)
break
if repetition is None:
repetition = Grid(
a_vector=inv_rot @ local_x,
b_vector=inv_rot @ local_y,
a_count=col_count,
b_count=row_count,
)
args['repetition'] = repetition
pat.ref(**args)
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
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]],
@ -552,10 +407,12 @@ def _mrefs_to_drefs(
# 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
# Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90)
# AND the grid is already manhattan.
# Rotate basis vectors by the reference rotation to see where they end up in the DXF frame
rotated_a = rotation_matrix_2d(ref.rotation) @ a
rotated_b = rotation_matrix_2d(ref.rotation) @ b
if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8):
attribs['column_count'] = rep.a_count

View file

@ -19,9 +19,11 @@ Notes:
* GDS creation/modification/access times are set to 1900-01-01 for reproducibility.
* Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01)
"""
from typing import IO, Any
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
from types import MappingProxyType
import io
import mmap
import logging
import pathlib
import gzip
@ -33,37 +35,79 @@ 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
from .utils import is_gzipped, tmpfile
from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path
from ..repetition import Grid
from ..utils import layer_t, annotations_t
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
logger = logging.getLogger(__name__)
_PATH_CAP_MAP = {
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({})
RO_EMPTY_DICT: Mapping[int, bytes] = MappingProxyType({})
def _rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]:
return numpy.rint(val).astype(numpy.int32)
def _write_header(
def write(
library: Mapping[str, Pattern],
stream: IO[bytes],
meters_per_unit: float,
logical_units_per_unit: float,
library_name: str,
logical_units_per_unit: float = 1,
library_name: str = 'masque-klamath',
) -> None:
"""
Convert a library to a GDSII stream, mapping data as follows:
Pattern -> GDSII structure
Ref -> 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`
GDS does not support shape repetition (only cell repetition). Please call
`library.wrap_repeated_shapes()` before writing to file.
Other functions you may want to call:
- `masque.file.gdsii.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`
Args:
library: A {name: Pattern} mapping of patterns to write.
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'.
"""
if not isinstance(library, ILibrary):
if isinstance(library, dict):
library = Library(library)
else:
library = Library(dict(library))
# Create library
header = klamath.library.FileHeader(
name=library_name.encode('ASCII'),
user_units_per_db_unit=logical_units_per_unit,
@ -71,19 +115,51 @@ def _write_header(
)
header.write(stream)
# Now create a structure for each pattern, and add in any Boundary and SREF elements
for name, pat in library.items():
elements: list[klamath.elements.Element] = []
elements += _shapes_to_elements(pat.shapes)
elements += _labels_to_texts(pat.labels)
elements += _mrefs_to_grefs(pat.refs)
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:
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
records.ENDLIB.write(stream, None)
def writefile(
library: Mapping[str, Pattern],
filename: 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:
library: {name: Pattern} pairs to save.
filename: Filename to save to.
*args: passed to `write()`
**kwargs: passed to `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', compresslevel=6))
streams = (stream,) + streams
else:
stream = base_stream
try:
write(library, stream, *args, **kwargs)
finally:
for ss in streams:
ss.close()
def readfile(
filename: str | pathlib.Path,
*args,
@ -140,7 +216,7 @@ def read(
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)
pat = read_elements(stream, raw_mode=raw_mode)
mlib[name.decode('ASCII')] = pat
found_struct = records.BGNSTR.skip_past(stream)
@ -160,7 +236,7 @@ def _read_header(stream: IO[bytes]) -> dict[str, Any]:
return library_info
def _read_elements(
def read_elements(
stream: IO[bytes],
raw_mode: bool = True,
) -> Pattern:
@ -242,45 +318,31 @@ def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]:
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]
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
mpath = Path(
vertices=gpath.xy.astype(float),
width=gpath.width,
cap=cap,
offset=numpy.zeros(2),
annotations=_properties_to_annotations(gpath.properties),
raw=raw_mode,
)
if cap == Path.Cap.SquareCustom:
cap_extensions = numpy.asarray(gpath.extension, dtype=float)
if raw_mode:
mpath = Path._from_raw(
vertices=vertices,
width=gpath.width,
cap=cap,
cap_extensions=cap_extensions,
annotations=annotations,
)
else:
mpath = Path(
vertices=vertices,
width=gpath.width,
cap=cap,
cap_extensions=cap_extensions,
offset=numpy.zeros(2),
annotations=annotations,
)
mpath.cap_extensions = gpath.extension
return gpath.layer, mpath
def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]:
vertices = boundary.xy[:-1].astype(float)
annotations = _properties_to_annotations(boundary.properties)
if raw_mode:
poly = Polygon._from_raw(vertices=vertices, annotations=annotations)
else:
poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations)
return boundary.layer, poly
return boundary.layer, Polygon(
vertices=boundary.xy[:-1].astype(float),
offset=numpy.zeros(2),
annotations=_properties_to_annotations(boundary.properties),
raw=raw_mode,
)
def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]:
@ -305,7 +367,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
])
aref = klamath.library.Reference(
struct_name=encoded_name,
xy=_rint_cast(xy),
xy=rint_cast(xy),
colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)),
angle_deg=angle_deg,
invert_y=ref.mirrored,
@ -316,7 +378,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
elif rep is None:
sref = klamath.library.Reference(
struct_name=encoded_name,
xy=_rint_cast([ref.offset]),
xy=rint_cast([ref.offset]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
@ -328,7 +390,7 @@ def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.R
new_srefs = [
klamath.library.Reference(
struct_name=encoded_name,
xy=_rint_cast([ref.offset + dd]),
xy=rint_cast([ref.offset + dd]),
colrow=None,
angle_deg=angle_deg,
invert_y=ref.mirrored,
@ -348,7 +410,7 @@ def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t
def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]:
if annotations is None:
return _EMPTY_PROPERTIES
return RO_EMPTY_DICT
cum_len = 0
props = {}
for key, vals in annotations.items():
@ -385,13 +447,13 @@ def _shapes_to_elements(
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
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))
extension = tuple(rint_cast(shape.cap_extensions))
else:
extension = (0, 0)
@ -404,20 +466,6 @@ def _shapes_to_elements(
properties=properties,
)
elements.append(path)
elif isinstance(shape, RectCollection):
for rect in shape.rects:
xy_closed = numpy.empty((5, 2), dtype=numpy.int32)
xy_closed[0] = _rint_cast((rect[0], rect[1]))
xy_closed[1] = _rint_cast((rect[0], rect[3]))
xy_closed[2] = _rint_cast((rect[2], rect[3]))
xy_closed[3] = _rint_cast((rect[2], rect[1]))
xy_closed[4] = xy_closed[0]
boundary = klamath.elements.Boundary(
layer=(layer, data_type),
xy=xy_closed,
properties=properties,
)
elements.append(boundary)
elif isinstance(shape, Polygon):
polygon = shape
xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32)
@ -449,7 +497,7 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
layer, text_type = _mlayer2gds(mlayer)
for label in lseq:
properties = _annotations_to_properties(label.annotations, 128)
xy = _rint_cast([label.offset])
xy = rint_cast([label.offset])
text = klamath.elements.Text(
layer=(layer, text_type),
xy=xy,
@ -466,6 +514,117 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
return texts
def load_library(
stream: IO[bytes],
*,
full_load: bool = False,
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Scan a GDSII stream to determine what structures are present, and create
a library from them. This enables deferred reading of structures
on an as-needed basis.
All structures are loaded as secondary
Args:
stream: Seekable stream. Position 0 should be the start of the file.
The caller should leave the stream open while the library
is still in use, since the library will need to access it
in order to read the structure contents.
full_load: If True, force all structures to be read immediately rather
than as-needed. Since data is read sequentially from the file, this
will be faster than using the resulting library's `precache` method.
postprocess: If given, this function is used to post-process each
pattern *upon first load only*.
Returns:
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
stream.seek(0)
lib = LazyLibrary()
if full_load:
# Full load approach (immediately load everything)
patterns, library_info = read(stream)
for name, pattern in patterns.items():
if postprocess is not None:
lib[name] = postprocess(lib, name, pattern)
else:
lib[name] = pattern
return lib, library_info
# Normal approach (scan and defer load)
library_info = _read_header(stream)
structs = klamath.library.scan_structs(stream)
for name_bytes, pos in structs.items():
name = name_bytes.decode('ASCII')
def mkstruct(pos: int = pos, name: str = name) -> Pattern:
stream.seek(pos)
pat = read_elements(stream, raw_mode=True)
if postprocess is not None:
pat = postprocess(lib, name, pat)
return pat
lib[name] = mkstruct
return lib, library_info
def load_libraryfile(
filename: str | pathlib.Path,
*,
use_mmap: bool = True,
full_load: bool = False,
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Wrapper for `load_library()` that takes a filename or path instead of a stream.
Will automatically decompress the file if it is gzipped.
NOTE that any streams/mmaps opened will remain open until ALL of the
`PatternGenerator` objects in the library are garbage collected.
Args:
path: filename or path to read from
use_mmap: If `True`, will attempt to memory-map the file instead
of buffering. In the case of gzipped files, the file
is decompressed into a python `bytes` object in memory
and reopened as an `io.BytesIO` stream.
full_load: If `True`, immediately loads all data. See `load_library`.
postprocess: Passed to `load_library`
Returns:
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
path = pathlib.Path(filename)
stream: IO[bytes]
if is_gzipped(path):
if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BytesIO(gz_stream.read()) # type: ignore
else:
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BufferedReader(gz_stream) # type: ignore
else: # noqa: PLR5501
if use_mmap:
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
else:
stream = path.open(mode='rb') # noqa: SIM115
try:
return load_library(stream, full_load=full_load, postprocess=postprocess)
finally:
if full_load:
stream.close()
def check_valid_names(
names: Iterable[str],
max_length: int = 32,

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

View file

@ -565,9 +565,8 @@ def _shapes_to_elements(
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)
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,

View file

@ -9,15 +9,14 @@ from numpy.typing import ArrayLike
import svgwrite # type: ignore
from .utils import mangle_name
from .. import Pattern, Ref
from ..library import IMaterializable
from .. import Pattern
from ..utils import rotation_matrix_2d
logger = logging.getLogger(__name__)
def _ref_to_svg_transform(ref: Ref) -> str:
def _ref_to_svg_transform(ref) -> str:
linear = rotation_matrix_2d(ref.rotation) * ref.scale
if ref.mirrored:
linear = linear @ numpy.diag((1.0, -1.0))
@ -46,13 +45,6 @@ def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]:
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()}
def writefile(
library: Mapping[str, Pattern],
top: str,
@ -61,12 +53,13 @@ def writefile(
annotate_ports: bool = False,
) -> None:
"""
Write a Pattern to an SVG file, by first calling .polygonize() on a detached
materialized copy
Write a Pattern to an SVG file, by first calling .polygonize() on it
to change the shapes into polygons, and then writing patterns as SVG
groups (<g>, inside <defs>), polygons as paths (<path>), and refs
as <use> elements.
Note that this function modifies the Pattern.
If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute
is written to the relevant elements.
@ -78,21 +71,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)`).
"""
detached = _detached_library(library)
pattern = detached[top]
pattern = library[top]
# Polygonize pattern
pattern.polygonize()
bounds = pattern.get_bounds(library=detached)
bounds = pattern.get_bounds(library=library)
if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)
@ -105,10 +96,10 @@ def writefile(
# Create file
svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string,
debug=(not custom_attributes))
svg_ids = _make_svg_ids(detached)
svg_ids = _make_svg_ids(library)
# Now create a group for each pattern and add in any Boundary and Use elements
for name, pat in detached.items():
for name, pat in library.items():
svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red')
for layer, shapes in pat.shapes.items():
@ -167,21 +158,21 @@ def writefile_inverted(
box and drawing the polygons with reverse vertex order inside it, all within
one `<path>` element.
Note that this function modifies the Pattern.
If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()`
prior to calling this function.
Args:
library: Mapping of pattern names to patterns.
top: Name of the top-level pattern to render.
pattern: Pattern to write to file. Modified by this function.
filename: Filename to write to.
"""
detached = _detached_library(library)
pattern = detached[top]
pattern = library[top]
# Polygonize and flatten pattern
pattern.polygonize().flatten(detached)
pattern.polygonize().flatten(library)
bounds = pattern.get_bounds(library=detached)
bounds = pattern.get_bounds(library=library)
if bounds is None:
bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]])
logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1)

View file

@ -14,188 +14,12 @@ 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 ..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:
"""
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.
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,
@ -206,35 +30,39 @@ def preflight(
wrap_repeated_shapes: bool = False,
) -> Library:
"""
Run a standard set of useful operations and checks on an entire library.
Run a standard set of useful operations and checks, usually done immediately prior
to writing to a file (or immediately after reading).
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.
Note that this helper is not copy-isolating. When `sort=True`, it constructs a new
`Library` wrapper around the same `Pattern` objects after sorting them in place, so
later mutating preflight steps such as `prune_empty_patterns` and
`wrap_repeated_shapes` may still mutate caller-owned patterns. Callers that need
isolation should deep-copy the library before calling `preflight()`.
Args:
sort: Whether to sort 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.
sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents.
Default True. Useful for reproducible builds.
sort_elements: Whether to sort the pattern contents. Requires sort=True to run.
allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not
in the provided library. If `True`, no check is performed; if `False`, a `LibraryError`
is raised instead.
allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by
a string instead of a number (or tuple).
prune_empty_patterns: Runs `Library.prune_empty()`, recursively deleting any empty patterns.
wrap_repeated_shapes: Runs `Library.wrap_repeated_shapes()`, turning repeated shapes into
repeated refs containing non-repeated shapes.
Returns:
`lib`, or an equivalent name-sorted `Library` when `sort=True`.
`lib` or an equivalent sorted library
"""
mutable_lib = lib
if sort:
mutable_lib = Library(dict(sorted(
(nn, pp.sort(sort_elements=sort_elements)) for nn, pp in mutable_lib.items()
lib = Library(dict(sorted(
(nn, pp.sort(sort_elements=sort_elements)) for nn, pp in lib.items()
)))
if not allow_dangling_refs:
refs = mutable_lib.referenced_patterns()
dangling = refs - set(mutable_lib.keys())
refs = lib.referenced_patterns()
dangling = refs - set(lib.keys())
if dangling:
msg = 'Dangling refs found: ' + pformat(dangling)
if allow_dangling_refs is None:
@ -244,7 +72,7 @@ def preflight(
if not allow_named_layers:
named_layers: Mapping[str, set] = defaultdict(set)
for name, pat in mutable_lib.items():
for name, pat in lib.items():
for layer in chain(pat.shapes.keys(), pat.labels.keys()):
if isinstance(layer, str):
named_layers[name].add(layer)
@ -253,8 +81,8 @@ def preflight(
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)
prune_dangling = 'error' if allow_dangling_refs is False else 'ignore'
pruned = lib.prune_empty(dangling=prune_dangling)
if pruned:
logger.info(f'Preflight pruned {len(pruned)} empty patterns')
logger.debug('Pruned: ' + pformat(pruned))
@ -262,9 +90,9 @@ def preflight(
logger.debug('Preflight found no empty patterns')
if wrap_repeated_shapes:
mutable_lib.wrap_repeated_shapes()
lib.wrap_repeated_shapes()
return mutable_lib
return lib
def mangle_name(name: str) -> str:

View file

@ -53,22 +53,6 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
@classmethod
def _from_raw(
cls,
string: str,
*,
offset: NDArray[numpy.float64],
repetition: Repetition | None = None,
annotations: annotations_t | None = None,
) -> Self:
new = cls.__new__(cls)
new._string = string
new._offset = offset
new._repetition = repetition
new._annotations = annotations
return new
def __copy__(self) -> Self:
return type(self)(
string=self.string,

File diff suppressed because it is too large Load diff

View file

@ -1,35 +0,0 @@
"""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

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

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

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 +0,0 @@
"""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
logger = logging.getLogger(__name__)
SINGLE_USE_PREFIX = '_'
"""
Names starting with this prefix are assumed to refer to single-use patterns,
which may be renamed automatically by `ILibrary.add()` (via
`rename_theirs=_rename_patterns()` )
"""
# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere?
class INameView(Collection[str], ABC):
"""
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.
"""
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)

View file

@ -30,41 +30,6 @@ from .ports import Port, PortList
logger = logging.getLogger(__name__)
def _check_ref_cycles(
pattern: 'Pattern',
library: Mapping[str, 'Pattern'],
*,
operation: str,
) -> None:
visited: set[str] = set()
active: list[str] = []
active_set: set[str] = set()
def visit(current: 'Pattern') -> None:
for target, refs in current.refs.items():
if target is None or not refs:
continue
if target in active_set:
cycle_start = active.index(target)
cycle = active[cycle_start:] + [target]
raise PatternError(
f'Circular reference while {operation}: {" -> ".join(cycle)}'
)
if target in visited:
continue
active.append(target)
active_set.add(target)
try:
visit(library[target])
finally:
active.pop()
active_set.remove(target)
visited.add(target)
visit(pattern)
@functools.total_ordering
class Pattern(PortList, AnnotatableImpl, Mirrorable):
"""
@ -544,8 +509,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
layer: layer_t,
flatten: bool = True,
library: Mapping[str, 'Pattern'] | None = None,
*,
_cycle_checked: bool = False,
) -> list[Polygon]:
"""
Collect all geometry effectively on a given layer as a list of polygons.
@ -561,15 +524,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
A list of `Polygon` objects.
Raises:
PatternError: If `flatten=True` and the referenced hierarchy contains a cycle.
"""
if flatten and self.has_refs() and library is None:
raise PatternError("Must provide a library to layer_as_polygons() when flatten=True")
if flatten and self.has_refs() and not _cycle_checked:
assert library is not None
_check_ref_cycles(self, library, operation='collecting layer polygons')
polys: list[Polygon] = []
@ -586,17 +543,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if flatten and self.has_refs():
assert library is not None
for target, refs in self.refs.items():
if target is None or not refs:
if target is None:
continue
target_pat = library[target]
for ref in refs:
# Get polygons from target pattern on the same layer
ref_polys = target_pat.layer_as_polygons(
layer,
flatten=True,
library=library,
_cycle_checked=True,
)
ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library)
# Apply ref transformations
for p in ref_polys:
p_pat = ref.as_pattern(Pattern(shapes={layer: [p]}))
@ -614,15 +566,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
A set of all pattern names referenced by this pattern.
"""
return {target for target, refs in self.refs.items() if refs}
return set(self.refs.keys())
def get_bounds(
self,
library: Mapping[str, 'Pattern'] | None = None,
recurse: bool = True,
cache: MutableMapping[str, NDArray[numpy.float64] | None] | None = None,
*,
_cycle_checked: bool = False,
) -> NDArray[numpy.float64] | None:
"""
Return a `numpy.ndarray` containing `[[x_min, y_min], [x_max, y_max]]`, corresponding to the
@ -639,13 +589,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
Returns:
`[[x_min, y_min], [x_max, y_max]]` or `None`
Raises:
PatternError: If recursive bounds are requested for a cyclic hierarchy.
"""
if recurse and self.has_refs() and library is not None and not _cycle_checked:
_check_ref_cycles(self, library, operation='calculating bounds')
if self.is_empty():
return None
@ -684,12 +628,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
if target in cache:
unrot_bounds = cache[target]
elif any(numpy.isclose(ref.rotation % (pi / 2), 0) for ref in refs):
unrot_bounds = library[target].get_bounds(
library=library,
recurse=recurse,
cache=cache,
_cycle_checked=True,
)
unrot_bounds = library[target].get_bounds(library=library, recurse=recurse, cache=cache)
cache[target] = unrot_bounds
for ref in refs:
@ -1188,17 +1127,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
overdraw: Whether to create a new figure or draw on a pre-existing one.
filename: If provided, save the figure to this file instead of showing it.
ports: If True, annotate the plot with arrows representing the ports.
Raises:
PatternError: If the referenced hierarchy contains a cycle.
"""
# TODO: add text labels to visualize()
if self.has_refs() and library is None:
raise PatternError('Must provide a library when visualizing a pattern with refs')
if self.has_refs():
assert library is not None
_check_ref_cycles(self, library, operation='visualizing pattern hierarchy')
try:
from matplotlib import pyplot # type: ignore #noqa: PLC0415
import matplotlib.collections # type: ignore #noqa: PLC0415
@ -1207,6 +1137,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
+ 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.')
raise
if self.has_refs() and library is None:
raise PatternError('Must provide a library when visualizing a pattern with refs')
# Cache for {Pattern object ID: List of local polygon vertex arrays}
# Polygons are stored relative to the pattern's origin (offset included)
poly_cache: dict[int, list[NDArray[numpy.float64]]] = {}
@ -1270,7 +1203,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
# 3. Recurse into refs
for target, refs in pat.refs.items():
if target is None or not refs:
if target is None:
continue
assert library is not None
target_pat = library[target]
@ -1465,9 +1398,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys())
if annotation_conflicts:
raise PatternError(f'Annotation keys overlap: {annotation_conflicts}')
elif isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
else:
if isinstance(other, Pattern):
raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. '
'Use `append=True` if you intended to append the full geometry.')
ports = {}
for name, port in other.ports.items():
@ -1593,10 +1527,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`).
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will emit a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
skip_geometry: If `True`, only ports are updated and geometry is
skipped. If a valid transform cannot be found (e.g. due to
misaligned ports), a 'best-effort' dummy transform is used
@ -1829,8 +1764,6 @@ def map_layers(
new_elements: defaultdict[layer_t, list[TT]] = defaultdict(list)
for old_layer, seq in elements.items():
new_layer = map_layer(old_layer)
if new_layer is None:
raise PatternError(f'Layer mapping returned None for source layer {old_layer!r}')
new_elements[new_layer].extend(seq)
return new_elements

View file

@ -12,7 +12,7 @@ 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 .utils import rotate_offsets_around, rotation_matrix_2d
from .error import PortError, format_stacktrace
@ -542,7 +542,7 @@ class PortList(metaclass=ABCMeta):
a_types = [pp.ptype for pp in a_ports]
b_types = [pp.ptype for pp in b_ports]
type_conflicts = numpy.array([not ptypes_compatible(at, bt)
type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt)
for at, bt in zip(a_types, b_types, strict=True)])
if type_conflicts.any():
@ -574,10 +574,10 @@ class PortList(metaclass=ABCMeta):
raise PortError(msg)
translations = a_offsets - b_offsets
if not numpy.allclose(a_offsets, b_offsets):
if not numpy.allclose(translations, 0):
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]):
if not numpy.allclose(translations[nn], 0):
msg += f'{kk} | {translations[nn]} | {vv}\n'
raise PortError(msg)
@ -641,10 +641,11 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `other` should be rotated. Otherwise,
`set_rotation` must remain `None`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will log a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
Returns:
- The (x, y) translation (performed last)
@ -693,10 +694,11 @@ class PortList(metaclass=ABCMeta):
port with `rotation=None`), `set_rotation` must be provided
to indicate how much `o_ports` should be rotated. Otherwise,
`set_rotation` must remain `None`.
ok_connections: Set of additional allowed ptype combinations.
Ptypes accepted by the shared compatibility policy are always
allowed. Non-allowed ptype connections will log a warning.
Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`.
ok_connections: Set of "allowed" ptype combinations. Identical
ptypes are always allowed to connect, as is `'unk'` with
any other ptypte. Non-allowed ptype connections will log a
warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`.
Returns:
- The (x, y) translation (performed last)
@ -723,10 +725,8 @@ class PortList(metaclass=ABCMeta):
o_rotations *= -1
ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]}
type_conflicts = numpy.array([
not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs
for st, ot in zip(s_types, o_types, strict=True)
])
type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs)
for st, ot in zip(s_types, o_types, strict=True)])
if type_conflicts.any():
msg = 'Ports have conflicting types:\n'
for nn, (kk, vv) in enumerate(map_in.items()):

View file

@ -86,26 +86,6 @@ class Ref(
self.repetition = repetition
self.annotations = annotations if annotations is not None else {}
@classmethod
def _from_raw(
cls,
*,
offset: NDArray[numpy.float64],
rotation: float,
mirrored: bool,
scale: float,
repetition: Repetition | None,
annotations: annotations_t | None,
) -> Self:
new = cls.__new__(cls)
new._offset = offset
new._rotation = rotation % (2 * pi)
new._scale = scale
new._mirrored = mirrored
new._repetition = repetition
new._annotations = annotations
return new
def __copy__(self) -> 'Ref':
new = Ref(
offset=self.offset.copy(),

View file

@ -113,22 +113,6 @@ class Grid(Repetition):
self.a_count = a_count
self.b_count = b_count
@classmethod
def _from_raw(
cls: type[GG],
*,
a_vector: NDArray[numpy.float64],
a_count: int,
b_vector: NDArray[numpy.float64],
b_count: int,
) -> GG:
new = cls.__new__(cls)
new._a_vector = a_vector
new._b_vector = b_vector
new._a_count = int(a_count)
new._b_count = int(b_count)
return new
@classmethod
def aligned(
cls: type[GG],

View file

@ -11,7 +11,6 @@ from .shape import (
from .polygon import Polygon as Polygon
from .poly_collection import PolyCollection as PolyCollection
from .rect_collection import RectCollection as RectCollection
from .circle import Circle as Circle
from .ellipse import Ellipse as Ellipse
from .arc import Arc as Arc

View file

@ -197,39 +197,29 @@ class Arc(PositionableImpl, Shape):
repetition: Repetition | None = None,
annotations: annotations_t = None,
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
raw: bool = False,
) -> None:
self.radii = radii
self.angles = angles
self.width = width
self.offset = offset
self.rotation = rotation
self.angle_ref = angle_ref
self.repetition = repetition
self.annotations = annotations
@classmethod
def _from_raw(
cls,
*,
radii: NDArray[numpy.float64],
angles: NDArray[numpy.float64],
width: float,
offset: NDArray[numpy.float64],
rotation: float,
annotations: annotations_t = None,
repetition: Repetition | None = None,
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
) -> 'Arc':
new = cls.__new__(cls)
new._radii = radii
new._angles = angles
new._width = width
new._offset = offset
new._rotation = rotation % (2 * pi)
new._angle_ref = ArcAngleRef(angle_ref)
new._repetition = repetition
new._annotations = annotations
return new
if raw:
assert isinstance(radii, numpy.ndarray)
assert isinstance(angles, numpy.ndarray)
assert isinstance(offset, numpy.ndarray)
self._radii = radii
self._angles = angles
self._width = width
self._offset = offset
self._rotation = rotation
self._angle_ref = ArcAngleRef(angle_ref)
self._repetition = repetition
self._annotations = annotations
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
def __deepcopy__(self, memo: dict | None = None) -> 'Arc':
memo = {} if memo is None else memo
@ -423,10 +413,11 @@ class Arc(PositionableImpl, Shape):
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()
if self.angle_ref != ArcAngleRef.Center:
x_major = self.radius_x > self.radius_y
y_major = self.radius_y > self.radius_x
if (axis == 0 and y_major) or (axis == 1 and x_major):
self._swap_focus_ref()
self.rotation *= -1
self.rotation += axis * pi
self.angles *= -1

View file

@ -50,27 +50,19 @@ class Circle(PositionableImpl, Shape):
offset: ArrayLike = (0.0, 0.0),
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
self.radius = radius
self.offset = offset
self.repetition = repetition
self.annotations = annotations
@classmethod
def _from_raw(
cls,
*,
radius: float,
offset: NDArray[numpy.float64],
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> 'Circle':
new = cls.__new__(cls)
new._radius = radius
new._offset = offset
new._repetition = repetition
new._annotations = annotations
return new
if raw:
assert isinstance(offset, numpy.ndarray)
self._radius = radius
self._offset = offset
self._repetition = repetition
self._annotations = annotations
else:
self.radius = radius
self.offset = offset
self.repetition = repetition
self.annotations = annotations
def __deepcopy__(self, memo: dict | None = None) -> 'Circle':
memo = {} if memo is None else memo

View file

@ -95,30 +95,22 @@ class Ellipse(PositionableImpl, Shape):
rotation: float = 0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
self.radii = radii
self.offset = offset
self.rotation = rotation
self.repetition = repetition
self.annotations = annotations
@classmethod
def _from_raw(
cls,
*,
radii: NDArray[numpy.float64],
offset: NDArray[numpy.float64],
rotation: float,
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._radii = radii
new._offset = offset
new._rotation = rotation % pi
new._repetition = repetition
new._annotations = annotations
return new
if raw:
assert isinstance(radii, numpy.ndarray)
assert isinstance(offset, numpy.ndarray)
self._radii = radii
self._offset = offset
self._rotation = rotation
self._repetition = repetition
self._annotations = annotations
else:
self.radii = radii
self.offset = offset
self.rotation = rotation
self.repetition = repetition
self.annotations = annotations
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo

View file

@ -201,43 +201,34 @@ class Path(Shape):
rotation: float = 0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
self._cap_extensions = None # Since .cap setter might access it
self.vertices = vertices
self.repetition = repetition
self.annotations = annotations
self._cap = cap
if cap == PathCap.SquareCustom and cap_extensions is None:
self._cap_extensions = numpy.zeros(2)
if raw:
assert isinstance(vertices, numpy.ndarray)
assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None
self._vertices = vertices
self._repetition = repetition
self._annotations = annotations
self._width = width
self._cap = cap
self._cap_extensions = cap_extensions
else:
self.cap_extensions = cap_extensions
self.width = width
self.vertices = vertices
self.repetition = repetition
self.annotations = annotations
self._cap = cap
if cap == PathCap.SquareCustom and cap_extensions is None:
self._cap_extensions = numpy.zeros(2)
else:
self.cap_extensions = cap_extensions
self.width = width
if rotation:
self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
@classmethod
def _from_raw(
cls,
*,
vertices: NDArray[numpy.float64],
width: float,
cap: PathCap,
cap_extensions: NDArray[numpy.float64] | None = None,
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._vertices = vertices
new._width = width
new._cap = cap
new._cap_extensions = cap_extensions
new._repetition = repetition
new._annotations = annotations
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Path':
memo = {} if memo is None else memo
new = copy.copy(self)

View file

@ -34,7 +34,7 @@ class PolyCollection(Shape):
_vertex_lists: NDArray[numpy.float64]
""" 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """
_vertex_offsets: NDArray[numpy.integer[Any]]
_vertex_offsets: NDArray[numpy.intp]
""" 1D NDArray specifying the starting offset for each polygon """
@property
@ -45,7 +45,7 @@ class PolyCollection(Shape):
return self._vertex_lists
@property
def vertex_offsets(self) -> NDArray[numpy.integer[Any]]:
def vertex_offsets(self) -> NDArray[numpy.intp]:
"""
Starting offset (in `vertex_lists`) for each polygon
"""
@ -63,7 +63,7 @@ class PolyCollection(Shape):
chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]),
strict=True,
):
yield slice(int(ii), int(ff))
yield slice(ii, ff)
@property
def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]:
@ -100,32 +100,25 @@ class PolyCollection(Shape):
rotation: float = 0.0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
self.repetition = repetition
self.annotations = annotations
if raw:
assert isinstance(vertex_lists, numpy.ndarray)
assert isinstance(vertex_offsets, numpy.ndarray)
self._vertex_lists = vertex_lists
self._vertex_offsets = vertex_offsets
self._repetition = repetition
self._annotations = annotations
else:
self._vertex_lists = numpy.asarray(vertex_lists, dtype=float)
self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp)
self.repetition = repetition
self.annotations = annotations
if rotation:
self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
@classmethod
def _from_raw(
cls,
*,
vertex_lists: NDArray[numpy.float64],
vertex_offsets: NDArray[numpy.integer[Any]],
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._vertex_lists = vertex_lists
new._vertex_offsets = vertex_offsets
new._repetition = repetition
new._annotations = annotations
return new
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)
@ -139,7 +132,7 @@ class PolyCollection(Shape):
return (
type(self) is type(other)
and numpy.array_equal(self._vertex_lists, other._vertex_lists)
and numpy.array_equal(self.vertex_offsets, other.vertex_offsets)
and numpy.array_equal(self._vertex_offsets, other._vertex_offsets)
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
@ -222,11 +215,11 @@ class PolyCollection(Shape):
# TODO: normalize mirroring?
return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()),
return ((type(self), rotated_vertices.data.tobytes() + self._vertex_offsets.tobytes()),
(offset, scale / norm_value, rotation, False),
lambda: PolyCollection(
vertex_lists=rotated_vertices * norm_value,
vertex_offsets=self.vertex_offsets.copy(),
vertex_offsets=self._vertex_offsets.copy(),
),
)

View file

@ -115,29 +115,22 @@ class Polygon(Shape):
rotation: float = 0.0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
self.vertices = vertices
self.repetition = repetition
self.annotations = annotations
if raw:
assert isinstance(vertices, numpy.ndarray)
self._vertices = vertices
self._repetition = repetition
self._annotations = annotations
else:
self.vertices = vertices
self.repetition = repetition
self.annotations = annotations
if rotation:
self.rotate(rotation)
if numpy.any(offset):
self.translate(offset)
@classmethod
def _from_raw(
cls,
*,
vertices: NDArray[numpy.float64],
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._vertices = vertices
new._repetition = repetition
new._annotations = annotations
return new
def __deepcopy__(self, memo: dict | None = None) -> 'Polygon':
memo = {} if memo is None else memo
new = copy.copy(self)

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

@ -73,40 +73,27 @@ class Text(PositionableImpl, RotatableImpl, Shape):
mirrored: bool = False,
repetition: Repetition | None = None,
annotations: annotations_t = None,
raw: bool = False,
) -> None:
self.offset = offset
self.string = string
self.height = height
self.rotation = rotation
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations
if raw:
assert isinstance(offset, numpy.ndarray)
self._offset = offset
self._string = string
self._height = height
self._rotation = rotation
self._mirrored = mirrored
self._repetition = repetition
self._annotations = annotations
else:
self.offset = offset
self.string = string
self.height = height
self.rotation = rotation
self.mirrored = mirrored
self.repetition = repetition
self.annotations = annotations
self.font_path = font_path
@classmethod
def _from_raw(
cls,
*,
string: str,
height: float,
font_path: str,
offset: NDArray[numpy.float64],
rotation: float,
mirrored: bool,
annotations: annotations_t = None,
repetition: Repetition | None = None,
) -> Self:
new = cls.__new__(cls)
new._offset = offset
new._string = string
new._height = height
new._rotation = rotation % (2 * pi)
new._mirrored = mirrored
new._repetition = repetition
new._annotations = annotations
new.font_path = font_path
return new
def __deepcopy__(self, memo: dict | None = None) -> Self:
memo = {} if memo is None else memo
new = copy.copy(self)

View file

@ -1,14 +1,9 @@
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]:
"""
@ -30,117 +25,3 @@ def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: flo
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

@ -14,21 +14,6 @@ def test_arc_init() -> None:
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)

File diff suppressed because it is too large Load diff

View file

@ -15,40 +15,6 @@ def _poly_area(poly: Polygon) -> float:
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]])

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ import pytest
from numpy.testing import assert_equal, assert_allclose
from numpy import pi
from ..builder import MinimumStatus, Pather, RouteFailureDetails
from ..builder import Pather
from ..builder.utils import ell
from ..error import BuildError
from ..library import Library
@ -11,59 +11,6 @@ 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")
@ -214,41 +161,3 @@ def test_ell_handles_array_spacing_when_ccw_none() -> None:
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,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

@ -5,7 +5,7 @@ from numpy.testing import assert_allclose
from ..pattern import Pattern
from ..library import Library
from ..shapes import Path as MPath, Circle, Polygon, RectCollection
from ..shapes import Path as MPath, Circle, Polygon
from ..repetition import Grid, Arbitrary
def create_test_library(for_gds: bool = False) -> Library:
@ -109,30 +109,3 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None:
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

@ -78,8 +78,3 @@ def test_gdsii_check_valid_names_validates_generator_lengths() -> None:
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,17 +1,13 @@
import pytest
from collections.abc import Mapping, MutableMapping
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose
from ..library import (
IBorrowing, INameView, IMaterializable, LayerMappedView, Library, LibraryView,
LazyLibrary, OverlayLibrary, PortLoadView,
)
from ..library import Library, LazyLibrary
from ..pattern import Pattern
from ..error import LibraryError, PatternError
from ..ports import Port
from ..repetition import Grid
from ..shapes import Arc, Ellipse, Path, Text
from ..file.utils import preflight, preflight_source_aware
from ..file.utils import preflight
if TYPE_CHECKING:
from ..shapes import Polygon
@ -22,7 +18,6 @@ def test_library_basic() -> None:
pat = Pattern()
lib["cell1"] = pat
assert isinstance(lib, INameView)
assert "cell1" in lib
assert lib["cell1"] is pat
assert len(lib) == 1
@ -31,58 +26,6 @@ def test_library_basic() -> None:
lib["cell1"] = Pattern() # Overwriting not allowed
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary, OverlayLibrary])
def test_writable_libraries_are_restricted_mappings(
library_cls: type[Library] | type[LazyLibrary] | type[OverlayLibrary],
) -> None:
lib = library_cls()
assert isinstance(lib, Mapping)
assert not isinstance(lib, MutableMapping)
for method in ("update", "setdefault", "pop", "popitem", "clear"):
assert not hasattr(lib, method)
lib["top"] = Pattern()
del lib["top"]
assert not lib
@pytest.mark.parametrize('cached', [False, True])
def test_overlay_reuses_names_and_keeps_new_sources_independent(cached: bool) -> None:
source = Library({'a': Pattern(), 'parent': Pattern().ref('a')})
overlay = OverlayLibrary()
overlay.add_source(source)
if cached:
overlay['parent']
overlay.rename('a', 'b', move_references=True)
overlay.rename('b', 'a', move_references=True)
assert overlay.child_graph()['parent'] == {'a'}
assert set(overlay.materialize('parent', persist=False).refs) == {'a'}
overlay.rename('a', 'b', move_references=True)
overlay.add_source(Library({'a': Pattern(), 'new_parent': Pattern().ref('a')}))
assert overlay.child_graph()['parent'] == {'b'}
assert overlay.child_graph()['new_parent'] == {'a'}
assert set(overlay['new_parent'].refs) == {'a'}
assert set(source['parent'].refs) == {'a'}
def test_overlay_reuses_dangling_targets_and_preserves_failed_rename() -> None:
overlay = OverlayLibrary()
overlay.add_source(Library({'parent': Pattern().ref('missing')}))
overlay.move_references('missing', 'other')
overlay.move_references('other', 'missing')
assert set(overlay['parent'].refs) == {'missing'}
overlay['used'] = Pattern()
before = list(overlay)
with pytest.raises(LibraryError, match='already exists'):
overlay.rename('parent', 'used', move_references=True)
assert list(overlay) == before
assert set(overlay['parent'].refs) == {'missing'}
del overlay['used']
overlay.rename('parent', 'used')
assert list(overlay) == ['used']
def test_library_tops() -> None:
lib = Library()
lib["child"] = Pattern()
@ -93,23 +36,6 @@ def test_library_tops() -> None:
assert lib.top() == "parent"
def test_empty_ref_buckets_do_not_create_hierarchy_edges() -> None:
parent = Pattern()
parent.refs["ghost"]
parent.refs["parent"]
lib = Library({"parent": parent, "ghost": Pattern()})
assert not parent.has_refs()
assert parent.referenced_patterns() == set()
assert set(lib.tops()) == {"parent", "ghost"}
assert lib.child_graph() == {"parent": set(), "ghost": set()}
assert lib.parent_graph() == {"parent": set(), "ghost": set()}
lib.dfs(parent, hierarchy=("parent",))
flat = lib.flatten("parent")["parent"]
assert not flat.refs
def test_library_dangling() -> None:
lib = Library()
lib["parent"] = Pattern()
@ -118,16 +44,6 @@ def test_library_dangling() -> None:
assert lib.dangling_refs() == {"missing"}
def test_library_reachability_ignores_unnamed_refs() -> None:
pattern = Pattern()
pattern.ref(None)
lib = Library({"top": pattern})
assert pattern.referenced_patterns() == {None}
assert lib.referenced_patterns() == set()
assert lib.dangling_refs() == set()
def test_library_dangling_graph_modes() -> None:
lib = Library()
lib["parent"] = Pattern()
@ -149,24 +65,6 @@ def test_library_dangling_graph_modes() -> None:
assert lib.child_order(dangling="include") == ["missing", "parent"]
@pytest.mark.parametrize(
("method", "args"),
[
("child_graph", ()),
("parent_graph", ()),
("child_order", ()),
("find_refs_local", ("top",)),
("find_refs_global", ("top",)),
("prune_empty", ()),
],
)
def test_library_rejects_unknown_dangling_mode(method: str, args: tuple[object, ...]) -> None:
lib = Library({"top": Pattern()})
with pytest.raises(ValueError, match="dangling-reference mode"):
getattr(lib, method)(*args, dangling="typo")
def test_find_refs_with_dangling_modes() -> None:
lib = Library()
lib["target"] = Pattern()
@ -199,16 +97,6 @@ def test_find_refs_with_dangling_modes() -> None:
assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]])
def test_find_refs_global_includes_composed_scale_column() -> None:
lib = Library({"leaf": Pattern(), "child": Pattern(), "top": Pattern()})
lib["child"].ref("leaf", scale=2)
lib["top"].ref("child", scale=3)
transforms = lib.find_refs_global("leaf")
assert_allclose(transforms[("top", "child", "leaf")], [[0, 0, 0, 0, 6]])
def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None:
def make_lib() -> Library:
lib = Library()
@ -229,84 +117,6 @@ def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptu
preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True)
def test_preflight_source_aware_skips_source_backed_patterns() -> None:
source = Library()
source["copied"] = Pattern()
source["materialized"] = Pattern()
mapped_names: list[str] = []
def map_layer(layer: int | tuple[int, int] | str) -> int | tuple[int, int] | str:
mapped_names.append(str(layer))
return layer
view = LayerMappedView(source, map_layer, copy_through=True)
view["materialized"].rect("named", xmin=0, xmax=1, ymin=0, ymax=1)
result = preflight_source_aware(view, allow_named_layers=True)
assert isinstance(result, OverlayLibrary)
assert mapped_names == []
assert result.source_cell("copied") is not None
assert result.source_cell("materialized") is None
assert not source["materialized"].shapes
assert "named" in result["materialized"].shapes
def test_preflight_source_aware_checks_only_cells_without_provenance() -> None:
source = Library()
source["copied"] = Pattern().rect("copied_layer", xmin=0, xmax=1, ymin=0, ymax=1)
source["materialized"] = Pattern().rect("materialized_layer", xmin=0, xmax=1, ymin=0, ymax=1)
view = LayerMappedView(source, lambda layer: layer, copy_through=True)
preflight_source_aware(view, allow_named_layers=False)
view["materialized"]
with pytest.raises(PatternError, match="materialized_layer"):
preflight_source_aware(view, allow_named_layers=False)
def test_preflight_source_aware_prunes_only_safe_checked_patterns() -> None:
source = Library({"child": Pattern(), "parent": Pattern()})
source["parent"].ref("child")
view = LayerMappedView(source, lambda layer: layer, copy_through=True)
view["child"]
result = preflight_source_aware(view, prune_empty_patterns=True)
assert isinstance(result, OverlayLibrary)
assert "child" in result
assert result.source_cell("parent") is not None
view["parent"]
result = preflight_source_aware(view, prune_empty_patterns=True)
assert "child" not in result
assert "parent" not in result
assert "child" in source
assert "child" in source["parent"].refs
def test_preflight_source_aware_wraps_only_checked_patterns() -> None:
source = Library({"copied": Pattern(), "materialized": Pattern()})
for name in source:
source[name].rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1)
source[name].shapes[(1, 0)][0].repetition = Grid(a_vector=(10, 0), a_count=2)
view = LayerMappedView(source, lambda layer: layer, copy_through=True)
view["materialized"]
result = preflight_source_aware(view, wrap_repeated_shapes=True)
assert isinstance(result, OverlayLibrary)
assert result.source_cell("copied") is not None
assert source["copied"].shapes[(1, 0)][0].repetition is not None
assert result["materialized"].shapes[(1, 0)] == []
wrapper_names = result["materialized"].referenced_patterns()
assert len(wrapper_names) == 1
wrapper_name = next(iter(wrapper_names))
assert wrapper_name is not None
assert result[wrapper_name].shapes[(1, 0)][0].repetition is None
def test_library_flatten() -> None:
lib = Library()
child = Pattern()
@ -327,42 +137,6 @@ def test_library_flatten() -> None:
assert tuple(assert_vertices[0]) == (10.0, 10.0)
def test_pattern_polygon_traversal_ignores_empty_dangling_bucket() -> None:
child = Pattern()
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
parent = Pattern()
parent.ref("child")
parent.refs["missing"]
lib = Library({"child": child, "parent": parent})
polygons = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
assert len(polygons) == 1
def test_recursive_geometry_rejects_reference_cycles() -> None:
lib = Library({"a": Pattern(), "b": Pattern()})
lib["a"].ref("b")
lib["b"].ref("a")
with pytest.raises(PatternError, match=r"calculating bounds: .* -> .* ->"):
lib["a"].get_bounds(library=lib)
with pytest.raises(PatternError, match=r"collecting layer polygons: .* -> .* ->"):
lib["a"].layer_as_polygons((1, 0), library=lib)
with pytest.raises(PatternError, match=r"visualizing pattern hierarchy: .* -> .* ->"):
lib["a"].visualize(library=lib)
with pytest.raises(PatternError, match="Circular reference"):
lib["a"].deepcopy().flatten(library=lib)
def test_nonrecursive_geometry_allows_reference_cycles() -> None:
lib = Library({"loop": Pattern()})
lib["loop"].ref("loop")
assert lib["loop"].get_bounds(library=lib, recurse=False) is None
assert lib["loop"].layer_as_polygons((1, 0), flatten=False, library=lib) == []
def test_library_flatten_preserves_ports_only_child() -> None:
lib = Library()
child = Pattern(ports={"P1": Port((1, 2), 0)})
@ -433,42 +207,6 @@ def test_lazy_library() -> None:
assert pat is pat2
def test_lazy_library_reachability_loads_only_reachable_cells() -> None:
lib = LazyLibrary()
calls = {"top": 0, "child": 0, "unused": 0}
def make(name: str, target: str | None = None) -> Pattern:
calls[name] += 1
pattern = Pattern()
if target is not None:
pattern.ref(target)
return pattern
lib["top"] = lambda: make("top", "child")
lib["child"] = lambda: make("child")
lib["unused"] = lambda: make("unused")
assert lib.referenced_patterns("top") == {"child"}
assert calls == {"top": 1, "child": 1, "unused": 0}
def test_abstract_view_membership_does_not_materialize_lazy_cells() -> None:
lib = LazyLibrary()
calls = 0
def make_pat() -> Pattern:
nonlocal calls
calls += 1
return Pattern()
lib["lazy"] = make_pat
abstracts = lib.abstract_view()
assert "lazy" in abstracts
assert "missing" not in abstracts
assert calls == 0
def test_library_rename() -> None:
lib = Library()
lib["old"] = Pattern()
@ -483,7 +221,7 @@ def test_library_rename() -> None:
assert "old" not in lib["parent"].refs
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
@ -497,7 +235,7 @@ def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibra
assert len(lib["parent"].refs["top"]) == 1
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
@ -507,7 +245,7 @@ def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyL
assert list(lib.keys()) == ["top"]
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_rename_missing_raises_library_error(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
@ -516,7 +254,7 @@ def test_library_rename_missing_raises_library_error(library_cls: type[Library]
lib.rename("missing", "new")
@pytest.mark.parametrize("library_cls", [Library, LazyLibrary])
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_move_references_same_target_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
@ -600,180 +338,6 @@ def test_library_add_returns_only_renamed_entries() -> None:
assert "keep" not in rename_map
def test_library_add_name_failure_is_atomic() -> None:
destination = Library({"x": Pattern(), "y": Pattern()})
source_parent = Pattern()
source_parent.ref("x")
source = Library({"x": Pattern(), "y": source_parent})
with pytest.raises(LibraryError, match="Unresolved duplicate"):
destination.add(source, rename_theirs=lambda _lib, _name: "z", mutate_other=True)
assert set(destination) == {"x", "y"}
assert set(source) == {"x", "y"}
assert set(source["y"].refs) == {"x"}
def test_library_add_callback_sees_earlier_name_reservations() -> None:
destination = Library({"_shape$A": Pattern(), "_shape$B": Pattern()})
source = Library({"_shape$A": Pattern(), "_shape$B": Pattern()})
callback_views: list[set[str]] = []
def rename(view: INameView, _name: str) -> str:
assert isinstance(view, INameView)
assert not isinstance(view, Mapping)
assert not hasattr(view, "__getitem__")
callback_views.append(set(view))
return view.get_name("_shape")
rename_map = destination.add(source, rename_theirs=rename)
assert len(set(rename_map.values())) == 2
assert rename_map["_shape$A"] in callback_views[1]
assert set(rename_map.values()) <= set(destination)
def test_library_can_add_itself_with_mutate_other() -> None:
lib = Library({"_helper": Pattern()})
rename_map = lib.add(lib, mutate_other=True)
assert rename_map["_helper"] in lib
assert len(lib) == 2
def test_overlay_add_source_callback_sees_earlier_name_reservations() -> None:
overlay = OverlayLibrary()
overlay["_shape$A"] = Pattern()
overlay["_shape$B"] = Pattern()
source = Library({"_shape$A": Pattern(), "_shape$B": Pattern()})
rename_map = overlay.add_source(
source,
rename_theirs=lambda view, _name: view.get_name("_shape"),
)
assert len(set(rename_map.values())) == 2
assert set(rename_map.values()) <= set(overlay)
def test_overlay_add_source_renames_conflicting_single_use_name_by_default() -> None:
overlay = OverlayLibrary()
overlay['_myCellName$F'] = Pattern()
source = Library({'_myCellName$F': Pattern(), 'source_top': Pattern()})
source['source_top'].ref('_myCellName$F')
rename_map = overlay.add_source(source)
assert rename_map == {'_myCellName$F': '_myCellName'}
assert set(overlay['source_top'].refs) == {'_myCellName'}
def test_overlay_add_source_can_explicitly_disable_default_renaming() -> None:
overlay = OverlayLibrary()
overlay['_helper$A'] = Pattern()
with pytest.raises(LibraryError, match='Conflicting name'):
overlay.add_source(Library({'_helper$A': Pattern()}), rename_theirs=None)
def test_port_load_view_detaches_already_materialized_overlay_pattern() -> None:
overlay = OverlayLibrary()
overlay.add_source(Library({"top": Pattern()}))
raw = overlay["top"]
processed = PortLoadView(
overlay,
ports={"top": {"P": Port((1, 2), 0)}},
)
processed_top = processed["top"]
assert processed_top is not raw
assert not raw.ports
assert set(processed_top.ports) == {"P"}
assert not hasattr(processed, "close")
def test_layer_mapped_view_detaches_and_maps_shapes_and_labels() -> None:
import masque
child = Pattern()
source_pattern = Pattern(ports={'P': Port((1, 2), 0, ptype='wire')})
source_pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]])
source_pattern.polygon('B', vertices=[[2, 0], [3, 0], [2, 1]])
source_pattern.label('TEXT', string='port', offset=(1, 2))
source_pattern.ref('child')
source_pattern.annotations['note'] = ['unchanged']
source = Library({'child': child, 'top': source_pattern})
mapped = LayerMappedView(
source,
lambda layer: {'A': 'DRAW', 'B': 'DRAW', 'TEXT': 'LABEL'}.get(layer, layer),
)
mapped_top = mapped['top']
assert mapped_top is not source_pattern
assert set(mapped_top.shapes) == {'DRAW'}
assert len(mapped_top.shapes['DRAW']) == 2
assert set(mapped_top.labels) == {'LABEL'}
assert set(mapped_top.ports) == {'P'}
assert set(mapped_top.refs) == {'child'}
assert mapped_top.annotations == {'note': ['unchanged']}
assert set(source_pattern.shapes) == {'A', 'B'}
assert set(source_pattern.labels) == {'TEXT'}
assert mapped.child_graph() == source.child_graph()
assert mapped.source_order() == source.source_order()
assert masque.LayerMappedView is LayerMappedView
assert masque.PortLoadView is PortLoadView
assert not hasattr(masque, 'PortsLibraryView')
assert not hasattr(masque.library, 'PortsLibraryView')
def test_layer_mapped_view_preflight_rejects_none_layer() -> None:
pattern = Pattern()
pattern.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
mapped = LayerMappedView(Library({'top': pattern}), lambda _layer: None) # type: ignore[arg-type]
with pytest.raises(PatternError, match=r"returned None for source layer \(1, 0\)"):
preflight_source_aware(mapped)
def test_layer_mapped_view_materialization_and_copy_through() -> None:
pattern = Pattern()
pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]])
source = Library({'top': pattern})
mapped = LayerMappedView(source, lambda _layer: 'B')
passthrough = LayerMappedView(source, lambda _layer: 'B', copy_through=True)
assert mapped.source_cell('top') is None
assert passthrough.source_cell('top') == (source, 'top')
transient = passthrough.materialize('top', persist=False)
assert set(transient.shapes) == {'B'}
assert passthrough.source_cell('top') == (source, 'top')
persistent = passthrough['top']
assert passthrough['top'] is persistent
assert passthrough.source_cell('top') is None
assert set(persistent.shapes) == {'B'}
def test_layer_mapped_view_composes_with_ports_and_overlay() -> None:
pattern = Pattern()
pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]])
source = Library({'top': pattern})
ports = PortLoadView(source, ports={'top': {'P': Port((1, 2), 0)}})
mapped = LayerMappedView(ports, lambda _layer: 'B')
overlay = OverlayLibrary()
overlay.add_source(mapped)
top = overlay['top']
assert set(top.shapes) == {'B'}
assert set(top.ports) == {'P'}
assert mapped.borrowed_sources() == (ports,)
assert not pattern.ports
def test_library_subtree() -> None:
lib = Library()
lib["a"] = Pattern()
@ -782,300 +346,9 @@ def test_library_subtree() -> None:
lib["a"].ref("b")
sub = lib.subtree("a")
assert isinstance(sub, Library)
assert "a" in sub
assert "b" in sub
assert "c" not in sub
assert sub["a"] is lib["a"]
del sub["b"]
assert "b" in lib
def test_lazy_library_subtree_preserves_type_and_shares_materialized_patterns() -> None:
lib = LazyLibrary()
child = Pattern()
top = Pattern()
top.ref("child")
lib["child"] = lambda: child
lib["top"] = lambda: top
lib["unused"] = Pattern()
subtree = lib.subtree("top")
assert isinstance(subtree, LazyLibrary)
assert set(subtree) == {"child", "top"}
assert subtree["top"] is lib["top"]
del subtree["child"]
assert "child" in lib
def test_overlay_subtree_preserves_type_sources_and_independent_materialization() -> None:
source = Library({"child": Pattern(), "top": Pattern(), "unused": Pattern()})
source["top"].ref("child")
overlay = OverlayLibrary()
overlay.add_source(source)
subtree = overlay.subtree("top")
assert isinstance(subtree, OverlayLibrary)
assert set(subtree) == {"child", "top"}
assert subtree.borrowed_sources() == overlay.borrowed_sources()
subtree_top = subtree["top"]
overlay_top = overlay["top"]
assert subtree_top is not overlay_top
shared_subtree = overlay.subtree("top")
assert shared_subtree["top"] is overlay_top
del shared_subtree["child"]
assert "child" in overlay
def test_overlay_subtree_preserves_reference_remaps() -> None:
source = Library({"leaf": Pattern(), "parent": Pattern()})
source["parent"].ref("leaf")
overlay = OverlayLibrary()
overlay.add_source(source)
overlay.rename("leaf", "renamed", move_references=True)
subtree = overlay.subtree("parent")
assert isinstance(subtree, OverlayLibrary)
assert set(subtree) == {"parent", "renamed"}
assert set(subtree["parent"].refs) == {"renamed"}
def test_read_only_subtree_is_a_borrowed_subtree_view() -> None:
source = Library({"child": Pattern(), "top": Pattern(), "unused": Pattern()})
source["top"].ref("child")
view = LibraryView(source)
subtree = view.subtree("top")
assert subtree.source_order() == ("child", "top")
assert subtree["top"] is source["top"]
assert "unused" not in subtree
with pytest.raises(KeyError):
_ = subtree["unused"]
def test_library_materialization_and_borrowing_capabilities() -> None:
lazy = LazyLibrary()
lazy["top"] = lambda: Pattern()
assert isinstance(lazy, IMaterializable)
assert not isinstance(lazy, IBorrowing)
assert not hasattr(lazy, "borrowed_sources")
transient = lazy.materialize("top", persist=False)
assert "top" not in lazy.cache
assert transient is not lazy["top"]
overlay = OverlayLibrary()
overlay.add_source(lazy)
ports = PortLoadView(overlay)
subtree = ports.subtree("top")
assert isinstance(overlay, IMaterializable)
assert isinstance(overlay, IBorrowing)
assert isinstance(ports, IMaterializable)
assert isinstance(ports, IBorrowing)
assert isinstance(subtree, IMaterializable)
assert isinstance(subtree, IBorrowing)
assert overlay.borrowed_sources() == (lazy,)
assert ports.borrowed_sources() == (overlay,)
assert subtree.borrowed_sources() == (ports,)
eager = Library({"top": Pattern()})
plain_view = LibraryView(lazy)
assert not isinstance(eager, IMaterializable | IBorrowing)
assert not isinstance(plain_view, IMaterializable | IBorrowing)
def test_detached_materialization_default_is_owned_and_deduplicated() -> None:
shared_a = Pattern()
shared_b = Pattern()
lazy = LazyLibrary()
lazy['a'] = lambda: shared_a
lazy['b'] = lambda: shared_b
detached = lazy.materialize_many_detached(('b', 'a', 'b'))
assert tuple(detached) == ('b', 'a')
assert detached['a'] is not shared_a
assert detached['b'] is not shared_b
detached['a'].polygon('L', vertices=[[0, 0], [1, 0], [0, 1]])
assert not shared_a.shapes
assert not lazy.cache
def test_nested_detached_views_copy_plain_source_once(monkeypatch: pytest.MonkeyPatch) -> None:
source_pattern = Pattern()
source_pattern.polygon('A', vertices=[[0, 0], [1, 0], [0, 1]])
source = Library({'top': source_pattern})
ports = PortLoadView(source, ports={'top': {'P': Port((1, 2), 0)}})
mapped = LayerMappedView(ports, lambda _layer: 'B')
overlay = OverlayLibrary()
overlay.add_source(mapped)
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)
detached = overlay.materialize_detached('top')
assert copied == [source_pattern]
assert set(detached.shapes) == {'B'}
assert set(detached.ports) == {'P'}
assert not source_pattern.ports
assert not ports._cache
assert not mapped._cache
assert overlay.source_cell('top') == (mapped, 'top')
def test_nested_detached_views_copy_cached_pattern_once(monkeypatch: pytest.MonkeyPatch) -> None:
source_pattern = Pattern()
source = Library({'top': source_pattern})
ports = PortLoadView(source, ports={'top': {'P': Port((1, 2), 0)}})
cached = ports['top']
mapped = LayerMappedView(ports, 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)
detached = mapped.materialize_detached('top')
assert copied == [cached]
assert detached is not cached
detached.ports.clear()
assert set(cached.ports) == {'P'}
def test_borrowed_source_cell_tracks_persistent_materialization() -> None:
source = Library({"top": Pattern()})
source.library_info = {"name": "not-forwarded"} # type: ignore[attr-defined]
processed = PortLoadView(source)
assert processed.source_cell("top") == (source, "top")
assert not hasattr(processed, "library_info")
assert not hasattr(processed, "raw_struct_bytes")
_ = processed.materialize_many(("top",), persist=False)
assert processed.source_cell("top") == (source, "top")
subtree = processed.subtree("top")
assert subtree.source_cell("top") == (processed, "top")
assert not hasattr(subtree, "library_info")
_ = subtree["top"]
assert processed.source_cell("top") is None
assert subtree.source_cell("top") == (processed, "top")
def test_overlay_source_cell_tracks_names_references_and_materialization() -> None:
source = Library({"leaf": Pattern(), "parent": Pattern()})
source["parent"].ref("leaf")
overlay = OverlayLibrary()
overlay.add_source(source)
assert overlay.source_cell("parent") == (source, "parent")
overlay.rename("parent", "renamed_parent")
assert overlay.source_cell("renamed_parent") == (source, "parent")
overlay.rename("leaf", "renamed_leaf", move_references=True)
assert overlay.source_cell("renamed_parent") is None
materialized = OverlayLibrary()
materialized.add_source(source)
_ = materialized["parent"]
assert materialized.source_cell("parent") is None
@pytest.mark.parametrize("materialize_parent", [False, True])
@pytest.mark.parametrize("move_references", [False, True])
def test_overlay_rename_is_independent_of_materialization(
materialize_parent: bool,
move_references: bool,
) -> None:
source = Library({"leaf": Pattern(), "parent": Pattern()})
source["parent"].ref("leaf")
overlay = OverlayLibrary()
overlay.add_source(source)
if materialize_parent:
_ = overlay["parent"]
overlay.rename("leaf", "renamed", move_references=move_references)
expected_target = "renamed" if move_references else "leaf"
assert overlay.child_graph(dangling="include")["parent"] == {expected_target}
assert set(overlay["parent"].refs) == {expected_target}
assert overlay.child_graph(dangling="include")["parent"] == {expected_target}
def test_overlay_rejects_unknown_dangling_mode() -> None:
overlay = OverlayLibrary()
overlay.add_source(Library({"top": Pattern()}))
with pytest.raises(ValueError, match="dangling-reference mode"):
overlay.child_graph(dangling="typo")
with pytest.raises(ValueError, match="dangling-reference mode"):
overlay.find_refs_local("top", dangling="typo")
def test_overlay_rename_preserves_initial_import_target_without_moving_refs() -> None:
source = Library({"leaf": Pattern(), "parent": Pattern()})
source["parent"].ref("leaf")
overlay = OverlayLibrary()
overlay["leaf"] = Pattern()
rename_map = overlay.add_source(source, rename_theirs=lambda lib, name: lib.get_name(name))
imported_leaf = rename_map["leaf"]
overlay.rename(imported_leaf, "renamed", move_references=False)
assert set(overlay["parent"].refs) == {imported_leaf}
def test_overlay_chained_rename_moves_unmaterialized_references() -> None:
source = Library({"leaf": Pattern(), "parent": Pattern()})
source["parent"].ref("leaf")
overlay = OverlayLibrary()
overlay.add_source(source)
overlay.rename("leaf", "middle", move_references=True)
overlay.rename("middle", "final", move_references=True)
assert set(overlay["parent"].refs) == {"final"}
def _assert_tree_merge_rejected(tree: Library) -> None:
destination = Library({"existing": Pattern()})
with pytest.raises(LibraryError, match="exactly one topcell"):
destination << tree
assert set(destination) == {"existing"}
def test_library_tree_merge_rejects_empty_tree() -> None:
_assert_tree_merge_rejected(Library())
def test_library_tree_merge_rejects_cycle_without_top() -> None:
tree = Library({"a": Pattern(), "b": Pattern()})
tree["a"].ref("b")
tree["b"].ref("a")
_assert_tree_merge_rejected(tree)
def test_library_tree_merge_rejects_multiple_tops() -> None:
_assert_tree_merge_rejected(Library({"a": Pattern(), "b": Pattern()}))
def test_library_child_order_cycle_raises_library_error() -> None:

View file

@ -7,30 +7,30 @@ 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:
def make_straight(length, width=2, ptype="wire"):
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:
def make_bend(R, width=2, ptype="wire", clockwise=True):
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.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype)
pat.ports["B"] = Port((R, -R), 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.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype)
pat.ports["B"] = Port((R, R), -pi/2, ptype=ptype)
return pat
@pytest.fixture
def multi_bend_tool() -> tuple[AutoTool, Library]:
def multi_bend_tool():
lib = Library()
lib["b1"] = make_bend(2, ptype="wire")
@ -38,13 +38,19 @@ def multi_bend_tool() -> tuple[AutoTool, Library]:
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)
)
tool = AutoTool(
straights=[
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
AutoTool.Straight(ptype="wire", fn=lambda l: make_straight(l, width=4), in_port_name="A", out_port_name="B", length_range=(10, 1e8))
],
bends=[
AutoTool.Bend(b1_abs, "A", "B", clockwise=True, mirror=True),
AutoTool.Bend(b2_abs, "A", "B", clockwise=True, mirror=True)
],
sbends=[],
transitions={},
default_out_ptype="wire"
)
return tool, lib
def test_autotool_uturn() -> None:
@ -64,13 +70,15 @@ def test_autotool_uturn() -> None:
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)
)
tool = AutoTool(
straights=[AutoTool.Straight(ptype='wire', fn=make_straight, in_port_name='in', out_port_name='out')],
bends=[AutoTool.Bend(abstract=lib.abstract('bend'), in_port_name='in', out_port_name='out', clockwise=True)],
sbends=[],
transitions={},
default_out_ptype='wire'
)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), 0)
p.at('A').uturn(offset=-2000, length=1000)
@ -80,9 +88,9 @@ def test_autotool_uturn() -> None:
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:
def test_deferred_render_autotool_double_L(multi_bend_tool) -> None:
tool, lib = multi_bend_tool
rp = Pather(lib, tools=tool)
rp = Pather(lib, tools=tool, auto_render=False)
rp.ports["A"] = Port((0,0), 0, ptype="wire")
rp.jog("A", 10, length=20)
@ -93,10 +101,22 @@ def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Libr
rp.render()
assert len(rp.pattern.refs) > 0
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None:
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
tool, lib = multi_bend_tool
p = Pather(lib, tools=tool)
class BasicTool(AutoTool):
def planU(self, *args, **kwargs):
raise NotImplementedError()
tool_basic = BasicTool(
straights=tool.straights,
bends=tool.bends,
sbends=tool.sbends,
transitions=tool.transitions,
default_out_ptype=tool.default_out_ptype
)
p = Pather(lib, tools=tool_basic)
p.ports["A"] = Port((0,0), 0, ptype="wire")
p.uturn("A", 10, length=5)

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,10 @@
from typing import Any
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose, assert_equal
from masque import Pather, Library, Pattern, Port, RouteError
from masque.builder import PathTool, PrimitiveOffer, StraightOffer
from masque.builder.planner import RoutingPlanner
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool
from masque.error import BuildError, PortError
@ -20,138 +17,6 @@ def pather_setup() -> tuple[Pather, PathTool, Library]:
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return p, tool, lib
def test_builder_tool_symbol_exports() -> None:
import masque
import masque.builder
import masque.builder.tools as builder_tools
package_root_exports = (
'RenderStep',
'PortPather',
)
builder_tool_names = (
'PrimitiveOffer',
'StraightOffer',
'BendOffer',
'SOffer',
'UOffer',
)
internal_names = (
'PTypeMatch',
'canonicalize_domain_value',
'ptype_match',
'ptypes_compatible',
)
for name in (*package_root_exports, *builder_tool_names):
assert hasattr(masque.builder, name)
for name in package_root_exports:
assert hasattr(masque, name)
for name in (*builder_tool_names, *internal_names):
assert not hasattr(masque, name)
for name in internal_names:
assert not hasattr(masque.builder, name)
for name in builder_tool_names:
assert hasattr(masque.builder, name)
assert hasattr(builder_tools, name)
def test_pather_pending_render_steps_are_private() -> None:
p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1))
assert not hasattr(p, 'paths')
assert hasattr(p, '_paths')
def test_pather_accepts_and_reuses_planner_instance() -> None:
class CountingPlanner(RoutingPlanner):
def __init__(self) -> None:
self.trace_to_calls = 0
def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any:
self.trace_to_calls += 1
return super().plan_trace_to_route(*args, **kwargs)
planner = CountingPlanner()
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
render='deferred',
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 1)
p.straight('A', 2)
assert p.planner is planner
assert planner.trace_to_calls == 2
def test_pather_copies_and_forwards_plan_options_to_planner() -> None:
class RecordingPlanner(RoutingPlanner):
def __init__(self) -> None:
super().__init__()
self.received_plan_options: Any = None
def plan_trace_to_route(
self,
*args: Any,
plan_options: Any = None,
**kwargs: Any,
) -> Any:
self.received_plan_options = plan_options
return super().plan_trace_to_route(
*args,
plan_options=plan_options,
**kwargs,
)
planner = RecordingPlanner()
p = Pather(
Library(),
tools=PathTool(layer=(1, 0), width=1),
render='deferred',
planner=planner,
)
p.ports['A'] = Port((0, 0), rotation=0)
supplied = {'strategy': 'turn_first'}
p.straight('A', 1, plan_options=supplied)
assert planner.received_plan_options == supplied
assert planner.received_plan_options is not supplied
def test_port_tool_policy_and_portpather_selection_follow_names() -> None:
default_tool = PathTool(layer=(1, 0), width=1, ptype='wire')
named_tool = PathTool(layer=(2, 0), width=1, ptype='wire')
p = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools={None: default_tool, 'A': named_tool},
render='deferred',
)
selected = p.at('A')
p.rename_ports({'A': 'B'})
assert selected.ports == ['A']
assert p.tools['A'] is named_tool
assert 'B' not in p.tools
p.straight('B', 1)
assert p._paths['B'][0].tool is default_tool
p.mkport('A', Port((10, 0), rotation=0, ptype='wire'))
p.straight('A', 1)
assert p._paths['A'][0].tool is named_tool
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
p.straight("start", 10)
@ -195,20 +60,19 @@ def test_pather_dead_ports() -> None:
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
p.set_dead()
with pytest.raises(RouteError, match='finite and nonnegative'):
p.straight("in", -10)
p.straight("in", -10)
assert_allclose(p.ports["in"].offset, [0, 0], atol=1e-10)
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
p.straight("in", 20)
assert_allclose(p.ports["in"].offset, [-20, 0], atol=1e-10)
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
assert not p.pattern.has_shapes()
def test_pather_trace_basic() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
# Routing extends opposite the port's inward-facing rotation.
p.pattern.ports['A'] = Port((0, 0), rotation=0)
@ -224,7 +88,7 @@ def test_pather_trace_basic() -> None:
def test_pather_trace_to() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
@ -237,7 +101,7 @@ def test_pather_trace_to() -> None:
def test_pather_bundle_trace() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -254,13 +118,13 @@ def test_pather_bundle_trace() -> None:
def test_portpather_default_spacing_matches_explicit_spacing() -> None:
lib_default = Library()
tool_default = PathTool(layer='M1', width=1000)
p_default = Pather(lib_default, tools=tool_default)
p_default = Pather(lib_default, tools=tool_default, auto_render=False)
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
lib_explicit = Library()
tool_explicit = PathTool(layer='M1', width=1000)
p_explicit = Pather(lib_explicit, tools=tool_explicit)
p_explicit = Pather(lib_explicit, tools=tool_explicit, auto_render=False)
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -271,11 +135,11 @@ def test_portpather_default_spacing_matches_explicit_spacing() -> None:
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
def test_portpather_default_spacing_reused_and_overridden() -> None:
p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_default.pattern.ports['A'] = Port((0, 0), rotation=0)
p_default.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -287,11 +151,11 @@ def test_portpather_default_spacing_reused_and_overridden() -> None:
assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset)
assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset)
p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_override.pattern.ports['A'] = Port((0, 0), rotation=0)
p_override.pattern.ports['B'] = Port((0, 2000), rotation=0)
p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000))
p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False)
p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0)
p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -304,7 +168,7 @@ def test_portpather_default_spacing_reused_and_overridden() -> None:
def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
@ -316,7 +180,7 @@ def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None:
def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
p.pattern.ports['C'] = Port((0, 4000), rotation=0)
@ -330,7 +194,7 @@ def test_portpather_default_spacing_vector_revalidated_after_selection_change()
def test_pather_each_bound() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-1000, 2000), rotation=0)
@ -362,22 +226,6 @@ def test_selection_management() -> None:
assert 'B' not in p.pattern.ports
assert pp.ports == []
@pytest.mark.parametrize('action', ['plug', 'plugged', 'rename', 'mark', 'fork'])
def test_empty_selection_exact_one_operations_raise_build_error(action: str) -> None:
p = Pather(Library())
pp = p.at([])
operations = {
'plug': lambda: pp.plug('unused', 'A'),
'plugged': lambda: pp.plugged('A'),
'rename': lambda: pp.rename('new'),
'mark': lambda: pp.mark('new'),
'fork': lambda: pp.fork('new'),
}
with pytest.raises(BuildError, match='expected exactly one'):
operations[action]()
def test_mark_fork() -> None:
lib = Library()
p = Pather(lib)
@ -466,98 +314,15 @@ def test_rename() -> None:
def test_pather_dead_fallback_preserves_out_ptype() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
p.straight('A', 1000, out_ptype='other')
p.straight('A', -1000, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 0))
assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0))
assert p.pattern.ports['A'].ptype == 'other'
assert len(p._paths['A']) == 0
def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None:
class MismatchedEndpointTool(PathTool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001,ARG002
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
return (StraightOffer(
in_ptype=in_ptype,
out_ptype='other',
endpoint_planner=endpoint,
commit_planner=lambda length: {'length': length},
),)
p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.set_dead()
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
p.straight('A', 1000)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None:
class PartlyMismatchedEndpointTool(PathTool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001,ARG002
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def mismatched_endpoint(length: float) -> Port:
ptype = 'wire' if numpy.isclose(length, 0) else 'other'
return Port((length, 0), rotation=pi, ptype=ptype)
def valid_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
return (
StraightOffer(
in_ptype=in_ptype,
out_ptype='wire',
endpoint_planner=mismatched_endpoint,
commit_planner=lambda length: {'kind': 'mismatched', 'length': length},
),
StraightOffer(
in_ptype=in_ptype,
out_ptype='wire',
endpoint_planner=valid_endpoint,
commit_planner=lambda length: {'kind': 'valid', 'length': length},
),
)
p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire'))
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='does not match declared offer out_ptype'):
p.straight('A', 1000)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert p.pattern.ports['A'].ptype == 'wire'
assert len(p._paths['A']) == 0
assert len(p.paths['A']) == 0
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
lib = Library()

View file

@ -1,16 +1,18 @@
from typing import Any
import pytest
import numpy
from numpy import pi
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool
from masque.error import PortError, PatternError
from masque.builder.tools import PathTool, Tool
from masque.error import BuildError, PortError, PatternError
def test_pather_place_treeview_resolves_once() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p = Pather(lib, tools=tool)
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
@ -24,7 +26,7 @@ def test_pather_place_treeview_resolves_once() -> None:
def test_pather_plug_treeview_resolves_once() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
@ -39,12 +41,12 @@ def test_pather_plug_treeview_resolves_once() -> None:
def test_pather_failed_plug_does_not_add_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.annotations = {'k': [1]}
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').trace(None, 5000)
assert [step.opcode for step in p._paths['A']] == ['L']
assert [step.opcode for step in p.paths['A']] == ['L']
other = Pattern(
annotations={'k': [2]},
@ -54,13 +56,13 @@ def test_pather_failed_plug_does_not_add_break_marker() -> None:
with pytest.raises(PatternError, match='Annotation keys overlap'):
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
assert [step.opcode for step in p._paths['A']] == ['L']
assert [step.opcode for step in p.paths['A']] == ['L']
assert set(p.pattern.ports) == {'A'}
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
@ -70,7 +72,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
p.place(other, port_map={'X': 'A'}, append=True)
p.at('A').straight(2000)
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
p.render()
assert p.pattern.has_shapes()
@ -80,7 +82,7 @@ def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((0, 0), rotation=0)
@ -96,7 +98,7 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
p.at('A').straight(2000)
assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L']
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
p.render()
assert p.pattern.has_shapes()
@ -107,14 +109,14 @@ def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool, render='deferred')
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.at('A').straight(5000)
assert [step.opcode for step in p._paths['A']] == ['L']
assert [step.opcode for step in p.paths['A']] == ['L']
with pytest.raises(PortError, match='Connection destination ports were not found'):
p.plugged({'A': 'missing'})
assert [step.opcode for step in p._paths['A']] == ['L']
assert set(p._paths) == {'A'}
assert [step.opcode for step in p.paths['A']] == ['L']
assert set(p.paths) == {'A'}

View file

@ -1,910 +0,0 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Literal
import numpy
import pytest
from numpy import pi
from masque import Library, Path, Port, Pather, ToolContractError
from masque.builder.planner import RoutingPlanner
from masque.builder.planner.planner import Solver, SolverRequest
from masque.builder.tools import (
BendOffer,
PathTool,
PrimitiveOffer,
SOffer,
StraightOffer,
Tool,
UOffer,
)
from masque.error import BuildError
from masque.utils import PTypeMatch, ptype_match
def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]:
return {
'endpoint_planner': lambda parameter: planner(parameter)[0],
'commit_planner': lambda parameter: planner(parameter)[1],
}
class PlanningOnlyTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('planning_only_tool')
pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk')
return tree
def test_tool_contract_error_is_fatal_even_when_an_alternate_offer_exists() -> None:
class BrokenTool(PlanningOnlyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (
StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wrong'),
commit_planner=lambda length: length,
),
StraightOffer.generated('wire', lambda length: length),
)
pather = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=BrokenTool(),
render='deferred',
)
with pytest.raises(ToolContractError, match='declared offer out_ptype'):
pather.straight('A', 5)
def test_callback_build_error_remains_recoverable_candidate_rejection() -> None:
class RecoverableTool(PlanningOnlyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
def rejected(length: float) -> Port:
raise BuildError(f'unsupported length {length}')
return (
StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=rejected,
commit_planner=lambda length: length,
),
StraightOffer.generated('wire', lambda length: length),
)
pather = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=RecoverableTool(),
render='deferred',
)
pather.straight('A', 5)
assert numpy.allclose(pather.ports['A'].offset, (-5, 0))
def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None:
class CountingTool(PlanningOnlyTool):
calls = 0
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
self.calls += 1
assert kwargs == {'nested': []}
return ()
tool = CountingTool()
solver = Solver(SolverRequest(
family='straight',
tool=tool,
in_ptype='wire',
tool_options={'nested': []},
))
solver.primitive_offers('straight', 'wire')
solver.primitive_offers('straight', 'wire')
assert tool.calls == 1
def test_solver_finalize_chooses_cheapest_parameter_allocation() -> None:
tool = PlanningOnlyTool()
solver = Solver(SolverRequest(
family='straight',
tool=tool,
in_ptype='wire',
tool_options={},
length=10,
))
expensive_offer = StraightOffer.generated('wire', lambda length: length, cost=1)
cheap_offer = StraightOffer.generated('wire', lambda length: length, cost=0.3)
expensive = solver.evaluate(expensive_offer, 0, 'wire', out_ptype=None, role='main')
cheap = solver.evaluate(cheap_offer, 0, 'wire', out_ptype=None, role='main')
for steps in ((expensive, cheap), (cheap, expensive)):
candidate = solver.finalize(steps)
parameters = {step.offer: step.parameter for step in candidate.steps}
assert parameters[cheap_offer] == pytest.approx(10)
assert parameters[expensive_offer] == pytest.approx(0)
assert candidate.cost == pytest.approx(3)
def test_solver_strategy_rank_covers_all_main_steps_and_ignores_adapters() -> None:
tool = PlanningOnlyTool()
straight_first = Solver(SolverRequest(
family='s',
tool=tool,
in_ptype='wire',
tool_options={},
strategy='straight_first',
))
turn_first = Solver(SolverRequest(
family='s',
tool=tool,
in_ptype='wire',
tool_options={},
strategy='turn_first',
))
straight_offer = StraightOffer.generated('wire', lambda length: length)
bend_offer = BendOffer.prebuilt(
'wire',
'wire',
Port((1, 1), 3 * pi / 2, ptype='wire'),
None,
ccw=True,
)
adapter_offer = StraightOffer.prebuilt(
'wire',
'adapted',
Port((1, 0), pi, ptype='adapted'),
None,
)
straight = straight_first.evaluate(straight_offer, 0, 'wire', out_ptype=None, role='main')
turn = straight_first.evaluate(bend_offer, 1, 'wire', out_ptype=None, role='main')
adapter = straight_first.evaluate(adapter_offer, 1, 'wire', out_ptype=None, role='adapter')
alternating = (straight, turn, adapter, straight, turn)
delayed_straight = (straight, turn, adapter, turn, straight)
assert straight_first.strategy_rank(alternating) < straight_first.strategy_rank(delayed_straight)
assert turn_first.strategy_rank(delayed_straight) < turn_first.strategy_rank(alternating)
assert straight_first.strategy_rank(alternating) == straight_first.strategy_rank(
(straight, turn, straight, turn),
)
assert straight_first.strategy_rank((straight,)) < straight_first.strategy_rank((turn,))
assert turn_first.strategy_rank((turn,)) < turn_first.strategy_rank((straight,))
def test_tool_requires_primitive_offers_override() -> None:
class RenderOnlyTool(Tool):
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(TypeError):
RenderOnlyTool()
def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None:
class BaseCallingTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(NotImplementedError):
BaseCallingTool().primitive_offers('straight')
def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float:
offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
return offer.canonicalize_parameter(value)
def test_offer_canonicalize_parameter_half_open_and_singleton() -> None:
assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0
assert canonicalize_offer_parameter(3, (0, 10)) == 3
with pytest.raises(BuildError, match='outside half-open domain'):
canonicalize_offer_parameter(10, (0, 10))
assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5
with pytest.raises(BuildError, match='outside singleton domain'):
canonicalize_offer_parameter(5.1, (5, 5))
@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf])
def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None:
with pytest.raises(BuildError, match='must be finite'):
canonicalize_offer_parameter(value, (0, 10))
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=(10, 0))
@pytest.mark.parametrize('domain', [(numpy.nan, 1), (1, numpy.nan), (numpy.inf, numpy.inf)])
def test_offer_rejects_invalid_domain_at_construction(domain: tuple[float, float]) -> None:
with pytest.raises(BuildError, match='domain'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
@pytest.mark.parametrize('domain', [(-1, 2), (-numpy.inf, 2)])
def test_length_offer_requires_finite_nonnegative_minimum(domain: tuple[float, float]) -> None:
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None:
assert ptype_match('wire', 'wire') is PTypeMatch.EXACT
assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD
assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD
assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH
def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None:
endpoint_calls: list[float] = []
commit_calls: list[float] = []
def endpoint(length: float) -> Port:
endpoint_calls.append(length)
return Port((length, 2), rotation=pi, ptype='wire')
def commit(length: float) -> dict[str, float]:
commit_calls.append(length)
return {'length': length}
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
length_domain=(5, 5),
endpoint_planner=endpoint,
commit_planner=commit,
)
assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2))
assert offer.cost_at(5 + 1e-13) == 5 + pi
assert commit_calls == []
assert offer.commit(5 + 1e-13) == {'length': 5}
assert endpoint_calls == [5, 5]
assert commit_calls == [5]
def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None:
def data_at(length: float) -> dict[str, float]:
return {'length': length}
offer = StraightOffer.generated(
'wire',
data_at,
length_domain=(2, 8),
cost=3,
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]),
)
endpoint = offer.endpoint_at(4)
assert offer.length_domain == (2, 8)
assert offer.cost == 3
assert offer.cost_at(4) == 12
assert numpy.allclose(endpoint.offset, [4, 0])
assert endpoint.rotation == pi
assert endpoint.ptype == 'wire'
assert offer.commit(4) == {'length': 4}
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]])
def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None:
def endpoint_at(jog: float) -> Port:
return Port((10, jog), rotation=pi, ptype='wire')
def data_at(jog: float) -> dict[str, Any]:
return {'jog': abs(jog), 'mirrored': jog < 0}
offer = SOffer.generated(
'wire',
endpoint_at,
data_at,
jog_domain=(-5, 5),
bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]),
)
endpoint = offer.endpoint_at(-3)
assert numpy.allclose(endpoint.offset, [10, -3])
assert offer.commit(-3) == {'jog': 3, 'mirrored': True}
assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]])
def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
def endpoint_at(length: float) -> Port:
return Port((length, length), rotation=-pi / 2, ptype='wire')
def data_at(length: float) -> dict[str, float]:
return {'length': length}
offer = BendOffer.generated(
'wire',
endpoint_at,
data_at,
ccw=True,
length_domain=(4, 4),
bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]),
)
endpoint = offer.endpoint_at(4)
assert offer.ccw
assert offer.length_domain == (4, 4)
assert numpy.allclose(endpoint.offset, [4, 4])
assert endpoint.rotation == 3 * pi / 2
assert offer.commit(4) == {'length': 4}
assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]])
def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None:
def endpoint_at(jog: float) -> Port:
return Port((12, jog), rotation=0, ptype='wire')
def data_at(jog: float) -> dict[str, float]:
return {'jog': jog}
offer = UOffer.generated(
'wire',
endpoint_at,
data_at,
jog_domain=(-6, 6),
bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]),
)
endpoint = offer.endpoint_at(-4)
assert offer.jog_domain == (-6, 6)
assert numpy.allclose(endpoint.offset, [12, -4])
assert endpoint.rotation == 0
assert offer.commit(-4) == {'jog': -4}
assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]])
def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None:
data = {'kind': 'prebuilt'}
bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731
endpoint = Port((5, 2), rotation=pi / 2, ptype='out')
cases = [
(StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5),
(BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5),
(SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
(UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2),
]
for offer, parameter in cases:
first = offer.endpoint_at(parameter)
first.offset[:] = 99
second = offer.endpoint_at(parameter)
assert numpy.allclose(second.offset, [5, 2])
assert second.rotation == pi / 2
assert second.ptype == 'out'
assert offer.commit(parameter) is data
assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]])
@pytest.mark.parametrize('cost', [-1, numpy.inf, numpy.nan, 'expensive'])
def test_offer_rejects_invalid_cost_factor(cost: Any) -> None:
with pytest.raises(BuildError, match='cost factor'):
StraightOffer(in_ptype='wire', out_ptype='wire', cost=cost)
@pytest.mark.parametrize('result', [-1, numpy.inf, numpy.nan, 'expensive'])
def test_offer_rejects_invalid_callable_cost_result(result: Any) -> None:
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
cost=lambda _parameter, _endpoint: result,
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), None)),
)
with pytest.raises(BuildError, match='Primitive cost'):
offer.cost_at(5)
def test_offer_callable_cost_replaces_default_cost() -> None:
seen: list[tuple[float, Port]] = []
def cost(parameter: float, endpoint: Port) -> float:
seen.append((parameter, endpoint))
return parameter + endpoint.y
offer = SOffer(
in_ptype='wire',
out_ptype='wire',
cost=cost,
jog_domain=(2, 2),
**offer_callbacks(lambda jog: (Port((10, jog), rotation=pi, ptype='wire'), None)),
)
assert offer.cost_at(2 + 1e-13) == 4
assert seen[0][0] == 2
assert numpy.allclose(seen[0][1].offset, (10, 2))
def test_offer_rejects_one_sided_split_callbacks() -> None:
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='require both'):
StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint)
def test_offer_bbox_at_validates_bounds() -> None:
offer = StraightOffer(
in_ptype='wire',
out_ptype='wire',
bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]),
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
)
assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]])
bad = StraightOffer(
in_ptype='wire',
out_ptype='wire',
bbox_planner=lambda _length: numpy.array([0, 1]),
**offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})),
)
with pytest.raises(BuildError, match='shape'):
bad.bbox_at(3)
def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('straight', in_ptype='wire')[0]
bounds = offer.bbox_at(10)
expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single()
assert numpy.allclose(bounds, expected)
def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0]
bounds = offer.bbox_at(1)
expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single()
assert isinstance(offer, BendOffer)
assert offer.length_domain == (1, 1)
assert numpy.allclose(bounds, expected)
def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offer = tool.primitive_offers('s', in_ptype='wire')[0]
bounds = offer.bbox_at(3)
expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single()
assert isinstance(offer, SOffer)
assert numpy.allclose(bounds, expected)
def test_pathtool_u_offers_remain_unsupported() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
assert tool.primitive_offers('u', in_ptype='wire') == ()
@pytest.mark.parametrize(
('kind', 'kwargs'),
[
('straight', {}),
('bend', {'ccw': True}),
('s', {}),
],
)
def test_pathtool_out_ptype_unk_is_wildcard(
kind: Literal['straight', 'bend', 's'],
kwargs: dict[str, Any],
) -> None:
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs)
assert offers
assert offers[0].out_ptype == 'wire'
def test_pathtool_rejects_unsupported_planning_options() -> None:
tool = PathTool(layer='M1', width=1)
with pytest.raises(BuildError, match='does not support tool options: marker'):
tool.primitive_offers('straight', marker='sentinel')
def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None:
class NoOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
raise NotImplementedError
p = Pather(Library(), tools=NoOfferTool())
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError, match='No legal primitive offer for trace'):
p.straight('A', 5)
@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError])
def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None:
class BrokenTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
raise err_type('offer query failed')
p = Pather(Library(), tools=BrokenTool())
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(err_type):
p.straight('A', 5)
def test_pather_selects_lowest_cost_offer() -> None:
class MultiOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def high(length: float) -> tuple[Port, dict[str, str | float]]:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'}
def low(length: float) -> tuple[Port, dict[str, str | float]]:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'}
return (
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, cost=10, **offer_callbacks(high)),
StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, cost=1, **offer_callbacks(low)),
)
p = Pather(Library(), tools=MultiOfferTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'low'}
assert numpy.allclose(p.ports['A'].offset, (-7, 0))
def test_planner_deduplication_distinguishes_offer_costs() -> None:
@dataclass(frozen=True, slots=True)
class MarkedStraightOffer(StraightOffer):
marker: str = ''
def commit(self, parameter: float) -> dict[str, str | float]:
return {'kind': self.marker, 'length': self.canonicalize_parameter(parameter)}
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='wire')
def shared_commit(length: float) -> dict[str, float]:
return {'length': length}
class DuplicateGeometryTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
common = {
'in_ptype': in_ptype,
'out_ptype': out_ptype,
'endpoint_planner': endpoint,
'commit_planner': shared_commit,
}
return (
MarkedStraightOffer(cost=100, marker='expensive', **common),
MarkedStraightOffer(cost=1, marker='cheap', **common),
)
pather = Pather(Library(), tools=DuplicateGeometryTool(), render='deferred')
pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
pather.straight('A', 7)
assert pather._paths['A'][0].data == {'kind': 'cheap', 'length': 7}
class StrategyTieTool(PlanningOnlyTool):
def __init__(self) -> None:
self.seen_kwargs: list[dict[str, Any]] = []
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
self.seen_kwargs.append(dict(kwargs))
endpoint_ptype = out_ptype or in_ptype
marker = kwargs.get('marker')
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length, 'marker': marker},
)),
),)
if kind == 's':
return (SOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((3, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog, 'marker': marker},
)),
),)
return ()
def pather_with_strategy_tool(
planner: RoutingPlanner | None = None,
) -> tuple[Pather, StrategyTieTool]:
tool = StrategyTieTool()
pather = Pather(Library(), tools=tool, planner=planner, render='deferred')
pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
return pather, tool
def test_pather_route_strategy_defaults_to_straight_first() -> None:
pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10)
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
assert pather._paths['A'][0].data['length'] == 7
def test_pather_route_strategy_uses_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10)
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
assert pather._paths['A'][1].data['length'] == 7
def test_pather_route_strategy_per_route_overrides_planner_default() -> None:
pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first'))
pather.jog('A', 4, length=10, plan_options={'strategy': 'straight_first'})
assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's']
def test_pather_route_strategy_per_route_can_request_turn_first() -> None:
pather, _tool = pather_with_strategy_tool()
pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'})
assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight']
def test_pather_plan_options_are_not_forwarded_to_tool() -> None:
pather, tool = pather_with_strategy_tool()
pather.jog(
'A',
4,
length=10,
plan_options={'strategy': 'turn_first'},
tool_options={'marker': 'sentinel'},
)
assert tool.seen_kwargs
assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs)
assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs)
assert all(step.data['marker'] == 'sentinel' for step in pather._paths['A'])
def test_pather_route_strategy_rejects_invalid_values() -> None:
with pytest.raises(BuildError, match='Invalid route strategy'):
RoutingPlanner(strategy='sideways')
pather, _tool = pather_with_strategy_tool()
with pytest.raises(BuildError, match='Invalid route strategy'):
pather.jog('A', 4, length=10, plan_options={'strategy': 'sideways'})
def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None:
invalid_parameters: list[float] = []
class RotationOfferTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def invalid_endpoint(length: float) -> Port:
invalid_parameters.append(length)
return Port((length, 0), rotation=0, ptype=out_ptype or in_ptype)
def valid_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
return (
StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
endpoint_planner=invalid_endpoint,
commit_planner=lambda length: {'kind': 'invalid', 'length': length},
),
StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
endpoint_planner=valid_endpoint,
commit_planner=lambda length: {'kind': 'valid', 'length': length},
),
)
p = Pather(Library(), tools=RotationOfferTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7}
assert invalid_parameters == [0.0]
def test_pather_commits_only_selected_offer() -> None:
committed: list[str] = []
class RecordingTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
if kind != 'straight':
return ()
def make(label: str, cost: float) -> StraightOffer:
def endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype)
def commit(length: float) -> dict[str, float | str]:
committed.append(label)
return {'kind': label, 'length': length}
return StraightOffer(
in_ptype=in_ptype,
out_ptype=out_ptype,
cost=cost,
endpoint_planner=endpoint,
commit_planner=commit,
)
return (make('expensive', 100), make('cheap', 1))
p = Pather(Library(), tools=RecordingTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 5)
assert committed == ['cheap']
assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5}
def test_pather_routes_with_offer_only_s_and_u_tool() -> None:
class OfferOnlyTool(PlanningOnlyTool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kwargs
endpoint_ptype = out_ptype or in_ptype
if kind == 'straight':
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda length: (
Port((length, 0), rotation=pi, ptype=endpoint_ptype),
{'kind': 'straight', 'length': length},
)),
),)
if kind == 's':
return (SOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((5, jog), rotation=pi, ptype=endpoint_ptype),
{'kind': 's', 'jog': jog},
)),
),)
if kind == 'u':
return (UOffer(
in_ptype=in_ptype,
out_ptype=endpoint_ptype,
**offer_callbacks(lambda jog: (
Port((2, jog), rotation=0, ptype=endpoint_ptype),
{'kind': 'u', 'jog': jog},
)),
),)
return ()
p = Pather(Library(), tools=OfferOnlyTool(), render='deferred')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.jog('A', 3)
p.uturn('A', 4)
assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u']

View file

@ -1,13 +1,12 @@
from typing import TYPE_CHECKING, cast
import logging
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose
from ..builder import Pather, RouteError, ToolContractError
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
from ..builder import Pather
from ..builder.tools import PathTool, Tool
from ..error import BuildError
from ..library import Library
from ..pattern import Pattern
@ -21,33 +20,16 @@ if TYPE_CHECKING:
def deferred_render_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
rp = Pather(lib, tools=tool, render='deferred')
rp = Pather(lib, tools=tool, auto_render=False)
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
return rp, tool, lib
def add_pending_straight(pather: Pather) -> None:
pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
pather.straight("start", 10)
def route_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
def fail_in_context(pather: Pather) -> None:
with pather:
add_pending_straight(pather)
raise RuntimeError('body failed')
def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10).straight(10)
assert not rp.pattern.has_shapes()
assert len(rp._paths["start"]) == 2
assert len(rp.paths["start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
@ -64,19 +46,21 @@ def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Lib
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
# The bend route is explicit straight run plus a fixed-size bend.
assert len(path_shape.vertices) == 5
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10)
# Clockwise bend adds the bend endpoint after the straight segment vertex.
assert len(path_shape.vertices) == 4
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
def test_deferred_render_jog_uses_native_pathtool_planS(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").jog(4, length=10)
assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"]
assert len(rp.paths["start"]) == 1
assert rp.paths["start"][0].opcode == "S"
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -8], [0, -9], [1, -9], [3, -9], [4, -9], [4, -10]], atol=1e-10)
# Native PathTool S-bends place the jog width/2 before the route end.
assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10)
assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10)
def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
@ -87,7 +71,7 @@ def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10)
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10)
def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool1, lib = deferred_render_setup
@ -101,60 +85,6 @@ def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, L
assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 0)]) == 1
def test_deferred_render_batches_tools_by_identity() -> None:
class CountingTool(PathTool):
def __init__(self, *args, **kwargs) -> None: # noqa: ANN002,ANN003
super().__init__(*args, **kwargs)
self.render_calls = 0
def render(self, *args, **kwargs): # noqa: ANN002,ANN003,ANN202
self.render_calls += 1
return super().render(*args, **kwargs)
lib = Library()
tool1 = CountingTool(layer=(1, 0), width=2, ptype='wire')
tool2 = CountingTool(layer=(1, 0), width=2, ptype='wire')
assert tool1 == tool2
assert tool1 is not tool2
p = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool1,
render='deferred',
)
p.straight('A', 5)
p.retool(tool2, 'A')
p.straight('A', 5)
p.render()
assert tool1.render_calls == 1
assert tool2.render_calls == 1
assert len(p.pattern.shapes[(1, 0)]) == 2
def test_deleted_name_reuse_does_not_retarget_pending_render_steps() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype='wire')
p = Pather(
lib,
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=tool,
render='deferred',
)
p.straight('A', 5)
original_step = p._paths['A'][0]
p.rename_ports({'A': None})
p.mkport('A', Port((100, 0), rotation=0, ptype='wire'))
p.straight('A', 5)
assert len(p._paths['A']) == 2
assert_allclose(original_step.start_port.offset, (0, 0))
assert_allclose(original_step.end_port.offset, (-5, 0))
assert_allclose(p._paths['A'][1].start_port.offset, (100, 0))
def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
pp = rp.at("start")
@ -173,112 +103,26 @@ def test_portpather_translate_only_affects_future_steps(deferred_render_setup: t
def test_deferred_render_dead_ports() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=1)
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred')
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False)
rp.set_dead()
with pytest.raises(RouteError, match='finite and nonnegative'):
rp.straight("in", -10)
rp.straight("in", -10)
assert_allclose(rp.ports["in"].offset, [0, 0], atol=1e-10)
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
assert len(rp._paths["in"]) == 0
assert len(rp.paths["in"]) == 0
rp.render()
assert not rp.pattern.has_shapes()
def test_pather_default_auto_policy_renders_immediately_outside_context() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool)
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
p.straight("start", 10)
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with Pather(lib, tools=tool) as p:
add_pending_straight(p)
assert not p.pattern.has_shapes()
assert len(p._paths["start"]) == 1
assert p.pattern.has_shapes()
assert not any(p._paths.values())
def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p:
add_pending_straight(p)
records = [
record
for record in caplog.records
if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage()
]
assert len(records) == 1
assert records[0].stack_info is None
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_error_policy_rejects_pending_paths() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(BuildError, match='1 pending render step on 1 port'):
route_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p:
add_pending_straight(p)
assert not caplog.records
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_context_policy_does_not_mask_body_exception() -> None:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='error')
with pytest.raises(RuntimeError, match='body failed'):
fail_in_context(p)
assert len(p._paths["start"]) == 1
assert not p.pattern.has_shapes()
def test_pather_rejects_invalid_render_policy() -> None:
with pytest.raises(BuildError, match='Invalid render policy'):
Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type]
def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup
rp.at("start").straight(10)
rp.rename_ports({"start": "new_start"})
rp.at("new_start").straight(10)
assert "start" not in rp._paths
assert len(rp._paths["new_start"]) == 2
assert "start" not in rp.paths
assert len(rp.paths["new_start"]) == 2
rp.render()
assert rp.pattern.has_shapes()
@ -293,7 +137,7 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende
rp.at("start").straight(10).drop()
assert "start" not in rp.ports
assert len(rp._paths["start"]) == 1
assert len(rp.paths["start"]) == 1
rp.render()
assert rp.pattern.has_shapes()
@ -301,44 +145,38 @@ def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_rende
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
def test_pathtool_bend_offer_render_geometry_matches_ports() -> None:
def test_pathtool_traceL_bend_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(1)
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(1)),))
tree = tool.traceL(True, 10)
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert offer.length_domain == (1, 1)
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10)
assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10)
def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
def test_pathtool_traceS_geometry_matches_ports() -> None:
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
offer = tool.primitive_offers("s", in_ptype="wire")[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(4)
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(4)),))
tree = tool.traceS(10, 4)
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
assert_allclose(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10)
assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10)
def test_deferred_render_uturn_fallback() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, render='deferred')
rp = Pather(lib, tools=tool, auto_render=False)
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').uturn(offset=10000, length=5000)
assert len(rp._paths['A']) == 4
assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L']
assert len(rp.paths['A']) == 2
assert rp.paths['A'][0].opcode == 'L'
assert rp.paths['A'][1].opcode == 'L'
rp.render()
assert rp.pattern.ports['A'].rotation is not None
@ -346,23 +184,15 @@ def test_deferred_render_uturn_fallback() -> None:
def test_pather_render_auto_renames_single_use_tool_children() -> None:
class FullTreeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
port_names[1]: Port((1, 0), pi, ptype='wire'),
})
child = Pattern(annotations={'batch': [len(batch)]})
top.ref('_seg')
@ -371,7 +201,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
return tree
lib = Library()
p = Pather(lib, tools=FullTreeTool())
p = Pather(lib, tools=FullTreeTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -385,24 +215,13 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
assert all(name.startswith('_seg') for name in lib)
assert p.pattern.referenced_patterns() <= set(lib.keys())
def test_custom_tool_render_preserves_segment_subtrees() -> None:
def test_tool_render_fallback_preserves_segment_subtrees() -> None:
class TraceTreeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: self._trace(length),
),)
def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
child = Pattern(annotations={'length': [length]})
top.ref('_seg')
@ -410,13 +229,8 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
tree['_seg'] = child
return tree
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
assert len(batch) == 1
assert isinstance(batch[0].data, Library)
return batch[0].data
lib = Library()
p = Pather(lib, tools=TraceTreeTool())
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -428,18 +242,11 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
class MissingSingleUseTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
@ -451,7 +258,7 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
lib = Library()
lib['_seg'] = Pattern(annotations={'stale': [1]})
p = Pather(lib, tools=MissingSingleUseTool(), render='deferred')
p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -463,23 +270,15 @@ def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
class SharedRefTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
return Port((length, 0), rotation=pi, ptype=ptype), {'length': length}
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
port_names[1]: Port((1, 0), pi, ptype='wire'),
})
top.ref('shared')
tree['_top'] = top
@ -487,7 +286,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
lib = Library()
lib['shared'] = Pattern(annotations={'shared': [1]})
p = Pather(lib, tools=SharedRefTool())
p = Pather(lib, tools=SharedRefTool(), auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
@ -496,131 +295,17 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
assert 'shared' in p.pattern.refs
assert p.pattern.referenced_patterns() <= set(lib.keys())
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None:
class WrongEndpointTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length + 1, 0), pi, ptype='wire'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongEndpointTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
def test_pather_render_rejects_opposite_output_rotation() -> None:
class WrongRotationTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer.generated('wire', lambda length: length),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
return tree
p = Pather(Library(), tools=WrongRotationTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(ToolContractError, match='does not match planned endpoint'):
p.render()
def test_pather_render_allows_unspecified_output_rotation() -> None:
class UnspecifiedRotationTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer.generated('wire', lambda length: length),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), None, ptype='wire'),
})
return tree
p = Pather(Library(), tools=UnspecifiedRotationTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
p.render()
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
class WrongPtypeTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
ptype = out_ptype or in_ptype or 'wire'
return (StraightOffer(
in_ptype=in_ptype,
out_ptype=ptype,
endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype),
commit_planner=lambda length: {'length': length},
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data['length']
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='metal'),
})
return tree
lib = Library()
p = Pather(lib, tools=WrongPtypeTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(BuildError, match='does not match planned endpoint'):
p.render(append=append)
assert not p.pattern.refs
assert not lib
def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
rp = Pather(lib, tools=tool, render='deferred')
rp = Pather(lib, tools=tool, auto_render=False)
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
rp.at('A').straight(5000)
rp.rename_ports({'A': None})
assert 'A' not in rp.pattern.ports
assert len(rp._paths['A']) == 1
assert len(rp.paths['A']) == 1
rp.render()
assert rp.pattern.has_shapes()

View file

@ -1,166 +0,0 @@
from collections.abc import Mapping, Sequence
from typing import Any, Literal
import numpy
import pytest
from masque import Library, Pather, Port
from masque.builder import PathTool, RenderStep, RouteCompletionCallback, Tool
from masque.error import BuildError
def test_route_completion_can_label_before_automatic_render() -> None:
calls = 0
def label_endpoints(pather: Pather, endpoints: Mapping[str, Port]) -> None:
nonlocal calls
calls += 1
assert not pather.pattern.has_shapes()
assert numpy.allclose(pather.ports['A'].offset, endpoints['A'].offset)
for name, port in endpoints.items():
pather.label('LABELS', string=name, offset=port.offset)
callback: RouteCompletionCallback = label_endpoints
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
on_route_complete=callback,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.at('A').straight(5)
assert calls == 1
assert p.pattern.has_shapes()
assert len(p.pattern.labels['LABELS']) == 1
assert p.pattern.labels['LABELS'][0].string == 'A'
assert numpy.allclose(p.pattern.labels['LABELS'][0].offset, p.ports['A'].offset)
def test_bundle_route_completion_is_ordered_read_only_and_isolated() -> None:
calls: list[Mapping[str, Port]] = []
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
_ = pather
calls.append(endpoints)
with pytest.raises(TypeError):
endpoints['extra'] = Port((0, 0)) # type: ignore[index]
endpoints['B'].translate((100, 100))
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
on_route_complete=record,
)
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.trace(['A', 'B'], None, each=5)
assert len(calls) == 1
assert list(calls[0]) == ['A', 'B']
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
assert numpy.allclose(p.ports['B'].offset, (-5, 2))
def test_trace_into_completion_retains_consumed_source_endpoint() -> None:
calls: list[Mapping[str, Port]] = []
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
assert set(pather.ports) == {'src'}
assert numpy.allclose(pather.ports['src'].offset, (20, 0))
calls.append(endpoints)
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
on_route_complete=record,
)
p.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
p.ports['dst'] = Port((-10, 0), rotation=numpy.pi, ptype='wire')
p.ports['thru'] = Port((20, 0), rotation=0, ptype='wire')
p.trace_into('src', 'dst', thru='thru')
assert len(calls) == 1
assert list(calls[0]) == ['src']
assert numpy.allclose(calls[0]['src'].offset, (-10, 0))
def test_route_completion_exception_propagates_before_render() -> None:
def fail(pather: Pather, endpoints: Mapping[str, Port]) -> None:
_ = pather, endpoints
raise RuntimeError('completion failed')
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
on_route_complete=fail,
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(RuntimeError, match='completion failed'):
p.straight('A', 5)
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
assert p._paths['A']
assert not p.pattern.has_shapes()
class NoRouteTool(Tool):
def primitive_offers(
self,
kind: Literal['straight', 'bend', 's', 'u'],
**kwargs: Any,
) -> tuple[()]:
_ = kind, kwargs
return ()
def render(
self,
batch: Sequence[RenderStep],
*,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs: Any,
) -> Library:
_ = batch, port_names, kwargs
return Library()
def test_dead_bundle_fallback_invokes_route_completion_once() -> None:
calls: list[Mapping[str, Port]] = []
p = Pather(
Library(),
tools=NoRouteTool(),
on_route_complete=lambda _pather, endpoints: calls.append(endpoints),
)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
p.set_dead()
p.trace(['B', 'A'], None, each=5)
assert len(calls) == 1
assert list(calls[0]) == ['B', 'A']
assert numpy.allclose(calls[0]['B'].offset, (-5, 2))
assert numpy.allclose(calls[0]['A'].offset, (-5, 0))
assert not p.pattern.has_shapes()
def test_failed_route_does_not_invoke_route_completion() -> None:
calls = 0
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
nonlocal calls
_ = pather, endpoints
calls += 1
p = Pather(Library(), tools=NoRouteTool(), on_route_complete=record)
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
with pytest.raises(BuildError):
p.straight('A', 5)
assert calls == 0

View file

@ -1,25 +1,22 @@
from typing import Any
import numpy
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_equal
from masque import Library, PathTool, Port, Pather, RouteFailurePolicy
from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import Candidate, SolverRequest
from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
from masque.error import BuildError, PortError
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool
from masque.error import BuildError, PortError, PatternError
@pytest.fixture
def trace_into_setup() -> tuple[Pather, PathTool, Library]:
lib = Library()
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
p = Pather(lib, tools=tool, render='immediate', render_append=False)
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
return p, tool, lib
def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -31,7 +28,6 @@ def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library])
assert "dst" not in p.ports
assert len(p.pattern.refs) == 1
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -41,9 +37,10 @@ def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
assert "src" not in p.ports
assert "dst" not in p.ports
# `trace_into()` batches internal legs before auto-rendering so the operation
# rolls back cleanly on later failures.
assert len(p.pattern.refs) == 1
def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -54,7 +51,6 @@ def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) ->
assert "src" not in p.ports
assert "dst" not in p.ports
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
p, _tool, _lib = trace_into_setup
p.ports["src"] = Port((0, 0), 0, ptype="wire")
@ -67,11 +63,10 @@ def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> N
assert_equal(p.ports["src"].offset, [10, 10])
assert "other" not in p.ports
def test_pather_trace_into_shapes() -> None:
def test_pather_trace_into() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000)
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0)
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
@ -81,7 +76,7 @@ def test_pather_trace_into_shapes() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
p.pattern.ports['C'] = Port((0, 0), rotation=0)
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2)
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2)
p.at('C').trace_into('D', plug_destination=False)
assert 'D' in p.pattern.ports
assert 'C' in p.pattern.ports
@ -112,171 +107,10 @@ def test_pather_trace_into_shapes() -> None:
assert p.pattern.ports['I'].rotation is not None
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
@pytest.mark.parametrize(
'dst',
[
Port((-10_000, 0), rotation=pi),
Port((-10_000, 2_000), rotation=pi),
Port((-5_000, 5_000), rotation=pi / 2),
Port((-10_000, 2_000), rotation=0),
],
)
def test_pather_trace_into_minimal_policy_accepts_required_topologies(dst: Port) -> None:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1_000),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0)
pather.ports['dst'] = dst
pather.trace_into(
'src',
'dst',
plug_destination=False,
plan_options={'bend_policy': 'minimal'},
)
assert numpy.allclose(pather.ports['src'].offset, dst.offset)
assert pather.ports['src'].rotation is not None
assert numpy.isclose((pather.ports['src'].rotation - dst.rotation) % (2 * pi), pi)
def test_pather_trace_into_bend_policy_changes_real_solver_fallback() -> None:
def make_pather() -> Pather:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((2, 0), rotation=pi, ptype='wire')
return pather
flexible = make_pather()
flexible.at('src').trace_into(
'dst',
plug_destination=False,
plan_options={'bend_policy': 'flexible'},
)
assert_equal(flexible.ports['src'].offset, (2, 0))
assert flexible.ports['src'].rotation is not None
assert numpy.isclose(flexible.ports['src'].rotation, 0)
bend_roles = sum(
1 if step.kind == 'bend' else 2 if step.kind in ('s', 'u') else 0
for step in flexible._paths['src']
)
assert bend_roles == 4
minimal = make_pather()
with pytest.raises(BuildError):
minimal.at('src').trace_into(
'dst',
plug_destination=False,
)
assert set(minimal.ports) == {'src', 'dst'}
assert_equal(minimal.ports['src'].offset, (0, 0))
assert numpy.isclose(minimal.ports['src'].rotation, 0)
assert_equal(minimal.ports['dst'].offset, (2, 0))
assert numpy.isclose(minimal.ports['dst'].rotation, pi)
assert not minimal._paths
def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None:
p = Pather(
Library(),
tools=PathTool(layer='M1', width=1000, ptype='wire'),
render='deferred',
)
p.ports['src'] = Port((123.25, 456.75), rotation=0, ptype='wire')
p.ports['dst'] = Port((-99_999_876.75, 20_000_456.75), rotation=0, ptype='wire')
p.trace_into('src', 'dst')
assert 'src' not in p.ports
assert 'dst' not in p.ports
assert [step.kind for step in p._paths['src']] == ['straight', 'bend', 'straight', 'bend', 'plug']
def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None:
class TransitionTool(Tool):
def primitive_offers(
self,
kind, # noqa: ANN001
*,
in_ptype=None, # noqa: ANN001
out_ptype=None, # noqa: ANN001
**kwargs, # noqa: ANN003
) -> tuple[PrimitiveOffer, ...]:
_ = in_ptype
if kind == 'straight':
def native_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='m1wire')
native = StraightOffer(
in_ptype='m1wire',
out_ptype='m1wire',
endpoint_planner=native_endpoint,
commit_planner=lambda length: {'kind': 'straight', 'length': length},
)
if out_ptype in ('unk', 'm1wire'):
return (native,)
def transition_endpoint(length: float) -> Port:
return Port((length, 0), rotation=pi, ptype='m2wire')
transition = StraightOffer(
in_ptype='m1wire',
out_ptype='m2wire',
length_domain=(2500, numpy.inf),
endpoint_planner=transition_endpoint,
commit_planner=lambda length: {'kind': 'transition', 'length': length},
)
return native, transition
if kind == 'bend':
ccw = bool(kwargs['ccw'])
def endpoint(length: float) -> Port:
return Port(
(length, 500 if ccw else -500),
rotation=-pi / 2 if ccw else pi / 2,
ptype='m1wire',
)
return (BendOffer(
in_ptype='m1wire',
out_ptype='m1wire',
ccw=ccw,
length_domain=(500, numpy.inf),
endpoint_planner=endpoint,
commit_planner=lambda length: {'kind': 'bend', 'length': length},
),)
return ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
tree, pat = Library.mktree('transition_tool')
pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire')
return tree
p = Pather(Library(), tools=TransitionTool(), render='deferred')
p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire')
p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire')
p.trace_into('src', 'dst')
assert 'src' not in p.pattern.ports
assert 'dst' not in p.pattern.ports
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1000, ptype='wire')
p = Pather(lib, tools=tool)
p = Pather(lib, tools=tool, auto_render=False)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire')
p.set_dead()
@ -287,29 +121,27 @@ def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
assert p.pattern.ports['A'].rotation is not None
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert len(p._paths['A']) == 0
assert len(p.paths['A']) == 0
assert not p.pattern.has_shapes()
assert not p.pattern.has_refs()
def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None:
def test_pather_trace_into_failure_rolls_back_ports_and_paths() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire')
with pytest.raises(BuildError):
with pytest.raises(BuildError, match='does not match path ptype'):
p.trace_into('A', 'B', plug_destination=False, out_ptype='other')
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5))
assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2)
assert len(p._paths['A']) == 0
assert len(p.paths['A']) == 0
def test_pather_trace_into_rename_failure_propagates() -> None:
def test_pather_trace_into_rename_failure_rolls_back_ports_and_paths() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(lib, tools=tool)
@ -320,14 +152,19 @@ def test_pather_trace_into_rename_failure_propagates() -> None:
with pytest.raises(PortError, match='overwritten'):
p.trace_into('A', 'B', plug_destination=False, thru='other')
assert set(p.pattern.ports) == {'A', 'B', 'other'}
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
assert numpy.allclose(p.pattern.ports['B'].offset, (-10, 0))
assert numpy.allclose(p.pattern.ports['other'].offset, (3, 4))
assert len(p.paths['A']) == 0
@pytest.mark.parametrize(
('dst', 'kwargs', 'match'),
[
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'),
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'),
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'),
],
(
(Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'trace_to\(\) arguments: x'),
(Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'jog\(\) arguments: length'),
(Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'uturn\(\) arguments: length'),
),
)
def test_pather_trace_into_rejects_reserved_route_kwargs(
dst: Port,
@ -340,8 +177,7 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.pattern.ports['B'] = dst
_ = match
with pytest.raises(TypeError, match='unexpected keyword argument'):
with pytest.raises(BuildError, match=match):
p.trace_into('A', 'B', plug_destination=False, **kwargs)
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
@ -350,224 +186,4 @@ def test_pather_trace_into_rejects_reserved_route_kwargs(
assert dst.rotation is not None
assert p.pattern.ports['B'].rotation is not None
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
assert len(p._paths['A']) == 0
class TraceIntoBudgetSolver:
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
self.successes = successes
self.fatal_at = set() if fatal_at is None else fatal_at
self.attempts: list[tuple[int, int]] = []
def solve(
self,
*,
min_bends: int = 0,
max_bends: int | None = None,
) -> Candidate:
assert max_bends is not None
band = (min_bends, max_bends)
self.attempts.append(band)
if band in self.fatal_at:
raise RoutePlanningError('fatal', policy=RouteFailurePolicy.FATAL)
if band not in self.successes:
raise BuildError('try next budget')
return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0)
class TraceIntoBudgetPlanner(RoutingPlanner):
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
super().__init__()
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
self.solver_requests = 0
def solver_for_request(self, request: SolverRequest) -> Any:
_ = request
self.solver_requests += 1
return self.solver
def prepared_result_from_legs(
self,
legs: Any,
*,
renames: tuple[tuple[str, str], ...] = (),
) -> PreparedRouteResult:
_ = legs, renames
return PreparedRouteResult(())
@pytest.mark.parametrize(
('dst', 'successes', 'attempts'),
[
(Port((-10, 0), rotation=pi, ptype='wire'), {(0, 2)}, [(0, 2)]),
(Port((-10, 0), rotation=pi, ptype='wire'), {(4, 4)}, [(0, 2), (4, 4)]),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(1, 1)}, [(1, 1)]),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(3, 3)}, [(1, 1), (3, 3)]),
],
)
def test_trace_into_reuses_solver_across_staged_bend_bands(
dst: Port,
successes: set[tuple[int, int]],
attempts: list[tuple[int, int]],
) -> None:
planner = TraceIntoBudgetPlanner(successes)
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert planner.solver.attempts == attempts
assert planner.solver_requests == 1
def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None:
planner = TraceIntoBudgetPlanner({(4, 4)}, fatal_at={(0, 2)})
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
with pytest.raises(RoutePlanningError, match='fatal'):
planner.plan_trace_into(
context,
'dst',
Port((-10, 0), rotation=pi, ptype='wire'),
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert planner.solver.attempts == [(0, 2)]
assert planner.solver_requests == 1
def test_trace_into_bend_bands_respect_max_bends() -> None:
class OneBendPlanner(RoutingPlanner):
TRACE_INTO_MAX_BENDS = 1
planner = OneBendPlanner(bend_policy='flexible')
assert planner.trace_into_bend_bands('straight') == ((0, 0),)
assert planner.trace_into_bend_bands('s') == ((0, 0),)
assert planner.trace_into_bend_bands('bend') == ((1, 1),)
@pytest.mark.parametrize(
('family', 'expected'),
[
('straight', ((0, 0),)),
('bend', ((1, 1),)),
('s', ((2, 2),)),
('u', ((2, 2),)),
],
)
def test_trace_into_default_minimal_bend_bands(family: str, expected: tuple[tuple[int, int], ...]) -> None:
planner = RoutingPlanner()
assert planner.trace_into_bend_bands(family) == expected
assert planner.trace_into_bend_bands(family, bend_policy='flexible') == (
((1, 1), (3, 3)) if family == 'bend' else ((0, 2), (4, 4))
)
@pytest.mark.parametrize(
('dst', 'required_band'),
[
(Port((-10, 0), rotation=pi, ptype='wire'), (0, 0)),
(Port((-10, -5), rotation=pi, ptype='wire'), (2, 2)),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), (1, 1)),
(Port((-10, -5), rotation=0, ptype='wire'), (2, 2)),
],
)
def test_trace_into_minimal_policy_uses_orientation_required_band(
dst: Port,
required_band: tuple[int, int],
) -> None:
planner = TraceIntoBudgetPlanner({required_band})
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'minimal'},
)
assert planner.solver.attempts == [required_band]
def test_trace_into_minimal_policy_rejects_fallback_without_mutation() -> None:
planner = TraceIntoBudgetPlanner({(4, 4)})
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
planner=planner,
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='try next budget'):
pather.trace_into('src', 'dst', plan_options={'bend_policy': 'minimal'})
assert planner.solver.attempts == [(0, 0)]
assert set(pather.ports) == {'src', 'dst'}
assert_equal(pather.ports['src'].offset, (0, 0))
assert_equal(pather.ports['dst'].offset, (-10, 0))
assert not pather._paths
def test_trace_into_bend_policy_planner_default_and_route_override() -> None:
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
dst = Port((-10, 0), rotation=pi, ptype='wire')
minimal_planner = TraceIntoBudgetPlanner({(4, 4)})
with pytest.raises(BuildError, match='try next budget'):
minimal_planner.plan_trace_into(
context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None,
)
assert minimal_planner.solver.attempts == [(0, 0)]
flexible_planner = TraceIntoBudgetPlanner({(4, 4)})
flexible_planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
plan_options={'bend_policy': 'flexible'},
)
assert flexible_planner.solver.attempts == [(0, 2), (4, 4)]
def test_trace_into_rejects_invalid_bend_policy() -> None:
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
RoutingPlanner(bend_policy='sideways') # type: ignore[arg-type]
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
pather.trace_into('src', 'dst', plan_options={'bend_policy': 'sideways'})
assert len(p.paths['A']) == 0

View file

@ -169,47 +169,6 @@ def test_port_list_plugged() -> None:
assert not pl.ports # Both should be removed
def test_port_list_plugged_uses_coordinate_relative_tolerance() -> None:
pattern = Pattern(ports={
"A": Port((10, 10), 0),
"B": Port((10 + 5e-8, 10), pi),
})
pattern.plugged({"A": "B"})
assert not pattern.ports
def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("WARNING", logger="masque.ports")
compatible_cases = [
("wire", "wire"),
("unk", "wire"),
(None, "wire"),
]
for left, right in compatible_cases:
caplog.clear()
pl = Pattern(ports={
"A": Port((10, 10), 0, ptype=left), # type: ignore[arg-type]
"B": Port((10, 10), pi, ptype=right), # type: ignore[arg-type]
})
pl.plugged({"A": "B"})
assert not any("conflicting types" in record.message for record in caplog.records)
caplog.clear()
pl = Pattern(ports={
"A": Port((10, 10), 0, ptype="wire"),
"B": Port((10, 10), pi, ptype="metal"),
})
pl.plugged({"A": "B"})
assert any("conflicting types" in record.message for record in caplog.records)
def test_port_list_plugged_empty_raises() -> None:
class MyPorts(PortList):
def __init__(self) -> None:
@ -332,34 +291,3 @@ def test_find_transform_requires_connection_map() -> None:
with pytest.raises(PortError, match="at least one port connection"):
Pattern.find_port_transform({}, {}, {})
def test_find_transform_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("WARNING", logger="masque.ports")
compatible_cases = [
("wire", "wire"),
("wire", "unk"),
("wire", None),
]
for left, right in compatible_cases:
caplog.clear()
host = Pattern(ports={"A": Port((0, 0), 0, ptype=left)}) # type: ignore[arg-type]
other = Pattern(ports={"X": Port((0, 0), pi, ptype=right)}) # type: ignore[arg-type]
host.find_transform(other, {"A": "X"})
assert not any("conflicting types" in record.message for record in caplog.records)
caplog.clear()
host = Pattern(ports={"A": Port((0, 0), 0, ptype="wire")})
other = Pattern(ports={"X": Port((0, 0), pi, ptype="metal")})
host.find_transform(other, {"A": "X"})
assert any("conflicting types" in record.message for record in caplog.records)
caplog.clear()
host.find_transform(other, {"A": "X"}, ok_connections={("wire", "metal")})
assert not any("conflicting types" in record.message for record in caplog.records)

View file

@ -60,15 +60,6 @@ def test_data_to_ports_hierarchical() -> None:
assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10)
def test_data_to_ports_ignores_empty_dangling_ref_bucket() -> None:
parent = Pattern()
parent.refs["missing"]
data_to_ports([(10, 0)], Library(), parent, max_depth=1)
assert not parent.ports
def test_data_to_ports_hierarchical_scaled_ref() -> None:
lib = Library()

View file

@ -1,97 +0,0 @@
import numpy
from numpy import pi
from numpy.testing import assert_allclose
from ..shapes import Arc, Circle, Ellipse, Path, Text
def test_circle_raw_constructor_matches_public() -> None:
raw = Circle._from_raw(
radius=5.0,
offset=numpy.array([1.0, 2.0]),
annotations={'1': ['circle']},
)
public = Circle(
radius=5.0,
offset=(1.0, 2.0),
annotations={'1': ['circle']},
)
assert raw == public
def test_ellipse_raw_constructor_matches_public() -> None:
raw = Ellipse._from_raw(
radii=numpy.array([3.0, 5.0]),
offset=numpy.array([1.0, 2.0]),
rotation=5 * pi / 2,
annotations={'2': ['ellipse']},
)
public = Ellipse(
radii=(3.0, 5.0),
offset=(1.0, 2.0),
rotation=5 * pi / 2,
annotations={'2': ['ellipse']},
)
assert raw == public
def test_arc_raw_constructor_matches_public() -> None:
raw = Arc._from_raw(
radii=numpy.array([10.0, 6.0]),
angles=numpy.array([0.0, pi / 2]),
width=2.0,
offset=numpy.array([1.0, 2.0]),
rotation=5 * pi / 2,
annotations={'3': ['arc']},
)
public = Arc(
radii=(10.0, 6.0),
angles=(0.0, pi / 2),
width=2.0,
offset=(1.0, 2.0),
rotation=5 * pi / 2,
annotations={'3': ['arc']},
)
assert raw == public
def test_path_raw_constructor_matches_public() -> None:
raw = Path._from_raw(
vertices=numpy.array([[0.0, 0.0], [10.0, 0.0], [10.0, 5.0]]),
width=2.0,
cap=Path.Cap.SquareCustom,
cap_extensions=numpy.array([1.0, 3.0]),
annotations={'4': ['path']},
)
public = Path(
vertices=((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)),
width=2.0,
cap=Path.Cap.SquareCustom,
cap_extensions=(1.0, 3.0),
annotations={'4': ['path']},
)
assert raw == public
assert raw.cap_extensions is not None
assert_allclose(raw.cap_extensions, [1.0, 3.0])
def test_text_raw_constructor_matches_public() -> None:
raw = Text._from_raw(
string='RAW',
height=12.0,
font_path='font.otf',
offset=numpy.array([1.0, 2.0]),
rotation=5 * pi / 2,
mirrored=True,
annotations={'5': ['text']},
)
public = Text(
string='RAW',
height=12.0,
font_path='font.otf',
offset=(1.0, 2.0),
rotation=5 * pi / 2,
mirrored=True,
annotations={'5': ['text']},
)
assert raw == public

View file

@ -1,70 +0,0 @@
import copy
import numpy
import pytest
from numpy.testing import assert_allclose, assert_equal
from ..error import PatternError
from ..shapes import Polygon, RectCollection
def test_rect_collection_init_and_to_polygons() -> None:
rects = RectCollection([[10, 10, 12, 12], [0, 0, 5, 5]])
assert_equal(rects.rects, [[0, 0, 5, 5], [10, 10, 12, 12]])
polys = rects.to_polygons()
assert len(polys) == 2
assert all(isinstance(poly, Polygon) for poly in polys)
assert_equal(polys[0].vertices, [[0, 0], [0, 5], [5, 5], [5, 0]])
def test_rect_collection_rejects_invalid_rects() -> None:
with pytest.raises(PatternError):
RectCollection([[0, 0, 1]])
with pytest.raises(PatternError):
RectCollection([[5, 0, 1, 2]])
with pytest.raises(PatternError):
RectCollection([[0, 5, 1, 2]])
def test_rect_collection_raw_constructor_matches_public() -> None:
raw = RectCollection._from_raw(
rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]),
annotations={'1': ['rects']},
)
public = RectCollection(
[[0, 0, 5, 5], [10, 10, 12, 12]],
annotations={'1': ['rects']},
)
assert raw == public
assert_equal(raw.get_bounds_single(), [[0, 0], [12, 12]])
def test_rect_collection_manhattan_transforms() -> None:
rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]])
mirrored = copy.deepcopy(rects).mirror(1)
assert_equal(mirrored.rects, [[-2, 0, 0, 4], [-12, 20, -10, 22]])
scaled = copy.deepcopy(rects).scale_by(-2)
assert_equal(scaled.rects, [[-4, -8, 0, 0], [-24, -44, -20, -40]])
rotated = copy.deepcopy(rects).rotate(numpy.pi / 2)
assert_equal(rotated.rects, [[-4, 0, 0, 2], [-22, 10, -20, 12]])
def test_rect_collection_non_manhattan_rotation_raises() -> None:
rects = RectCollection([[0, 0, 2, 4]])
with pytest.raises(PatternError, match='Manhattan rotations'):
rects.rotate(numpy.pi / 4)
def test_rect_collection_normalized_form_rebuild_is_independent() -> None:
rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]])
_intrinsic, extrinsic, rebuild = rects.normalized_form(2)
clone = rebuild()
clone.rects[:] = [[1, 1, 2, 2], [3, 3, 4, 4]]
assert_allclose(extrinsic[0], [6, 11.5])
assert_equal(rects.rects, [[0, 0, 2, 4], [10, 20, 12, 22]])

View file

@ -17,8 +17,7 @@ def test_shape_mirror() -> None:
a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
a.mirror(1)
# The pi rotation already reflects the X-major focus across the Y axis.
assert a.angle_ref == Arc.AngleRef.FocusPos
assert a.angle_ref == Arc.AngleRef.FocusNeg
a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
a.mirror(0)

View file

@ -1,338 +0,0 @@
from typing import Any
import numpy
import pytest
from numpy import pi
from masque.builder import (
AutoTool,
BendOffer,
PathTool,
PrimitiveKind,
PrimitiveOffer,
RenderStep,
SOffer,
StraightOffer,
Tool,
ToolContractCase,
ToolContractError,
UOffer,
validate_tool_contract,
)
from masque.error import BuildError
from masque.library import ILibrary, Library, SINGLE_USE_PREFIX
from masque.pattern import Pattern
from masque.ports import Port
def make_straight(length: float, *, ptype: str = 'wire') -> Pattern:
return Pattern(ports={
'A': Port((0, 0), 0, ptype=ptype),
'B': Port((length, 0), pi, ptype=ptype),
})
class EmptyTool(Tool):
def primitive_offers(
self,
kind: PrimitiveKind,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(
self,
batch: tuple[RenderStep, ...],
*,
port_names: tuple[str, str] = ('A', 'B'),
) -> ILibrary:
_ = batch
tree, pattern = Library.mktree('empty_tool')
pattern.add_port_pair(names=port_names)
return tree
@pytest.mark.parametrize(
('offer', 'kind', 'opcode'),
[
(StraightOffer('wire', 'wire'), 'straight', 'L'),
(BendOffer('wire', 'wire'), 'bend', 'L'),
(SOffer('wire', 'wire'), 's', 'S'),
(UOffer('wire', 'wire'), 'u', 'U'),
],
)
def test_offer_kind_is_canonical_and_opcode_is_derived(
offer: StraightOffer | BendOffer | SOffer | UOffer,
kind: str,
opcode: str,
) -> None:
assert offer.kind == kind
assert offer.opcode == opcode
def test_render_step_stores_kind_and_derives_opcode() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
port = Port((0, 0), 0, ptype='wire')
step = RenderStep('straight', tool, port, port, None)
plug = RenderStep('plug', None, port, port, None)
assert step.kind == 'straight'
assert step.opcode == 'L'
assert step.transformed(numpy.zeros(2), 0, numpy.zeros(2)).kind == 'straight'
assert step.mirrored(0).kind == 'straight'
assert plug.opcode == 'P'
with pytest.raises(BuildError, match='Unrecognized RenderStep kind'):
RenderStep('L', tool, port, port, None) # type: ignore[arg-type]
with pytest.raises(BuildError, match='requires tool=None'):
RenderStep('plug', tool, port, port, None)
def test_standard_offer_endpoint_callback_runs_once_per_solver_evaluation() -> None:
endpoint_calls: list[float] = []
received_endpoints: list[Port] = []
def endpoint(length: float) -> Port:
endpoint_calls.append(length)
return Port((length, 0), pi, ptype='wire')
def cost(length: float, out_port: Port) -> float:
_ = length
received_endpoints.append(out_port)
return out_port.x
class OneOfferTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
cost=cost,
endpoint_planner=endpoint,
commit_planner=lambda length: length,
),)
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=OneOfferTool(),
render='deferred',
)
pather.straight('A', 5)
assert endpoint_calls.count(5) == 1
assert len(received_endpoints) >= 1
def test_custom_cost_at_override_remains_authoritative() -> None:
calls: list[float] = []
class CustomCostOffer(StraightOffer):
def cost_at(self, parameter: float) -> float:
calls.append(parameter)
return 0
offer = CustomCostOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wire'),
commit_planner=lambda length: length,
)
class CustomCostTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (offer,) if kind == 'straight' else ()
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=CustomCostTool(),
render='deferred',
)
pather.straight('A', 5)
assert 5 in calls
def test_solver_rejects_offer_kind_mismatch() -> None:
class WrongKindTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (SOffer.generated(
'wire',
lambda jog: Port((1, jog), pi, ptype='wire'),
lambda jog: jog,
),)
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=WrongKindTool(),
render='deferred',
)
with pytest.raises(ToolContractError, match='returned.*s.*offer'):
pather.straight('A', 5)
def test_validate_tool_contract_accepts_pathtool() -> None:
tool = PathTool(layer='M1', width=2, ptype='wire')
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
ToolContractCase('bend', in_ptype='wire', ccw=False, check_bbox=True),
ToolContractCase('bend', in_ptype='wire', ccw=True, check_bbox=True),
ToolContractCase('s', in_ptype='wire', check_bbox=True),
ToolContractCase('u', in_ptype='wire', require_offers=False),
))
def test_validate_tool_contract_accepts_autotool_with_explicit_probe() -> None:
tool = AutoTool().add_straight(
make_straight,
'wire',
'A',
length_range=(1, 10),
)
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(7,)),
))
def test_validate_tool_contract_empty_offer_policy() -> None:
tool = EmptyTool()
validate_tool_contract(tool, (ToolContractCase('u', require_offers=False),))
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(tool, (ToolContractCase('u'),))
assert all(isinstance(err, ToolContractError) for err in exc_info.value.exceptions)
assert any('no offers' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_rejects_unmatched_explicit_probe() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
with pytest.raises(ExceptionGroup, match='Tool contract validation') as exc_info:
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
))
assert any('outside every discovered offer domain' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_aggregates_independent_violations() -> None:
class BrokenTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length + 1, 0), 0, ptype='wrong'),
commit_planner=lambda length: length,
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(BrokenTool(), (
ToolContractCase('straight', in_ptype='wire', label='broken straight'),
))
errors = exc_info.value.exceptions
assert len(errors) > 1
assert all(isinstance(err, ToolContractError) for err in errors)
assert all('broken straight' in str(err) for err in errors)
def test_validate_tool_contract_detects_repeated_discovery_changes() -> None:
class ChangingTool(EmptyTool):
calls = 0
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
self.calls += 1
shift = float(self.calls - 1)
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length + shift, 0), pi, ptype='wire'),
commit_planner=lambda length: length,
),)
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(ChangingTool(), (
ToolContractCase('straight', in_ptype='wire'),
))
assert any('changed after repeated discovery' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_checks_render_ports_and_single_use_refs() -> None:
class BrokenRenderTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
tree = Library()
pattern = Pattern(ports={port_names[0]: Port((0, 0), 0, ptype='wire')})
pattern.ref(SINGLE_USE_PREFIX + 'missing')
tree['top'] = pattern
return tree
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(BrokenRenderTool(), (
ToolContractCase('straight', in_ptype='wire'),
))
messages = [str(err) for err in exc_info.value.exceptions]
assert any('missing single-use refs' in message for message in messages)
assert any('missing ports' in message for message in messages)
def test_validate_tool_contract_bbox_is_opt_in() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
validate_tool_contract(tool, (ToolContractCase('straight', in_ptype='wire'),))
# AutoTool supplies bbox support, so use a minimal custom offer without it.
class NoBBoxTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
return tree
validate_tool_contract(NoBBoxTool(), (ToolContractCase('straight', in_ptype='wire'),))
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(NoBBoxTool(), (
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
))
assert any('bbox_at()' in str(err) for err in exc_info.value.exceptions)
@pytest.mark.parametrize(
'kwargs',
[
{'kind': 'straight', 'ccw': True},
{'kind': 'bend'},
{'kind': 'straight', 'probe_parameters': (numpy.inf,)},
{'kind': 'straight', 'tool_options': {'ccw': True}},
],
)
def test_tool_contract_case_validates_configuration(kwargs: dict[str, Any]) -> None:
with pytest.raises(ValueError, match='ccw|requires|finite|reserved'):
ToolContractCase(**kwargs) # type: ignore[arg-type]

View file

@ -89,23 +89,6 @@ def test_rotation_matrix_non_manhattan() -> None:
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10)
def test_rotation_matrix_canonicalizes_before_caching() -> None:
expected = rotation_matrix_2d(pi / 2)
for angle in (pi / 2 + 1e-12, pi / 2 - 1e-12, -3 * pi / 2, 5 * pi / 2):
assert rotation_matrix_2d(angle) is expected
assert not expected.flags.writeable
def test_rotation_matrix_does_not_snap_non_manhattan_angle() -> None:
cardinal = rotation_matrix_2d(pi / 2)
nearby = rotation_matrix_2d(pi / 2 + 2e-3)
assert nearby is not cardinal
assert not numpy.array_equal(nearby, cardinal)
def test_apply_transforms() -> None:
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
t1 = [10, 20, 0, 0]

View file

@ -10,11 +10,6 @@ from .array import is_scalar as is_scalar
from .autoslots import AutoSlots as AutoSlots
from .deferreddict import DeferredDict as DeferredDict
from .decorators import oneshot as oneshot
from .ptypes import (
PTypeMatch as PTypeMatch,
ptype_match as ptype_match,
ptypes_compatible as ptypes_compatible,
)
from .bitwise import (
get_bit as get_bit,

View file

@ -89,8 +89,8 @@ def boolean(
Perform a boolean operation on two sets of polygons.
Args:
subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded.
clips: Clips (shapes or vertex arrays). Shape repetitions are expanded.
subjects: List of subjects (Polygons or vertex arrays).
clips: List of clips (Polygons or vertex arrays).
operation: The boolean operation to perform.
scale: Scaling factor for integer conversion (pyclipper uses integers).
@ -115,45 +115,52 @@ def boolean(
def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]:
if objs is None:
return []
if isinstance(objs, numpy.ndarray):
return [objs]
if hasattr(objs, 'to_polygons'):
verts = []
for poly in objs.to_polygons():
if poly.repetition is None:
verts.append(poly.vertices)
else:
verts.extend(poly.vertices + dd for dd in poly.repetition.displacements)
return verts
if isinstance(objs, str | bytes) or not isinstance(objs, Iterable):
if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon):
objs = (objs,)
elif not isinstance(objs, Iterable):
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
return [vertices for obj in objs for vertices in to_vertices(obj)]
verts = []
for obj in objs:
if hasattr(obj, 'to_polygons'):
for p in obj.to_polygons():
verts.append(p.vertices)
elif isinstance(obj, numpy.ndarray):
verts.append(obj)
elif isinstance(obj, Polygon):
verts.append(obj.vertices)
else:
# Try to iterate if it's an iterable of shapes
try:
for sub in obj:
if hasattr(sub, 'to_polygons'):
for p in sub.to_polygons():
verts.append(p.vertices)
elif isinstance(sub, Polygon):
verts.append(sub.vertices)
except TypeError:
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
return verts
op = op_map[operation.lower()]
subject_verts = to_vertices(subjects)
clip_verts = to_vertices(clips)
if not subject_verts:
if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts:
return []
subject_verts, clip_verts = clip_verts, []
if not clip_verts and op == pyclipper.CT_INTERSECTION:
if operation in ('union', 'xor'):
return [Polygon(vertices) for vertices in clip_verts]
return []
if not clip_verts:
if operation == 'intersection':
return []
return [Polygon(vertices) for vertices in subject_verts]
pc = pyclipper.Pyclipper()
pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True)
if clip_verts:
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
# Use GetPolyTree to distinguish between outers and holes
polytree = pc.Execute2(op, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
return _polytree_to_polygons(polytree, scale)
def _polytree_to_polygons(polytree: Any, scale: float) -> list[Polygon]:
"""Convert a Clipper result, bridging holes for masque's polygon representation."""
import pyclipper # noqa: PLC0415
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
result_polygons = []

View file

@ -9,15 +9,7 @@ def annotation2key(aaa: int | float | str) -> tuple[bool, Any]:
return (isinstance(aaa, str), aaa)
def _normalized_annotations(annotations: annotations_t) -> annotations_t:
if not annotations:
return None
return annotations
def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool:
aa = _normalized_annotations(aa)
bb = _normalized_annotations(bb)
if aa is None:
return bb is not None
elif bb is None: # noqa: RET505
@ -44,8 +36,6 @@ def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool:
def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool:
aa = _normalized_annotations(aa)
bb = _normalized_annotations(bb)
if aa is None:
return bb is None
elif bb is None: # noqa: RET505

View file

@ -105,8 +105,8 @@ def data_to_ports(
# Load ports for all subpatterns, and use any we find
found_ports = False
for target, refs in pattern.refs.items():
if target is None or not refs:
for target in pattern.refs:
if target is None:
continue
pp = data_to_ports(
layers = layers,

View file

@ -1,22 +0,0 @@
from enum import Enum
class PTypeMatch(Enum):
"""Result of comparing two port types."""
EXACT = 'exact'
WILDCARD = 'wildcard'
MISMATCH = 'mismatch'
def ptype_match(left: str | None, right: str | None) -> PTypeMatch:
"""Compare ptypes, treating `None` and `"unk"` as wildcards."""
if left in (None, 'unk') or right in (None, 'unk'):
return PTypeMatch.WILDCARD
if left == right:
return PTypeMatch.EXACT
return PTypeMatch.MISMATCH
def ptypes_compatible(left: str | None, right: str | None) -> bool:
"""Return true when two ptypes may connect under normal compatibility rules."""
return ptype_match(left, right) is not PTypeMatch.MISMATCH

View file

@ -3,7 +3,6 @@ Geometric transforms
"""
from collections.abc import Sequence
from functools import lru_cache
from math import acos
import numpy
from numpy.typing import NDArray, ArrayLike
@ -14,26 +13,8 @@ from numpy import pi
R90 = pi / 2
R180 = pi
# Preserve the effective tolerance of the historical
# ``isclose(cos(4 * theta), 1, atol=1e-12)`` Manhattan check. Expressing it as
# an angular distance lets us canonicalize before consulting the matrix cache.
_MANHATTAN_SNAP_ATOL = acos(1 - (1e-5 + 1e-12)) / 4
_CARDINAL_ANGLES = (0.0, R90, R180, 3 * R90)
@lru_cache
def _rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
"""Build and cache an immutable matrix for a canonicalized angle."""
arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
[numpy.sin(theta), +numpy.cos(theta)]])
if theta in _CARDINAL_ANGLES:
arr = numpy.round(arr)
arr.flags.writeable = False
return arr
def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
"""
2D rotation matrix for rotating counterclockwise around the origin.
@ -44,11 +25,16 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]:
Returns:
rotation matrix
"""
theta = float(theta)
quarter_turn = round(theta / R90)
if abs(theta - quarter_turn * R90) <= _MANHATTAN_SNAP_ATOL:
theta = _CARDINAL_ANGLES[quarter_turn % 4]
return _rotation_matrix_2d(theta)
arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)],
[numpy.sin(theta), +numpy.cos(theta)]])
# If this was a manhattan rotation, round to remove some inaccuracies in sin & cos
# cos(4*theta) is 1 for any multiple of pi/2.
if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12):
arr = numpy.round(arr)
arr.flags.writeable = False
return arr
def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]:

View file

@ -44,7 +44,7 @@ dependencies = [
[dependency-groups]
dev = [
"masque[arrow]",
"pytest",
"masque[oasis]",
"masque[dxf]",
"masque[svg]",
@ -52,7 +52,6 @@ dev = [
"masque[text]",
"masque[manhattanize]",
"masque[manhattanize_slow]",
"masque[boolean]",
"matplotlib>=3.10.8",
"pytest>=9.0.2",
"ruff>=0.15.5",
@ -63,27 +62,12 @@ dev = [
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.sdist]
only-include = [
"masque",
"examples",
"stubs",
"pyproject.toml",
"README.md",
"LICENSE.md",
"MIGRATION.md",
]
[tool.hatch.build.targets.wheel]
packages = ["masque"]
[tool.hatch.version]
path = "masque/__init__.py"
[project.optional-dependencies]
arrow = ["pyarrow", "cffi"]
oasis = ["fatamorgana>=0.14"]
dxf = ["ezdxf~=1.4", "pyclipper"]
oasis = ["fatamorgana~=0.11"]
dxf = ["ezdxf~=1.4"]
svg = ["svgwrite"]
visualize = ["matplotlib"]
text = ["matplotlib", "freetype-py"]
@ -137,3 +121,4 @@ mypy_path = "stubs"
python_version = "3.11"
strict = false
check_untyped_defs = true

View file

@ -1 +0,0 @@
"""Repository development tools."""

View file

@ -1,626 +0,0 @@
"""
Synthetic GDS fixture generation for reader/writer performance testing.
The presets here are intentionally hierarchical and deterministic. They aim to
approximate a pair of real-world layout families discussed during GDS reader and
writer work:
* `many_cells`: tens of thousands of cells, moderate reference count, very heavy
box usage after flattening, and moderate polygon density.
* `many_instances`: a much smaller cell library with very high reference count,
similar box density, and far fewer polygons.
Fixtures are written by streaming structures through `klamath` directly so large
benchmark files can be produced without first materializing an equally large
`masque.Library` in Python.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import argparse
import json
import math
import numpy
import klamath
from klamath import elements
EMPTY_PROPERTIES: dict[int, bytes] = {}
METERS_PER_DB_UNIT = 1e-9
USER_UNITS_PER_DB_UNIT = 1e-3
TOTAL_LAYERS = 200
@dataclass(frozen=True)
class FixturePreset:
name: str
total_layers: int
box_layers: int
heavy_box_layers: int
polygon_layers: int
box_cells: int
poly_cells: int
box_wrappers: int
poly_wrappers: int
box_clusters: int
poly_clusters: int
box_cluster_refs: int
poly_cluster_refs: int
top_direct_box_refs: int
top_direct_poly_refs: int
heavy_boxes_per_cell: int
regular_boxes_per_cell: int
polygons_per_cell: int
path_stride: int
text_stride: int
box_cluster_array: tuple[int, int]
top_box_array: tuple[int, int]
poly_cluster_array: tuple[int, int]
top_poly_array: tuple[int, int]
rare_annotation_stride: int
PRESETS: dict[str, FixturePreset] = {
'many_cells': FixturePreset(
name='many_cells',
total_layers=TOTAL_LAYERS,
box_layers=20,
heavy_box_layers=3,
polygon_layers=20,
box_cells=17_000,
poly_cells=6_000,
box_wrappers=18_000,
poly_wrappers=6_000,
box_clusters=2_000,
poly_clusters=999,
box_cluster_refs=24,
poly_cluster_refs=16,
top_direct_box_refs=21_000,
top_direct_poly_refs=7_000,
heavy_boxes_per_cell=6,
regular_boxes_per_cell=2,
polygons_per_cell=50,
path_stride=2,
text_stride=3,
box_cluster_array=(24, 16),
top_box_array=(8, 8),
poly_cluster_array=(4, 2),
top_poly_array=(3, 2),
rare_annotation_stride=1_250,
),
'many_instances': FixturePreset(
name='many_instances',
total_layers=TOTAL_LAYERS,
box_layers=25,
heavy_box_layers=3,
polygon_layers=10,
box_cells=2_500,
poly_cells=500,
box_wrappers=1_000,
poly_wrappers=500,
box_clusters=1_000,
poly_clusters=499,
box_cluster_refs=1_200,
poly_cluster_refs=400,
top_direct_box_refs=102_001,
top_direct_poly_refs=0,
heavy_boxes_per_cell=40,
regular_boxes_per_cell=16,
polygons_per_cell=60,
path_stride=1,
text_stride=2,
box_cluster_array=(1, 1),
top_box_array=(1, 1),
poly_cluster_array=(1, 1),
top_poly_array=(1, 1),
rare_annotation_stride=250,
),
}
@dataclass(frozen=True)
class FixtureManifest:
preset: str
scale: float
gds_path: str
library_name: str
cells: int
refs: int
layers: int
box_layers: int
heavy_box_layers: list[list[int]]
polygon_layers: list[list[int]]
hierarchical_boxes_per_heavy_layer: int
hierarchical_boxes_per_regular_layer: int
hierarchical_polygons_total: int
hierarchical_paths_total: int
hierarchical_texts_total: int
flattened_box_placements: int
flattened_poly_placements: int
estimated_flat_boxes_per_heavy_layer: int
estimated_flat_polygons_per_active_polygon_layer: int
def _scaled_count(value: int, scale: float, minimum: int = 0) -> int:
if value == 0:
return 0
scaled = int(math.ceil(value * scale))
return max(minimum, scaled)
def _scaled_preset(preset: FixturePreset, scale: float) -> FixturePreset:
if scale <= 0:
raise ValueError(f'scale must be positive, got {scale!r}')
return FixturePreset(
name=preset.name,
total_layers=preset.total_layers,
box_layers=min(preset.box_layers, preset.total_layers),
heavy_box_layers=min(preset.heavy_box_layers, preset.box_layers),
polygon_layers=min(preset.polygon_layers, preset.total_layers),
box_cells=_scaled_count(preset.box_cells, scale, minimum=1),
poly_cells=_scaled_count(preset.poly_cells, scale, minimum=1),
box_wrappers=_scaled_count(preset.box_wrappers, scale),
poly_wrappers=_scaled_count(preset.poly_wrappers, scale),
box_clusters=_scaled_count(preset.box_clusters, scale, minimum=1),
poly_clusters=_scaled_count(preset.poly_clusters, scale, minimum=1),
box_cluster_refs=_scaled_count(preset.box_cluster_refs, scale, minimum=1),
poly_cluster_refs=_scaled_count(preset.poly_cluster_refs, scale, minimum=1),
top_direct_box_refs=_scaled_count(preset.top_direct_box_refs, scale),
top_direct_poly_refs=_scaled_count(preset.top_direct_poly_refs, scale),
heavy_boxes_per_cell=max(1, preset.heavy_boxes_per_cell),
regular_boxes_per_cell=max(1, preset.regular_boxes_per_cell),
polygons_per_cell=max(1, preset.polygons_per_cell),
path_stride=max(1, preset.path_stride),
text_stride=max(1, preset.text_stride),
box_cluster_array=preset.box_cluster_array,
top_box_array=preset.top_box_array,
poly_cluster_array=preset.poly_cluster_array,
top_poly_array=preset.top_poly_array,
rare_annotation_stride=max(1, _scaled_count(preset.rare_annotation_stride, scale, minimum=1)),
)
def _rect_xy(xmin: int, ymin: int, xmax: int, ymax: int) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]:
return numpy.array(
[[xmin, ymin], [xmin, ymax], [xmax, ymax], [xmax, ymin], [xmin, ymin]],
dtype=numpy.int32,
)
def _poly_xy(points: list[tuple[int, int]]) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]:
closed = points + [points[0]]
return numpy.array(closed, dtype=numpy.int32)
def _sref(
target: str,
xy: tuple[int, int],
properties: dict[int, bytes] | None = None,
) -> elements.Reference:
return klamath.library.Reference(
struct_name=target.encode('ASCII'),
invert_y=False,
mag=1.0,
angle_deg=0.0,
xy=numpy.array([xy], dtype=numpy.int32),
colrow=None,
properties=EMPTY_PROPERTIES if properties is None else properties,
)
def _aref(
target: str,
origin: tuple[int, int],
counts: tuple[int, int],
step: tuple[int, int],
properties: dict[int, bytes] | None = None,
) -> elements.Reference:
cols, rows = counts
dx, dy = step
xy = numpy.array(
[
origin,
(origin[0] + cols * dx, origin[1]),
(origin[0], origin[1] + rows * dy),
],
dtype=numpy.int32,
)
return klamath.library.Reference(
struct_name=target.encode('ASCII'),
invert_y=False,
mag=1.0,
angle_deg=0.0,
xy=xy,
colrow=(cols, rows),
properties=EMPTY_PROPERTIES if properties is None else properties,
)
def _annotation(index: int) -> dict[int, bytes]:
return {1: f'perf-{index}'.encode('ASCII')}
def _make_box_cell(index: int, cfg: FixturePreset) -> list[elements.Element]:
cell_elements: list[elements.Element] = []
xbase = (index % 17) * 600
ybase = (index // 17) * 180
for layer in range(cfg.heavy_box_layers):
for box_idx in range(cfg.heavy_boxes_per_cell):
x0 = xbase + box_idx * 22
y0 = ybase + layer * 40
width = 10 + ((index + box_idx + layer) % 7) * 6
height = 10 + ((index * 3 + box_idx + layer) % 5) * 8
properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 and box_idx == 0 and layer == 0 else EMPTY_PROPERTIES
cell_elements.append(elements.Boundary(
layer=(layer, 0),
xy=_rect_xy(x0, y0, x0 + width, y0 + height),
properties=properties,
))
for layer in range(cfg.heavy_box_layers, cfg.box_layers):
for box_idx in range(cfg.regular_boxes_per_cell):
x0 = xbase + box_idx * 38
y0 = ybase + (layer - cfg.heavy_box_layers) * 28 + 400
width = 18 + ((index + layer + box_idx) % 9) * 4
height = 12 + ((index + 2 * layer + box_idx) % 6) * 5
cell_elements.append(elements.Boundary(
layer=(layer, 0),
xy=_rect_xy(x0, y0, x0 + width, y0 + height),
properties=EMPTY_PROPERTIES,
))
return cell_elements
def _make_poly_cell(index: int, cfg: FixturePreset) -> list[elements.Element]:
cell_elements: list[elements.Element] = []
xbase = (index % 19) * 900
ybase = (index // 19) * 260
for poly_idx in range(cfg.polygons_per_cell):
layer = poly_idx % cfg.polygon_layers
dx = xbase + (poly_idx % 5) * 120
dy = ybase + (poly_idx // 5) * 80
size = 18 + ((index + poly_idx + layer) % 11) * 7
points = [
(dx, dy),
(dx + size, dy + size // 5),
(dx + size + size // 3, dy + size),
(dx + size // 2, dy + size + size // 2),
(dx - size // 4, dy + size // 2),
]
properties = _annotation(index) if poly_idx == 0 and index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES
cell_elements.append(elements.Boundary(
layer=(layer, 0),
xy=_poly_xy(points),
properties=properties,
))
if index % cfg.path_stride == 0:
layer = index % cfg.polygon_layers
cell_elements.append(elements.Path(
layer=(layer, 1),
path_type=2,
width=12 + (index % 5) * 4,
extension=(0, 0),
xy=numpy.array(
[
[xbase, ybase + 900],
[xbase + 240, ybase + 930],
[xbase + 420, ybase + 960],
],
dtype=numpy.int32,
),
properties=EMPTY_PROPERTIES,
))
if index % cfg.text_stride == 0:
layer = index % cfg.polygon_layers
properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES
cell_elements.append(elements.Text(
layer=(layer, 2),
presentation=0,
path_type=0,
width=0,
invert_y=False,
mag=1.0,
angle_deg=0.0,
xy=numpy.array([[xbase + 64, ybase + 1536]], dtype=numpy.int32),
string=f'T{index:05d}'.encode('ASCII'),
properties=properties,
))
return cell_elements
def _write_struct(stream: Any, name: str, cell_elements: list[elements.Element]) -> None:
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=cell_elements)
def _box_name(index: int) -> str:
return f'box_{index:05d}'
def _poly_name(index: int) -> str:
return f'poly_{index:05d}'
def _box_wrapper_name(index: int) -> str:
return f'box_wrap_{index:05d}'
def _poly_wrapper_name(index: int) -> str:
return f'poly_wrap_{index:05d}'
def _box_cluster_name(index: int) -> str:
return f'box_cluster_{index:05d}'
def _poly_cluster_name(index: int) -> str:
return f'poly_cluster_{index:05d}'
def _write_box_cells(stream: Any, cfg: FixturePreset) -> None:
for idx in range(cfg.box_cells):
_write_struct(stream, _box_name(idx), _make_box_cell(idx, cfg))
def _write_poly_cells(stream: Any, cfg: FixturePreset) -> None:
for idx in range(cfg.poly_cells):
_write_struct(stream, _poly_name(idx), _make_poly_cell(idx, cfg))
def _write_wrappers(stream: Any, cfg: FixturePreset) -> None:
for idx in range(cfg.box_wrappers):
target = _box_name(idx % cfg.box_cells)
origin = ((idx % 97) * 2_000, (idx // 97) * 2_000)
_write_struct(stream, _box_wrapper_name(idx), [_sref(target, origin)])
for idx in range(cfg.poly_wrappers):
target = _poly_name(idx % cfg.poly_cells)
origin = ((idx % 61) * 3_200, (idx // 61) * 3_200)
_write_struct(stream, _poly_wrapper_name(idx), [_sref(target, origin)])
def _write_box_clusters(stream: Any, cfg: FixturePreset) -> None:
array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4))
for idx in range(cfg.box_clusters):
cell_elements: list[elements.Element] = []
for ref_idx in range(cfg.box_cluster_refs):
target = _box_name((idx * cfg.box_cluster_refs + ref_idx) % cfg.box_cells)
origin = (
(ref_idx % 6) * 48_000,
(ref_idx // 6) * 48_000,
)
if ref_idx < array_refs:
cell_elements.append(_aref(target, origin, cfg.box_cluster_array, (720, 900)))
else:
cell_elements.append(_sref(target, origin))
_write_struct(stream, _box_cluster_name(idx), cell_elements)
def _write_poly_clusters(stream: Any, cfg: FixturePreset) -> None:
array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2)
for idx in range(cfg.poly_clusters):
cell_elements: list[elements.Element] = []
for ref_idx in range(cfg.poly_cluster_refs):
target = _poly_name((idx * cfg.poly_cluster_refs + ref_idx) % cfg.poly_cells)
origin = (
(ref_idx % 10) * 96_000,
(ref_idx // 10) * 96_000,
)
if ref_idx < array_refs:
cell_elements.append(_aref(target, origin, cfg.poly_cluster_array, (12_000, 8_500)))
else:
cell_elements.append(_sref(target, origin))
_write_struct(stream, _poly_cluster_name(idx), cell_elements)
def _top_box_refs(cfg: FixturePreset) -> list[elements.Reference]:
refs: list[elements.Reference] = []
for idx in range(cfg.box_wrappers):
refs.append(_sref(
_box_wrapper_name(idx),
((idx % 240) * 240_000, (idx // 240) * 240_000),
))
for idx in range(cfg.box_clusters):
refs.append(_sref(
_box_cluster_name(idx),
((idx % 100) * 800_000, (idx // 100) * 800_000 + 14_000_000),
))
for idx in range(cfg.top_direct_box_refs):
target = _box_name(idx % cfg.box_cells)
origin = (
(idx % 150) * 160_000,
(idx // 150) * 160_000 + 26_000_000,
)
if cfg.top_box_array == (1, 1):
refs.append(_sref(target, origin))
else:
refs.append(_aref(target, origin, cfg.top_box_array, (1_100, 1_350)))
return refs
def _top_poly_refs(cfg: FixturePreset) -> list[elements.Reference]:
refs: list[elements.Reference] = []
for idx in range(cfg.poly_wrappers):
refs.append(_sref(
_poly_wrapper_name(idx),
((idx % 180) * 360_000, (idx // 180) * 360_000 + 44_000_000),
))
for idx in range(cfg.poly_clusters):
refs.append(_sref(
_poly_cluster_name(idx),
((idx % 70) * 1_100_000, (idx // 70) * 1_100_000 + 58_000_000),
))
for idx in range(cfg.top_direct_poly_refs):
target = _poly_name(idx % cfg.poly_cells)
origin = (
(idx % 110) * 420_000,
(idx // 110) * 420_000 + 72_000_000,
)
if cfg.top_poly_array == (1, 1):
refs.append(_sref(target, origin))
else:
refs.append(_aref(target, origin, cfg.top_poly_array, (16_000, 14_000)))
return refs
def _write_top(stream: Any, cfg: FixturePreset) -> None:
cell_elements: list[elements.Element] = []
cell_elements.extend(_top_box_refs(cfg))
cell_elements.extend(_top_poly_refs(cfg))
_write_struct(stream, 'TOP', cell_elements)
def fixture_manifest(path: str | Path, preset: str, scale: float = 1.0) -> FixtureManifest:
base = PRESETS[preset]
cfg = _scaled_preset(base, scale)
box_cluster_array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4))
box_cluster_array_mult = cfg.box_cluster_array[0] * cfg.box_cluster_array[1]
box_cluster_ref_instances = (
box_cluster_array_refs * box_cluster_array_mult
+ (cfg.box_cluster_refs - box_cluster_array_refs)
)
poly_cluster_array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2)
poly_cluster_array_mult = cfg.poly_cluster_array[0] * cfg.poly_cluster_array[1]
poly_cluster_ref_instances = (
poly_cluster_array_refs * poly_cluster_array_mult
+ (cfg.poly_cluster_refs - poly_cluster_array_refs)
)
flattened_box_placements = (
cfg.box_wrappers
+ cfg.box_clusters * box_cluster_ref_instances
+ cfg.top_direct_box_refs * cfg.top_box_array[0] * cfg.top_box_array[1]
)
flattened_poly_placements = (
cfg.poly_wrappers
+ cfg.poly_clusters * poly_cluster_ref_instances
+ cfg.top_direct_poly_refs * cfg.top_poly_array[0] * cfg.top_poly_array[1]
)
polygon_layers = max(1, cfg.polygon_layers)
polys_per_layer = (cfg.poly_cells * cfg.polygons_per_cell) // polygon_layers
return FixtureManifest(
preset=cfg.name,
scale=scale,
gds_path=str(Path(path)),
library_name=f'masque-perf-{cfg.name}',
cells=cfg.box_cells + cfg.poly_cells + cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters + 1,
refs=(
cfg.box_wrappers
+ cfg.poly_wrappers
+ cfg.box_clusters * cfg.box_cluster_refs
+ cfg.poly_clusters * cfg.poly_cluster_refs
+ cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters
+ cfg.top_direct_box_refs + cfg.top_direct_poly_refs
),
layers=cfg.total_layers,
box_layers=cfg.box_layers,
heavy_box_layers=[[layer, 0] for layer in range(cfg.heavy_box_layers)],
polygon_layers=[[layer, 0] for layer in range(cfg.polygon_layers)],
hierarchical_boxes_per_heavy_layer=cfg.box_cells * cfg.heavy_boxes_per_cell,
hierarchical_boxes_per_regular_layer=cfg.box_cells * cfg.regular_boxes_per_cell,
hierarchical_polygons_total=cfg.poly_cells * cfg.polygons_per_cell,
hierarchical_paths_total=(cfg.poly_cells - 1) // cfg.path_stride + 1,
hierarchical_texts_total=(cfg.poly_cells - 1) // cfg.text_stride + 1,
flattened_box_placements=flattened_box_placements,
flattened_poly_placements=flattened_poly_placements,
estimated_flat_boxes_per_heavy_layer=flattened_box_placements * cfg.heavy_boxes_per_cell,
estimated_flat_polygons_per_active_polygon_layer=flattened_poly_placements * polys_per_layer // cfg.poly_cells if cfg.poly_cells else 0,
)
def write_fixture(
path: str | Path,
*,
preset: str,
scale: float = 1.0,
write_manifest: bool = True,
) -> FixtureManifest:
if preset not in PRESETS:
known = ', '.join(sorted(PRESETS))
raise KeyError(f'unknown preset {preset!r}; expected one of: {known}')
manifest = fixture_manifest(path, preset, scale)
cfg = _scaled_preset(PRESETS[preset], scale)
output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open('wb') as stream:
header = klamath.library.FileHeader(
name=manifest.library_name.encode('ASCII'),
user_units_per_db_unit=USER_UNITS_PER_DB_UNIT,
meters_per_db_unit=METERS_PER_DB_UNIT,
)
header.write(stream)
_write_box_cells(stream, cfg)
_write_poly_cells(stream, cfg)
_write_wrappers(stream, cfg)
_write_box_clusters(stream, cfg)
_write_poly_clusters(stream, cfg)
_write_top(stream, cfg)
klamath.records.ENDLIB.write(stream, None)
if write_manifest:
manifest_path = output.with_suffix(output.suffix + '.json')
manifest_path.write_text(json.dumps(asdict(manifest), indent=2, sort_keys=True) + '\n')
return manifest
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description='Generate synthetic GDS fixtures for GDS reader/writer performance work.')
parser.add_argument(
'preset',
nargs='?',
default='many_cells',
choices=sorted(PRESETS),
help='Fixture family to generate.',
)
parser.add_argument(
'output',
nargs='?',
help='Output .gds path. Defaults to build/gds_perf/<preset>.gds',
)
parser.add_argument(
'--scale',
type=float,
default=1.0,
help='Scale the preset counts down or up while keeping the same shape mix. Default: 1.0',
)
parser.add_argument(
'--no-manifest',
action='store_true',
help='Do not write the sidecar JSON manifest.',
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
output = Path(args.output) if args.output is not None else Path('build/gds_perf') / f'{args.preset}.gds'
manifest = write_fixture(output, preset=args.preset, scale=args.scale, write_manifest=not args.no_manifest)
print(json.dumps(asdict(manifest), indent=2, sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())