Compare commits
21 commits
7439e4cae9
...
80a24539f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 80a24539f6 | |||
| 3ea67a9a6f | |||
| 8b765413f6 | |||
| 4eff8203ce | |||
| d73a20cfca | |||
| d6b6e39dbe | |||
| 1d7f82386e | |||
| b7d7b1ac19 | |||
| d30695f7f6 | |||
| bdb9b03892 | |||
| 07e569520c | |||
| 51cab706da | |||
| c68e76a40e | |||
| e2ebbbe2a5 | |||
| 7487806318 | |||
| 1dbf195251 | |||
| 844e040174 | |||
| 4d57936da8 | |||
| 51c7fa9add | |||
| e4a52b2c90 | |||
| e33a0f5ae1 |
43 changed files with 4624 additions and 2748 deletions
|
|
@ -20,10 +20,10 @@ Contents
|
||||||
* Use `Pather` to snap ports together into a circuit
|
* Use `Pather` to snap ports together into a circuit
|
||||||
* Check for dangling references
|
* Check for dangling references
|
||||||
- [library](library.py)
|
- [library](library.py)
|
||||||
* Continue from `devices.py` using a lazy library
|
* Continue from `devices.py` by declaring a mixed library with `BuildLibrary`
|
||||||
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
|
* Import source-backed GDS cells and register python-generated recipes together
|
||||||
|
* Call `build()` to produce a normal library for downstream `Pather` usage and writing
|
||||||
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
|
||||||
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
|
|
||||||
- [pather](pather.py)
|
- [pather](pather.py)
|
||||||
* Use `Pather` to route individual wires and wire bundles
|
* Use `Pather` to route individual wires and wire bundles
|
||||||
* Use `AutoTool` to generate paths
|
* Use `AutoTool` to generate paths
|
||||||
|
|
|
||||||
|
|
@ -1,142 +1,114 @@
|
||||||
"""
|
"""
|
||||||
Tutorial: using `LazyLibrary` and `Pather.interface()`.
|
Tutorial: authoring a mixed library with `BuildLibrary`.
|
||||||
|
|
||||||
This example assumes you have already read `devices.py` and generated the
|
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
|
`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry
|
||||||
itself, but rather how Masque lets you mix lazily loaded GDS content with
|
itself, but rather how Masque lets you combine imported GDS cells with
|
||||||
python-generated devices inside one library.
|
python-generated recipes, then turn that declaration set into a normal library
|
||||||
|
for downstream assembly and writing.
|
||||||
"""
|
"""
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
|
||||||
|
|
||||||
from masque import Pather, LazyLibrary
|
from masque import BuildLibrary, Pather, Pattern, cell
|
||||||
from masque.file.gdsii import writefile, load_libraryfile
|
from masque.file.gdsii import writefile
|
||||||
|
from masque.file.gdsii_lazy import readfile
|
||||||
|
|
||||||
import basic_shapes
|
import basic_shapes
|
||||||
import devices
|
import devices
|
||||||
from devices import data_to_ports
|
|
||||||
from basic_shapes import GDS_OPTS
|
from basic_shapes import GDS_OPTS
|
||||||
|
|
||||||
|
|
||||||
|
def make_mixed_waveguide(lib: BuildLibrary) -> Pattern:
|
||||||
|
"""
|
||||||
|
Recipe which assembles imported and generated cells behind the builder API.
|
||||||
|
"""
|
||||||
|
circ = Pather(library=lib, ports='tri_l3cav')
|
||||||
|
|
||||||
|
# First way to specify what we are plugging in: request an explicit abstract.
|
||||||
|
circ.plug(lib.abstract('wg10'), {'input': 'right'})
|
||||||
|
|
||||||
|
# Second way: use an AbstractView, which behaves like a mapping of names
|
||||||
|
# to abstracts.
|
||||||
|
abstracts = lib.abstract_view()
|
||||||
|
circ.plug(abstracts['wg10'], {'output': 'left'})
|
||||||
|
|
||||||
|
# Third way: let Pather resolve a pattern name through its own library.
|
||||||
|
circ.plug('tri_wg10', {'input': 'right'})
|
||||||
|
circ.plug('tri_wg10', {'output': 'left'})
|
||||||
|
|
||||||
|
return circ.pattern
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# A `LazyLibrary` delays work until a pattern is actually needed.
|
builder = BuildLibrary()
|
||||||
# That applies both to GDS cells we load from disk and to python callables
|
cells = builder.cells
|
||||||
# that generate patterns on demand.
|
|
||||||
lib = LazyLibrary()
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Load some devices from a GDS file
|
# Load some devices from a GDS file
|
||||||
#
|
#
|
||||||
|
|
||||||
# Scan circuit.gds and prepare to lazy-load its contents
|
# Scan circuit.gds and prepare to lazy-load its contents. Port labels are
|
||||||
gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports)
|
# imported on first materialization, but the raw source remains untouched
|
||||||
|
# until we build the final library.
|
||||||
|
gds_lib, _properties = readfile('circuit.gds')
|
||||||
|
builder.add_source(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1))
|
||||||
|
|
||||||
# Add those cells into our lazy library.
|
print('Registered imported cells:\n' + pformat(list(gds_lib.keys())))
|
||||||
# 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())))
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Add some new devices to the library, this time from python code rather than GDS
|
# Register some new devices, this time from python code rather than GDS.
|
||||||
#
|
#
|
||||||
|
|
||||||
lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS)
|
cells.triangle = basic_shapes.triangle(devices.RADIUS)
|
||||||
opts: dict[str, Any] = dict(
|
opts: dict[str, Any] = dict(
|
||||||
lattice_constant = devices.LATTICE_CONSTANT,
|
lattice_constant=devices.LATTICE_CONSTANT,
|
||||||
hole = 'triangle',
|
hole='triangle',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Triangle-based variants. These lambdas are only recipes for building the
|
cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts)
|
||||||
# patterns; they do not execute until someone asks for the cell.
|
cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts)
|
||||||
lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts)
|
cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts)
|
||||||
lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts)
|
cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts)
|
||||||
lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts)
|
cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts)
|
||||||
lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts)
|
cells.tri_l3cav = cell(devices.perturbed_l3)(xy_size=(4, 10), **opts, hole_lib=builder)
|
||||||
lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts)
|
cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder)
|
||||||
lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib)
|
|
||||||
|
print('Declared cells waiting to be built:\n' + pformat(list(builder.keys())))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a mixed waveguide with an L3 cavity in the middle
|
# Build the declaration set into a normal library.
|
||||||
#
|
#
|
||||||
|
|
||||||
# Start a new design by copying the ports from an existing library cell.
|
built = builder.build()
|
||||||
# This gives `circ2` the same external interface as `tri_l3cav`.
|
print('Built library contains:\n' + pformat(list(built.keys())))
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Build a second device that is explicitly designed to mate with `circ2`.
|
# Continue designing against the built library.
|
||||||
#
|
#
|
||||||
|
|
||||||
# `Pather.interface()` makes a new pattern whose ports mirror an existing
|
# The built result behaves like a normal mutable library, so downstream code
|
||||||
# design's external interface. That is useful when you want to design an
|
# can use Pather, abstract views, and writing without going back through the
|
||||||
# adapter, continuation, or mating structure.
|
# builder interface.
|
||||||
circ3 = Pather.interface(source=circ2)
|
circ = Pather.interface(source='mixed_wg_cav', library=built)
|
||||||
|
circ.plug('tri_bend0', {'input': 'right'})
|
||||||
# Continue routing outward from those inherited ports.
|
circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
||||||
circ3.plug('tri_bend0', {'input': 'right'})
|
circ.plug('tri_bend0', {'input': 'right'})
|
||||||
circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('tri_bend0', {'input': 'right'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('bend0', {'output': 'left'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('tri_wg10', {'input': 'right'})
|
||||||
circ3.plug('bend0', {'output': 'left'})
|
circ.plug('tri_wg28', {'input': 'right'})
|
||||||
circ3.plug('tri_wg10', {'input': 'right'})
|
circ.plug('tri_wg10', {'input': 'right', 'output': 'left'})
|
||||||
circ3.plug('tri_wg28', {'input': 'right'})
|
built['loop_segment'] = circ.pattern
|
||||||
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...')
|
print('Writing library to file...')
|
||||||
writefile(lib, 'library.gds', **GDS_OPTS)
|
writefile(built, 'library.gds', **GDS_OPTS)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
#class prout:
|
|
||||||
# def place(
|
|
||||||
# self,
|
|
||||||
# other: Pattern,
|
|
||||||
# label_layer: layer_t = 'WATLAYER',
|
|
||||||
# *,
|
|
||||||
# port_map: Dict[str, str | None] | None = None,
|
|
||||||
# **kwargs,
|
|
||||||
# ) -> 'prout':
|
|
||||||
#
|
|
||||||
# Pattern.place(self, other, port_map=port_map, **kwargs)
|
|
||||||
# name: str | None
|
|
||||||
# for name in other.ports:
|
|
||||||
# if port_map:
|
|
||||||
# assert(name is not None)
|
|
||||||
# name = port_map.get(name, name)
|
|
||||||
# if name is None:
|
|
||||||
# continue
|
|
||||||
# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer)
|
|
||||||
# return self
|
|
||||||
#
|
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,15 @@ from .library import (
|
||||||
ILibrary as ILibrary,
|
ILibrary as ILibrary,
|
||||||
LibraryView as LibraryView,
|
LibraryView as LibraryView,
|
||||||
Library as Library,
|
Library as Library,
|
||||||
|
BuiltLibrary as BuiltLibrary,
|
||||||
|
BuildLibrary as BuildLibrary,
|
||||||
|
BuildReport as BuildReport,
|
||||||
|
CellProvenance as CellProvenance,
|
||||||
LazyLibrary as LazyLibrary,
|
LazyLibrary as LazyLibrary,
|
||||||
AbstractView as AbstractView,
|
AbstractView as AbstractView,
|
||||||
TreeView as TreeView,
|
TreeView as TreeView,
|
||||||
Tree as Tree,
|
Tree as Tree,
|
||||||
|
cell as cell,
|
||||||
)
|
)
|
||||||
from .ports import (
|
from .ports import (
|
||||||
Port as Port,
|
Port as Port,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
"""
|
"""
|
||||||
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
|
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
|
||||||
|
|
||||||
Concrete tools may implement native planning/rendering for `L`, `S`, or `U` routes.
|
The `Tool` interface has two layers:
|
||||||
Any unimplemented planning method falls back to the corresponding `trace*()` method,
|
|
||||||
and `Pather` may further synthesize some routes from simpler primitives when needed.
|
* `traceL`/`traceS`/`traceU` create concrete single-use geometry immediately.
|
||||||
|
* `planL`/`planS`/`planU` return an output `Port` plus tool-specific render
|
||||||
|
data, allowing `Pather(auto_render=False)` to defer geometry creation until
|
||||||
|
`Tool.render()` is called with a batch of `RenderStep`s.
|
||||||
|
|
||||||
|
Plans are expressed in local tool coordinates: the input port is at `(0, 0)`
|
||||||
|
with rotation `0`, `length` is measured along the input axis, and positive
|
||||||
|
`jog` is left of the direction of travel. Concrete tools may implement native
|
||||||
|
planning/rendering for L, S, and U routes; otherwise the base planning methods
|
||||||
|
fall back to the corresponding `trace*()` methods. `Pather` may also synthesize
|
||||||
|
some routes from simpler primitives when a tool does not provide a native route.
|
||||||
"""
|
"""
|
||||||
from typing import Literal, Any, Self, cast
|
from typing import Literal, Any, Self, cast
|
||||||
from collections.abc import Sequence, Callable, Iterator
|
from collections.abc import Sequence, Callable, Iterator
|
||||||
|
|
@ -25,8 +35,11 @@ from ..error import BuildError
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RenderStep:
|
class RenderStep:
|
||||||
"""
|
"""
|
||||||
Representation of a single saved operation, used by deferred `Pather`
|
A single deferred routing operation.
|
||||||
instances and passed to `Tool.render()` when `Pather.render()` is called.
|
|
||||||
|
`Pather(auto_render=False)` stores these records while routing and later
|
||||||
|
passes batches of compatible steps to `Tool.render()` when `Pather.render()`
|
||||||
|
is called.
|
||||||
"""
|
"""
|
||||||
opcode: Literal['L', 'S', 'U', 'P']
|
opcode: Literal['L', 'S', 'U', 'P']
|
||||||
""" What operation is being performed.
|
""" What operation is being performed.
|
||||||
|
|
@ -37,10 +50,13 @@ class RenderStep:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tool: 'Tool | None'
|
tool: 'Tool | None'
|
||||||
""" The current tool. May be `None` if `opcode='P'` """
|
""" Tool that produced this step, or `None` for `opcode='P'`. """
|
||||||
|
|
||||||
start_port: Port
|
start_port: Port
|
||||||
|
""" Input-side port before this step is rendered. """
|
||||||
|
|
||||||
end_port: Port
|
end_port: Port
|
||||||
|
""" Output-side port after this step is rendered. """
|
||||||
|
|
||||||
data: Any
|
data: Any
|
||||||
""" Arbitrary tool-specific data"""
|
""" Arbitrary tool-specific data"""
|
||||||
|
|
@ -101,7 +117,9 @@ class RenderStep:
|
||||||
|
|
||||||
def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]:
|
def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]:
|
||||||
"""
|
"""
|
||||||
Extracts a Port and returns the tree (as data) for tool planning fallbacks.
|
Measure generated geometry for the base `Tool.plan*()` fallbacks.
|
||||||
|
|
||||||
|
Returns the calculated output port and the original tree as render data.
|
||||||
"""
|
"""
|
||||||
pat = tree.top_pattern()
|
pat = tree.top_pattern()
|
||||||
in_p = pat[port_names[0]]
|
in_p = pat[port_names[0]]
|
||||||
|
|
@ -113,6 +131,13 @@ def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port
|
||||||
class Tool:
|
class Tool:
|
||||||
"""
|
"""
|
||||||
Interface for path (e.g. wire or waveguide) generation.
|
Interface for path (e.g. wire or waveguide) generation.
|
||||||
|
|
||||||
|
Subclasses may implement immediate `trace*()` methods, deferred
|
||||||
|
`plan*()`/`render()` methods, or both. The base `plan*()` implementations
|
||||||
|
call the matching `trace*()` method and measure the resulting ports, so a
|
||||||
|
simple immediate-rendering tool can implement only `traceL`, `traceS`, or
|
||||||
|
`traceU` as needed. Tools that support deferred rendering should return
|
||||||
|
compact, tool-specific data from `plan*()` and consume it in `render()`.
|
||||||
"""
|
"""
|
||||||
def traceL(
|
def traceL(
|
||||||
self,
|
self,
|
||||||
|
|
@ -172,7 +197,7 @@ class Tool:
|
||||||
"""
|
"""
|
||||||
Create a wire or waveguide that travels exactly `length` distance along the axis
|
Create a wire or waveguide that travels exactly `length` distance along the axis
|
||||||
of its input port, and `jog` distance on the perpendicular axis.
|
of its input port, and `jog` distance on the perpendicular axis.
|
||||||
`jog` is positive when moving left of the direction of travel (from input to ouput port).
|
`jog` is positive when moving left of the direction of travel (from input to output port).
|
||||||
|
|
||||||
Used by `Pather`.
|
Used by `Pather`.
|
||||||
|
|
||||||
|
|
@ -236,7 +261,7 @@ class Tool:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
||||||
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
|
Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
|
|
@ -284,7 +309,7 @@ class Tool:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
||||||
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
|
Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
|
|
@ -312,8 +337,9 @@ class Tool:
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Library:
|
) -> Library:
|
||||||
"""
|
"""
|
||||||
Create a wire or waveguide that travels exactly `jog` distance along the axis
|
Create a wire or waveguide whose output is displaced by `length` along
|
||||||
perpendicular to its input port (i.e. a U-bend).
|
the input axis and `jog` along the perpendicular axis, while preserving
|
||||||
|
the input orientation (i.e. a U-bend or jogged U-turn).
|
||||||
|
|
||||||
Used by `Pather`. Tools may leave this unimplemented if they
|
Used by `Pather`. Tools may leave this unimplemented if they
|
||||||
do not support a native U-bend primitive.
|
do not support a native U-bend primitive.
|
||||||
|
|
@ -328,6 +354,7 @@ class Tool:
|
||||||
jog: The total offset from the input to output, along the perpendicular axis.
|
jog: The total offset from the input to output, along the perpendicular axis.
|
||||||
A positive number implies a leftwards shift (i.e. counterclockwise bend
|
A positive number implies a leftwards shift (i.e. counterclockwise bend
|
||||||
followed by a clockwise bend)
|
followed by a clockwise bend)
|
||||||
|
length: The total offset from the input to output, along the input axis.
|
||||||
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
|
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
|
||||||
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
|
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
|
||||||
port_names: The output pattern will have its input port named `port_names[0]` and
|
port_names: The output pattern will have its input port named `port_names[0]` and
|
||||||
|
|
@ -351,8 +378,9 @@ class Tool:
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> tuple[Port, Any]:
|
) -> tuple[Port, Any]:
|
||||||
"""
|
"""
|
||||||
Plan a wire or waveguide that travels exactly `jog` distance along the axis
|
Plan a wire or waveguide whose output is displaced by optional `length`
|
||||||
perpendicular to its input port (i.e. a U-bend).
|
along the input axis and `jog` along the perpendicular axis, while
|
||||||
|
preserving the input orientation (i.e. a U-bend or jogged U-turn).
|
||||||
|
|
||||||
Used by `Pather` when `auto_render=False`. This is an optional native-planning hook: tools may
|
Used by `Pather` when `auto_render=False`. This is an optional native-planning hook: tools may
|
||||||
implement it when they can represent a U-turn directly, otherwise they may rely
|
implement it when they can represent a U-turn directly, otherwise they may rely
|
||||||
|
|
@ -374,7 +402,7 @@ class Tool:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
|
||||||
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
|
Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
BuildError if an impossible or unsupported geometry is requested.
|
BuildError if an impossible or unsupported geometry is requested.
|
||||||
|
|
@ -404,6 +432,11 @@ class Tool:
|
||||||
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
|
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
|
||||||
(a Library with a single topcell).
|
(a Library with a single topcell).
|
||||||
|
|
||||||
|
The base implementation is intended for steps whose plan data came from
|
||||||
|
the base fallback planners, where `RenderStep.data` is already an
|
||||||
|
`ILibrary`. Subclasses with native `plan*()` data should generally
|
||||||
|
override this method.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
batch: A sequence of `RenderStep` objects containing the ports and data
|
batch: A sequence of `RenderStep` objects containing the ports and data
|
||||||
provided by this tool's `planL`/`planS`/`planU` functions.
|
provided by this tool's `planL`/`planS`/`planU` functions.
|
||||||
|
|
@ -464,15 +497,18 @@ abstract_tuple_t = tuple[Abstract, str, str]
|
||||||
@dataclass
|
@dataclass
|
||||||
class SimpleTool(Tool, metaclass=ABCMeta):
|
class SimpleTool(Tool, metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
A simple tool which relies on a single pre-rendered `bend` pattern, a function
|
Minimal L-route tool built from one straight generator and one bend.
|
||||||
for generating straight paths, and a table of pre-rendered `transitions` for converting
|
|
||||||
from non-native ptypes.
|
`SimpleTool` supports straight segments and single-bend L routes through
|
||||||
|
`planL`/`traceL`/`render`. It does not perform automatic port-type
|
||||||
|
transitions and does not provide native S or U routes. Use `AutoTool` when
|
||||||
|
routes need multiple candidate primitives, transitions, S-bends, or U-turns.
|
||||||
"""
|
"""
|
||||||
straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str]
|
straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str]
|
||||||
""" `create_straight(length: float), in_port_name, out_port_name` """
|
""" `(create_straight, in_port_name, out_port_name)` for straight segments. """
|
||||||
|
|
||||||
bend: abstract_tuple_t # Assumed to be clockwise
|
bend: abstract_tuple_t # Assumed to be clockwise
|
||||||
""" `clockwise_bend_abstract, in_port_name, out_port_name` """
|
""" `(clockwise_bend_abstract, in_port_name, out_port_name)` for L turns. """
|
||||||
|
|
||||||
default_out_ptype: str
|
default_out_ptype: str
|
||||||
""" Default value for out_ptype """
|
""" Default value for out_ptype """
|
||||||
|
|
@ -482,7 +518,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LData:
|
class LData:
|
||||||
""" Data for planL """
|
""" Deferred render data returned by `planL()`. """
|
||||||
straight_length: float
|
straight_length: float
|
||||||
straight_kwargs: dict[str, Any]
|
straight_kwargs: dict[str, Any]
|
||||||
ccw: SupportsBool | None
|
ccw: SupportsBool | None
|
||||||
|
|
@ -608,43 +644,114 @@ class SimpleTool(Tool, metaclass=ABCMeta):
|
||||||
@dataclass
|
@dataclass
|
||||||
class AutoTool(Tool, metaclass=ABCMeta):
|
class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
A simple tool which relies on a single pre-rendered `bend` pattern, a function
|
A routing tool assembled from reusable path primitives.
|
||||||
for generating straight paths, and a table of pre-rendered `transitions` for converting
|
|
||||||
from non-native ptypes.
|
`AutoTool` chooses among prioritized straight generators, pre-rendered bends,
|
||||||
|
optional native S-bend generators, and pre-rendered transitions to satisfy the
|
||||||
|
`Tool` planning/rendering interface used by `Pather`.
|
||||||
|
|
||||||
|
Route selection is greedy in the order supplied by `straights`, `bends`, and
|
||||||
|
`sbends`. For each route, the planner subtracts any transition and bend
|
||||||
|
overhead from the requested distance, then uses the first candidate whose
|
||||||
|
remaining straight or jog length falls within that candidate's range.
|
||||||
|
|
||||||
|
`planL` uses one straight and, if `ccw` is not `None`, one bend. `planS`
|
||||||
|
first tries a straight plus a native S-bend, then a pure native S-bend, and
|
||||||
|
falls back to a two-L route when no native S-bend candidate fits. `planU`
|
||||||
|
is implemented as a two-L route.
|
||||||
|
|
||||||
|
Transition keys are `(external_ptype, internal_ptype)`. For example, a
|
||||||
|
transition keyed by `('m2wire', 'm1wire')` is used when the route is being
|
||||||
|
attached to an external `m2wire` port but the selected primitive is `m1wire`.
|
||||||
|
Call `add_complementary_transitions()` to automatically add reversed entries
|
||||||
|
for any missing opposite directions.
|
||||||
|
|
||||||
|
Straight and S-bend generator functions may return either a `Pattern` or a
|
||||||
|
single-top `Library`. Extra keyword arguments passed to `trace*()` or
|
||||||
|
`render()` are forwarded to those generators, along with any keyword
|
||||||
|
arguments captured during `plan*()`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Straight:
|
class Straight:
|
||||||
""" Description of a straight-path generator """
|
"""
|
||||||
|
Description of a straight-path generator.
|
||||||
|
|
||||||
|
`fn(length, **kwargs)` must return a path whose `in_port_name` and
|
||||||
|
`out_port_name` ports are separated by `length` along the input axis.
|
||||||
|
The planner considers this generator only when the required length is in
|
||||||
|
`length_range`, with an inclusive lower bound and exclusive upper bound.
|
||||||
|
"""
|
||||||
ptype: str
|
ptype: str
|
||||||
|
""" Port type produced by this straight segment. """
|
||||||
|
|
||||||
fn: Callable[[float], Pattern] | Callable[[float], Library]
|
fn: Callable[[float], Pattern] | Callable[[float], Library]
|
||||||
|
""" Generator function called as `fn(length, **kwargs)`. """
|
||||||
|
|
||||||
in_port_name: str
|
in_port_name: str
|
||||||
|
""" Name of the input port on the generated pattern. """
|
||||||
|
|
||||||
out_port_name: str
|
out_port_name: str
|
||||||
|
""" Name of the output port on the generated pattern. """
|
||||||
|
|
||||||
length_range: tuple[float, float] = (0, numpy.inf)
|
length_range: tuple[float, float] = (0, numpy.inf)
|
||||||
|
""" Valid generated lengths, as `(inclusive_min, exclusive_max)`. """
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SBend:
|
class SBend:
|
||||||
""" Description of an s-bend generator """
|
"""
|
||||||
|
Description of a native S-bend generator.
|
||||||
|
|
||||||
|
`fn(jog, **kwargs)` is called with a non-negative jog magnitude and must
|
||||||
|
return a path whose output port faces back toward the input port. For a
|
||||||
|
negative requested jog, `AutoTool` mirrors the generated S-bend during
|
||||||
|
rendering.
|
||||||
|
"""
|
||||||
ptype: str
|
ptype: str
|
||||||
|
""" Port type produced by this S-bend. """
|
||||||
|
|
||||||
fn: Callable[[float], Pattern] | Callable[[float], Library]
|
fn: Callable[[float], Pattern] | Callable[[float], Library]
|
||||||
"""
|
"""
|
||||||
Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel
|
Generator function called as `fn(abs(jog), **kwargs)`. The generated
|
||||||
and may be negative for a jog in the opposite direction. Won't be called if jog=0.
|
geometry is assumed to jog left, i.e. counterclockwise relative to the
|
||||||
|
direction of travel. This function is not called when the residual jog is
|
||||||
|
zero.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
in_port_name: str
|
in_port_name: str
|
||||||
|
""" Name of the input port on the generated pattern. """
|
||||||
|
|
||||||
out_port_name: str
|
out_port_name: str
|
||||||
|
""" Name of the output port on the generated pattern. """
|
||||||
|
|
||||||
jog_range: tuple[float, float] = (0, numpy.inf)
|
jog_range: tuple[float, float] = (0, numpy.inf)
|
||||||
|
""" Valid residual jog magnitudes, as `(inclusive_min, exclusive_max)`. """
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Bend:
|
class Bend:
|
||||||
""" Description of a pre-rendered bend """
|
"""
|
||||||
|
Description of a pre-rendered L-bend.
|
||||||
|
|
||||||
|
`abstract` must contain `in_port_name` and `out_port_name`. The
|
||||||
|
`clockwise` flag describes the in-to-out turn direction of that stored
|
||||||
|
bend. If `mirror` is true, `AutoTool` mirrors the stored bend to realize
|
||||||
|
the opposite turn direction; otherwise it plugs the bend from the
|
||||||
|
opposite port where possible.
|
||||||
|
"""
|
||||||
abstract: Abstract
|
abstract: Abstract
|
||||||
|
""" Abstract for the reusable bend pattern. """
|
||||||
|
|
||||||
in_port_name: str
|
in_port_name: str
|
||||||
|
""" Name of the bend input port. """
|
||||||
|
|
||||||
out_port_name: str
|
out_port_name: str
|
||||||
|
""" Name of the bend output port. """
|
||||||
|
|
||||||
clockwise: bool = True # Is in-to-out clockwise?
|
clockwise: bool = True # Is in-to-out clockwise?
|
||||||
|
""" Whether the stored bend turns clockwise from input to output. """
|
||||||
|
|
||||||
mirror: bool = True # Should we mirror to get the other rotation?
|
mirror: bool = True # Should we mirror to get the other rotation?
|
||||||
|
""" Whether to mirror the stored bend to produce the opposite turn. """
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def in_port(self) -> Port:
|
def in_port(self) -> Port:
|
||||||
|
|
@ -656,10 +763,22 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Transition:
|
class Transition:
|
||||||
""" Description of a pre-rendered transition """
|
"""
|
||||||
|
Description of a pre-rendered port-type transition.
|
||||||
|
|
||||||
|
`their_port_name` is the external side of the transition and
|
||||||
|
`our_port_name` is the side compatible with the selected internal
|
||||||
|
primitive. The transition table key should match that direction:
|
||||||
|
`(their_ptype, our_ptype)`.
|
||||||
|
"""
|
||||||
abstract: Abstract
|
abstract: Abstract
|
||||||
|
""" Abstract for the reusable transition pattern. """
|
||||||
|
|
||||||
their_port_name: str
|
their_port_name: str
|
||||||
|
""" Name of the external-side port. """
|
||||||
|
|
||||||
our_port_name: str
|
our_port_name: str
|
||||||
|
""" Name of the internal primitive-side port. """
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def our_port(self) -> Port:
|
def our_port(self) -> Port:
|
||||||
|
|
@ -674,7 +793,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LPlan:
|
class LPlan:
|
||||||
""" Template for an L-path configuration """
|
""" Candidate L-route configuration before final straight length is known. """
|
||||||
straight: 'AutoTool.Straight'
|
straight: 'AutoTool.Straight'
|
||||||
bend: 'AutoTool.Bend | None'
|
bend: 'AutoTool.Bend | None'
|
||||||
in_trans: 'AutoTool.Transition | None'
|
in_trans: 'AutoTool.Transition | None'
|
||||||
|
|
@ -687,7 +806,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LData:
|
class LData:
|
||||||
""" Data for planL """
|
""" Deferred render data returned by `planL()`. """
|
||||||
straight_length: float
|
straight_length: float
|
||||||
straight: 'AutoTool.Straight'
|
straight: 'AutoTool.Straight'
|
||||||
straight_kwargs: dict[str, Any]
|
straight_kwargs: dict[str, Any]
|
||||||
|
|
@ -758,7 +877,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SData:
|
class SData:
|
||||||
""" Data for planS """
|
""" Deferred render data for native-S routes returned by `planS()`. """
|
||||||
straight_length: float
|
straight_length: float
|
||||||
straight: 'AutoTool.Straight'
|
straight: 'AutoTool.Straight'
|
||||||
gen_kwargs: dict[str, Any]
|
gen_kwargs: dict[str, Any]
|
||||||
|
|
@ -770,7 +889,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class UData:
|
class UData:
|
||||||
""" Data for planU or planS (double-L) """
|
""" Deferred render data for `planU()` or double-L `planS()` routes. """
|
||||||
ldata0: 'AutoTool.LData'
|
ldata0: 'AutoTool.LData'
|
||||||
ldata1: 'AutoTool.LData'
|
ldata1: 'AutoTool.LData'
|
||||||
straight2: 'AutoTool.Straight'
|
straight2: 'AutoTool.Straight'
|
||||||
|
|
@ -834,21 +953,27 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}")
|
raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}")
|
||||||
|
|
||||||
straights: list[Straight]
|
straights: list[Straight]
|
||||||
""" List of straight-generators to choose from, in order of priority """
|
""" Straight generators to choose from, in priority order. """
|
||||||
|
|
||||||
bends: list[Bend]
|
bends: list[Bend]
|
||||||
""" List of bends to choose from, in order of priority """
|
""" L-bend primitives to choose from, in priority order. """
|
||||||
|
|
||||||
sbends: list[SBend]
|
sbends: list[SBend]
|
||||||
""" List of S-bend generators to choose from, in order of priority """
|
""" Native S-bend generators to choose from, in priority order. """
|
||||||
|
|
||||||
transitions: dict[tuple[str, str], Transition]
|
transitions: dict[tuple[str, str], Transition]
|
||||||
""" `{(external_ptype, internal_ptype): Transition, ...}` """
|
""" Mapping from `(external_ptype, internal_ptype)` to transition primitive. """
|
||||||
|
|
||||||
default_out_ptype: str
|
default_out_ptype: str
|
||||||
""" Default value for out_ptype """
|
""" Output port type used when a zero-length route provides no primitive ptype. """
|
||||||
|
|
||||||
def add_complementary_transitions(self) -> Self:
|
def add_complementary_transitions(self) -> Self:
|
||||||
|
"""
|
||||||
|
Add reversed transition entries for any missing opposite directions.
|
||||||
|
|
||||||
|
Existing explicit entries are preserved. The method mutates
|
||||||
|
`self.transitions` and returns `self` for fluent construction.
|
||||||
|
"""
|
||||||
for iioo in list(self.transitions.keys()):
|
for iioo in list(self.transitions.keys()):
|
||||||
ooii = (iioo[1], iioo[0])
|
ooii = (iioo[1], iioo[0])
|
||||||
self.transitions.setdefault(ooii, self.transitions[iioo].reversed())
|
self.transitions.setdefault(ooii, self.transitions[iioo].reversed())
|
||||||
|
|
@ -889,7 +1014,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
return numpy.zeros(2)
|
return numpy.zeros(2)
|
||||||
orot = out_transition.our_port.rotation
|
orot = out_transition.our_port.rotation
|
||||||
assert orot is not None
|
assert orot is not None
|
||||||
otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset)
|
otrans_dxy = rotation_matrix_2d(bend_angle - orot - pi) @ (out_transition.their_port.offset - out_transition.our_port.offset)
|
||||||
return otrans_dxy
|
return otrans_dxy
|
||||||
|
|
||||||
def planL(
|
def planL(
|
||||||
|
|
@ -928,7 +1053,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
straight_kwargs: dict[str, Any],
|
straight_kwargs: dict[str, Any],
|
||||||
) -> ILibrary:
|
) -> ILibrary:
|
||||||
"""
|
"""
|
||||||
Render an L step into a preexisting tree
|
Render an L step into an existing tree.
|
||||||
"""
|
"""
|
||||||
pat = tree.top_pattern()
|
pat = tree.top_pattern()
|
||||||
if data.in_transition:
|
if data.in_transition:
|
||||||
|
|
@ -1061,7 +1186,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
gen_kwargs: dict[str, Any],
|
gen_kwargs: dict[str, Any],
|
||||||
) -> ILibrary:
|
) -> ILibrary:
|
||||||
"""
|
"""
|
||||||
Render an L step into a preexisting tree
|
Render a native-S step into an existing tree.
|
||||||
"""
|
"""
|
||||||
pat = tree.top_pattern()
|
pat = tree.top_pattern()
|
||||||
if data.in_transition:
|
if data.in_transition:
|
||||||
|
|
@ -1207,19 +1332,21 @@ class AutoTool(Tool, metaclass=ABCMeta):
|
||||||
@dataclass
|
@dataclass
|
||||||
class PathTool(Tool, metaclass=ABCMeta):
|
class PathTool(Tool, metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
A tool which draws `Path` geometry elements.
|
Tool that renders routes directly as `Pattern.path()` geometry.
|
||||||
|
|
||||||
If `planL` / `render` are used, the `Path` elements can cover >2 vertices;
|
`PathTool` supports L and S routes. Immediate `traceL()` and `traceS()`
|
||||||
with `path` only individual rectangles will be drawn.
|
create one path element per route, while deferred `render()` combines a
|
||||||
|
compatible batch of L/S `RenderStep`s into one multi-vertex path. U routes
|
||||||
|
are left to `Pather` synthesis or to a different tool.
|
||||||
"""
|
"""
|
||||||
layer: layer_t
|
layer: layer_t
|
||||||
""" Layer to draw on """
|
""" Layer to draw generated path geometry on. """
|
||||||
|
|
||||||
width: float
|
width: float
|
||||||
""" `Path` width """
|
""" Width of generated path geometry. """
|
||||||
|
|
||||||
ptype: str = 'unk'
|
ptype: str = 'unk'
|
||||||
""" ptype for any ports in patterns generated by this tool """
|
""" Port type for generated input and output ports. """
|
||||||
|
|
||||||
#@dataclass(frozen=True, slots=True)
|
#@dataclass(frozen=True, slots=True)
|
||||||
#class LData:
|
#class LData:
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ Notes:
|
||||||
from typing import IO, cast, Any
|
from typing import IO, cast, Any
|
||||||
from collections.abc import Iterable, Mapping, Callable
|
from collections.abc import Iterable, Mapping, Callable
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
import io
|
|
||||||
import mmap
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import gzip
|
import gzip
|
||||||
|
|
@ -40,7 +38,7 @@ from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
|
||||||
from ..shapes import Polygon, Path, RectCollection
|
from ..shapes import Polygon, Path, RectCollection
|
||||||
from ..repetition import Grid
|
from ..repetition import Grid
|
||||||
from ..utils import layer_t, annotations_t
|
from ..utils import layer_t, annotations_t
|
||||||
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
|
from ..library import Library, ILibrary
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -542,117 +540,6 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
|
||||||
return texts
|
return texts
|
||||||
|
|
||||||
|
|
||||||
def load_library(
|
|
||||||
stream: IO[bytes],
|
|
||||||
*,
|
|
||||||
full_load: bool = False,
|
|
||||||
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
|
|
||||||
) -> tuple[LazyLibrary, dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Scan a GDSII stream to determine what structures are present, and create
|
|
||||||
a library from them. This enables deferred reading of structures
|
|
||||||
on an as-needed basis.
|
|
||||||
All structures are loaded as secondary
|
|
||||||
|
|
||||||
Args:
|
|
||||||
stream: Seekable stream. Position 0 should be the start of the file.
|
|
||||||
The caller should leave the stream open while the library
|
|
||||||
is still in use, since the library will need to access it
|
|
||||||
in order to read the structure contents.
|
|
||||||
full_load: If True, force all structures to be read immediately rather
|
|
||||||
than as-needed. Since data is read sequentially from the file, this
|
|
||||||
will be faster than using the resulting library's `precache` method.
|
|
||||||
postprocess: If given, this function is used to post-process each
|
|
||||||
pattern *upon first load only*.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LazyLibrary object, allowing for deferred load of structures.
|
|
||||||
Additional library info (dict, same format as from `read`).
|
|
||||||
"""
|
|
||||||
stream.seek(0)
|
|
||||||
lib = LazyLibrary()
|
|
||||||
|
|
||||||
if full_load:
|
|
||||||
# Full load approach (immediately load everything)
|
|
||||||
patterns, library_info = read(stream)
|
|
||||||
for name, pattern in patterns.items():
|
|
||||||
if postprocess is not None:
|
|
||||||
lib[name] = postprocess(lib, name, pattern)
|
|
||||||
else:
|
|
||||||
lib[name] = pattern
|
|
||||||
return lib, library_info
|
|
||||||
|
|
||||||
# Normal approach (scan and defer load)
|
|
||||||
library_info = _read_header(stream)
|
|
||||||
structs = klamath.library.scan_structs(stream)
|
|
||||||
|
|
||||||
for name_bytes, pos in structs.items():
|
|
||||||
name = name_bytes.decode('ASCII')
|
|
||||||
|
|
||||||
def mkstruct(pos: int = pos, name: str = name) -> Pattern:
|
|
||||||
stream.seek(pos)
|
|
||||||
pat = read_elements(stream, raw_mode=True)
|
|
||||||
if postprocess is not None:
|
|
||||||
pat = postprocess(lib, name, pat)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
lib[name] = mkstruct
|
|
||||||
|
|
||||||
return lib, library_info
|
|
||||||
|
|
||||||
|
|
||||||
def load_libraryfile(
|
|
||||||
filename: str | pathlib.Path,
|
|
||||||
*,
|
|
||||||
use_mmap: bool = True,
|
|
||||||
full_load: bool = False,
|
|
||||||
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
|
|
||||||
) -> tuple[LazyLibrary, dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Wrapper for `load_library()` that takes a filename or path instead of a stream.
|
|
||||||
|
|
||||||
Will automatically decompress the file if it is gzipped.
|
|
||||||
|
|
||||||
NOTE that any streams/mmaps opened will remain open until ALL of the
|
|
||||||
`PatternGenerator` objects in the library are garbage collected.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: filename or path to read from
|
|
||||||
use_mmap: If `True`, will attempt to memory-map the file instead
|
|
||||||
of buffering. In the case of gzipped files, the file
|
|
||||||
is decompressed into a python `bytes` object in memory
|
|
||||||
and reopened as an `io.BytesIO` stream.
|
|
||||||
full_load: If `True`, immediately loads all data. See `load_library`.
|
|
||||||
postprocess: Passed to `load_library`
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LazyLibrary object, allowing for deferred load of structures.
|
|
||||||
Additional library info (dict, same format as from `read`).
|
|
||||||
"""
|
|
||||||
path = pathlib.Path(filename)
|
|
||||||
stream: IO[bytes]
|
|
||||||
if is_gzipped(path):
|
|
||||||
if use_mmap:
|
|
||||||
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
|
|
||||||
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
|
|
||||||
stream = io.BytesIO(gz_stream.read()) # type: ignore
|
|
||||||
else:
|
|
||||||
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
|
|
||||||
stream = io.BufferedReader(gz_stream) # type: ignore
|
|
||||||
else: # noqa: PLR5501
|
|
||||||
if use_mmap:
|
|
||||||
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
|
|
||||||
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
|
|
||||||
else:
|
|
||||||
stream = path.open(mode='rb') # noqa: SIM115
|
|
||||||
|
|
||||||
try:
|
|
||||||
return load_library(stream, full_load=full_load, postprocess=postprocess)
|
|
||||||
finally:
|
|
||||||
if full_load:
|
|
||||||
stream.close()
|
|
||||||
|
|
||||||
|
|
||||||
def check_valid_names(
|
def check_valid_names(
|
||||||
names: Iterable[str],
|
names: Iterable[str],
|
||||||
max_length: int = 32,
|
max_length: int = 32,
|
||||||
|
|
|
||||||
388
masque/file/gdsii_lazy.py
Normal file
388
masque/file/gdsii_lazy.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""
|
||||||
|
Classic source-backed lazy GDSII reader built on the pure-python klamath path.
|
||||||
|
|
||||||
|
This module provides the non-Arrow half of Masque's lazy GDS architecture:
|
||||||
|
|
||||||
|
- `GdsLibrarySource` scans a GDS stream once to discover library metadata,
|
||||||
|
struct order, and child edges without materializing every cell.
|
||||||
|
- cells are materialized on demand through the classic `gdsii` decoder
|
||||||
|
whenever a caller indexes the lazy view
|
||||||
|
- the source can be wrapped in `PortsLibraryView` or merged through
|
||||||
|
`OverlayLibrary`, both of which live in `gdsii_lazy_core`
|
||||||
|
|
||||||
|
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, Any, cast
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterator, Sequence
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import mmap
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import klamath
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
from klamath import records
|
||||||
|
|
||||||
|
from . import gdsii
|
||||||
|
from .utils import is_gzipped
|
||||||
|
from .gdsii_lazy_core import OverlayLibrary, PortsLibraryView, _pattern_children, write, writefile
|
||||||
|
from ..error import LibraryError
|
||||||
|
from ..library import ILibraryView, LibraryView, dangling_mode_t
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..utils import apply_transforms
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SourceHandle:
|
||||||
|
""" Owns the underlying stream and any companion file handle for a source. """
|
||||||
|
path: pathlib.Path | None
|
||||||
|
stream: IO[bytes]
|
||||||
|
handle: IO[bytes] | None = None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.stream.close()
|
||||||
|
if self.handle is not None and self.handle is not self.stream:
|
||||||
|
self.handle.close()
|
||||||
|
self.handle = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CellScan:
|
||||||
|
""" Scan-time metadata for one cell in the source stream. """
|
||||||
|
offset: int
|
||||||
|
children: set[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _open_source_stream(
|
||||||
|
filename: str | pathlib.Path,
|
||||||
|
*,
|
||||||
|
use_mmap: bool,
|
||||||
|
) -> _SourceHandle:
|
||||||
|
path = pathlib.Path(filename).expanduser().resolve()
|
||||||
|
if is_gzipped(path):
|
||||||
|
if use_mmap:
|
||||||
|
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
|
||||||
|
with gzip.open(path, mode='rb') as stream:
|
||||||
|
data = stream.read()
|
||||||
|
return _SourceHandle(path=path, stream=io.BytesIO(data))
|
||||||
|
stream = cast('IO[bytes]', gzip.open(path, mode='rb'))
|
||||||
|
return _SourceHandle(path=path, stream=stream)
|
||||||
|
|
||||||
|
if use_mmap:
|
||||||
|
handle = path.open(mode='rb', buffering=0)
|
||||||
|
mapped = cast('IO[bytes]', mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ))
|
||||||
|
return _SourceHandle(path=path, stream=mapped, handle=handle)
|
||||||
|
|
||||||
|
stream = path.open(mode='rb')
|
||||||
|
return _SourceHandle(path=path, stream=stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_library(
|
||||||
|
stream: IO[bytes],
|
||||||
|
) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]:
|
||||||
|
library_info = gdsii._read_header(stream)
|
||||||
|
order: list[str] = []
|
||||||
|
cells: dict[str, _CellScan] = {}
|
||||||
|
|
||||||
|
found_struct = records.BGNSTR.skip_past(stream)
|
||||||
|
while found_struct:
|
||||||
|
name = records.STRNAME.skip_and_read(stream).decode('ASCII')
|
||||||
|
offset = stream.tell()
|
||||||
|
elements = klamath.library.read_elements(stream)
|
||||||
|
children = {
|
||||||
|
element.struct_name.decode('ASCII')
|
||||||
|
for element in elements
|
||||||
|
if isinstance(element, klamath.elements.Reference)
|
||||||
|
}
|
||||||
|
order.append(name)
|
||||||
|
cells[name] = _CellScan(offset=offset, children=children)
|
||||||
|
found_struct = records.BGNSTR.skip_past(stream)
|
||||||
|
|
||||||
|
return library_info, order, cells
|
||||||
|
|
||||||
|
|
||||||
|
class GdsLibrarySource(ILibraryView):
|
||||||
|
"""
|
||||||
|
Read-only library backed by a seekable GDS stream.
|
||||||
|
|
||||||
|
Cells are scanned once up front to discover order and child edges, then
|
||||||
|
materialized one at a time through the classic `gdsii.read_elements` path.
|
||||||
|
|
||||||
|
The source owns the stream lifetime, preserves on-disk ordering through
|
||||||
|
`source_order()`, and answers graph queries from scan metadata whenever
|
||||||
|
possible so callers can inspect hierarchy without forcing a full load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: _SourceHandle,
|
||||||
|
library_info: dict[str, Any],
|
||||||
|
cell_order: Sequence[str],
|
||||||
|
cells: dict[str, _CellScan],
|
||||||
|
) -> None:
|
||||||
|
self.path = source.path
|
||||||
|
self.library_info = library_info
|
||||||
|
self._source = source
|
||||||
|
self._cell_order = tuple(cell_order)
|
||||||
|
self._cells = cells
|
||||||
|
self._cache: dict[str, Pattern] = {}
|
||||||
|
self._lookups_in_progress: list[str] = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(
|
||||||
|
cls,
|
||||||
|
filename: str | pathlib.Path,
|
||||||
|
*,
|
||||||
|
use_mmap: bool = True,
|
||||||
|
) -> GdsLibrarySource:
|
||||||
|
source = _open_source_stream(filename, use_mmap=use_mmap)
|
||||||
|
source.stream.seek(0)
|
||||||
|
library_info, cell_order, cells = _scan_library(source.stream)
|
||||||
|
return cls(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Pattern:
|
||||||
|
return self._materialize_pattern(key, persist=True)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
return iter(self._cell_order)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._cell_order)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
return key in self._cells
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return self._cell_order
|
||||||
|
|
||||||
|
def materialize_many(
|
||||||
|
self,
|
||||||
|
names: Sequence[str],
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
|
) -> LibraryView:
|
||||||
|
mats = {
|
||||||
|
name: self._materialize_pattern(name, persist=persist)
|
||||||
|
for name in dict.fromkeys(names)
|
||||||
|
}
|
||||||
|
return LibraryView(mats)
|
||||||
|
|
||||||
|
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||||
|
if name in self._cache:
|
||||||
|
return self._cache[name]
|
||||||
|
|
||||||
|
if name not in self._cells:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
if name in self._lookups_in_progress:
|
||||||
|
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||||
|
raise LibraryError(
|
||||||
|
f'Detected circular reference or recursive lookup of "{name}".\n'
|
||||||
|
f'Lookup chain: {chain}\n'
|
||||||
|
'This may be caused by an invalid (cyclical) reference, or buggy code.\n'
|
||||||
|
'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._lookups_in_progress.append(name)
|
||||||
|
try:
|
||||||
|
self._source.stream.seek(self._cells[name].offset)
|
||||||
|
pat = gdsii.read_elements(self._source.stream, raw_mode=True)
|
||||||
|
finally:
|
||||||
|
self._lookups_in_progress.pop()
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
self._cache[name] = pat
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def _raw_children(self, name: str) -> set[str]:
|
||||||
|
return set(self._cells[name].children)
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
graph: dict[str, set[str]] = {}
|
||||||
|
for name in self._cell_order:
|
||||||
|
if name in self._cache:
|
||||||
|
graph[name] = _pattern_children(self._cache[name])
|
||||||
|
else:
|
||||||
|
graph[name] = self._raw_children(name)
|
||||||
|
|
||||||
|
existing = set(graph)
|
||||||
|
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
||||||
|
if dangling == 'error':
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
||||||
|
return graph
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||||
|
|
||||||
|
for child in dangling_refs:
|
||||||
|
graph.setdefault(cast('str', child), set())
|
||||||
|
return graph
|
||||||
|
|
||||||
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||||
|
existing = set(self.keys())
|
||||||
|
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||||
|
for parent, children in child_graph.items():
|
||||||
|
for child in children:
|
||||||
|
if child in existing or dangling == 'include':
|
||||||
|
igraph.setdefault(child, set()).add(parent)
|
||||||
|
if dangling == 'error':
|
||||||
|
raw = self.child_graph(dangling='include')
|
||||||
|
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||||
|
return igraph
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return self.materialize_many(tuple(keep), persist=True)
|
||||||
|
|
||||||
|
def tops(self) -> list[str]:
|
||||||
|
graph = self.child_graph(dangling='ignore')
|
||||||
|
names = set(graph)
|
||||||
|
not_toplevel: set[str] = set()
|
||||||
|
for children in graph.values():
|
||||||
|
not_toplevel |= children
|
||||||
|
return list(names - not_toplevel)
|
||||||
|
|
||||||
|
def with_ports_from_data(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
layers: Sequence[tuple[int, int] | int],
|
||||||
|
max_depth: int = 0,
|
||||||
|
skip_subcells: bool = True,
|
||||||
|
) -> PortsLibraryView:
|
||||||
|
return PortsLibraryView(
|
||||||
|
self,
|
||||||
|
layers=layers,
|
||||||
|
max_depth=max_depth,
|
||||||
|
skip_subcells=skip_subcells,
|
||||||
|
)
|
||||||
|
|
||||||
|
def find_refs_local(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||||
|
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
||||||
|
if parent_graph is None:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return instances
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return instances
|
||||||
|
|
||||||
|
for parent in parent_graph.get(name, set()):
|
||||||
|
if parent in self._cache:
|
||||||
|
for ref in self._cache[parent].refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
continue
|
||||||
|
pat = self._materialize_pattern(parent, persist=False)
|
||||||
|
for ref in pat.refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
return instances
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
if order is None:
|
||||||
|
order = self.child_order(dangling=graph_mode)
|
||||||
|
if parent_graph is None:
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return {}
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self_keys = set(self.keys())
|
||||||
|
transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]]
|
||||||
|
transforms = defaultdict(list)
|
||||||
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||||
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
|
for next_name in order:
|
||||||
|
if next_name not in transforms:
|
||||||
|
continue
|
||||||
|
if not parent_graph.get(next_name, set()) & self_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
inners = transforms.pop(next_name)
|
||||||
|
for parent, outer in outers.items():
|
||||||
|
outer_tf = numpy.concatenate(outer)
|
||||||
|
for path, inner in inners:
|
||||||
|
combined = apply_transforms(outer_tf, inner)
|
||||||
|
transforms[parent].append(((next_name,) + path, combined))
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for parent, targets in transforms.items():
|
||||||
|
for path, instances in targets:
|
||||||
|
result[(parent,) + path] = instances
|
||||||
|
return result
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._source.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> GdsLibrarySource:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def read(
|
||||||
|
stream: IO[bytes],
|
||||||
|
) -> tuple[GdsLibrarySource, dict[str, Any]]:
|
||||||
|
source = _SourceHandle(path=None, stream=stream)
|
||||||
|
stream.seek(0)
|
||||||
|
library_info, cell_order, cells = _scan_library(stream)
|
||||||
|
lib = GdsLibrarySource(source=source, library_info=library_info, cell_order=cell_order, cells=cells)
|
||||||
|
return lib, library_info
|
||||||
|
|
||||||
|
|
||||||
|
def readfile(
|
||||||
|
filename: str | pathlib.Path,
|
||||||
|
*,
|
||||||
|
use_mmap: bool = True,
|
||||||
|
) -> tuple[GdsLibrarySource, dict[str, Any]]:
|
||||||
|
lib = GdsLibrarySource.from_file(filename, use_mmap=use_mmap)
|
||||||
|
return lib, lib.library_info
|
||||||
|
|
@ -9,8 +9,7 @@ from __future__ import annotations
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import IO, Any, cast
|
from typing import IO, Any, cast
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
from collections.abc import Iterator, Sequence
|
||||||
import copy
|
|
||||||
import gzip
|
import gzip
|
||||||
import logging
|
import logging
|
||||||
import mmap
|
import mmap
|
||||||
|
|
@ -19,13 +18,12 @@ import pathlib
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
import pyarrow
|
import pyarrow
|
||||||
import klamath
|
|
||||||
|
|
||||||
from . import gdsii, gdsii_arrow
|
from . import gdsii_arrow
|
||||||
from .utils import is_gzipped, tmpfile
|
from .utils import is_gzipped
|
||||||
from ..error import LibraryError
|
from .gdsii_lazy_core import OverlayLibrary, PortsLibraryView, _pattern_children, write, writefile
|
||||||
from ..library import ILibrary, ILibraryView, Library, LibraryView, dangling_mode_t
|
from ..library import ILibraryView, LibraryView, dangling_mode_t
|
||||||
from ..pattern import Pattern, map_targets
|
from ..pattern import Pattern
|
||||||
from ..utils import apply_transforms
|
from ..utils import apply_transforms
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -79,22 +77,6 @@ class _ScanPayload:
|
||||||
cells: dict[str, _CellScan]
|
cells: dict[str, _CellScan]
|
||||||
refs: _ScanRefs
|
refs: _ScanRefs
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _SourceLayer:
|
|
||||||
library: ILibraryView
|
|
||||||
source_to_visible: dict[str, str]
|
|
||||||
visible_to_source: dict[str, str]
|
|
||||||
child_graph: dict[str, set[str]]
|
|
||||||
order: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _SourceEntry:
|
|
||||||
layer_index: int
|
|
||||||
source_name: str
|
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
return gdsii_arrow.is_available()
|
return gdsii_arrow.is_available()
|
||||||
|
|
||||||
|
|
@ -174,30 +156,6 @@ def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload:
|
||||||
refs=ref_payload,
|
refs=ref_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pattern_children(pat: Pattern) -> set[str]:
|
|
||||||
return {child for child, refs in pat.refs.items() if child is not None and refs}
|
|
||||||
|
|
||||||
|
|
||||||
def _remap_pattern_targets(pat: Pattern, remap: Callable[[str | None], str | None]) -> Pattern:
|
|
||||||
if not pat.refs:
|
|
||||||
return pat
|
|
||||||
pat.refs = map_targets(pat.refs, remap)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_library_view(source: Mapping[str, Pattern] | ILibraryView) -> ILibraryView:
|
|
||||||
if isinstance(source, ILibraryView):
|
|
||||||
return source
|
|
||||||
return LibraryView(source)
|
|
||||||
|
|
||||||
|
|
||||||
def _source_order(source: ILibraryView) -> list[str]:
|
|
||||||
if isinstance(source, ArrowLibrary):
|
|
||||||
return list(source.source_order())
|
|
||||||
return list(source.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def _make_ref_rows(
|
def _make_ref_rows(
|
||||||
xy: NDArray[numpy.integer[Any]],
|
xy: NDArray[numpy.integer[Any]],
|
||||||
angle_rad: NDArray[numpy.floating[Any]],
|
angle_rad: NDArray[numpy.floating[Any]],
|
||||||
|
|
@ -285,6 +243,9 @@ class ArrowLibrary(ILibraryView):
|
||||||
struct_range = self._payload.cells[name].struct_range
|
struct_range = self._payload.cells[name].struct_range
|
||||||
return self._source.raw_slice(struct_range.start, struct_range.end)
|
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(
|
def materialize_many(
|
||||||
self,
|
self,
|
||||||
names: Sequence[str],
|
names: Sequence[str],
|
||||||
|
|
@ -435,6 +396,34 @@ class ArrowLibrary(ILibraryView):
|
||||||
not_toplevel |= children
|
not_toplevel |= children
|
||||||
return list(names - not_toplevel)
|
return list(names - not_toplevel)
|
||||||
|
|
||||||
|
def with_ports_from_data(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
layers: Sequence[tuple[int, int] | int],
|
||||||
|
max_depth: int = 0,
|
||||||
|
skip_subcells: bool = True,
|
||||||
|
) -> PortsLibraryView:
|
||||||
|
return PortsLibraryView(
|
||||||
|
self,
|
||||||
|
layers=layers,
|
||||||
|
max_depth=max_depth,
|
||||||
|
skip_subcells=skip_subcells,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
data = self._source.data
|
||||||
|
if isinstance(data, mmap.mmap):
|
||||||
|
data.close()
|
||||||
|
if self._source.handle is not None:
|
||||||
|
self._source.handle.close()
|
||||||
|
self._source.handle = None
|
||||||
|
|
||||||
|
def __enter__(self) -> ArrowLibrary:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
def find_refs_local(
|
def find_refs_local(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|
@ -517,304 +506,6 @@ class ArrowLibrary(ILibraryView):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class OverlayLibrary(ILibrary):
|
|
||||||
"""
|
|
||||||
Mutable overlay over one or more source libraries.
|
|
||||||
|
|
||||||
Source-backed cells remain lazy until accessed through `__getitem__`, at
|
|
||||||
which point that visible cell is promoted into an overlay-owned materialized
|
|
||||||
`Pattern`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._layers: list[_SourceLayer] = []
|
|
||||||
self._entries: dict[str, Pattern | _SourceEntry] = {}
|
|
||||||
self._order: list[str] = []
|
|
||||||
self._target_remap: dict[str, str] = {}
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[str]:
|
|
||||||
return (name for name in self._order if name in self._entries)
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self._entries)
|
|
||||||
|
|
||||||
def __contains__(self, key: object) -> bool:
|
|
||||||
return key in self._entries
|
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> Pattern:
|
|
||||||
return self._materialize_pattern(key, persist=True)
|
|
||||||
|
|
||||||
def __setitem__(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
value: Pattern | Callable[[], Pattern],
|
|
||||||
) -> None:
|
|
||||||
if key in self._entries:
|
|
||||||
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
|
||||||
pattern = value() if callable(value) else value
|
|
||||||
self._entries[key] = pattern
|
|
||||||
if key not in self._order:
|
|
||||||
self._order.append(key)
|
|
||||||
|
|
||||||
def __delitem__(self, key: str) -> None:
|
|
||||||
if key not in self._entries:
|
|
||||||
raise KeyError(key)
|
|
||||||
del self._entries[key]
|
|
||||||
|
|
||||||
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
|
||||||
self[key_self] = copy.deepcopy(other[key_other])
|
|
||||||
|
|
||||||
def add_source(
|
|
||||||
self,
|
|
||||||
source: Mapping[str, Pattern] | ILibraryView,
|
|
||||||
*,
|
|
||||||
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
view = _coerce_library_view(source)
|
|
||||||
source_order = _source_order(view)
|
|
||||||
child_graph = view.child_graph(dangling='include')
|
|
||||||
|
|
||||||
source_to_visible: dict[str, str] = {}
|
|
||||||
visible_to_source: dict[str, str] = {}
|
|
||||||
rename_map: dict[str, str] = {}
|
|
||||||
|
|
||||||
for name in source_order:
|
|
||||||
visible = name
|
|
||||||
if visible in self._entries or visible in visible_to_source:
|
|
||||||
if rename_theirs is None:
|
|
||||||
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
|
||||||
visible = rename_theirs(self, name)
|
|
||||||
if visible in self._entries or visible in visible_to_source:
|
|
||||||
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
|
||||||
rename_map[name] = visible
|
|
||||||
source_to_visible[name] = visible
|
|
||||||
visible_to_source[visible] = name
|
|
||||||
|
|
||||||
layer = _SourceLayer(
|
|
||||||
library=view,
|
|
||||||
source_to_visible=source_to_visible,
|
|
||||||
visible_to_source=visible_to_source,
|
|
||||||
child_graph=child_graph,
|
|
||||||
order=[source_to_visible[name] for name in source_order],
|
|
||||||
)
|
|
||||||
layer_index = len(self._layers)
|
|
||||||
self._layers.append(layer)
|
|
||||||
|
|
||||||
for source_name, visible_name in source_to_visible.items():
|
|
||||||
self._entries[visible_name] = _SourceEntry(layer_index=layer_index, source_name=source_name)
|
|
||||||
if visible_name not in self._order:
|
|
||||||
self._order.append(visible_name)
|
|
||||||
|
|
||||||
return rename_map
|
|
||||||
|
|
||||||
def rename(
|
|
||||||
self,
|
|
||||||
old_name: str,
|
|
||||||
new_name: str,
|
|
||||||
move_references: bool = False,
|
|
||||||
) -> OverlayLibrary:
|
|
||||||
if old_name not in self._entries:
|
|
||||||
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
|
||||||
if old_name == new_name:
|
|
||||||
return self
|
|
||||||
if new_name in self._entries:
|
|
||||||
raise LibraryError(f'"{new_name}" already exists in the library.')
|
|
||||||
|
|
||||||
entry = self._entries.pop(old_name)
|
|
||||||
self._entries[new_name] = entry
|
|
||||||
if isinstance(entry, _SourceEntry):
|
|
||||||
layer = self._layers[entry.layer_index]
|
|
||||||
layer.source_to_visible[entry.source_name] = new_name
|
|
||||||
del layer.visible_to_source[old_name]
|
|
||||||
layer.visible_to_source[new_name] = entry.source_name
|
|
||||||
|
|
||||||
idx = self._order.index(old_name)
|
|
||||||
self._order[idx] = new_name
|
|
||||||
|
|
||||||
if move_references:
|
|
||||||
self.move_references(old_name, new_name)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _resolve_target(self, target: str) -> str:
|
|
||||||
seen: set[str] = set()
|
|
||||||
current = target
|
|
||||||
while current in self._target_remap:
|
|
||||||
if current in seen:
|
|
||||||
raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}')
|
|
||||||
seen.add(current)
|
|
||||||
current = self._target_remap[current]
|
|
||||||
return current
|
|
||||||
|
|
||||||
def _set_target_remap(self, old_target: str, new_target: str) -> None:
|
|
||||||
resolved_new = self._resolve_target(new_target)
|
|
||||||
if resolved_new == old_target:
|
|
||||||
raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}')
|
|
||||||
self._target_remap[old_target] = resolved_new
|
|
||||||
for key in list(self._target_remap):
|
|
||||||
self._target_remap[key] = self._resolve_target(self._target_remap[key])
|
|
||||||
|
|
||||||
def move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
|
|
||||||
if old_target == new_target:
|
|
||||||
return self
|
|
||||||
self._set_target_remap(old_target, new_target)
|
|
||||||
for entry in list(self._entries.values()):
|
|
||||||
if isinstance(entry, Pattern) and old_target in entry.refs:
|
|
||||||
entry.refs[new_target].extend(entry.refs[old_target])
|
|
||||||
del entry.refs[old_target]
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _effective_target(self, layer: _SourceLayer, target: str) -> str:
|
|
||||||
visible = layer.source_to_visible.get(target, target)
|
|
||||||
return self._resolve_target(visible)
|
|
||||||
|
|
||||||
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
|
||||||
if name not in self._entries:
|
|
||||||
raise KeyError(name)
|
|
||||||
entry = self._entries[name]
|
|
||||||
if isinstance(entry, Pattern):
|
|
||||||
return entry
|
|
||||||
|
|
||||||
layer = self._layers[entry.layer_index]
|
|
||||||
source_pat = layer.library[entry.source_name].deepcopy()
|
|
||||||
remap = lambda target: None if target is None else self._effective_target(layer, target)
|
|
||||||
pat = _remap_pattern_targets(source_pat, remap)
|
|
||||||
if persist:
|
|
||||||
self._entries[name] = pat
|
|
||||||
return pat
|
|
||||||
|
|
||||||
def child_graph(
|
|
||||||
self,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[str, set[str]]:
|
|
||||||
graph: dict[str, set[str]] = {}
|
|
||||||
for name in self._order:
|
|
||||||
if name not in self._entries:
|
|
||||||
continue
|
|
||||||
entry = self._entries[name]
|
|
||||||
if isinstance(entry, Pattern):
|
|
||||||
graph[name] = _pattern_children(entry)
|
|
||||||
continue
|
|
||||||
layer = self._layers[entry.layer_index]
|
|
||||||
children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())}
|
|
||||||
graph[name] = children
|
|
||||||
|
|
||||||
existing = set(graph)
|
|
||||||
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
|
||||||
if dangling == 'error':
|
|
||||||
if dangling_refs:
|
|
||||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
|
||||||
return graph
|
|
||||||
if dangling == 'ignore':
|
|
||||||
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
|
||||||
|
|
||||||
for child in dangling_refs:
|
|
||||||
graph.setdefault(cast('str', child), set())
|
|
||||||
return graph
|
|
||||||
|
|
||||||
def parent_graph(
|
|
||||||
self,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[str, set[str]]:
|
|
||||||
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
|
||||||
existing = set(self.keys())
|
|
||||||
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
|
||||||
for parent, children in child_graph.items():
|
|
||||||
for child in children:
|
|
||||||
if child in existing or dangling == 'include':
|
|
||||||
igraph.setdefault(child, set()).add(parent)
|
|
||||||
if dangling == 'error':
|
|
||||||
raw = self.child_graph(dangling='include')
|
|
||||||
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
|
||||||
if dangling_refs:
|
|
||||||
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
|
||||||
return igraph
|
|
||||||
|
|
||||||
def subtree(
|
|
||||||
self,
|
|
||||||
tops: str | Sequence[str],
|
|
||||||
) -> ILibraryView:
|
|
||||||
if isinstance(tops, str):
|
|
||||||
tops = (tops,)
|
|
||||||
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
|
||||||
keep |= set(tops)
|
|
||||||
return LibraryView({name: self[name] for name in keep})
|
|
||||||
|
|
||||||
def find_refs_local(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
parent_graph: dict[str, set[str]] | None = None,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[str, list[NDArray[numpy.float64]]]:
|
|
||||||
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
|
||||||
if parent_graph is None:
|
|
||||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
|
||||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
|
||||||
|
|
||||||
if name not in self:
|
|
||||||
if name not in parent_graph:
|
|
||||||
return instances
|
|
||||||
if dangling == 'error':
|
|
||||||
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
|
||||||
if dangling == 'ignore':
|
|
||||||
return instances
|
|
||||||
|
|
||||||
for parent in parent_graph.get(name, set()):
|
|
||||||
pat = self._materialize_pattern(parent, persist=False)
|
|
||||||
for ref in pat.refs.get(name, []):
|
|
||||||
instances[parent].append(ref.as_transforms())
|
|
||||||
return instances
|
|
||||||
|
|
||||||
def find_refs_global(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
order: list[str] | None = None,
|
|
||||||
parent_graph: dict[str, set[str]] | None = None,
|
|
||||||
dangling: dangling_mode_t = 'error',
|
|
||||||
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
|
||||||
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
|
||||||
if order is None:
|
|
||||||
order = self.child_order(dangling=graph_mode)
|
|
||||||
if parent_graph is None:
|
|
||||||
parent_graph = self.parent_graph(dangling=graph_mode)
|
|
||||||
|
|
||||||
if name not in self:
|
|
||||||
if name not in parent_graph:
|
|
||||||
return {}
|
|
||||||
if dangling == 'error':
|
|
||||||
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
|
||||||
if dangling == 'ignore':
|
|
||||||
return {}
|
|
||||||
|
|
||||||
self_keys = set(self.keys())
|
|
||||||
transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]]
|
|
||||||
transforms = defaultdict(list)
|
|
||||||
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
|
||||||
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
|
||||||
|
|
||||||
for next_name in order:
|
|
||||||
if next_name not in transforms:
|
|
||||||
continue
|
|
||||||
if not parent_graph.get(next_name, set()) & self_keys:
|
|
||||||
continue
|
|
||||||
|
|
||||||
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
|
||||||
inners = transforms.pop(next_name)
|
|
||||||
for parent, outer in outers.items():
|
|
||||||
outer_tf = numpy.concatenate(outer)
|
|
||||||
for path, inner in inners:
|
|
||||||
combined = apply_transforms(outer_tf, inner)
|
|
||||||
transforms[parent].append(((next_name,) + path, combined))
|
|
||||||
|
|
||||||
result = {}
|
|
||||||
for parent, targets in transforms.items():
|
|
||||||
for path, instances in targets:
|
|
||||||
result[(parent,) + path] = instances
|
|
||||||
return result
|
|
||||||
|
|
||||||
def source_order(self) -> tuple[str, ...]:
|
|
||||||
return tuple(name for name in self._order if name in self._entries)
|
|
||||||
|
|
||||||
|
|
||||||
def readfile(
|
def readfile(
|
||||||
filename: str | pathlib.Path,
|
filename: str | pathlib.Path,
|
||||||
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||||
|
|
@ -826,135 +517,3 @@ def load_libraryfile(
|
||||||
filename: str | pathlib.Path,
|
filename: str | pathlib.Path,
|
||||||
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
) -> tuple[ArrowLibrary, dict[str, Any]]:
|
||||||
return readfile(filename)
|
return readfile(filename)
|
||||||
|
|
||||||
|
|
||||||
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]] = []
|
|
||||||
if isinstance(library, ArrowLibrary):
|
|
||||||
infos.append(library.library_info)
|
|
||||||
elif isinstance(library, OverlayLibrary):
|
|
||||||
for layer in library._layers:
|
|
||||||
if isinstance(layer.library, ArrowLibrary):
|
|
||||||
infos.append(layer.library.library_info)
|
|
||||||
|
|
||||||
if infos:
|
|
||||||
unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos}
|
|
||||||
if len(unit_pairs) > 1:
|
|
||||||
raise LibraryError('Merged lazy GDS sources must have identical units before writing')
|
|
||||||
info = infos[0]
|
|
||||||
meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit
|
|
||||||
logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit
|
|
||||||
name = info['name'] if library_name is None else library_name
|
|
||||||
return meters, logical, name
|
|
||||||
|
|
||||||
if meters_per_unit is None or logical_units_per_unit is None or library_name is None:
|
|
||||||
raise LibraryError('meters_per_unit, logical_units_per_unit, and library_name are required for non-GDS-backed lazy writes')
|
|
||||||
return meters_per_unit, logical_units_per_unit, library_name
|
|
||||||
|
|
||||||
|
|
||||||
def _can_copy_arrow_cell(library: ArrowLibrary, name: str) -> bool:
|
|
||||||
return name not in library._cache
|
|
||||||
|
|
||||||
|
|
||||||
def _can_copy_overlay_cell(library: OverlayLibrary, name: str, entry: _SourceEntry) -> bool:
|
|
||||||
layer = library._layers[entry.layer_index]
|
|
||||||
if not isinstance(layer.library, ArrowLibrary):
|
|
||||||
return False
|
|
||||||
if name != entry.source_name:
|
|
||||||
return False
|
|
||||||
children = layer.child_graph.get(entry.source_name, set())
|
|
||||||
return all(library._effective_target(layer, child) == child for child in children)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
|
|
||||||
elements: list[klamath.elements.Element] = []
|
|
||||||
elements += gdsii._shapes_to_elements(pat.shapes)
|
|
||||||
elements += gdsii._labels_to_texts(pat.labels)
|
|
||||||
elements += gdsii._mrefs_to_grefs(pat.refs)
|
|
||||||
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
|
|
||||||
|
|
||||||
|
|
||||||
def write(
|
|
||||||
library: Mapping[str, Pattern] | ILibraryView,
|
|
||||||
stream: IO[bytes],
|
|
||||||
*,
|
|
||||||
meters_per_unit: float | None = None,
|
|
||||||
logical_units_per_unit: float | None = None,
|
|
||||||
library_name: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
meters_per_unit, logical_units_per_unit, library_name = _get_write_info(
|
|
||||||
library,
|
|
||||||
meters_per_unit=meters_per_unit,
|
|
||||||
logical_units_per_unit=logical_units_per_unit,
|
|
||||||
library_name=library_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
header = klamath.library.FileHeader(
|
|
||||||
name=library_name.encode('ASCII'),
|
|
||||||
user_units_per_db_unit=logical_units_per_unit,
|
|
||||||
meters_per_db_unit=meters_per_unit,
|
|
||||||
)
|
|
||||||
header.write(stream)
|
|
||||||
|
|
||||||
if isinstance(library, ArrowLibrary):
|
|
||||||
for name in library.source_order():
|
|
||||||
if _can_copy_arrow_cell(library, name):
|
|
||||||
stream.write(library.raw_struct_bytes(name))
|
|
||||||
else:
|
|
||||||
_write_pattern_struct(stream, name, library._materialize_pattern(name, persist=False))
|
|
||||||
klamath.records.ENDLIB.write(stream, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(library, OverlayLibrary):
|
|
||||||
for name in library.source_order():
|
|
||||||
entry = library._entries[name]
|
|
||||||
if isinstance(entry, _SourceEntry) and _can_copy_overlay_cell(library, name, entry):
|
|
||||||
layer = library._layers[entry.layer_index]
|
|
||||||
assert isinstance(layer.library, ArrowLibrary)
|
|
||||||
stream.write(layer.library.raw_struct_bytes(entry.source_name))
|
|
||||||
else:
|
|
||||||
_write_pattern_struct(stream, name, library._materialize_pattern(name, persist=False))
|
|
||||||
klamath.records.ENDLIB.write(stream, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
gdsii.write(cast('Mapping[str, Pattern]', library), stream, meters_per_unit, logical_units_per_unit, library_name)
|
|
||||||
|
|
||||||
|
|
||||||
def writefile(
|
|
||||||
library: Mapping[str, Pattern] | ILibraryView,
|
|
||||||
filename: str | pathlib.Path,
|
|
||||||
*,
|
|
||||||
meters_per_unit: float | None = None,
|
|
||||||
logical_units_per_unit: float | None = None,
|
|
||||||
library_name: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
path = pathlib.Path(filename)
|
|
||||||
|
|
||||||
with tmpfile(path) as base_stream:
|
|
||||||
streams: tuple[Any, ...] = (base_stream,)
|
|
||||||
if path.suffix == '.gz':
|
|
||||||
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
|
|
||||||
streams = (stream,) + streams
|
|
||||||
else:
|
|
||||||
stream = base_stream
|
|
||||||
|
|
||||||
try:
|
|
||||||
write(
|
|
||||||
library,
|
|
||||||
stream,
|
|
||||||
meters_per_unit=meters_per_unit,
|
|
||||||
logical_units_per_unit=logical_units_per_unit,
|
|
||||||
library_name=library_name,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
for ss in streams:
|
|
||||||
ss.close()
|
|
||||||
|
|
|
||||||
706
masque/file/gdsii_lazy_core.py
Normal file
706
masque/file/gdsii_lazy_core.py
Normal file
|
|
@ -0,0 +1,706 @@
|
||||||
|
"""
|
||||||
|
Shared helpers for source-backed lazy GDS views.
|
||||||
|
|
||||||
|
This module contains the reusable pieces that sit between lazy source readers
|
||||||
|
and ordinary mutable library usage:
|
||||||
|
|
||||||
|
- `PortsLibraryView` layers a processed, ports-importing cache on top of a raw
|
||||||
|
source view without mutating the source itself
|
||||||
|
- `OverlayLibrary` exposes a mutable library surface that can mix source-backed
|
||||||
|
cells with overlay-owned materialized patterns
|
||||||
|
- the write helpers preserve source-backed copy-through behavior where
|
||||||
|
possible, falling back to normal pattern serialization when a cell has been
|
||||||
|
materialized or remapped
|
||||||
|
|
||||||
|
Both the classic and Arrow-backed lazy GDS readers rely on these helpers.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import IO, Any, cast
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||||
|
import copy
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import klamath
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from . import gdsii
|
||||||
|
from .utils import tmpfile
|
||||||
|
from ..error import LibraryError
|
||||||
|
from ..library import ILibrary, ILibraryView, LibraryView, dangling_mode_t
|
||||||
|
from ..pattern import Pattern, map_targets
|
||||||
|
from ..utils import apply_transforms
|
||||||
|
from ..utils.ports2data import data_to_ports
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SourceLayer:
|
||||||
|
""" One imported source layer tracked by an `OverlayLibrary`. """
|
||||||
|
library: ILibraryView
|
||||||
|
source_to_visible: dict[str, str]
|
||||||
|
visible_to_source: dict[str, str]
|
||||||
|
child_graph: dict[str, set[str]]
|
||||||
|
order: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SourceEntry:
|
||||||
|
""" Reference to a single visible source-backed cell in an overlay. """
|
||||||
|
layer_index: int
|
||||||
|
source_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_children(pat: Pattern) -> set[str]:
|
||||||
|
return {child for child, refs in pat.refs.items() if child is not None and refs}
|
||||||
|
|
||||||
|
|
||||||
|
def _remap_pattern_targets(pat: Pattern, remap: Callable[[str | None], str | None]) -> Pattern:
|
||||||
|
if not pat.refs:
|
||||||
|
return pat
|
||||||
|
pat.refs = map_targets(pat.refs, remap)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_library_view(source: Mapping[str, Pattern] | ILibraryView) -> ILibraryView:
|
||||||
|
if isinstance(source, ILibraryView):
|
||||||
|
return source
|
||||||
|
return LibraryView(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern:
|
||||||
|
func = getattr(view, '_materialize_pattern', None)
|
||||||
|
if callable(func):
|
||||||
|
return cast('Pattern', func(name, persist=False))
|
||||||
|
return view[name].deepcopy()
|
||||||
|
|
||||||
|
|
||||||
|
class PortsLibraryView(ILibraryView):
|
||||||
|
"""
|
||||||
|
Read-only view which imports ports into cells on first materialization.
|
||||||
|
|
||||||
|
The wrapped source remains untouched; this view owns a separate processed
|
||||||
|
cache so direct-copy workflows can continue to use the raw source view.
|
||||||
|
|
||||||
|
Graph queries, source ordering, and copy-through capabilities are delegated
|
||||||
|
to the wrapped source whenever possible, while `__getitem__` and
|
||||||
|
`materialize_many()` return port-imported patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: ILibraryView,
|
||||||
|
*,
|
||||||
|
layers: Sequence[gdsii.layer_t],
|
||||||
|
max_depth: int = 0,
|
||||||
|
skip_subcells: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._layers = tuple(layers)
|
||||||
|
self._max_depth = max_depth
|
||||||
|
self._skip_subcells = skip_subcells
|
||||||
|
self._cache: dict[str, Pattern] = {}
|
||||||
|
self._lookups_in_progress: list[str] = []
|
||||||
|
if hasattr(source, 'library_info'):
|
||||||
|
self.library_info = cast('dict[str, Any]', getattr(source, 'library_info'))
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Pattern:
|
||||||
|
return self._materialize_pattern(key, persist=True)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
return iter(self._source)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._source)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
return key in self._source
|
||||||
|
|
||||||
|
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||||
|
if name in self._cache:
|
||||||
|
return self._cache[name]
|
||||||
|
|
||||||
|
if name in self._lookups_in_progress:
|
||||||
|
chain = ' -> '.join(self._lookups_in_progress + [name])
|
||||||
|
raise LibraryError(
|
||||||
|
f'Detected circular reference or recursive lookup of "{name}".\n'
|
||||||
|
f'Lookup chain: {chain}\n'
|
||||||
|
'This may be caused by an invalid (cyclical) reference, or buggy code.'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._lookups_in_progress.append(name)
|
||||||
|
try:
|
||||||
|
pat = _materialize_detached_pattern(self._source, name)
|
||||||
|
pat = data_to_ports(
|
||||||
|
layers=self._layers,
|
||||||
|
library=self,
|
||||||
|
pattern=pat,
|
||||||
|
name=name,
|
||||||
|
max_depth=self._max_depth,
|
||||||
|
skip_subcells=self._skip_subcells,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._lookups_in_progress.pop()
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
self._cache[name] = pat
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def materialize_many(
|
||||||
|
self,
|
||||||
|
names: Sequence[str],
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
|
) -> LibraryView:
|
||||||
|
mats = {
|
||||||
|
name: self._materialize_pattern(name, persist=persist)
|
||||||
|
for name in dict.fromkeys(names)
|
||||||
|
}
|
||||||
|
return LibraryView(mats)
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return self._source.source_order()
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._source.child_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._source.parent_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self._source.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return self.materialize_many(tuple(keep), persist=True)
|
||||||
|
|
||||||
|
def tops(self) -> list[str]:
|
||||||
|
return self._source.tops()
|
||||||
|
|
||||||
|
def find_refs_local(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||||
|
finder = getattr(self._source, 'find_refs_local', None)
|
||||||
|
if callable(finder):
|
||||||
|
return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling))
|
||||||
|
return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
finder = getattr(self._source, 'find_refs_global', None)
|
||||||
|
if callable(finder):
|
||||||
|
return cast(
|
||||||
|
'dict[tuple[str, ...], NDArray[numpy.float64]]',
|
||||||
|
finder(name, order=order, parent_graph=parent_graph, dangling=dangling),
|
||||||
|
)
|
||||||
|
return super().find_refs_global(name, order=order, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
|
||||||
|
def raw_struct_bytes(self, name: str) -> bytes:
|
||||||
|
reader = getattr(self._source, 'raw_struct_bytes', None)
|
||||||
|
if not callable(reader):
|
||||||
|
raise AttributeError('raw_struct_bytes')
|
||||||
|
return cast('bytes', reader(name))
|
||||||
|
|
||||||
|
def can_copy_raw_struct(self, name: str) -> bool:
|
||||||
|
can_copy = getattr(self._source, 'can_copy_raw_struct', None)
|
||||||
|
if not callable(can_copy):
|
||||||
|
return False
|
||||||
|
return bool(can_copy(name))
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
closer = getattr(self._source, 'close', None)
|
||||||
|
if callable(closer):
|
||||||
|
closer()
|
||||||
|
|
||||||
|
def __enter__(self) -> PortsLibraryView:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayLibrary(ILibrary):
|
||||||
|
"""
|
||||||
|
Mutable overlay over one or more source libraries.
|
||||||
|
|
||||||
|
Source-backed cells remain lazy until accessed through `__getitem__`, at
|
||||||
|
which point that visible cell is promoted into an overlay-owned materialized
|
||||||
|
`Pattern`.
|
||||||
|
|
||||||
|
This is the main mutable integration surface for lazy GDS content. It lets
|
||||||
|
callers:
|
||||||
|
- expose one or more source-backed libraries behind a normal `ILibrary`
|
||||||
|
interface
|
||||||
|
- add or replace cells with overlay-owned patterns
|
||||||
|
- rename visible source cells
|
||||||
|
- remap references without immediately rewriting untouched source structs
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._layers: list[_SourceLayer] = []
|
||||||
|
self._entries: dict[str, Pattern | _SourceEntry] = {}
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._target_remap: dict[str, str] = {}
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
return (name for name in self._order if name in self._entries)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._entries)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
return key in self._entries
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Pattern:
|
||||||
|
return self._materialize_pattern(key, persist=True)
|
||||||
|
|
||||||
|
def __setitem__(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: Pattern | Callable[[], Pattern],
|
||||||
|
) -> None:
|
||||||
|
if key in self._entries:
|
||||||
|
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||||
|
pattern = value() if callable(value) else value
|
||||||
|
self._entries[key] = pattern
|
||||||
|
if key not in self._order:
|
||||||
|
self._order.append(key)
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
if key not in self._entries:
|
||||||
|
raise KeyError(key)
|
||||||
|
del self._entries[key]
|
||||||
|
|
||||||
|
def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None:
|
||||||
|
self[key_self] = copy.deepcopy(other[key_other])
|
||||||
|
|
||||||
|
def add_source(
|
||||||
|
self,
|
||||||
|
source: Mapping[str, Pattern] | ILibraryView,
|
||||||
|
*,
|
||||||
|
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
view = _coerce_library_view(source)
|
||||||
|
source_order = list(view.source_order())
|
||||||
|
child_graph = view.child_graph(dangling='include')
|
||||||
|
|
||||||
|
source_to_visible: dict[str, str] = {}
|
||||||
|
visible_to_source: dict[str, str] = {}
|
||||||
|
rename_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
for name in source_order:
|
||||||
|
visible = name
|
||||||
|
if visible in self._entries or visible in visible_to_source:
|
||||||
|
if rename_theirs is None:
|
||||||
|
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
||||||
|
visible = rename_theirs(self, name)
|
||||||
|
if visible in self._entries or visible in visible_to_source:
|
||||||
|
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
||||||
|
rename_map[name] = visible
|
||||||
|
source_to_visible[name] = visible
|
||||||
|
visible_to_source[visible] = name
|
||||||
|
|
||||||
|
layer = _SourceLayer(
|
||||||
|
library=view,
|
||||||
|
source_to_visible=source_to_visible,
|
||||||
|
visible_to_source=visible_to_source,
|
||||||
|
child_graph=child_graph,
|
||||||
|
order=[source_to_visible[name] for name in source_order],
|
||||||
|
)
|
||||||
|
layer_index = len(self._layers)
|
||||||
|
self._layers.append(layer)
|
||||||
|
|
||||||
|
for source_name, visible_name in source_to_visible.items():
|
||||||
|
self._entries[visible_name] = _SourceEntry(layer_index=layer_index, source_name=source_name)
|
||||||
|
if visible_name not in self._order:
|
||||||
|
self._order.append(visible_name)
|
||||||
|
|
||||||
|
return rename_map
|
||||||
|
|
||||||
|
def rename(
|
||||||
|
self,
|
||||||
|
old_name: str,
|
||||||
|
new_name: str,
|
||||||
|
move_references: bool = False,
|
||||||
|
) -> OverlayLibrary:
|
||||||
|
if old_name not in self._entries:
|
||||||
|
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||||
|
if old_name == new_name:
|
||||||
|
return self
|
||||||
|
if new_name in self._entries:
|
||||||
|
raise LibraryError(f'"{new_name}" already exists in the library.')
|
||||||
|
|
||||||
|
entry = self._entries.pop(old_name)
|
||||||
|
self._entries[new_name] = entry
|
||||||
|
if isinstance(entry, _SourceEntry):
|
||||||
|
layer = self._layers[entry.layer_index]
|
||||||
|
layer.source_to_visible[entry.source_name] = new_name
|
||||||
|
del layer.visible_to_source[old_name]
|
||||||
|
layer.visible_to_source[new_name] = entry.source_name
|
||||||
|
|
||||||
|
idx = self._order.index(old_name)
|
||||||
|
self._order[idx] = new_name
|
||||||
|
|
||||||
|
if move_references:
|
||||||
|
self.move_references(old_name, new_name)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _resolve_target(self, target: str) -> str:
|
||||||
|
seen: set[str] = set()
|
||||||
|
current = target
|
||||||
|
while current in self._target_remap:
|
||||||
|
if current in seen:
|
||||||
|
raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}')
|
||||||
|
seen.add(current)
|
||||||
|
current = self._target_remap[current]
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _set_target_remap(self, old_target: str, new_target: str) -> None:
|
||||||
|
resolved_new = self._resolve_target(new_target)
|
||||||
|
if resolved_new == old_target:
|
||||||
|
raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}')
|
||||||
|
self._target_remap[old_target] = resolved_new
|
||||||
|
for key in list(self._target_remap):
|
||||||
|
self._target_remap[key] = self._resolve_target(self._target_remap[key])
|
||||||
|
|
||||||
|
def move_references(self, old_target: str, new_target: str) -> OverlayLibrary:
|
||||||
|
if old_target == new_target:
|
||||||
|
return self
|
||||||
|
self._set_target_remap(old_target, new_target)
|
||||||
|
for entry in list(self._entries.values()):
|
||||||
|
if isinstance(entry, Pattern) and old_target in entry.refs:
|
||||||
|
entry.refs[new_target].extend(entry.refs[old_target])
|
||||||
|
del entry.refs[old_target]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _effective_target(self, layer: _SourceLayer, target: str) -> str:
|
||||||
|
visible = layer.source_to_visible.get(target, target)
|
||||||
|
return self._resolve_target(visible)
|
||||||
|
|
||||||
|
def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern:
|
||||||
|
if name not in self._entries:
|
||||||
|
raise KeyError(name)
|
||||||
|
entry = self._entries[name]
|
||||||
|
if isinstance(entry, Pattern):
|
||||||
|
return entry
|
||||||
|
|
||||||
|
layer = self._layers[entry.layer_index]
|
||||||
|
source_pat = layer.library[entry.source_name].deepcopy()
|
||||||
|
remap = lambda target: None if target is None else self._effective_target(layer, target)
|
||||||
|
pat = _remap_pattern_targets(source_pat, remap)
|
||||||
|
if persist:
|
||||||
|
self._entries[name] = pat
|
||||||
|
return pat
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
graph: dict[str, set[str]] = {}
|
||||||
|
for name in self._order:
|
||||||
|
if name not in self._entries:
|
||||||
|
continue
|
||||||
|
entry = self._entries[name]
|
||||||
|
if isinstance(entry, Pattern):
|
||||||
|
graph[name] = _pattern_children(entry)
|
||||||
|
continue
|
||||||
|
layer = self._layers[entry.layer_index]
|
||||||
|
children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())}
|
||||||
|
graph[name] = children
|
||||||
|
|
||||||
|
existing = set(graph)
|
||||||
|
dangling_refs = set().union(*(children - existing for children in graph.values()))
|
||||||
|
if dangling == 'error':
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph')
|
||||||
|
return graph
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {name: {child for child in children if child in existing} for name, children in graph.items()}
|
||||||
|
|
||||||
|
for child in dangling_refs:
|
||||||
|
graph.setdefault(cast('str', child), set())
|
||||||
|
return graph
|
||||||
|
|
||||||
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore')
|
||||||
|
existing = set(self.keys())
|
||||||
|
igraph: dict[str, set[str]] = {name: set() for name in child_graph}
|
||||||
|
for parent, children in child_graph.items():
|
||||||
|
for child in children:
|
||||||
|
if child in existing or dangling == 'include':
|
||||||
|
igraph.setdefault(child, set()).add(parent)
|
||||||
|
if dangling == 'error':
|
||||||
|
raw = self.child_graph(dangling='include')
|
||||||
|
dangling_refs = set().union(*(children - existing for children in raw.values()))
|
||||||
|
if dangling_refs:
|
||||||
|
raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph')
|
||||||
|
return igraph
|
||||||
|
|
||||||
|
def subtree(
|
||||||
|
self,
|
||||||
|
tops: str | Sequence[str],
|
||||||
|
) -> ILibraryView:
|
||||||
|
if isinstance(tops, str):
|
||||||
|
tops = (tops,)
|
||||||
|
keep = cast('set[str]', self.referenced_patterns(tops) - {None})
|
||||||
|
keep |= set(tops)
|
||||||
|
return LibraryView({name: self[name] for name in keep})
|
||||||
|
|
||||||
|
def find_refs_local(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, list[NDArray[numpy.float64]]]:
|
||||||
|
instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list)
|
||||||
|
if parent_graph is None:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return instances
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding local refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return instances
|
||||||
|
|
||||||
|
for parent in parent_graph.get(name, set()):
|
||||||
|
pat = self._materialize_pattern(parent, persist=False)
|
||||||
|
for ref in pat.refs.get(name, []):
|
||||||
|
instances[parent].append(ref.as_transforms())
|
||||||
|
return instances
|
||||||
|
|
||||||
|
def find_refs_global(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
order: list[str] | None = None,
|
||||||
|
parent_graph: dict[str, set[str]] | None = None,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[tuple[str, ...], NDArray[numpy.float64]]:
|
||||||
|
graph_mode = 'ignore' if dangling == 'ignore' else 'include'
|
||||||
|
if order is None:
|
||||||
|
order = self.child_order(dangling=graph_mode)
|
||||||
|
if parent_graph is None:
|
||||||
|
parent_graph = self.parent_graph(dangling=graph_mode)
|
||||||
|
|
||||||
|
if name not in self:
|
||||||
|
if name not in parent_graph:
|
||||||
|
return {}
|
||||||
|
if dangling == 'error':
|
||||||
|
raise self._dangling_refs_error({name}, f'finding global refs for {name!r}')
|
||||||
|
if dangling == 'ignore':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self_keys = set(self.keys())
|
||||||
|
transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]]
|
||||||
|
transforms = defaultdict(list)
|
||||||
|
for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items():
|
||||||
|
transforms[parent] = [((name,), numpy.concatenate(vals))]
|
||||||
|
|
||||||
|
for next_name in order:
|
||||||
|
if next_name not in transforms:
|
||||||
|
continue
|
||||||
|
if not parent_graph.get(next_name, set()) & self_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling)
|
||||||
|
inners = transforms.pop(next_name)
|
||||||
|
for parent, outer in outers.items():
|
||||||
|
outer_tf = numpy.concatenate(outer)
|
||||||
|
for path, inner in inners:
|
||||||
|
combined = apply_transforms(outer_tf, inner)
|
||||||
|
transforms[parent].append(((next_name,) + path, combined))
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for parent, targets in transforms.items():
|
||||||
|
for path, instances in targets:
|
||||||
|
result[(parent,) + path] = instances
|
||||||
|
return result
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return tuple(name for name in self._order if name in self._entries)
|
||||||
|
|
||||||
|
|
||||||
|
class BuiltOverlayLibrary(OverlayLibrary):
|
||||||
|
"""
|
||||||
|
Internal overlay output returned by `BuildLibrary.build(output='overlay')`.
|
||||||
|
|
||||||
|
The type is intentionally not part of the public API. It exists so build
|
||||||
|
outputs can carry a `build_report` while still behaving like an
|
||||||
|
`OverlayLibrary`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, build_report: Any | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.build_report = build_report
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_library_infos(library: Mapping[str, Pattern] | ILibraryView) -> Iterator[dict[str, Any]]:
|
||||||
|
info = getattr(library, 'library_info', None)
|
||||||
|
if isinstance(info, dict):
|
||||||
|
yield info
|
||||||
|
if isinstance(library, OverlayLibrary):
|
||||||
|
for layer in library._layers:
|
||||||
|
yield from _iter_library_infos(layer.library)
|
||||||
|
|
||||||
|
|
||||||
|
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(_iter_library_infos(library))
|
||||||
|
if infos:
|
||||||
|
unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos}
|
||||||
|
if len(unit_pairs) > 1:
|
||||||
|
raise LibraryError('Merged lazy GDS sources must have identical units before writing')
|
||||||
|
info = infos[0]
|
||||||
|
meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit
|
||||||
|
logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit
|
||||||
|
name = info['name'] if library_name is None else library_name
|
||||||
|
return meters, logical, name
|
||||||
|
|
||||||
|
if meters_per_unit is None or logical_units_per_unit is None or library_name is None:
|
||||||
|
raise LibraryError('meters_per_unit, logical_units_per_unit, and library_name are required for non-GDS-backed lazy writes')
|
||||||
|
return meters_per_unit, logical_units_per_unit, library_name
|
||||||
|
|
||||||
|
|
||||||
|
def _can_copy_raw_cell(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bool:
|
||||||
|
can_copy = getattr(library, 'can_copy_raw_struct', None)
|
||||||
|
if not callable(can_copy):
|
||||||
|
return False
|
||||||
|
return bool(can_copy(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_struct_bytes(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bytes:
|
||||||
|
reader = getattr(library, 'raw_struct_bytes', None)
|
||||||
|
if not callable(reader):
|
||||||
|
raise AttributeError('raw_struct_bytes')
|
||||||
|
return cast('bytes', reader(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _can_copy_overlay_cell(library: OverlayLibrary, name: str, entry: _SourceEntry) -> bool:
|
||||||
|
layer = library._layers[entry.layer_index]
|
||||||
|
if name != entry.source_name:
|
||||||
|
return False
|
||||||
|
if not _can_copy_raw_cell(layer.library, entry.source_name):
|
||||||
|
return False
|
||||||
|
children = layer.child_graph.get(entry.source_name, set())
|
||||||
|
return all(library._effective_target(layer, child) == child for child in children)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None:
|
||||||
|
elements: list[klamath.elements.Element] = []
|
||||||
|
elements += gdsii._shapes_to_elements(pat.shapes)
|
||||||
|
elements += gdsii._labels_to_texts(pat.labels)
|
||||||
|
elements += gdsii._mrefs_to_grefs(pat.refs)
|
||||||
|
klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements)
|
||||||
|
|
||||||
|
|
||||||
|
def write(
|
||||||
|
library: Mapping[str, Pattern] | ILibraryView,
|
||||||
|
stream: IO[bytes],
|
||||||
|
*,
|
||||||
|
meters_per_unit: float | None = None,
|
||||||
|
logical_units_per_unit: float | None = None,
|
||||||
|
library_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
meters_per_unit, logical_units_per_unit, library_name = _get_write_info(
|
||||||
|
library,
|
||||||
|
meters_per_unit=meters_per_unit,
|
||||||
|
logical_units_per_unit=logical_units_per_unit,
|
||||||
|
library_name=library_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
header = klamath.library.FileHeader(
|
||||||
|
name=library_name.encode('ASCII'),
|
||||||
|
user_units_per_db_unit=logical_units_per_unit,
|
||||||
|
meters_per_db_unit=meters_per_unit,
|
||||||
|
)
|
||||||
|
header.write(stream)
|
||||||
|
|
||||||
|
if isinstance(library, OverlayLibrary):
|
||||||
|
for name in library.source_order():
|
||||||
|
entry = library._entries[name]
|
||||||
|
if isinstance(entry, _SourceEntry) and _can_copy_overlay_cell(library, name, entry):
|
||||||
|
layer = library._layers[entry.layer_index]
|
||||||
|
stream.write(_raw_struct_bytes(layer.library, entry.source_name))
|
||||||
|
else:
|
||||||
|
_write_pattern_struct(stream, name, library._materialize_pattern(name, persist=False))
|
||||||
|
klamath.records.ENDLIB.write(stream, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(library, 'raw_struct_bytes'):
|
||||||
|
for name in library.source_order():
|
||||||
|
if _can_copy_raw_cell(library, name):
|
||||||
|
stream.write(_raw_struct_bytes(library, name))
|
||||||
|
else:
|
||||||
|
_write_pattern_struct(stream, name, _materialize_detached_pattern(cast('ILibraryView', library), name))
|
||||||
|
klamath.records.ENDLIB.write(stream, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
gdsii.write(cast('Mapping[str, Pattern]', library), stream, meters_per_unit, logical_units_per_unit, library_name)
|
||||||
|
|
||||||
|
|
||||||
|
def writefile(
|
||||||
|
library: Mapping[str, Pattern] | ILibraryView,
|
||||||
|
filename: str | pathlib.Path,
|
||||||
|
*,
|
||||||
|
meters_per_unit: float | None = None,
|
||||||
|
logical_units_per_unit: float | None = None,
|
||||||
|
library_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
path = pathlib.Path(filename)
|
||||||
|
|
||||||
|
with tmpfile(path) as base_stream:
|
||||||
|
streams: tuple[Any, ...] = (base_stream,)
|
||||||
|
if path.suffix == '.gz':
|
||||||
|
stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6))
|
||||||
|
streams = (stream,) + streams
|
||||||
|
else:
|
||||||
|
stream = base_stream
|
||||||
|
|
||||||
|
try:
|
||||||
|
write(
|
||||||
|
library,
|
||||||
|
stream,
|
||||||
|
meters_per_unit=meters_per_unit,
|
||||||
|
logical_units_per_unit=logical_units_per_unit,
|
||||||
|
library_name=library_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for ss in streams:
|
||||||
|
ss.close()
|
||||||
|
|
@ -14,7 +14,7 @@ Classes include:
|
||||||
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
|
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
|
||||||
library. Generated with `ILibraryView.abstract_view()`.
|
library. Generated with `ILibraryView.abstract_view()`.
|
||||||
"""
|
"""
|
||||||
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
|
from typing import Self, TYPE_CHECKING, Any, cast, TypeAlias, Protocol, Literal
|
||||||
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
|
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -22,12 +22,14 @@ import copy
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
from graphlib import TopologicalSorter, CycleError
|
from graphlib import TopologicalSorter, CycleError
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy.typing import ArrayLike, NDArray
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
|
||||||
from .error import LibraryError, PatternError
|
from .error import BuildError, LibraryError, PatternError
|
||||||
from .utils import layer_t, apply_transforms
|
from .utils import layer_t, apply_transforms
|
||||||
from .shapes import Shape, Polygon
|
from .shapes import Shape, Polygon
|
||||||
from .label import Label
|
from .label import Label
|
||||||
|
|
@ -40,6 +42,11 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ACTIVE_BUILD_SESSIONS: ContextVar[dict[int, '_BuildSessionLibrary'] | None] = ContextVar(
|
||||||
|
'masque_active_build_sessions',
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class visitor_function_t(Protocol):
|
class visitor_function_t(Protocol):
|
||||||
""" Signature for `Library.dfs()` visitor functions. """
|
""" Signature for `Library.dfs()` visitor functions. """
|
||||||
|
|
@ -62,6 +69,69 @@ Tree: TypeAlias = MutableMapping[str, 'Pattern']
|
||||||
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
|
||||||
""" How helpers should handle refs whose targets are not present in the library. """
|
""" How helpers should handle refs whose targets are not present in the library. """
|
||||||
|
|
||||||
|
emitted_via_t: TypeAlias = Literal['declaration', 'helper_write', 'tree_merge', 'source_import']
|
||||||
|
""" Build-provenance origin tags for emitted cells. """
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CellProvenance:
|
||||||
|
"""
|
||||||
|
Provenance record for one cell in a completed build output.
|
||||||
|
|
||||||
|
Each output name in a `BuildReport` maps to one `CellProvenance`. The
|
||||||
|
record captures both where the cell came from and how its visible name was
|
||||||
|
chosen.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
final_name: Name exposed by the completed library.
|
||||||
|
requested_name: First name requested for this cell during the build.
|
||||||
|
kind: Whether the cell came from a declaration, helper emission, or an
|
||||||
|
imported source library.
|
||||||
|
owner_declared_name: Declared cell responsible for this output cell, if
|
||||||
|
any. Imported source cells leave this as `None`.
|
||||||
|
emitted_via: High-level path by which the cell entered the output.
|
||||||
|
build_chain: Declared-cell dependency chain that was active when the
|
||||||
|
cell was emitted.
|
||||||
|
renamed_from: Original requested name when the final name differs.
|
||||||
|
source_name: Original on-source name for imported cells.
|
||||||
|
source_metadata: Optional source-library metadata copied through from
|
||||||
|
lazy GDS readers.
|
||||||
|
"""
|
||||||
|
final_name: str
|
||||||
|
requested_name: str
|
||||||
|
kind: Literal['declared', 'helper', 'source']
|
||||||
|
owner_declared_name: str | None
|
||||||
|
emitted_via: emitted_via_t
|
||||||
|
build_chain: tuple[str, ...]
|
||||||
|
renamed_from: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
source_metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuildReport:
|
||||||
|
"""
|
||||||
|
Immutable summary of one `BuildLibrary.validate()` or `.build()` run.
|
||||||
|
|
||||||
|
The report is designed to answer two questions after a build completes:
|
||||||
|
which declared cells depended on which other declared cells, and where each
|
||||||
|
output cell came from.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
requested_roots: Roots explicitly requested for the run. A full
|
||||||
|
`build()` uses all declared cells.
|
||||||
|
provenance: Mapping from final output name to provenance metadata.
|
||||||
|
owned_cells: Mapping from declared cell name to all final output cell
|
||||||
|
names it owns, including helper cells emitted while that declared
|
||||||
|
cell was building.
|
||||||
|
dependency_graph: Declared-cell dependency graph discovered through
|
||||||
|
library-mediated reads and explicit recipe hints.
|
||||||
|
"""
|
||||||
|
requested_roots: tuple[str, ...]
|
||||||
|
provenance: Mapping[str, CellProvenance]
|
||||||
|
owned_cells: Mapping[str, tuple[str, ...]]
|
||||||
|
dependency_graph: Mapping[str, frozenset[str]]
|
||||||
|
|
||||||
|
|
||||||
SINGLE_USE_PREFIX = '_'
|
SINGLE_USE_PREFIX = '_'
|
||||||
"""
|
"""
|
||||||
|
|
@ -131,6 +201,15 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
|
||||||
"""
|
"""
|
||||||
return Abstract(name=name, ports=self[name].ports)
|
return Abstract(name=name, ports=self[name].ports)
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
"""
|
||||||
|
Return names in the library's preferred source order.
|
||||||
|
|
||||||
|
Source-backed views may override this to preserve on-disk ordering
|
||||||
|
without materializing patterns.
|
||||||
|
"""
|
||||||
|
return tuple(self.keys())
|
||||||
|
|
||||||
def dangling_refs(
|
def dangling_refs(
|
||||||
self,
|
self,
|
||||||
tops: str | Sequence[str] | None = None,
|
tops: str | Sequence[str] | None = None,
|
||||||
|
|
@ -1388,6 +1467,819 @@ class Library(ILibrary):
|
||||||
return tree, pat
|
return tree, pat
|
||||||
|
|
||||||
|
|
||||||
|
class BuiltLibrary(Library):
|
||||||
|
"""
|
||||||
|
Eager library returned by `BuildLibrary.build(output='library')`.
|
||||||
|
|
||||||
|
This is a normal materialized `Library` with one additional attribute,
|
||||||
|
`build_report`, which records how the library was assembled from
|
||||||
|
declarations, helper emissions, and imported source-backed cells.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mapping: MutableMapping[str, 'Pattern'] | None = None,
|
||||||
|
*,
|
||||||
|
build_report: BuildReport | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(mapping=mapping)
|
||||||
|
self.build_report = build_report
|
||||||
|
|
||||||
|
|
||||||
|
class _CellFactory:
|
||||||
|
"""
|
||||||
|
Adapter that turns a plain pattern factory into a deferred recipe factory.
|
||||||
|
|
||||||
|
Calling the wrapper captures arguments and returns a `_BuildRecipe`
|
||||||
|
instead of executing the function immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, func: Callable[..., 'Pattern']) -> None:
|
||||||
|
self.func = func
|
||||||
|
self.__name__ = getattr(func, '__name__', type(self).__name__)
|
||||||
|
self.__doc__ = getattr(func, '__doc__')
|
||||||
|
|
||||||
|
def __call__(self, *args: Any, **kwargs: Any) -> '_BuildRecipe':
|
||||||
|
return _BuildRecipe(func=self.func, args=args, kwargs=kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _BuildRecipe:
|
||||||
|
""" Captured deferred call to a pattern factory. """
|
||||||
|
func: Callable[..., 'Pattern']
|
||||||
|
args: tuple[Any, ...]
|
||||||
|
kwargs: dict[str, Any]
|
||||||
|
explicit_dependencies: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def depends_on(self, *names: str) -> '_BuildRecipe':
|
||||||
|
self.explicit_dependencies += tuple(names)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PatternDeclaration:
|
||||||
|
""" Declared cell backed by an already-built `Pattern`. """
|
||||||
|
pattern: 'Pattern'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _RecipeDeclaration:
|
||||||
|
""" Declared cell backed by a deferred recipe. """
|
||||||
|
recipe: _BuildRecipe
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SourceDeclaration:
|
||||||
|
"""
|
||||||
|
Imported source-backed names registered with a `BuildLibrary`.
|
||||||
|
|
||||||
|
The declaration stores visible-name remapping plus pre-scanned graph
|
||||||
|
metadata. Underlying source cells stay lazy until a build session
|
||||||
|
materializes or copies them through.
|
||||||
|
"""
|
||||||
|
library: ILibraryView
|
||||||
|
source_to_visible: Mapping[str, str]
|
||||||
|
visible_to_source: Mapping[str, str]
|
||||||
|
child_graph: Mapping[str, set[str]]
|
||||||
|
order: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def cell(func: Callable[..., 'Pattern']) -> _CellFactory:
|
||||||
|
"""
|
||||||
|
Wrap a plain pattern factory so calls return deferred build recipes.
|
||||||
|
|
||||||
|
Use as either `cell(fn)(...)` or `@cell`.
|
||||||
|
"""
|
||||||
|
return _CellFactory(func)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildCellsView:
|
||||||
|
"""
|
||||||
|
Attribute-based declaration namespace for `BuildLibrary`.
|
||||||
|
|
||||||
|
This is the ergonomic authoring surface exposed as `builder.cells`. It is
|
||||||
|
intentionally write-focused: attribute assignment and deletion register
|
||||||
|
declarations, while attribute reads fail with guidance to build first and
|
||||||
|
use the returned library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, library: 'BuildLibrary') -> None:
|
||||||
|
object.__setattr__(self, '_library', library)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> 'Pattern':
|
||||||
|
raise BuildError(
|
||||||
|
f'BuildLibrary.cells.{name} is write-only during authoring. '
|
||||||
|
'Call build() and index the returned library instead.'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: 'Pattern | _BuildRecipe') -> None:
|
||||||
|
if name.startswith('_'):
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
return
|
||||||
|
self._library[name] = value
|
||||||
|
|
||||||
|
def __delattr__(self, name: str) -> None:
|
||||||
|
if name.startswith('_'):
|
||||||
|
raise AttributeError(name)
|
||||||
|
del self._library[name]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildLibrary(ILibrary):
|
||||||
|
"""
|
||||||
|
Two-phase declaration surface for mixed imported/generated libraries.
|
||||||
|
|
||||||
|
A `BuildLibrary` collects three kinds of inputs:
|
||||||
|
- direct declared `Pattern` objects
|
||||||
|
- deferred recipes created with `cell(...)`
|
||||||
|
- imported source-backed library views added with `add_source(...)`
|
||||||
|
|
||||||
|
The builder itself is not a normal readable library during authoring.
|
||||||
|
Instead, `validate()` and `build()` create a temporary build-session library
|
||||||
|
that recipes can read from and write helper cells into while dependencies
|
||||||
|
are resolved. `build()` then freezes the builder on success and returns a
|
||||||
|
normal library-like object carrying a `build_report`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, check_on_register: bool = False) -> None:
|
||||||
|
self.check_on_register = check_on_register
|
||||||
|
self.cells = BuildCellsView(self)
|
||||||
|
self.last_build_report: BuildReport | None = None
|
||||||
|
self._frozen = False
|
||||||
|
self._declarations: dict[str, _PatternDeclaration | _RecipeDeclaration] = {}
|
||||||
|
self._sources: list[_SourceDeclaration] = []
|
||||||
|
self._names: set[str] = set()
|
||||||
|
self._order: list[str] = []
|
||||||
|
|
||||||
|
def _active_session(self) -> '_BuildSessionLibrary | None':
|
||||||
|
sessions = _ACTIVE_BUILD_SESSIONS.get()
|
||||||
|
if sessions is None:
|
||||||
|
return None
|
||||||
|
return sessions.get(id(self))
|
||||||
|
|
||||||
|
def _require_active_session(self, operation: str) -> '_BuildSessionLibrary':
|
||||||
|
session = self._active_session()
|
||||||
|
if session is None:
|
||||||
|
raise BuildError(
|
||||||
|
f'BuildLibrary.{operation}() is only available while validate() or build() is running. '
|
||||||
|
'Use the built output library for reads.'
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _assert_editable(self) -> None:
|
||||||
|
if self._frozen:
|
||||||
|
raise BuildError('This BuildLibrary has already been built successfully and is now frozen.')
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return iter(session)
|
||||||
|
return iter(self._order)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return len(session)
|
||||||
|
return len(self._names)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return key in session
|
||||||
|
return key in self._names
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> 'Pattern':
|
||||||
|
return self._require_active_session('__getitem__')[key]
|
||||||
|
|
||||||
|
def __setitem__(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: 'Pattern | _BuildRecipe | Callable[[], Pattern]',
|
||||||
|
) -> None:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
session[key] = value
|
||||||
|
return
|
||||||
|
|
||||||
|
self._assert_editable()
|
||||||
|
if key in self._names:
|
||||||
|
raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!')
|
||||||
|
|
||||||
|
declaration: _PatternDeclaration | _RecipeDeclaration
|
||||||
|
if isinstance(value, _BuildRecipe):
|
||||||
|
declaration = _RecipeDeclaration(value)
|
||||||
|
else:
|
||||||
|
if callable(value):
|
||||||
|
raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.')
|
||||||
|
declaration = _PatternDeclaration(value)
|
||||||
|
|
||||||
|
self._declarations[key] = declaration
|
||||||
|
self._names.add(key)
|
||||||
|
self._order.append(key)
|
||||||
|
|
||||||
|
if self.check_on_register:
|
||||||
|
try:
|
||||||
|
self.validate(names=(key,))
|
||||||
|
except Exception:
|
||||||
|
del self._declarations[key]
|
||||||
|
self._names.remove(key)
|
||||||
|
self._order.remove(key)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
del session[key]
|
||||||
|
return
|
||||||
|
|
||||||
|
self._assert_editable()
|
||||||
|
if key not in self._declarations:
|
||||||
|
raise KeyError(key)
|
||||||
|
del self._declarations[key]
|
||||||
|
self._names.remove(key)
|
||||||
|
self._order.remove(key)
|
||||||
|
|
||||||
|
def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
session._merge(key_self, other, key_other)
|
||||||
|
return
|
||||||
|
self[key_self] = copy.deepcopy(other[key_other])
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
other: Mapping[str, 'Pattern'],
|
||||||
|
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
|
||||||
|
mutate_other: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
return session.add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||||
|
return super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||||
|
|
||||||
|
def rename(
|
||||||
|
self,
|
||||||
|
old_name: str,
|
||||||
|
new_name: str,
|
||||||
|
move_references: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
"""
|
||||||
|
Rename an imported source-backed visible name during authoring.
|
||||||
|
|
||||||
|
Only imported source-backed cells may be renamed on the builder itself.
|
||||||
|
Declared/generated cells must be registered under their intended final
|
||||||
|
names. `move_references=True` is intentionally unsupported here because
|
||||||
|
deferred recipes and declaration internals cannot be rewritten safely.
|
||||||
|
"""
|
||||||
|
session = self._active_session()
|
||||||
|
if session is not None:
|
||||||
|
session.rename(old_name, new_name, move_references=move_references)
|
||||||
|
return self
|
||||||
|
|
||||||
|
self._assert_editable()
|
||||||
|
if old_name == new_name:
|
||||||
|
return self
|
||||||
|
if old_name in self._declarations:
|
||||||
|
raise BuildError(
|
||||||
|
f'Cannot rename declared build cell "{old_name}" during authoring. '
|
||||||
|
'Register it under the intended final name instead.'
|
||||||
|
)
|
||||||
|
if old_name not in self._names:
|
||||||
|
raise LibraryError(f'"{old_name}" does not exist in the builder.')
|
||||||
|
if new_name in self._names:
|
||||||
|
raise LibraryError(f'"{new_name}" already exists in the builder.')
|
||||||
|
if move_references:
|
||||||
|
raise BuildError(
|
||||||
|
'BuildLibrary.rename(..., move_references=True) is not supported for imported source cells. '
|
||||||
|
'Builder-level renames only change the visible imported name.'
|
||||||
|
)
|
||||||
|
|
||||||
|
source_index = next(
|
||||||
|
(idx for idx, spec in enumerate(self._sources) if old_name in spec.visible_to_source),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if source_index is None:
|
||||||
|
raise BuildError(
|
||||||
|
f'Cannot rename "{old_name}" during authoring because only imported source-backed '
|
||||||
|
'cells may be renamed on a BuildLibrary.'
|
||||||
|
)
|
||||||
|
|
||||||
|
spec = self._sources[source_index]
|
||||||
|
source_name = spec.visible_to_source[old_name]
|
||||||
|
source_to_visible = dict(spec.source_to_visible)
|
||||||
|
visible_to_source = dict(spec.visible_to_source)
|
||||||
|
order = list(spec.order)
|
||||||
|
|
||||||
|
source_to_visible[source_name] = new_name
|
||||||
|
del visible_to_source[old_name]
|
||||||
|
visible_to_source[new_name] = source_name
|
||||||
|
order[order.index(old_name)] = new_name
|
||||||
|
|
||||||
|
self._sources[source_index] = replace(
|
||||||
|
spec,
|
||||||
|
source_to_visible=source_to_visible,
|
||||||
|
visible_to_source=visible_to_source,
|
||||||
|
order=tuple(order),
|
||||||
|
)
|
||||||
|
self._names.remove(old_name)
|
||||||
|
self._names.add(new_name)
|
||||||
|
self._order[self._order.index(old_name)] = new_name
|
||||||
|
return self
|
||||||
|
|
||||||
|
def abstract(self, name: str) -> Abstract:
|
||||||
|
return self._require_active_session('abstract').abstract(name)
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
other: 'Abstract | str | Pattern | TreeView',
|
||||||
|
append: bool = False,
|
||||||
|
) -> 'Abstract | Pattern':
|
||||||
|
return self._require_active_session('resolve').resolve(other, append=append)
|
||||||
|
|
||||||
|
def add_source(
|
||||||
|
self,
|
||||||
|
source: Mapping[str, 'Pattern'] | ILibraryView,
|
||||||
|
*,
|
||||||
|
rename_theirs: Callable[[ILibraryView, str], str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Register an imported source-backed library with the builder.
|
||||||
|
|
||||||
|
The source is not materialized immediately. Instead, its names and
|
||||||
|
child graph are scanned once and stored as an import declaration. The
|
||||||
|
source may be renamed on entry to avoid collisions with existing
|
||||||
|
declarations or other imported sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapping of `{source_name: visible_name}` for imported names that
|
||||||
|
were renamed while being added.
|
||||||
|
"""
|
||||||
|
self._assert_editable()
|
||||||
|
|
||||||
|
view = source if isinstance(source, ILibraryView) else LibraryView(source)
|
||||||
|
source_order = tuple(view.source_order())
|
||||||
|
child_graph = view.child_graph(dangling='include')
|
||||||
|
|
||||||
|
source_to_visible: dict[str, str] = {}
|
||||||
|
visible_to_source: dict[str, str] = {}
|
||||||
|
rename_map: dict[str, str] = {}
|
||||||
|
new_names: list[str] = []
|
||||||
|
|
||||||
|
for name in source_order:
|
||||||
|
visible = name
|
||||||
|
if visible in self._names or visible in visible_to_source:
|
||||||
|
if rename_theirs is None:
|
||||||
|
raise LibraryError(f'Conflicting name while adding source: {name!r}')
|
||||||
|
visible = rename_theirs(self, name)
|
||||||
|
if visible in self._names or visible in visible_to_source:
|
||||||
|
raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}')
|
||||||
|
rename_map[name] = visible
|
||||||
|
source_to_visible[name] = visible
|
||||||
|
visible_to_source[visible] = name
|
||||||
|
new_names.append(visible)
|
||||||
|
|
||||||
|
self._sources.append(_SourceDeclaration(
|
||||||
|
library=view,
|
||||||
|
source_to_visible=dict(source_to_visible),
|
||||||
|
visible_to_source=dict(visible_to_source),
|
||||||
|
child_graph={name: set(children) for name, children in child_graph.items()},
|
||||||
|
order=tuple(source_to_visible[name] for name in source_order),
|
||||||
|
))
|
||||||
|
for visible in new_names:
|
||||||
|
self._names.add(visible)
|
||||||
|
self._order.append(visible)
|
||||||
|
return rename_map
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
names: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
allow_dangling: bool = False,
|
||||||
|
) -> BuildReport:
|
||||||
|
"""
|
||||||
|
Run the full build logic and return a `BuildReport` without producing output.
|
||||||
|
|
||||||
|
This is a dry run over the same dependency resolution and recipe
|
||||||
|
execution path used by `build()`. Any generated library is discarded
|
||||||
|
after validation completes.
|
||||||
|
"""
|
||||||
|
report, _output = self._run_build(names=names, output='overlay', allow_dangling=allow_dangling, persist_output=False)
|
||||||
|
self.last_build_report = report
|
||||||
|
return report
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
output: Literal['overlay', 'library'] = 'overlay',
|
||||||
|
allow_dangling: bool = False,
|
||||||
|
) -> 'BuiltLibrary | ILibrary':
|
||||||
|
"""
|
||||||
|
Materialize declarations and return a usable output library.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output: `'overlay'` preserves imported source-backed cells where
|
||||||
|
possible, while `'library'` eagerly materializes the full
|
||||||
|
result.
|
||||||
|
allow_dangling: If `False`, fail the build when the completed
|
||||||
|
library still contains dangling references.
|
||||||
|
"""
|
||||||
|
self._assert_editable()
|
||||||
|
report, built_output = self._run_build(names=None, output=output, allow_dangling=allow_dangling, persist_output=True)
|
||||||
|
self._frozen = True
|
||||||
|
self.last_build_report = report
|
||||||
|
return built_output
|
||||||
|
|
||||||
|
def _run_build(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
names: Sequence[str] | None,
|
||||||
|
output: Literal['overlay', 'library'],
|
||||||
|
allow_dangling: bool,
|
||||||
|
persist_output: bool,
|
||||||
|
) -> tuple[BuildReport, BuiltLibrary | ILibrary | None]:
|
||||||
|
roots = tuple(dict.fromkeys(names if names is not None else self._declarations.keys()))
|
||||||
|
unknown = [name for name in roots if name not in self._names]
|
||||||
|
if unknown:
|
||||||
|
raise BuildError(f'Unknown build roots requested: {unknown}')
|
||||||
|
|
||||||
|
session = _BuildSessionLibrary(self)
|
||||||
|
sessions = dict(_ACTIVE_BUILD_SESSIONS.get() or {})
|
||||||
|
sessions[id(self)] = session
|
||||||
|
token = _ACTIVE_BUILD_SESSIONS.set(sessions)
|
||||||
|
try:
|
||||||
|
session.materialize_many(roots)
|
||||||
|
if not allow_dangling:
|
||||||
|
session.child_graph(dangling='error')
|
||||||
|
if output == 'library':
|
||||||
|
built_output = session.to_library() if persist_output else None
|
||||||
|
elif persist_output:
|
||||||
|
built_output = session.to_overlay()
|
||||||
|
else:
|
||||||
|
built_output = None
|
||||||
|
finally:
|
||||||
|
_ACTIVE_BUILD_SESSIONS.reset(token)
|
||||||
|
|
||||||
|
report = session.build_report(roots)
|
||||||
|
if built_output is not None:
|
||||||
|
built_output.build_report = report
|
||||||
|
return report, built_output
|
||||||
|
|
||||||
|
|
||||||
|
class _BuildSessionLibrary(ILibrary):
|
||||||
|
"""
|
||||||
|
Internal overlay-backed library used while a `BuildLibrary` is executing.
|
||||||
|
|
||||||
|
This object provides the mutable-library surface that recipes expect while
|
||||||
|
also tracking declared-cell dependencies, helper-cell provenance, and
|
||||||
|
imported source cells. It exists only for the duration of a validation or
|
||||||
|
build run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, builder: BuildLibrary) -> None:
|
||||||
|
from .file.gdsii_lazy_core import BuiltOverlayLibrary, _SourceEntry, _SourceLayer # noqa: PLC0415
|
||||||
|
|
||||||
|
self._builder = builder
|
||||||
|
self._overlay = BuiltOverlayLibrary()
|
||||||
|
self._source_entry_type = _SourceEntry
|
||||||
|
self._source_layer_type = _SourceLayer
|
||||||
|
self._states: dict[str, Literal['unbuilt', 'building', 'built']] = {
|
||||||
|
name: 'unbuilt' for name in builder._declarations
|
||||||
|
}
|
||||||
|
self._declared_stack: list[str] = []
|
||||||
|
self._emission_stack: list[str] = []
|
||||||
|
self._emission_via_stack: list[emitted_via_t] = []
|
||||||
|
self._names = set(builder._names)
|
||||||
|
self._order = list(builder._order)
|
||||||
|
self._provenance: dict[str, CellProvenance] = {}
|
||||||
|
self._owned_cells: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set)
|
||||||
|
self._install_sources()
|
||||||
|
|
||||||
|
def _install_sources(self) -> None:
|
||||||
|
for spec in self._builder._sources:
|
||||||
|
layer = self._source_layer_type(
|
||||||
|
library=spec.library,
|
||||||
|
source_to_visible=dict(spec.source_to_visible),
|
||||||
|
visible_to_source=dict(spec.visible_to_source),
|
||||||
|
child_graph={name: set(children) for name, children in spec.child_graph.items()},
|
||||||
|
order=list(spec.order),
|
||||||
|
)
|
||||||
|
layer_index = len(self._overlay._layers)
|
||||||
|
self._overlay._layers.append(layer)
|
||||||
|
source_info = getattr(spec.library, 'library_info', None)
|
||||||
|
source_meta = dict(source_info) if isinstance(source_info, dict) else None
|
||||||
|
|
||||||
|
for source_name, visible_name in spec.source_to_visible.items():
|
||||||
|
self._overlay._entries[visible_name] = self._source_entry_type(
|
||||||
|
layer_index=layer_index,
|
||||||
|
source_name=source_name,
|
||||||
|
)
|
||||||
|
if visible_name not in self._overlay._order:
|
||||||
|
self._overlay._order.append(visible_name)
|
||||||
|
self._provenance[visible_name] = CellProvenance(
|
||||||
|
final_name=visible_name,
|
||||||
|
requested_name=source_name,
|
||||||
|
kind='source',
|
||||||
|
owner_declared_name=None,
|
||||||
|
emitted_via='source_import',
|
||||||
|
build_chain=(),
|
||||||
|
renamed_from=source_name if visible_name != source_name else None,
|
||||||
|
source_name=source_name,
|
||||||
|
source_metadata=source_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
return (name for name in self._order if name in self._names)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._names)
|
||||||
|
|
||||||
|
def __contains__(self, key: object) -> bool:
|
||||||
|
return key in self._names or key in self._overlay
|
||||||
|
|
||||||
|
def _touch_name(self, key: str) -> None:
|
||||||
|
if key not in self._names:
|
||||||
|
self._names.add(key)
|
||||||
|
self._order.append(key)
|
||||||
|
|
||||||
|
def _current_declared(self) -> str | None:
|
||||||
|
if not self._declared_stack:
|
||||||
|
return None
|
||||||
|
return self._declared_stack[-1]
|
||||||
|
|
||||||
|
def _record_dependency(self, target: str) -> None:
|
||||||
|
current = self._current_declared()
|
||||||
|
if current is None or current == target or target not in self._builder._declarations:
|
||||||
|
return
|
||||||
|
self._dependency_graph[current].add(target)
|
||||||
|
|
||||||
|
def _guard_mutable_output_name(self, key: str, *, operation: str) -> None:
|
||||||
|
if key in self._builder._declarations:
|
||||||
|
raise BuildError(f'Cannot {operation} declared build cell "{key}" during an active build session.')
|
||||||
|
|
||||||
|
provenance = self._provenance.get(key)
|
||||||
|
if provenance is not None and provenance.kind == 'source':
|
||||||
|
raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.')
|
||||||
|
|
||||||
|
def _remove_owned_cell(self, owner: str | None, name: str) -> None:
|
||||||
|
if owner is None or owner not in self._owned_cells:
|
||||||
|
return
|
||||||
|
cells = self._owned_cells[owner]
|
||||||
|
self._owned_cells[owner] = [cell for cell in cells if cell != name]
|
||||||
|
if not self._owned_cells[owner]:
|
||||||
|
del self._owned_cells[owner]
|
||||||
|
|
||||||
|
def rename(
|
||||||
|
self,
|
||||||
|
old_name: str,
|
||||||
|
new_name: str,
|
||||||
|
move_references: bool = False,
|
||||||
|
) -> Self:
|
||||||
|
if old_name == new_name:
|
||||||
|
return self
|
||||||
|
if old_name not in self._overlay:
|
||||||
|
if old_name in self._builder._declarations:
|
||||||
|
self._guard_mutable_output_name(old_name, operation='rename')
|
||||||
|
raise LibraryError(f'"{old_name}" does not exist in the library.')
|
||||||
|
|
||||||
|
self._guard_mutable_output_name(old_name, operation='rename')
|
||||||
|
if new_name in self._names:
|
||||||
|
raise LibraryError(f'"{new_name}" already exists in the library.')
|
||||||
|
|
||||||
|
self._overlay.rename(old_name, new_name, move_references=move_references)
|
||||||
|
self._names.discard(old_name)
|
||||||
|
self._names.add(new_name)
|
||||||
|
if old_name in self._order:
|
||||||
|
idx = self._order.index(old_name)
|
||||||
|
self._order[idx] = new_name
|
||||||
|
|
||||||
|
provenance = self._provenance.pop(old_name)
|
||||||
|
requested_name = provenance.requested_name
|
||||||
|
self._provenance[new_name] = replace(
|
||||||
|
provenance,
|
||||||
|
final_name=new_name,
|
||||||
|
renamed_from=requested_name if new_name != requested_name else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = provenance.owner_declared_name
|
||||||
|
if owner is not None and owner in self._owned_cells:
|
||||||
|
self._owned_cells[owner] = [
|
||||||
|
new_name if cell_name == old_name else cell_name
|
||||||
|
for cell_name in self._owned_cells[owner]
|
||||||
|
]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> 'Pattern':
|
||||||
|
if key in self._builder._declarations:
|
||||||
|
self._record_dependency(key)
|
||||||
|
self._ensure_declared(key)
|
||||||
|
return self._overlay[key]
|
||||||
|
|
||||||
|
def __setitem__(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: 'Pattern | Callable[[], Pattern]',
|
||||||
|
) -> None:
|
||||||
|
if key in self._overlay:
|
||||||
|
raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!')
|
||||||
|
current = self._current_declared()
|
||||||
|
if key in self._builder._declarations and key != current:
|
||||||
|
raise LibraryError(f'"{key}" is reserved for a declared cell and cannot be used as a helper name.')
|
||||||
|
|
||||||
|
pattern = value() if callable(value) else value
|
||||||
|
self._overlay[key] = pattern
|
||||||
|
self._touch_name(key)
|
||||||
|
|
||||||
|
kind: Literal['declared', 'helper']
|
||||||
|
via = self._emission_via_stack[-1] if self._emission_via_stack else 'helper_write'
|
||||||
|
if current is not None and key == current:
|
||||||
|
kind = 'declared'
|
||||||
|
via = 'declaration'
|
||||||
|
else:
|
||||||
|
kind = 'helper'
|
||||||
|
if not self._emission_via_stack:
|
||||||
|
via = 'helper_write'
|
||||||
|
|
||||||
|
self._emission_stack.append(key)
|
||||||
|
try:
|
||||||
|
self._record_provenance(
|
||||||
|
final_name=key,
|
||||||
|
requested_name=key,
|
||||||
|
kind=kind,
|
||||||
|
owner_declared_name=current if kind == 'helper' else key,
|
||||||
|
emitted_via=via,
|
||||||
|
build_chain=tuple(self._declared_stack),
|
||||||
|
renamed_from=None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._emission_stack.pop()
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
if key not in self._overlay:
|
||||||
|
if key in self._builder._declarations:
|
||||||
|
self._guard_mutable_output_name(key, operation='delete')
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
self._guard_mutable_output_name(key, operation='delete')
|
||||||
|
provenance = self._provenance.get(key)
|
||||||
|
if key in self._overlay:
|
||||||
|
del self._overlay[key]
|
||||||
|
self._names.discard(key)
|
||||||
|
if key in self._order:
|
||||||
|
self._order.remove(key)
|
||||||
|
self._provenance.pop(key, None)
|
||||||
|
if provenance is not None:
|
||||||
|
self._remove_owned_cell(provenance.owner_declared_name, 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(
|
||||||
|
self,
|
||||||
|
other: Mapping[str, 'Pattern'],
|
||||||
|
rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns,
|
||||||
|
mutate_other: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
self._emission_via_stack.append('tree_merge')
|
||||||
|
try:
|
||||||
|
rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other)
|
||||||
|
finally:
|
||||||
|
self._emission_via_stack.pop()
|
||||||
|
|
||||||
|
current = self._current_declared()
|
||||||
|
for old_name, new_name in rename_map.items():
|
||||||
|
if new_name in self._provenance:
|
||||||
|
self._provenance[new_name] = replace(
|
||||||
|
self._provenance[new_name],
|
||||||
|
requested_name=old_name,
|
||||||
|
renamed_from=old_name,
|
||||||
|
owner_declared_name=current if current is not None else self._provenance[new_name].owner_declared_name,
|
||||||
|
)
|
||||||
|
return rename_map
|
||||||
|
|
||||||
|
def _record_provenance(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
final_name: str,
|
||||||
|
requested_name: str,
|
||||||
|
kind: Literal['declared', 'helper'],
|
||||||
|
owner_declared_name: str | None,
|
||||||
|
emitted_via: emitted_via_t,
|
||||||
|
build_chain: tuple[str, ...],
|
||||||
|
renamed_from: str | None,
|
||||||
|
) -> None:
|
||||||
|
self._provenance[final_name] = CellProvenance(
|
||||||
|
final_name=final_name,
|
||||||
|
requested_name=requested_name,
|
||||||
|
kind=kind,
|
||||||
|
owner_declared_name=owner_declared_name,
|
||||||
|
emitted_via=emitted_via,
|
||||||
|
build_chain=build_chain,
|
||||||
|
renamed_from=renamed_from,
|
||||||
|
)
|
||||||
|
if owner_declared_name is not None and final_name not in self._owned_cells[owner_declared_name]:
|
||||||
|
self._owned_cells[owner_declared_name].append(final_name)
|
||||||
|
|
||||||
|
def _wrap_error(self, name: str, exc: Exception) -> BuildError:
|
||||||
|
helper = self._emission_stack[-1] if self._emission_stack else None
|
||||||
|
chain = tuple(self._declared_stack)
|
||||||
|
msg = [f'Failed while building declared cell "{name}"']
|
||||||
|
if helper is not None and helper != name:
|
||||||
|
msg.append(f'while materializing helper/output "{helper}"')
|
||||||
|
if chain:
|
||||||
|
msg.append(f'Dependency chain: {" -> ".join(chain)}')
|
||||||
|
msg.append(f'Cause: {exc}')
|
||||||
|
return BuildError('\n'.join(msg))
|
||||||
|
|
||||||
|
def _ensure_named(self, name: str) -> None:
|
||||||
|
if name in self._builder._declarations:
|
||||||
|
self._record_dependency(name)
|
||||||
|
self._ensure_declared(name)
|
||||||
|
return
|
||||||
|
if name in self._overlay:
|
||||||
|
return
|
||||||
|
raise BuildError(f'Missing dependency "{name}"')
|
||||||
|
|
||||||
|
def _ensure_declared(self, name: str) -> None:
|
||||||
|
from .pattern import Pattern # noqa: PLC0415
|
||||||
|
|
||||||
|
state = self._states[name]
|
||||||
|
if state == 'built':
|
||||||
|
return
|
||||||
|
if state == 'building':
|
||||||
|
chain = ' -> '.join(self._declared_stack + [name])
|
||||||
|
raise BuildError(f'Cycle detected while building declared cells: {chain}')
|
||||||
|
|
||||||
|
declaration = self._builder._declarations[name]
|
||||||
|
self._states[name] = 'building'
|
||||||
|
self._declared_stack.append(name)
|
||||||
|
try:
|
||||||
|
if isinstance(declaration, _PatternDeclaration):
|
||||||
|
pattern = declaration.pattern.deepcopy()
|
||||||
|
else:
|
||||||
|
for dep in declaration.recipe.explicit_dependencies:
|
||||||
|
self._ensure_named(dep)
|
||||||
|
pattern = declaration.recipe.func(*declaration.recipe.args, **declaration.recipe.kwargs)
|
||||||
|
if not isinstance(pattern, Pattern):
|
||||||
|
raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern')
|
||||||
|
|
||||||
|
if name in self._overlay:
|
||||||
|
if self._overlay[name] is not pattern:
|
||||||
|
raise BuildError(
|
||||||
|
f'Recipe for "{name}" wrote a different pattern into the session under its own name.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self[name] = pattern
|
||||||
|
self._states[name] = 'built'
|
||||||
|
except Exception as exc:
|
||||||
|
self._states[name] = 'unbuilt'
|
||||||
|
raise self._wrap_error(name, exc) from exc
|
||||||
|
finally:
|
||||||
|
self._declared_stack.pop()
|
||||||
|
|
||||||
|
def materialize_many(self, names: Sequence[str]) -> None:
|
||||||
|
for name in dict.fromkeys(names):
|
||||||
|
self._ensure_named(name)
|
||||||
|
|
||||||
|
def source_order(self) -> tuple[str, ...]:
|
||||||
|
return self._overlay.source_order()
|
||||||
|
|
||||||
|
def child_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._overlay.child_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def parent_graph(
|
||||||
|
self,
|
||||||
|
dangling: dangling_mode_t = 'error',
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
return self._overlay.parent_graph(dangling=dangling)
|
||||||
|
|
||||||
|
def build_report(self, requested_roots: Sequence[str]) -> BuildReport:
|
||||||
|
dependency_graph = {
|
||||||
|
name: frozenset(self._dependency_graph.get(name, set()))
|
||||||
|
for name in self._builder._declarations
|
||||||
|
if name in self._dependency_graph or name in requested_roots
|
||||||
|
}
|
||||||
|
owned_cells = {
|
||||||
|
name: tuple(cells)
|
||||||
|
for name, cells in self._owned_cells.items()
|
||||||
|
}
|
||||||
|
return BuildReport(
|
||||||
|
requested_roots=tuple(dict.fromkeys(requested_roots)),
|
||||||
|
provenance=dict(self._provenance),
|
||||||
|
owned_cells=owned_cells,
|
||||||
|
dependency_graph=dependency_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_overlay(self) -> ILibrary:
|
||||||
|
return self._overlay
|
||||||
|
|
||||||
|
def to_library(self) -> BuiltLibrary:
|
||||||
|
mapping = {name: self._overlay[name] for name in self._overlay.source_order()}
|
||||||
|
return BuiltLibrary(mapping)
|
||||||
|
|
||||||
|
|
||||||
class LazyLibrary(ILibrary):
|
class LazyLibrary(ILibrary):
|
||||||
"""
|
"""
|
||||||
This class is usually used to create a library of Patterns by mapping names to
|
This class is usually used to create a library of Patterns by mapping names to
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
import copy
|
import copy
|
||||||
import functools
|
import functools
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
@ -13,18 +14,37 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
|
||||||
from ..traits import PositionableImpl
|
from ..traits import PositionableImpl
|
||||||
|
|
||||||
|
|
||||||
|
@functools.total_ordering
|
||||||
|
class ArcAngleRef(Enum):
|
||||||
|
Center = 'center'
|
||||||
|
FocusPos = 'focus_pos'
|
||||||
|
FocusNeg = 'focus_neg'
|
||||||
|
|
||||||
|
def __lt__(self, other: Any) -> bool:
|
||||||
|
if self.__class__ is not other.__class__:
|
||||||
|
return self.__class__.__name__ < other.__class__.__name__
|
||||||
|
order = {
|
||||||
|
ArcAngleRef.Center: 0,
|
||||||
|
ArcAngleRef.FocusPos: 1,
|
||||||
|
ArcAngleRef.FocusNeg: 2,
|
||||||
|
}
|
||||||
|
return order[self] < order[other]
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Arc(PositionableImpl, Shape):
|
class Arc(PositionableImpl, Shape):
|
||||||
"""
|
"""
|
||||||
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
|
An elliptical arc, formed by cutting off an elliptical ring with two rays.
|
||||||
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
|
By default the rays exit from its center, but they can optionally exit from one of the
|
||||||
|
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
|
||||||
|
a rotation, and a width.
|
||||||
|
|
||||||
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
||||||
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
||||||
The start and stop angle are measured counterclockwise from the first (x) radius.
|
The start and stop angle are measured counterclockwise from the first (x) radius.
|
||||||
"""
|
"""
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'_radii', '_angles', '_width', '_rotation',
|
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
|
||||||
# Inherited
|
# Inherited
|
||||||
'_offset', '_repetition', '_annotations',
|
'_offset', '_repetition', '_annotations',
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +61,11 @@ class Arc(PositionableImpl, Shape):
|
||||||
_width: float
|
_width: float
|
||||||
""" Width of the arc """
|
""" Width of the arc """
|
||||||
|
|
||||||
|
_angle_ref: ArcAngleRef
|
||||||
|
""" Origin used by start/stop rays """
|
||||||
|
|
||||||
|
AngleRef = ArcAngleRef
|
||||||
|
|
||||||
# radius properties
|
# radius properties
|
||||||
@property
|
@property
|
||||||
def radii(self) -> NDArray[numpy.float64]:
|
def radii(self) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -113,6 +138,18 @@ class Arc(PositionableImpl, Shape):
|
||||||
def stop_angle(self, val: float) -> None:
|
def stop_angle(self, val: float) -> None:
|
||||||
self.angles = (self.angles[0], val)
|
self.angles = (self.angles[0], val)
|
||||||
|
|
||||||
|
# Angle reference property
|
||||||
|
@property
|
||||||
|
def angle_ref(self) -> ArcAngleRef:
|
||||||
|
"""
|
||||||
|
Origin used to interpret start and stop angle rays.
|
||||||
|
"""
|
||||||
|
return self._angle_ref
|
||||||
|
|
||||||
|
@angle_ref.setter
|
||||||
|
def angle_ref(self, val: ArcAngleRef | str) -> None:
|
||||||
|
self._angle_ref = ArcAngleRef(val)
|
||||||
|
|
||||||
# Rotation property
|
# Rotation property
|
||||||
@property
|
@property
|
||||||
def rotation(self) -> float:
|
def rotation(self) -> float:
|
||||||
|
|
@ -159,12 +196,14 @@ class Arc(PositionableImpl, Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
|
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.radii = radii
|
self.radii = radii
|
||||||
self.angles = angles
|
self.angles = angles
|
||||||
self.width = width
|
self.width = width
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
|
self.angle_ref = angle_ref
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
||||||
|
|
@ -186,6 +225,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
new._width = width
|
new._width = width
|
||||||
new._offset = offset
|
new._offset = offset
|
||||||
new._rotation = rotation % (2 * pi)
|
new._rotation = rotation % (2 * pi)
|
||||||
|
new._angle_ref = ArcAngleRef(angle_ref)
|
||||||
new._repetition = repetition
|
new._repetition = repetition
|
||||||
new._annotations = annotations
|
new._annotations = annotations
|
||||||
return new
|
return new
|
||||||
|
|
@ -208,6 +248,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
and numpy.array_equal(self.angles, other.angles)
|
and numpy.array_equal(self.angles, other.angles)
|
||||||
and self.width == other.width
|
and self.width == other.width
|
||||||
and self.rotation == other.rotation
|
and self.rotation == other.rotation
|
||||||
|
and self.angle_ref == other.angle_ref
|
||||||
and self.repetition == other.repetition
|
and self.repetition == other.repetition
|
||||||
and annotations_eq(self.annotations, other.annotations)
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
)
|
)
|
||||||
|
|
@ -224,6 +265,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
return tuple(self.radii) < tuple(other.radii)
|
return tuple(self.radii) < tuple(other.radii)
|
||||||
if not numpy.array_equal(self.angles, other.angles):
|
if not numpy.array_equal(self.angles, other.angles):
|
||||||
return tuple(self.angles) < tuple(other.angles)
|
return tuple(self.angles) < tuple(other.angles)
|
||||||
|
if self.angle_ref != other.angle_ref:
|
||||||
|
return self.angle_ref < other.angle_ref
|
||||||
if not numpy.array_equal(self.offset, other.offset):
|
if not numpy.array_equal(self.offset, other.offset):
|
||||||
return tuple(self.offset) < tuple(other.offset)
|
return tuple(self.offset) < tuple(other.offset)
|
||||||
if self.rotation != other.rotation:
|
if self.rotation != other.rotation:
|
||||||
|
|
@ -379,6 +422,11 @@ class Arc(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Arc':
|
def mirror(self, axis: int = 0) -> 'Arc':
|
||||||
|
if self.angle_ref != ArcAngleRef.Center:
|
||||||
|
x_major = self.radius_x > self.radius_y
|
||||||
|
y_major = self.radius_y > self.radius_x
|
||||||
|
if (axis == 0 and y_major) or (axis == 1 and x_major):
|
||||||
|
self._swap_focus_ref()
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
self.rotation += axis * pi
|
self.rotation += axis * pi
|
||||||
self.angles *= -1
|
self.angles *= -1
|
||||||
|
|
@ -390,6 +438,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
angle_ref = self.angle_ref
|
||||||
if self.radius_x < self.radius_y:
|
if self.radius_x < self.radius_y:
|
||||||
radii = self.radii / self.radius_x
|
radii = self.radii / self.radius_x
|
||||||
scale = self.radius_x
|
scale = self.radius_x
|
||||||
|
|
@ -400,23 +449,26 @@ class Arc(PositionableImpl, Shape):
|
||||||
scale = self.radius_y
|
scale = self.radius_y
|
||||||
rotation = self.rotation + pi / 2
|
rotation = self.rotation + pi / 2
|
||||||
angles = self.angles - pi / 2
|
angles = self.angles - pi / 2
|
||||||
|
angle_ref = _swapped_focus_ref(angle_ref)
|
||||||
|
|
||||||
delta_angle = angles[1] - angles[0]
|
delta_angle = angles[1] - angles[0]
|
||||||
start_angle = angles[0] % (2 * pi)
|
start_angle = angles[0] % (2 * pi)
|
||||||
if start_angle >= pi:
|
if start_angle >= pi:
|
||||||
start_angle -= pi
|
start_angle -= pi
|
||||||
rotation += pi
|
rotation += pi
|
||||||
|
angle_ref = _swapped_focus_ref(angle_ref)
|
||||||
|
|
||||||
norm_angles = (start_angle, start_angle + delta_angle)
|
norm_angles = (start_angle, start_angle + delta_angle)
|
||||||
rotation %= 2 * pi
|
rotation %= 2 * pi
|
||||||
width = self.width
|
width = self.width
|
||||||
|
|
||||||
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value),
|
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
|
||||||
(self.offset, scale / norm_value, rotation, False),
|
(self.offset, scale / norm_value, rotation, False),
|
||||||
lambda: Arc(
|
lambda: Arc(
|
||||||
radii=radii * norm_value,
|
radii=radii * norm_value,
|
||||||
angles=norm_angles,
|
angles=norm_angles,
|
||||||
width=width * norm_value,
|
width=width * norm_value,
|
||||||
|
angle_ref=angle_ref,
|
||||||
))
|
))
|
||||||
|
|
||||||
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -427,27 +479,16 @@ class Arc(PositionableImpl, Shape):
|
||||||
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
a_ranges = self._angles_to_parameters()
|
||||||
|
|
||||||
mins = []
|
cuts = []
|
||||||
maxs = []
|
for index in range(2):
|
||||||
|
edge = []
|
||||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||||
wh = sgn * self.width / 2
|
wh = sgn * self.width / 2
|
||||||
rx = self.radius_x + wh
|
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
|
||||||
ry = self.radius_y + wh
|
cuts.append(edge)
|
||||||
|
return numpy.array(cuts) + self.offset
|
||||||
sin_r = numpy.sin(self.rotation)
|
|
||||||
cos_r = numpy.cos(self.rotation)
|
|
||||||
sin_a = numpy.sin(aa)
|
|
||||||
cos_a = numpy.cos(aa)
|
|
||||||
|
|
||||||
# arc endpoints
|
|
||||||
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
|
|
||||||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
|
||||||
|
|
||||||
mins.append([xn, yn])
|
|
||||||
maxs.append([xp, yp])
|
|
||||||
return numpy.array([mins, maxs]) + self.offset
|
|
||||||
|
|
||||||
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -468,7 +509,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
rx = self.radius_x + wh
|
rx = self.radius_x + wh
|
||||||
ry = self.radius_y + wh
|
ry = self.radius_y + wh
|
||||||
|
|
||||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
|
||||||
sign = numpy.sign(d_angle)
|
sign = numpy.sign(d_angle)
|
||||||
if sign != numpy.sign(a1 - a0):
|
if sign != numpy.sign(a1 - a0):
|
||||||
a1 += sign * 2 * pi
|
a1 += sign * 2 * pi
|
||||||
|
|
@ -476,9 +517,93 @@ class Arc(PositionableImpl, Shape):
|
||||||
aa.append((a0, a1))
|
aa.append((a0, a1))
|
||||||
return numpy.array(aa, dtype=float)
|
return numpy.array(aa, dtype=float)
|
||||||
|
|
||||||
|
def _angle_to_parameter(self, angle: float, rx: float, ry: float) -> float:
|
||||||
|
"""
|
||||||
|
Convert an angle-reference ray to the ellipse parameter for one boundary edge.
|
||||||
|
|
||||||
|
Center-referenced arcs convert the ray angle from polar coordinates about the origin.
|
||||||
|
Focus-referenced arcs solve the forward ray/ellipse intersection from the selected
|
||||||
|
nominal focus and return the parameter `t` for `[rx*cos(t), ry*sin(t)]`.
|
||||||
|
"""
|
||||||
|
if self.angle_ref == ArcAngleRef.Center:
|
||||||
|
return numpy.arctan2(rx * numpy.sin(angle), ry * numpy.cos(angle))
|
||||||
|
|
||||||
|
focus = self._focus_point()
|
||||||
|
if rx <= 0 or ry <= 0:
|
||||||
|
raise PatternError('Focus-referenced arc boundary radii must be positive')
|
||||||
|
|
||||||
|
fx, fy = focus
|
||||||
|
origin_position = fx * fx / (rx * rx) + fy * fy / (ry * ry)
|
||||||
|
if origin_position >= 1:
|
||||||
|
raise PatternError('Focus-referenced arc ray origin must be inside both arc boundary ellipses')
|
||||||
|
|
||||||
|
dx = numpy.cos(angle)
|
||||||
|
dy = numpy.sin(angle)
|
||||||
|
aa = dx * dx / (rx * rx) + dy * dy / (ry * ry)
|
||||||
|
bb = 2 * (fx * dx / (rx * rx) + fy * dy / (ry * ry))
|
||||||
|
cc = origin_position - 1
|
||||||
|
determinant = bb * bb - 4 * aa * cc
|
||||||
|
if determinant < 0:
|
||||||
|
raise PatternError('Focus-referenced arc ray does not intersect boundary ellipse')
|
||||||
|
|
||||||
|
roots = numpy.array((
|
||||||
|
(-bb - numpy.sqrt(determinant)) / (2 * aa),
|
||||||
|
(-bb + numpy.sqrt(determinant)) / (2 * aa),
|
||||||
|
))
|
||||||
|
positive_roots = roots[roots > 0]
|
||||||
|
if positive_roots.size != 1:
|
||||||
|
raise PatternError('Focus-referenced arc ray must have exactly one forward boundary intersection')
|
||||||
|
|
||||||
|
point = focus + positive_roots[0] * numpy.array((dx, dy))
|
||||||
|
return numpy.arctan2(point[1] / ry, point[0] / rx)
|
||||||
|
|
||||||
|
def _focus_point(self) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Return the selected nominal focus in the arc's unrotated local coordinates.
|
||||||
|
|
||||||
|
`FocusPos` and `FocusNeg` select opposite directions along the major axis. Circles
|
||||||
|
have coincident foci, so both focus modes intentionally collapse to the center.
|
||||||
|
"""
|
||||||
|
if self.angle_ref == ArcAngleRef.Center or self.radius_x == self.radius_y:
|
||||||
|
return numpy.zeros(2)
|
||||||
|
|
||||||
|
sign = 1 if self.angle_ref == ArcAngleRef.FocusPos else -1
|
||||||
|
if self.radius_x > self.radius_y:
|
||||||
|
return numpy.array((sign * numpy.sqrt(self.radius_x * self.radius_x - self.radius_y * self.radius_y), 0.0))
|
||||||
|
return numpy.array((0.0, sign * numpy.sqrt(self.radius_y * self.radius_y - self.radius_x * self.radius_x)))
|
||||||
|
|
||||||
|
def _point_on_edge(self, rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Return a rotated local-space point on a boundary ellipse, before applying offset.
|
||||||
|
"""
|
||||||
|
sin_r = numpy.sin(self.rotation)
|
||||||
|
cos_r = numpy.cos(self.rotation)
|
||||||
|
return numpy.array((
|
||||||
|
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
|
||||||
|
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _swap_focus_ref(self) -> None:
|
||||||
|
"""
|
||||||
|
Swap `focus_pos` and `focus_neg`, leaving center-referenced arcs unchanged.
|
||||||
|
"""
|
||||||
|
self.angle_ref = _swapped_focus_ref(self.angle_ref)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
angles = f' a°{numpy.rad2deg(self.angles)}'
|
angles = f' a°{numpy.rad2deg(self.angles)}'
|
||||||
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||||
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
|
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
|
||||||
|
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
|
||||||
|
|
||||||
|
|
||||||
|
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
|
||||||
|
"""
|
||||||
|
Return the opposite focus reference, or center for center-referenced arcs.
|
||||||
|
"""
|
||||||
|
if angle_ref == ArcAngleRef.FocusPos:
|
||||||
|
return ArcAngleRef.FocusNeg
|
||||||
|
if angle_ref == ArcAngleRef.FocusNeg:
|
||||||
|
return ArcAngleRef.FocusPos
|
||||||
|
return angle_ref
|
||||||
|
|
||||||
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
||||||
|
|
|
||||||
27
masque/test/helpers.py
Normal file
27
masque/test/helpers.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy.typing import ArrayLike, NDArray
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
|
||||||
|
def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Return lengths for each edge of an implicitly closed vertex loop.
|
||||||
|
"""
|
||||||
|
vv = numpy.asarray(vertices, dtype=float)
|
||||||
|
return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1))
|
||||||
|
|
||||||
|
|
||||||
|
def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None:
|
||||||
|
"""
|
||||||
|
Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`.
|
||||||
|
"""
|
||||||
|
assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None:
|
||||||
|
"""
|
||||||
|
Assert that an object's single-shape bounds match `expected`.
|
||||||
|
"""
|
||||||
|
assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol)
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import PathTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def advanced_pather() -> tuple[Pather, PathTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
# Simple PathTool: 2um width on layer (1,0)
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
p = Pather(lib, tools=tool, auto_render=True, auto_render_append=False)
|
|
||||||
return p, tool, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_straight(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
# Facing ports
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Facing East (into device)
|
|
||||||
# Forward (+pi relative to port) is West (-x).
|
|
||||||
# Put destination at (-20, 0) pointing East (pi).
|
|
||||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
|
||||||
|
|
||||||
p.trace_into("src", "dst")
|
|
||||||
|
|
||||||
assert "src" not in p.ports
|
|
||||||
assert "dst" not in p.ports
|
|
||||||
# Pather._traceL adds a Reference to the generated pattern
|
|
||||||
assert len(p.pattern.refs) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
# Source at (0,0) rot 0 (facing East). Forward is West (-x).
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
|
||||||
# Destination at (-20, -20) rot pi (facing West). Forward is East (+x).
|
|
||||||
# Wait, src forward is -x. dst is at -20, -20.
|
|
||||||
# To use a single bend, dst should be at some -x, -y and its rotation should be 3pi/2 (facing South).
|
|
||||||
# Forward for South is North (+y).
|
|
||||||
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
|
|
||||||
|
|
||||||
p.trace_into("src", "dst")
|
|
||||||
|
|
||||||
assert "src" not in p.ports
|
|
||||||
assert "dst" not in p.ports
|
|
||||||
# `trace_into()` now batches its internal legs before auto-rendering so the operation
|
|
||||||
# can roll back cleanly on later failures.
|
|
||||||
assert len(p.pattern.refs) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
# Facing but offset ports
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire") # Forward is West (-x)
|
|
||||||
p.ports["dst"] = Port((-20, -10), pi, ptype="wire") # Facing East (rot pi)
|
|
||||||
|
|
||||||
p.trace_into("src", "dst")
|
|
||||||
|
|
||||||
assert "src" not in p.ports
|
|
||||||
assert "dst" not in p.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_into_thru(advanced_pather: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = advanced_pather
|
|
||||||
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
|
||||||
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
|
||||||
p.ports["other"] = Port((10, 10), 0)
|
|
||||||
|
|
||||||
p.trace_into("src", "dst", thru="other")
|
|
||||||
|
|
||||||
assert "src" in p.ports
|
|
||||||
assert_equal(p.ports["src"].offset, [10, 10])
|
|
||||||
assert "other" not in p.ports
|
|
||||||
87
masque/test/test_arc.py
Normal file
87
masque/test/test_arc.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..shapes import Arc
|
||||||
|
from .helpers import assert_closed_edges_within
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_init() -> None:
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
||||||
|
assert_equal(a.radii, [10, 10])
|
||||||
|
assert_equal(a.angles, [0, pi / 2])
|
||||||
|
assert a.width == 2
|
||||||
|
|
||||||
|
def test_arc_to_polygons() -> None:
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
polys = a.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
|
||||||
|
# Quarter-circle ring section with outer radius 11 and inner radius 9.
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_arc_focus_to_polygons() -> None:
|
||||||
|
a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
polys = a.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
|
||||||
|
focus = numpy.array([8.0, 0.0])
|
||||||
|
cuts = a.get_cap_edges()
|
||||||
|
for angle, cut in zip(a.angles, cuts, strict=True):
|
||||||
|
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||||
|
for point in cut:
|
||||||
|
delta = point - focus
|
||||||
|
assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10)
|
||||||
|
assert numpy.dot(direction, delta) > 0
|
||||||
|
|
||||||
|
def test_arc_circle_focus_matches_center() -> None:
|
||||||
|
center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
|
||||||
|
assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices,
|
||||||
|
center.to_polygons(num_vertices=32)[0].vertices,
|
||||||
|
atol=1e-10)
|
||||||
|
|
||||||
|
def test_arc_edge_cases() -> None:
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
||||||
|
a.to_polygons(num_vertices=64)
|
||||||
|
bounds = a.get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
||||||
|
arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200))
|
||||||
|
bounds = arc.get_bounds_single()
|
||||||
|
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None:
|
||||||
|
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4,
|
||||||
|
offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
bounds = arc.get_bounds_single()
|
||||||
|
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
||||||
|
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
|
||||||
|
with pytest.raises(PatternError, match='valid max_arclen'):
|
||||||
|
arc.to_polygons(num_vertices=24)
|
||||||
|
|
||||||
|
def test_focus_arc_rejects_focus_outside_inner_boundary() -> None:
|
||||||
|
arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
with pytest.raises(PatternError, match='inside both arc boundary ellipses'):
|
||||||
|
arc.to_polygons(num_vertices=24)
|
||||||
|
|
||||||
|
def test_focus_arc_max_arclen_limits_segments() -> None:
|
||||||
|
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg)
|
||||||
|
assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2)
|
||||||
|
|
||||||
|
def test_arc_rejects_zero_radii_up_front() -> None:
|
||||||
|
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||||
|
Arc(radii=(0, 5), angles=(0, 1), width=1)
|
||||||
|
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||||
|
Arc(radii=(5, 0), angles=(0, 1), width=1)
|
||||||
|
with pytest.raises(PatternError, match='Radii must be positive'):
|
||||||
|
Arc(radii=(0, 0), angles=(0, 1), width=1)
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import AutoTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..pattern import Pattern
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
|
||||||
pat.ports["in"] = Port((0, 0), 0, ptype=ptype)
|
|
||||||
pat.ports["out"] = Port((length, 0), pi, ptype=ptype)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def autotool_setup() -> tuple[Pather, AutoTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Define a simple bend
|
|
||||||
bend_pat = Pattern()
|
|
||||||
# 2x2 bend from (0,0) rot 0 to (2, -2) rot pi/2 (Clockwise)
|
|
||||||
bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire")
|
|
||||||
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire")
|
|
||||||
lib["bend"] = bend_pat
|
|
||||||
lib.abstract("bend")
|
|
||||||
|
|
||||||
# Define a transition (e.g., via)
|
|
||||||
via_pat = Pattern()
|
|
||||||
via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1")
|
|
||||||
via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2")
|
|
||||||
lib["via"] = via_pat
|
|
||||||
via_abs = lib.abstract("via")
|
|
||||||
|
|
||||||
tool_m1 = AutoTool(
|
|
||||||
straights=[
|
|
||||||
AutoTool.Straight(ptype="wire_m1", fn=lambda length: make_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out")
|
|
||||||
],
|
|
||||||
bends=[],
|
|
||||||
sbends=[],
|
|
||||||
transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")},
|
|
||||||
default_out_ptype="wire_m1",
|
|
||||||
)
|
|
||||||
|
|
||||||
p = Pather(lib, tools=tool_m1)
|
|
||||||
# Start with an m2 port
|
|
||||||
p.ports["start"] = Port((0, 0), pi, ptype="wire_m2")
|
|
||||||
|
|
||||||
return p, tool_m1, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None:
|
|
||||||
p, _tool, _lib = autotool_setup
|
|
||||||
|
|
||||||
# Route m1 from an m2 port. Should trigger via.
|
|
||||||
# length 10. Via length is 1. So straight m1 should be 9.
|
|
||||||
p.straight("start", 10)
|
|
||||||
|
|
||||||
# Start at (0,0) rot pi (facing West).
|
|
||||||
# Forward (+pi relative to port) is East (+x).
|
|
||||||
# Via: m2(1,0)pi -> m1(0,0)0.
|
|
||||||
# Plug via m2 into start(0,0)pi: transformation rot=mod(pi-pi-pi, 2pi)=pi.
|
|
||||||
# rotate via by pi: m2 at (0,0), m1 at (-1, 0) rot pi.
|
|
||||||
# Then straight m1 of length 9 from (-1, 0) rot pi -> ends at (8, 0) rot pi.
|
|
||||||
# Wait, (length, 0) relative to (-1, 0) rot pi:
|
|
||||||
# transform (9, 0) by pi: (-9, 0).
|
|
||||||
# (-1, 0) + (-9, 0) = (-10, 0)? No.
|
|
||||||
# Let's re-calculate.
|
|
||||||
# start (0,0) rot pi. Direction East.
|
|
||||||
# via m2 is at (0,0), m1 is at (1,0).
|
|
||||||
# When via is plugged into start: m2 goes to (0,0).
|
|
||||||
# since start is pi and m2 is pi, rotation is 0.
|
|
||||||
# so via m1 is at (1,0) rot 0.
|
|
||||||
# then straight m1 length 9 from (1,0) rot 0: ends at (10, 0) rot 0.
|
|
||||||
|
|
||||||
assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10)
|
|
||||||
assert p.ports["start"].ptype == "wire_m1"
|
|
||||||
|
|
@ -1,12 +1,61 @@
|
||||||
import pytest
|
import pytest
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
from masque.builder.tools import AutoTool
|
from masque.builder.tools import AutoTool
|
||||||
|
from masque.builder.pather import Pather
|
||||||
|
from masque.library import Library
|
||||||
from masque.pattern import Pattern
|
from masque.pattern import Pattern
|
||||||
from masque.ports import Port
|
from masque.ports import Port
|
||||||
from masque.library import Library
|
|
||||||
from masque.builder.pather import Pather
|
|
||||||
|
def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width)
|
||||||
|
pat.ports["in"] = Port((0, 0), 0, ptype=ptype)
|
||||||
|
pat.ports["out"] = Port((length, 0), pi, ptype=ptype)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def autotool_setup() -> tuple[Pather, AutoTool, Library]:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
bend_pat = Pattern()
|
||||||
|
bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire")
|
||||||
|
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire")
|
||||||
|
lib["bend"] = bend_pat
|
||||||
|
lib.abstract("bend")
|
||||||
|
|
||||||
|
via_pat = Pattern()
|
||||||
|
via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1")
|
||||||
|
via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2")
|
||||||
|
lib["via"] = via_pat
|
||||||
|
via_abs = lib.abstract("via")
|
||||||
|
|
||||||
|
tool_m1 = AutoTool(
|
||||||
|
straights=[
|
||||||
|
AutoTool.Straight(ptype="wire_m1", fn=lambda length: _make_transition_straight(length, ptype="wire_m1"), in_port_name="in", out_port_name="out")
|
||||||
|
],
|
||||||
|
bends=[],
|
||||||
|
sbends=[],
|
||||||
|
transitions={("wire_m2", "wire_m1"): AutoTool.Transition(via_abs, "m2", "m1")},
|
||||||
|
default_out_ptype="wire_m1",
|
||||||
|
)
|
||||||
|
|
||||||
|
p = Pather(lib, tools=tool_m1)
|
||||||
|
p.ports["start"] = Port((0, 0), pi, ptype="wire_m2")
|
||||||
|
|
||||||
|
return p, tool_m1, lib
|
||||||
|
|
||||||
|
def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None:
|
||||||
|
p, _tool, _lib = autotool_setup
|
||||||
|
|
||||||
|
p.straight("start", 10)
|
||||||
|
|
||||||
|
# Via length is 1, so the remaining wire_m1 straight length is 9.
|
||||||
|
assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10)
|
||||||
|
assert p.ports["start"].ptype == "wire_m1"
|
||||||
|
|
||||||
def make_straight(length, width=2, ptype="wire"):
|
def make_straight(length, width=2, ptype="wire"):
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
|
|
@ -17,15 +66,13 @@ def make_straight(length, width=2, ptype="wire"):
|
||||||
|
|
||||||
def make_bend(R, width=2, ptype="wire", clockwise=True):
|
def make_bend(R, width=2, ptype="wire", clockwise=True):
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
# 90 degree arc approximation (just two rects for start and end)
|
# Rectangular approximation of a 90 degree bend.
|
||||||
if clockwise:
|
if clockwise:
|
||||||
# (0,0) rot 0 to (R, -R) rot pi/2
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
|
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.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0)
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||||
pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype)
|
pat.ports["B"] = Port((R, -R), pi/2, ptype=ptype)
|
||||||
else:
|
else:
|
||||||
# (0,0) rot 0 to (R, R) rot -pi/2
|
|
||||||
pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width)
|
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.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R)
|
||||||
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
|
||||||
|
|
@ -36,18 +83,14 @@ def make_bend(R, width=2, ptype="wire", clockwise=True):
|
||||||
def multi_bend_tool():
|
def multi_bend_tool():
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
||||||
# Bend 1: R=2
|
|
||||||
lib["b1"] = make_bend(2, ptype="wire")
|
lib["b1"] = make_bend(2, ptype="wire")
|
||||||
b1_abs = lib.abstract("b1")
|
b1_abs = lib.abstract("b1")
|
||||||
# Bend 2: R=5
|
|
||||||
lib["b2"] = make_bend(5, ptype="wire")
|
lib["b2"] = make_bend(5, ptype="wire")
|
||||||
b2_abs = lib.abstract("b2")
|
b2_abs = lib.abstract("b2")
|
||||||
|
|
||||||
tool = AutoTool(
|
tool = AutoTool(
|
||||||
straights=[
|
straights=[
|
||||||
# Straight 1: only for length < 10
|
|
||||||
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
|
AutoTool.Straight(ptype="wire", fn=make_straight, in_port_name="A", out_port_name="B", length_range=(0, 10)),
|
||||||
# Straight 2: for length >= 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))
|
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=[
|
bends=[
|
||||||
|
|
@ -60,7 +103,6 @@ def multi_bend_tool():
|
||||||
)
|
)
|
||||||
return tool, lib
|
return tool, lib
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def asymmetric_transition_tool() -> AutoTool:
|
def asymmetric_transition_tool() -> AutoTool:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
@ -102,7 +144,6 @@ def asymmetric_transition_tool() -> AutoTool:
|
||||||
default_out_ptype="core",
|
default_out_ptype="core",
|
||||||
).add_complementary_transitions()
|
).add_complementary_transitions()
|
||||||
|
|
||||||
|
|
||||||
def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None:
|
def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None:
|
||||||
pat = tree.top_pattern()
|
pat = tree.top_pattern()
|
||||||
out_port = pat[port_names[1]]
|
out_port = pat[port_names[1]]
|
||||||
|
|
@ -113,33 +154,66 @@ def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[
|
||||||
assert_allclose(rot, plan_port.rotation)
|
assert_allclose(rot, plan_port.rotation)
|
||||||
assert out_port.ptype == plan_port.ptype
|
assert out_port.ptype == plan_port.ptype
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_planL_selection(multi_bend_tool) -> None:
|
def test_autotool_planL_selection(multi_bend_tool) -> None:
|
||||||
tool, _ = multi_bend_tool
|
tool, _ = multi_bend_tool
|
||||||
|
|
||||||
# Small length: should pick straight 1 and bend 1 (R=2)
|
|
||||||
# L = straight + R. If L=5, straight=3.
|
|
||||||
p, data = tool.planL(True, 5)
|
p, data = tool.planL(True, 5)
|
||||||
assert data.straight.length_range == (0, 10)
|
assert data.straight.length_range == (0, 10)
|
||||||
assert data.straight_length == 3
|
assert data.straight_length == 3
|
||||||
assert data.bend.abstract.name == "b1"
|
assert data.bend.abstract.name == "b1"
|
||||||
assert_allclose(p.offset, [5, 2])
|
assert_allclose(p.offset, [5, 2])
|
||||||
|
|
||||||
# Large length: should pick straight 2 and bend 1 (R=2)
|
|
||||||
# If L=15, straight=13.
|
|
||||||
p, data = tool.planL(True, 15)
|
p, data = tool.planL(True, 15)
|
||||||
assert data.straight.length_range == (10, 1e8)
|
assert data.straight.length_range == (10, 1e8)
|
||||||
assert data.straight_length == 13
|
assert data.straight_length == 13
|
||||||
assert_allclose(p.offset, [15, 2])
|
assert_allclose(p.offset, [15, 2])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ccw", [False, True])
|
||||||
|
def test_autotool_traceL_matches_plan_with_post_bend_transition(ccw: bool) -> None:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
bend_pat = Pattern()
|
||||||
|
bend_pat.ports["A"] = Port((0, 0), 0, ptype="core")
|
||||||
|
bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="core")
|
||||||
|
lib["core_bend"] = bend_pat
|
||||||
|
|
||||||
|
trans_pat = Pattern()
|
||||||
|
trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core")
|
||||||
|
trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext")
|
||||||
|
lib["out_trans"] = trans_pat
|
||||||
|
|
||||||
|
tool = AutoTool(
|
||||||
|
straights=[
|
||||||
|
AutoTool.Straight(
|
||||||
|
ptype="core",
|
||||||
|
fn=lambda length: make_straight(length, ptype="core"),
|
||||||
|
in_port_name="A",
|
||||||
|
out_port_name="B",
|
||||||
|
length_range=(0, 1e8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bends=[
|
||||||
|
AutoTool.Bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True),
|
||||||
|
],
|
||||||
|
sbends=[],
|
||||||
|
transitions={
|
||||||
|
("ext", "core"): AutoTool.Transition(lib.abstract("out_trans"), "EXT", "CORE"),
|
||||||
|
},
|
||||||
|
default_out_ptype="core",
|
||||||
|
)
|
||||||
|
|
||||||
|
plan_port, data = tool.planL(ccw, 10, out_ptype="ext")
|
||||||
|
|
||||||
|
assert data.out_transition is not None
|
||||||
|
|
||||||
|
tree = tool.traceL(ccw, 10, out_ptype="ext")
|
||||||
|
assert_trace_matches_plan(plan_port, tree)
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_planU_consistency(multi_bend_tool) -> None:
|
def test_autotool_planU_consistency(multi_bend_tool) -> None:
|
||||||
tool, lib = multi_bend_tool
|
tool, lib = multi_bend_tool
|
||||||
|
|
||||||
# length=10, jog=20.
|
|
||||||
# U-turn: Straight1 -> Bend1 -> Straight_mid -> Straight3(0) -> Bend2
|
|
||||||
# X = L1_total - R2 = length
|
|
||||||
# Y = R1 + L2_mid + R2 = jog
|
|
||||||
|
|
||||||
p, data = tool.planU(20, length=10)
|
p, data = tool.planU(20, length=10)
|
||||||
assert data.ldata0.straight_length == 7
|
assert data.ldata0.straight_length == 7
|
||||||
assert data.ldata0.bend.abstract.name == "b2"
|
assert data.ldata0.bend.abstract.name == "b2"
|
||||||
|
|
@ -147,7 +221,6 @@ def test_autotool_planU_consistency(multi_bend_tool) -> None:
|
||||||
assert data.ldata1.straight_length == 0
|
assert data.ldata1.straight_length == 0
|
||||||
assert data.ldata1.bend.abstract.name == "b1"
|
assert data.ldata1.bend.abstract.name == "b1"
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
|
def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
|
||||||
tool = asymmetric_transition_tool
|
tool = asymmetric_transition_tool
|
||||||
|
|
||||||
|
|
@ -159,14 +232,9 @@ def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_tran
|
||||||
tree = tool.traceU(12, length=0, in_ptype="core")
|
tree = tool.traceU(12, length=0, in_ptype="core")
|
||||||
assert_trace_matches_plan(plan_port, tree)
|
assert_trace_matches_plan(plan_port, tree)
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_planS_double_L(multi_bend_tool) -> None:
|
def test_autotool_planS_double_L(multi_bend_tool) -> None:
|
||||||
tool, lib = multi_bend_tool
|
tool, lib = multi_bend_tool
|
||||||
|
|
||||||
# length=20, jog=10. S-bend (ccw1, cw2)
|
|
||||||
# X = L1_total + R2 = length
|
|
||||||
# Y = R1 + L2_mid + R2 = jog
|
|
||||||
|
|
||||||
p, data = tool.planS(20, 10)
|
p, data = tool.planS(20, 10)
|
||||||
assert_allclose(p.offset, [20, 10])
|
assert_allclose(p.offset, [20, 10])
|
||||||
assert_allclose(p.rotation, pi)
|
assert_allclose(p.rotation, pi)
|
||||||
|
|
@ -175,7 +243,6 @@ def test_autotool_planS_double_L(multi_bend_tool) -> None:
|
||||||
assert data.ldata1.straight_length == 0
|
assert data.ldata1.straight_length == 0
|
||||||
assert data.l2_length == 6
|
assert data.l2_length == 6
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
|
def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
|
||||||
tool = asymmetric_transition_tool
|
tool = asymmetric_transition_tool
|
||||||
|
|
||||||
|
|
@ -188,7 +255,6 @@ def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymme
|
||||||
tree = tool.traceS(4, 10, in_ptype="core")
|
tree = tool.traceS(4, 10, in_ptype="core")
|
||||||
assert_trace_matches_plan(plan_port, tree)
|
assert_trace_matches_plan(plan_port, tree)
|
||||||
|
|
||||||
|
|
||||||
def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
|
def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
||||||
|
|
@ -242,65 +308,3 @@ def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
|
||||||
assert data.straight_length == 0
|
assert data.straight_length == 0
|
||||||
assert data.jog_remaining == 4
|
assert data.jog_remaining == 4
|
||||||
assert data.in_transition is not None
|
assert data.in_transition is not None
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_autotool_double_L(multi_bend_tool) -> None:
|
|
||||||
tool, lib = multi_bend_tool
|
|
||||||
rp = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
|
||||||
|
|
||||||
# This should trigger double-L fallback in planS
|
|
||||||
rp.jog("A", 10, length=20)
|
|
||||||
|
|
||||||
# port_rot=0 -> forward is -x. jog=10 (left) is -y.
|
|
||||||
assert_allclose(rp.ports["A"].offset, [-20, -10])
|
|
||||||
assert_allclose(rp.ports["A"].rotation, 0) # jog rot is pi relative to input, input rot is pi relative to port.
|
|
||||||
# Wait, planS returns out_port at (length, jog) rot pi relative to input (0,0) rot 0.
|
|
||||||
# Input rot relative to port is pi.
|
|
||||||
# Rotate (length, jog) rot pi by pi: (-length, -jog) rot 0. Correct.
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert len(rp.pattern.refs) > 0
|
|
||||||
|
|
||||||
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
|
|
||||||
tool, lib = multi_bend_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") # facing West (Actually East points Inwards, West is Extension)
|
|
||||||
|
|
||||||
# uturn jog=10, length=5.
|
|
||||||
# R=2. L1 = 5+2=7. L2 = 10-2=8.
|
|
||||||
p.uturn("A", 10, length=5)
|
|
||||||
|
|
||||||
# port_rot=0 -> forward is -x. jog=10 (left) is -y.
|
|
||||||
# L1=7 along -x -> (-7, 0). Bend1 (ccw) -> rot -pi/2 (South).
|
|
||||||
# L2=8 along -y -> (-7, -8). Bend2 (ccw) -> rot 0 (East).
|
|
||||||
# wait. CCW turn from facing South (-y): turn towards East (+x).
|
|
||||||
# Wait.
|
|
||||||
# Input facing -x. CCW turn -> face -y.
|
|
||||||
# Input facing -y. CCW turn -> face +x.
|
|
||||||
# So final rotation is 0.
|
|
||||||
# Bend1 (ccw) relative to -x: global offset is (-7, -2)?
|
|
||||||
# Let's re-run my manual calculation.
|
|
||||||
# Port rot 0. Wire input rot pi. Wire output relative to input:
|
|
||||||
# L1=7, R1=2, CCW=True. Output (7, 2) rot pi/2.
|
|
||||||
# Rotate wire by pi: output (-7, -2) rot 3pi/2.
|
|
||||||
# Second turn relative to (-7, -2) rot 3pi/2:
|
|
||||||
# local output (8, 2) rot pi/2.
|
|
||||||
# global: (-7, -2) + 8*rot(3pi/2)*x + 2*rot(3pi/2)*y
|
|
||||||
# = (-7, -2) + 8*(0, -1) + 2*(1, 0) = (-7, -2) + (0, -8) + (2, 0) = (-5, -10).
|
|
||||||
# YES! ACTUAL result was (-5, -10).
|
|
||||||
assert_allclose(p.ports["A"].offset, [-5, -10])
|
|
||||||
assert_allclose(p.ports["A"].rotation, pi)
|
|
||||||
|
|
@ -48,12 +48,7 @@ def test_layer_as_polygons_flatten() -> None:
|
||||||
|
|
||||||
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
|
||||||
assert len(polys) == 1
|
assert len(polys) == 1
|
||||||
# Original child at (0,0) with rot pi/2 is still at (0,0) in its own space?
|
# Child vertices are rotated by the ref and then translated by the ref offset.
|
||||||
# No, ref.as_pattern(child) will apply the transform.
|
|
||||||
# Child (0,0), (1,0), (1,1) rotated pi/2 around (0,0) -> (0,0), (0,1), (-1,1)
|
|
||||||
# Then offset by (10,10) -> (10,10), (10,11), (9,11)
|
|
||||||
|
|
||||||
# Let's verify the vertices
|
|
||||||
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
expected = numpy.array([[10, 10], [10, 11], [9, 11]])
|
||||||
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
assert_allclose(polys[0].vertices, expected, atol=1e-10)
|
||||||
|
|
||||||
|
|
|
||||||
315
masque/test/test_build_library.py
Normal file
315
masque/test/test_build_library.py
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ..builder import Pather
|
||||||
|
from ..error import BuildError
|
||||||
|
from ..library import BuildLibrary, BuiltLibrary, Library, cell
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..ports import Port
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_traces_declared_dependencies_out_of_order() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
assert lib.abstract("child").name == "child"
|
||||||
|
return pat
|
||||||
|
|
||||||
|
builder.cells.parent = cell(make_parent)(builder)
|
||||||
|
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
|
||||||
|
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
assert "parent" in built
|
||||||
|
assert "child" in built
|
||||||
|
assert built.build_report.dependency_graph["parent"] == frozenset({"child"})
|
||||||
|
assert built.build_report.provenance["parent"].kind == "declared"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
tree = Library({"_helper": Pattern()})
|
||||||
|
name_a = lib << tree
|
||||||
|
name_b = lib << tree
|
||||||
|
top = Pattern()
|
||||||
|
top.ref(name_a)
|
||||||
|
top.ref(name_b)
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
helpers = [
|
||||||
|
prov for prov in report.provenance.values()
|
||||||
|
if prov.owner_declared_name == "top" and prov.kind == "helper"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "top" in report.owned_cells["top"]
|
||||||
|
assert len(helpers) == 2
|
||||||
|
assert all(prov.emitted_via == "tree_merge" for prov in helpers)
|
||||||
|
assert any(prov.renamed_from == "_helper" for prov in helpers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["leaf"] = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="validate\\(\\) or build\\(\\)"):
|
||||||
|
_ = builder["leaf"]
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="write-only"):
|
||||||
|
_ = builder.cells.leaf
|
||||||
|
|
||||||
|
built = builder.build(output="library")
|
||||||
|
|
||||||
|
assert isinstance(built, BuiltLibrary)
|
||||||
|
assert built.build_report.requested_roots == ("leaf",)
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="frozen"):
|
||||||
|
builder["later"] = Pattern()
|
||||||
|
with pytest.raises(BuildError, match="frozen"):
|
||||||
|
builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_validate_is_retryable_after_failure() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
lib.abstract("child")
|
||||||
|
return pat
|
||||||
|
|
||||||
|
builder.cells.parent = cell(make_parent)(builder)
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Failed while building declared cell "parent"'):
|
||||||
|
builder.validate()
|
||||||
|
|
||||||
|
builder["child"] = Pattern(ports={"p": Port((0, 0), 0)})
|
||||||
|
report = builder.validate()
|
||||||
|
|
||||||
|
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_check_on_register_rolls_back_failed_declarations() -> None:
|
||||||
|
builder = BuildLibrary(check_on_register=True)
|
||||||
|
|
||||||
|
def make_parent(lib: BuildLibrary) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
lib.abstract("child")
|
||||||
|
return pat
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Failed while building declared cell "parent"'):
|
||||||
|
builder.cells.parent = cell(make_parent)(builder)
|
||||||
|
|
||||||
|
assert "parent" not in builder
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["child"] = Pattern()
|
||||||
|
|
||||||
|
def make_parent() -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ref("child")
|
||||||
|
return pat
|
||||||
|
|
||||||
|
builder.cells.parent = cell(make_parent)().depends_on("child")
|
||||||
|
report = builder.validate(names=("parent",))
|
||||||
|
|
||||||
|
assert report.requested_roots == ("parent",)
|
||||||
|
assert report.dependency_graph["parent"] == frozenset({"child"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_validate_rejects_removed_output_argument() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["leaf"] = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
builder.validate(output="library") # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_allows_helper_writes_via_pather() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)})
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
helper = Pather(library=lib, ports="leaf", name="_route")
|
||||||
|
top = Pattern()
|
||||||
|
top.ref("_route")
|
||||||
|
top.ref("leaf")
|
||||||
|
top.ports.update(helper.pattern.ports)
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
helper_prov = built.build_report.provenance["_route"]
|
||||||
|
assert helper_prov.kind == "helper"
|
||||||
|
assert helper_prov.owner_declared_name == "top"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_preserves_source_cells_and_records_source_provenance() -> None:
|
||||||
|
source = Library({"src": Pattern()})
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder.add_source(source)
|
||||||
|
builder.cells.top = cell(lambda: Pattern())()
|
||||||
|
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
assert "src" in built
|
||||||
|
assert built.build_report.provenance["src"].kind == "source"
|
||||||
|
assert built.build_report.provenance["src"].emitted_via == "source_import"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_can_rename_imported_source_cells_during_authoring() -> None:
|
||||||
|
source = Library()
|
||||||
|
source["child"] = Pattern()
|
||||||
|
parent = Pattern()
|
||||||
|
parent.ref("child")
|
||||||
|
source["parent"] = parent
|
||||||
|
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder.add_source(source)
|
||||||
|
builder.rename("child", "renamed_child")
|
||||||
|
|
||||||
|
built = builder.build()
|
||||||
|
|
||||||
|
assert "renamed_child" in built
|
||||||
|
assert "child" not in built
|
||||||
|
assert "renamed_child" in built["parent"].refs
|
||||||
|
assert built.build_report.provenance["renamed_child"].source_name == "child"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_move_references_for_source_rename() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder.add_source(Library({"src": Pattern()}))
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match="move_references=True"):
|
||||||
|
builder.rename("src", "renamed_src", move_references=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_renaming_declared_cells_during_authoring() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
builder["declared"] = Pattern()
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Cannot rename declared build cell "declared"'):
|
||||||
|
builder.rename("declared", "renamed_declared")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_helper_rename_updates_provenance_and_owned_cells() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib["_helper"] = Pattern()
|
||||||
|
lib.rename("_helper", "final_helper")
|
||||||
|
top = Pattern()
|
||||||
|
top.ref("final_helper")
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
assert "final_helper" in built
|
||||||
|
assert "_helper" not in built
|
||||||
|
assert "final_helper" in report.owned_cells["top"]
|
||||||
|
assert "_helper" not in report.owned_cells["top"]
|
||||||
|
prov = report.provenance["final_helper"]
|
||||||
|
assert prov.kind == "helper"
|
||||||
|
assert prov.requested_name == "_helper"
|
||||||
|
assert prov.renamed_from == "_helper"
|
||||||
|
assert prov.final_name == "final_helper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_helper_delete_removes_provenance_and_ownership() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib["_helper"] = Pattern()
|
||||||
|
del lib["_helper"]
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
assert "_helper" not in built
|
||||||
|
assert "_helper" not in report.provenance
|
||||||
|
assert report.owned_cells["top"] == ("top",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None:
|
||||||
|
builder = BuildLibrary()
|
||||||
|
|
||||||
|
def make_top(lib: BuildLibrary) -> Pattern:
|
||||||
|
tree = Library({"_helper": Pattern()})
|
||||||
|
_ = lib << tree
|
||||||
|
renamed = lib << tree
|
||||||
|
lib.rename(renamed, "final_helper")
|
||||||
|
top = Pattern()
|
||||||
|
top.ref("_helper")
|
||||||
|
top.ref("final_helper")
|
||||||
|
return top
|
||||||
|
|
||||||
|
builder.cells.top = cell(make_top)(builder)
|
||||||
|
built = builder.build()
|
||||||
|
report = built.build_report
|
||||||
|
|
||||||
|
assert "final_helper" in built
|
||||||
|
prov = report.provenance["final_helper"]
|
||||||
|
assert prov.requested_name == "_helper"
|
||||||
|
assert prov.renamed_from == "_helper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None:
|
||||||
|
declared = BuildLibrary()
|
||||||
|
declared["leaf"] = Pattern()
|
||||||
|
|
||||||
|
def rename_declared(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib.rename("leaf", "renamed_leaf")
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
declared.cells.top = cell(rename_declared)(declared)
|
||||||
|
with pytest.raises(BuildError, match='Cannot rename declared build cell "leaf"'):
|
||||||
|
declared.build()
|
||||||
|
|
||||||
|
source = BuildLibrary()
|
||||||
|
source.add_source(Library({"src": Pattern()}))
|
||||||
|
|
||||||
|
def rename_source(lib: BuildLibrary) -> Pattern:
|
||||||
|
lib.rename("src", "renamed_src")
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
source.cells.top = cell(rename_source)(source)
|
||||||
|
with pytest.raises(BuildError, match='Cannot rename imported source cell "src"'):
|
||||||
|
source.build()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_library_rejects_deleting_declared_or_source_cells_during_build() -> None:
|
||||||
|
declared = BuildLibrary()
|
||||||
|
declared["leaf"] = Pattern()
|
||||||
|
|
||||||
|
def delete_declared(lib: BuildLibrary) -> Pattern:
|
||||||
|
del lib["leaf"]
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
declared.cells.top = cell(delete_declared)(declared)
|
||||||
|
with pytest.raises(BuildError, match='Cannot delete declared build cell "leaf"'):
|
||||||
|
declared.build()
|
||||||
|
|
||||||
|
source = BuildLibrary()
|
||||||
|
source.add_source(Library({"src": Pattern()}))
|
||||||
|
|
||||||
|
def delete_source(lib: BuildLibrary) -> Pattern:
|
||||||
|
del lib["src"]
|
||||||
|
return Pattern()
|
||||||
|
|
||||||
|
source.cells.top = cell(delete_source)(source)
|
||||||
|
with pytest.raises(BuildError, match='Cannot delete imported source cell "src"'):
|
||||||
|
source.build()
|
||||||
17
masque/test/test_circle.py
Normal file
17
masque/test/test_circle.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..shapes import Circle, Polygon
|
||||||
|
|
||||||
|
|
||||||
|
def test_circle_init() -> None:
|
||||||
|
c = Circle(radius=10, offset=(5, 5))
|
||||||
|
assert c.radius == 10
|
||||||
|
assert_equal(c.offset, [5, 5])
|
||||||
|
|
||||||
|
def test_circle_to_polygons() -> None:
|
||||||
|
c = Circle(radius=10)
|
||||||
|
polys = c.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert isinstance(polys[0], Polygon)
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
||||||
26
masque/test/test_curve_polygonization.py
Normal file
26
masque/test/test_curve_polygonization.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from numpy import pi
|
||||||
|
|
||||||
|
from ..shapes import Arc, Circle, Ellipse
|
||||||
|
from .helpers import assert_closed_edges_within
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_arclen() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5))
|
||||||
|
polys = e.to_polygons(max_arclen=5)
|
||||||
|
v = polys[0].vertices
|
||||||
|
assert_closed_edges_within(v, 5)
|
||||||
|
assert len(v) > 10
|
||||||
|
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
polys = a.to_polygons(max_arclen=2)
|
||||||
|
assert_closed_edges_within(polys[0].vertices, 2)
|
||||||
|
|
||||||
|
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
||||||
|
for shape in (
|
||||||
|
Circle(radius=10),
|
||||||
|
Ellipse(radii=(10, 20)),
|
||||||
|
Arc(radii=(10, 20), angles=(0, 1), width=2),
|
||||||
|
):
|
||||||
|
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert len(polys[0].vertices) >= 3
|
||||||
|
|
@ -26,19 +26,16 @@ def test_dxf_roundtrip(tmp_path: Path):
|
||||||
lib = Library()
|
lib = Library()
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
|
|
||||||
# 1. Polygon (closed)
|
|
||||||
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
pat.polygon("1", vertices=poly_verts)
|
pat.polygon("1", vertices=poly_verts)
|
||||||
|
|
||||||
# 2. Path (open, 3 points)
|
|
||||||
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
|
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
|
||||||
pat.path("2", vertices=path_verts, width=2)
|
pat.path("2", vertices=path_verts, width=2)
|
||||||
|
|
||||||
# 3. Path (open, 2 points) - Testing the fix for 2-point polylines
|
# Two-point paths remain paths rather than being polygonized.
|
||||||
path2_verts = numpy.array([[40, 0], [50, 10]])
|
path2_verts = numpy.array([[40, 0], [50, 10]])
|
||||||
pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful
|
pat.path("3", vertices=path2_verts, width=0)
|
||||||
|
|
||||||
# 4. Ref with Grid repetition (Manhattan)
|
|
||||||
subpat = Pattern()
|
subpat = Pattern()
|
||||||
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
|
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
lib["sub"] = subpat
|
lib["sub"] = subpat
|
||||||
|
|
@ -52,38 +49,29 @@ def test_dxf_roundtrip(tmp_path: Path):
|
||||||
|
|
||||||
read_lib, _ = dxf.readfile(dxf_file)
|
read_lib, _ = dxf.readfile(dxf_file)
|
||||||
|
|
||||||
# In DXF read, the top level is usually called "Model"
|
|
||||||
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||||
|
|
||||||
# Verify Polygon
|
|
||||||
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
|
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
|
||||||
assert len(polys) >= 1
|
assert len(polys) >= 1
|
||||||
poly_read = polys[0]
|
poly_read = polys[0]
|
||||||
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
|
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
|
||||||
|
|
||||||
# Verify 3-point Path
|
|
||||||
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
|
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
|
||||||
assert len(paths) >= 1
|
assert len(paths) >= 1
|
||||||
path_read = paths[0]
|
path_read = paths[0]
|
||||||
assert _matches_open_path(path_read.vertices, path_verts)
|
assert _matches_open_path(path_read.vertices, path_verts)
|
||||||
assert path_read.width == 2
|
assert path_read.width == 2
|
||||||
|
|
||||||
# Verify 2-point Path
|
|
||||||
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
|
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
|
||||||
assert len(paths2) >= 1
|
assert len(paths2) >= 1
|
||||||
path2_read = paths2[0]
|
path2_read = paths2[0]
|
||||||
assert _matches_open_path(path2_read.vertices, path2_verts)
|
assert _matches_open_path(path2_read.vertices, path2_verts)
|
||||||
assert path2_read.width == 0
|
assert path2_read.width == 0
|
||||||
|
|
||||||
# Verify Ref with Grid
|
|
||||||
# Finding the sub pattern name might be tricky because of how DXF stores blocks
|
|
||||||
# but "sub" should be in read_lib
|
|
||||||
assert "sub" in read_lib
|
assert "sub" in read_lib
|
||||||
|
|
||||||
# Check refs in the top pattern
|
|
||||||
found_grid = False
|
found_grid = False
|
||||||
for target, reflist in top_pat.refs.items():
|
for target, reflist in top_pat.refs.items():
|
||||||
# DXF names might be case-insensitive or modified, but ezdxf usually preserves them
|
|
||||||
if target.upper() == "SUB":
|
if target.upper() == "SUB":
|
||||||
for ref in reflist:
|
for ref in reflist:
|
||||||
if isinstance(ref.repetition, Grid):
|
if isinstance(ref.repetition, Grid):
|
||||||
|
|
@ -95,16 +83,12 @@ def test_dxf_roundtrip(tmp_path: Path):
|
||||||
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
|
assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}"
|
||||||
|
|
||||||
def test_dxf_manhattan_precision(tmp_path: Path):
|
def test_dxf_manhattan_precision(tmp_path: Path):
|
||||||
# Test that float precision doesn't break Manhattan grid detection
|
|
||||||
lib = Library()
|
lib = Library()
|
||||||
sub = Pattern()
|
sub = Pattern()
|
||||||
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
|
||||||
lib["sub"] = sub
|
lib["sub"] = sub
|
||||||
|
|
||||||
top = Pattern()
|
top = Pattern()
|
||||||
# 90 degree rotation: in masque the grid is NOT rotated, so it stays [[10,0],[0,10]]
|
|
||||||
# In DXF, an array with rotation 90 has basis vectors [[0,10],[-10,0]].
|
|
||||||
# So a masque grid [[10,0],[0,10]] with ref rotation 90 matches a DXF array.
|
|
||||||
angle = numpy.pi / 2 # 90 degrees
|
angle = numpy.pi / 2 # 90 degrees
|
||||||
top.ref("sub", offset=(0, 0), rotation=angle,
|
top.ref("sub", offset=(0, 0), rotation=angle,
|
||||||
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
|
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
|
||||||
|
|
@ -114,7 +98,7 @@ def test_dxf_manhattan_precision(tmp_path: Path):
|
||||||
dxf_file = tmp_path / "precision.dxf"
|
dxf_file = tmp_path / "precision.dxf"
|
||||||
dxf.writefile(lib, "top", dxf_file)
|
dxf.writefile(lib, "top", dxf_file)
|
||||||
|
|
||||||
# If the isclose() fix works, this should still be a Grid when read back
|
# Near-integer rotated basis vectors round-trip as a Manhattan Grid.
|
||||||
read_lib, _ = dxf.readfile(dxf_file)
|
read_lib, _ = dxf.readfile(dxf_file)
|
||||||
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]
|
||||||
|
|
||||||
|
|
|
||||||
29
masque/test/test_ellipse.py
Normal file
29
masque/test/test_ellipse.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..shapes import Ellipse
|
||||||
|
|
||||||
|
|
||||||
|
def test_ellipse_init() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
||||||
|
assert_equal(e.radii, [10, 5])
|
||||||
|
assert_equal(e.offset, [1, 2])
|
||||||
|
assert e.rotation == pi / 4
|
||||||
|
|
||||||
|
def test_ellipse_to_polygons() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5))
|
||||||
|
polys = e.to_polygons(num_vertices=64)
|
||||||
|
assert len(polys) == 1
|
||||||
|
bounds = polys[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
|
||||||
|
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
|
||||||
|
bounds = ellipse.get_bounds_single()
|
||||||
|
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
||||||
|
ellipse = Ellipse(radii=(10, 20))
|
||||||
|
ellipse.scale_by(0.5)
|
||||||
|
assert_allclose(ellipse.radii, [5, 10])
|
||||||
|
|
@ -11,45 +11,31 @@ from ..repetition import Grid, Arbitrary
|
||||||
def create_test_library(for_gds: bool = False) -> Library:
|
def create_test_library(for_gds: bool = False) -> Library:
|
||||||
lib = Library()
|
lib = Library()
|
||||||
|
|
||||||
# 1. Polygons
|
|
||||||
pat_poly = Pattern()
|
pat_poly = Pattern()
|
||||||
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
|
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
|
||||||
lib["polygons"] = pat_poly
|
lib["polygons"] = pat_poly
|
||||||
|
|
||||||
# 2. Paths with different endcaps
|
|
||||||
pat_paths = Pattern()
|
pat_paths = Pattern()
|
||||||
# Flush
|
|
||||||
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
|
pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush)
|
||||||
# Square
|
|
||||||
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
|
pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square)
|
||||||
# Circle (Only for GDS)
|
|
||||||
if for_gds:
|
if for_gds:
|
||||||
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
|
pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle)
|
||||||
# SquareCustom
|
|
||||||
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
|
pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5))
|
||||||
lib["paths"] = pat_paths
|
lib["paths"] = pat_paths
|
||||||
|
|
||||||
# 3. Circles (only for OASIS or polygonized for GDS)
|
|
||||||
pat_circles = Pattern()
|
pat_circles = Pattern()
|
||||||
if for_gds:
|
if for_gds:
|
||||||
# GDS writer calls to_polygons() for non-supported shapes,
|
|
||||||
# but we can also pre-polygonize
|
|
||||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
|
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0])
|
||||||
else:
|
else:
|
||||||
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
|
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
|
||||||
lib["circles"] = pat_circles
|
lib["circles"] = pat_circles
|
||||||
|
|
||||||
# 4. Refs with repetitions
|
|
||||||
pat_refs = Pattern()
|
pat_refs = Pattern()
|
||||||
# Simple Ref
|
|
||||||
pat_refs.ref("polygons", offset=(0, 0))
|
pat_refs.ref("polygons", offset=(0, 0))
|
||||||
# Ref with Grid repetition
|
|
||||||
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
|
pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2))
|
||||||
# Ref with Arbitrary repetition
|
|
||||||
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
|
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
|
||||||
lib["refs"] = pat_refs
|
lib["refs"] = pat_refs
|
||||||
|
|
||||||
# 5. Shapes with repetitions (OASIS only, must be wrapped for GDS)
|
|
||||||
pat_rep_shapes = Pattern()
|
pat_rep_shapes = Pattern()
|
||||||
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
|
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
|
||||||
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
|
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
|
||||||
|
|
@ -68,16 +54,10 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
|
||||||
|
|
||||||
read_lib, _ = gdsii.readfile(gds_file)
|
read_lib, _ = gdsii.readfile(gds_file)
|
||||||
|
|
||||||
# Check existence
|
|
||||||
for name in lib:
|
for name in lib:
|
||||||
assert name in read_lib
|
assert name in read_lib
|
||||||
|
|
||||||
# Check Paths
|
|
||||||
read_paths = read_lib["paths"]
|
read_paths = read_lib["paths"]
|
||||||
# Check caps (GDS stores them as path_type)
|
|
||||||
# Order might be different depending on how they were written,
|
|
||||||
# but here they should match the order they were added if dict order is preserved.
|
|
||||||
# Actually, they are grouped by layer.
|
|
||||||
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
|
p_flush = cast("MPath", read_paths.shapes[(2, 0)][0])
|
||||||
assert p_flush.cap == MPath.Cap.Flush
|
assert p_flush.cap == MPath.Cap.Flush
|
||||||
|
|
||||||
|
|
@ -92,20 +72,16 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
|
||||||
assert p_custom.cap_extensions is not None
|
assert p_custom.cap_extensions is not None
|
||||||
assert_allclose(p_custom.cap_extensions, (1, 5))
|
assert_allclose(p_custom.cap_extensions, (1, 5))
|
||||||
|
|
||||||
# Check Refs with repetitions
|
|
||||||
read_refs = read_lib["refs"]
|
read_refs = read_lib["refs"]
|
||||||
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
|
assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs)
|
||||||
|
|
||||||
# AREF check
|
|
||||||
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
|
arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None]
|
||||||
assert len(arefs) == 1
|
assert len(arefs) == 1
|
||||||
assert isinstance(arefs[0].repetition, Grid)
|
assert isinstance(arefs[0].repetition, Grid)
|
||||||
assert arefs[0].repetition.a_count == 3
|
assert arefs[0].repetition.a_count == 3
|
||||||
assert arefs[0].repetition.b_count == 2
|
assert arefs[0].repetition.b_count == 2
|
||||||
|
|
||||||
# Check wrapped shapes
|
# GDS stores repeated shapes through refs created by wrap_repeated_shapes().
|
||||||
# lib.wrap_repeated_shapes() created new patterns
|
|
||||||
# Original pattern "rep_shapes" now should have a Ref
|
|
||||||
assert len(read_lib["rep_shapes"].refs) > 0
|
assert len(read_lib["rep_shapes"].refs) > 0
|
||||||
|
|
||||||
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
||||||
|
|
@ -117,34 +93,17 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None:
|
||||||
|
|
||||||
read_lib, _ = oasis.readfile(oas_file)
|
read_lib, _ = oasis.readfile(oas_file)
|
||||||
|
|
||||||
# Check existence
|
|
||||||
for name in lib:
|
for name in lib:
|
||||||
assert name in read_lib
|
assert name in read_lib
|
||||||
|
|
||||||
# Check Circle
|
|
||||||
read_circles = read_lib["circles"]
|
read_circles = read_lib["circles"]
|
||||||
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
|
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
|
||||||
assert read_circles.shapes[(3, 0)][0].radius == 5
|
assert read_circles.shapes[(3, 0)][0].radius == 5
|
||||||
|
|
||||||
# Check Path caps
|
|
||||||
read_paths = read_lib["paths"]
|
read_paths = read_lib["paths"]
|
||||||
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
|
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
|
||||||
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
|
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
|
||||||
# OASIS HalfWidth is Square. masque's Square is also HalfWidth extension.
|
|
||||||
# Wait, Circle cap in OASIS?
|
|
||||||
# masque/file/oasis.py:
|
|
||||||
# path_cap_map = {
|
|
||||||
# PathExtensionScheme.Flush: Path.Cap.Flush,
|
|
||||||
# PathExtensionScheme.HalfWidth: Path.Cap.Square,
|
|
||||||
# PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom,
|
|
||||||
# }
|
|
||||||
# It seems Circle cap is NOT supported in OASIS by masque currently.
|
|
||||||
# Let's verify what happens with Circle cap in OASIS write.
|
|
||||||
# _shapes_to_elements in oasis.py:
|
|
||||||
# path_type = next(k for k, v in path_cap_map.items() if v == shape.cap)
|
|
||||||
# This will raise StopIteration if Circle is not in path_cap_map.
|
|
||||||
|
|
||||||
# Check Shape repetition
|
|
||||||
read_rep_shapes = read_lib["rep_shapes"]
|
read_rep_shapes = read_lib["rep_shapes"]
|
||||||
poly = read_rep_shapes.shapes[(4, 0)][0]
|
poly = read_rep_shapes.shapes[(4, 0)][0]
|
||||||
assert poly.repetition is not None
|
assert poly.repetition is not None
|
||||||
101
masque/test/test_gdsii_lazy.py
Normal file
101
masque/test/test_gdsii_lazy.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from ..file import gdsii, gdsii_lazy
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..library import Library
|
||||||
|
|
||||||
|
|
||||||
|
def _make_lazy_port_library() -> Library:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
leaf = Pattern()
|
||||||
|
leaf.label(layer=(10, 0), string='A:type1 0', offset=(5, 0))
|
||||||
|
lib['leaf'] = leaf
|
||||||
|
|
||||||
|
child = Pattern()
|
||||||
|
child.ref('leaf', offset=(10, 20), rotation=numpy.pi / 2)
|
||||||
|
lib['child'] = child
|
||||||
|
|
||||||
|
top = Pattern()
|
||||||
|
top.ref('child', offset=(100, 200))
|
||||||
|
lib['top'] = top
|
||||||
|
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
|
||||||
|
gds_file = tmp_path / 'lazy_source.gds'
|
||||||
|
src = _make_lazy_port_library()
|
||||||
|
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-lazy')
|
||||||
|
|
||||||
|
lib, info = gdsii_lazy.readfile(gds_file)
|
||||||
|
|
||||||
|
assert info['name'] == 'classic-lazy'
|
||||||
|
assert lib.source_order() == ('leaf', 'child', 'top')
|
||||||
|
assert lib.child_graph(dangling='ignore') == {
|
||||||
|
'leaf': set(),
|
||||||
|
'child': {'leaf'},
|
||||||
|
'top': {'child'},
|
||||||
|
}
|
||||||
|
assert not lib._cache
|
||||||
|
|
||||||
|
child = lib['child']
|
||||||
|
assert list(child.refs.keys()) == ['leaf']
|
||||||
|
assert set(lib._cache) == {'child'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_lazy_ports_view_keeps_raw_source_unmodified(tmp_path: Path) -> None:
|
||||||
|
gds_file = tmp_path / 'lazy_ports.gds'
|
||||||
|
src = _make_lazy_port_library()
|
||||||
|
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports')
|
||||||
|
|
||||||
|
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||||
|
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||||
|
|
||||||
|
top = processed['top']
|
||||||
|
assert set(top.ports) == {'A'}
|
||||||
|
assert_allclose(top.ports['A'].offset, [110, 225], atol=1e-10)
|
||||||
|
assert not raw._cache
|
||||||
|
|
||||||
|
raw_top = raw['top']
|
||||||
|
assert not raw_top.ports
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: Path) -> None:
|
||||||
|
gds_file = tmp_path / 'lazy_overlay.gds'
|
||||||
|
src = _make_lazy_port_library()
|
||||||
|
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay')
|
||||||
|
|
||||||
|
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||||
|
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||||
|
|
||||||
|
overlay = gdsii_lazy.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_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None:
|
||||||
|
gds_file = tmp_path / 'lazy_roundtrip.gds'
|
||||||
|
src = _make_lazy_port_library()
|
||||||
|
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip')
|
||||||
|
|
||||||
|
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||||
|
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||||
|
|
||||||
|
out_file = tmp_path / 'lazy_roundtrip_out.gds'
|
||||||
|
gdsii_lazy.writefile(processed, out_file)
|
||||||
|
|
||||||
|
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gdsii_removed_closure_based_lazy_loader() -> None:
|
||||||
|
assert not hasattr(gdsii, 'load_library')
|
||||||
|
assert not hasattr(gdsii, 'load_libraryfile')
|
||||||
17
masque/test/test_manhattanize.py
Normal file
17
masque/test/test_manhattanize.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
from ..shapes import Polygon
|
||||||
|
|
||||||
|
|
||||||
|
def test_manhattanize() -> None:
|
||||||
|
pytest.importorskip("float_raster")
|
||||||
|
pytest.importorskip("skimage.measure")
|
||||||
|
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
||||||
|
grid = numpy.arange(0, 11, 1)
|
||||||
|
|
||||||
|
manhattan_polys = poly.manhattanize(grid, grid)
|
||||||
|
assert len(manhattan_polys) >= 1
|
||||||
|
for mp in manhattan_polys:
|
||||||
|
dv = numpy.diff(mp.vertices, axis=0)
|
||||||
|
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
from ..shapes import Path
|
from ..shapes import Path, Path as MPath
|
||||||
|
|
||||||
|
|
||||||
def test_path_init() -> None:
|
def test_path_init() -> None:
|
||||||
|
|
@ -14,7 +14,6 @@ def test_path_to_polygons_flush() -> None:
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
|
||||||
polys = p.to_polygons()
|
polys = p.to_polygons()
|
||||||
assert len(polys) == 1
|
assert len(polys) == 1
|
||||||
# Rectangle from (0, -1) to (10, 1)
|
|
||||||
bounds = polys[0].get_bounds_single()
|
bounds = polys[0].get_bounds_single()
|
||||||
assert_equal(bounds, [[0, -1], [10, 1]])
|
assert_equal(bounds, [[0, -1], [10, 1]])
|
||||||
|
|
||||||
|
|
@ -23,8 +22,6 @@ def test_path_to_polygons_square() -> None:
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
|
||||||
polys = p.to_polygons()
|
polys = p.to_polygons()
|
||||||
assert len(polys) == 1
|
assert len(polys) == 1
|
||||||
# Square cap adds width/2 = 1 to each end
|
|
||||||
# Rectangle from (-1, -1) to (11, 1)
|
|
||||||
bounds = polys[0].get_bounds_single()
|
bounds = polys[0].get_bounds_single()
|
||||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||||
|
|
||||||
|
|
@ -32,11 +29,8 @@ def test_path_to_polygons_square() -> None:
|
||||||
def test_path_to_polygons_circle() -> None:
|
def test_path_to_polygons_circle() -> None:
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
|
||||||
polys = p.to_polygons(num_vertices=32)
|
polys = p.to_polygons(num_vertices=32)
|
||||||
# Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps
|
|
||||||
assert len(polys) >= 3
|
assert len(polys) >= 3
|
||||||
|
|
||||||
# Combined bounds should be from (-1, -1) to (11, 1)
|
|
||||||
# But wait, Path.get_bounds_single() handles this more directly
|
|
||||||
bounds = p.get_bounds_single()
|
bounds = p.get_bounds_single()
|
||||||
assert_equal(bounds, [[-1, -1], [11, 1]])
|
assert_equal(bounds, [[-1, -1], [11, 1]])
|
||||||
|
|
||||||
|
|
@ -45,32 +39,21 @@ def test_path_custom_cap() -> None:
|
||||||
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
|
||||||
polys = p.to_polygons()
|
polys = p.to_polygons()
|
||||||
assert len(polys) == 1
|
assert len(polys) == 1
|
||||||
# Extends 5 units at start, 10 at end
|
|
||||||
# Starts at -5, ends at 20
|
|
||||||
bounds = polys[0].get_bounds_single()
|
bounds = polys[0].get_bounds_single()
|
||||||
assert_equal(bounds, [[-5, -1], [20, 1]])
|
assert_equal(bounds, [[-5, -1], [20, 1]])
|
||||||
|
|
||||||
|
|
||||||
def test_path_bend() -> None:
|
def test_path_bend() -> None:
|
||||||
# L-shaped path
|
|
||||||
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
|
||||||
polys = p.to_polygons()
|
polys = p.to_polygons()
|
||||||
assert len(polys) == 1
|
assert len(polys) == 1
|
||||||
bounds = polys[0].get_bounds_single()
|
bounds = polys[0].get_bounds_single()
|
||||||
# Outer corner at (11, -1) is not right.
|
|
||||||
# Segments: (0,0)-(10,0) and (10,0)-(10,10)
|
|
||||||
# Corners of segment 1: (0,1), (10,1), (10,-1), (0,-1)
|
|
||||||
# Corners of segment 2: (9,0), (9,10), (11,10), (11,0)
|
|
||||||
# Bounds should be [[-1 (if start is square), -1], [11, 11]]?
|
|
||||||
# Flush cap start at (0,0) with width 2 means y from -1 to 1.
|
|
||||||
# Vertical segment end at (10,10) with width 2 means x from 9 to 11.
|
|
||||||
# So bounds should be x: [0, 11], y: [-1, 10]
|
|
||||||
assert_equal(bounds, [[0, -1], [11, 10]])
|
assert_equal(bounds, [[0, -1], [11, 10]])
|
||||||
|
|
||||||
|
|
||||||
def test_path_mirror() -> None:
|
def test_path_mirror() -> None:
|
||||||
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
p = Path(vertices=[[10, 5], [20, 10]], width=2)
|
||||||
p.mirror(0) # Mirror across x axis (y -> -y)
|
p.mirror(0)
|
||||||
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
assert_equal(p.vertices, [[10, -5], [20, -10]])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,3 +92,10 @@ def test_path_normalized_form_distinguishes_custom_caps() -> None:
|
||||||
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
|
p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4))
|
||||||
|
|
||||||
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
|
assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_edge_cases() -> None:
|
||||||
|
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
||||||
|
polys = p.to_polygons()
|
||||||
|
assert len(polys) == 1
|
||||||
|
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import pytest
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import PathTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pather_setup() -> tuple[Pather, PathTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
# Simple PathTool: 2um width on layer (1,0)
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
# Add an initial port facing North (pi/2)
|
|
||||||
# Port rotation points INTO device. So "North" rotation means device is North of port.
|
|
||||||
# Pathing "forward" moves South.
|
|
||||||
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
|
||||||
return p, tool, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# Route 10um "forward"
|
|
||||||
p.straight("start", 10)
|
|
||||||
|
|
||||||
# port rot pi/2 (North). Travel +pi relative to port -> South.
|
|
||||||
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
|
|
||||||
assert p.ports["start"].rotation is not None
|
|
||||||
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# Start (0,0) rot pi/2 (North).
|
|
||||||
# Path 10um "forward" (South), then turn Clockwise (ccw=False).
|
|
||||||
# Facing South, turn Right -> West.
|
|
||||||
p.cw("start", 10)
|
|
||||||
|
|
||||||
# PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0.
|
|
||||||
# Transformed by port rot pi/2 (North) + pi (to move "forward" away from device):
|
|
||||||
# Transformation rot = pi/2 + pi = 3pi/2.
|
|
||||||
# (10, -1) rotated 3pi/2: (x,y) -> (y, -x) -> (-1, -10).
|
|
||||||
|
|
||||||
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
|
|
||||||
# North (pi/2) + CW (90 deg) -> West (pi)?
|
|
||||||
# Actual behavior results in 0 (East) - apparently rotation is flipped.
|
|
||||||
assert p.ports["start"].rotation is not None
|
|
||||||
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# start at (0,0) rot pi/2 (North)
|
|
||||||
# path "forward" (South) to y=-50
|
|
||||||
p.straight("start", y=-50)
|
|
||||||
assert_equal(p.ports["start"].offset, [0, -50])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
|
|
||||||
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
|
|
||||||
|
|
||||||
# Path both "forward" (South) to y=-20
|
|
||||||
p.straight(["A", "B"], ymin=-20)
|
|
||||||
assert_equal(p.ports["A"].offset, [0, -20])
|
|
||||||
assert_equal(p.ports["B"].offset, [10, -20])
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
p, tool, lib = pather_setup
|
|
||||||
# Fluent API test
|
|
||||||
p.at("start").straight(10).ccw(10)
|
|
||||||
# 10um South -> (0, -10) rot pi/2
|
|
||||||
# then 10um South and turn CCW (Facing South, CCW is East)
|
|
||||||
# PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0
|
|
||||||
# Transform (10, 1) by 3pi/2: (x,y) -> (y, -x) -> (1, -10)
|
|
||||||
# (0, -10) + (1, -10) = (1, -20)
|
|
||||||
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
|
|
||||||
# pi/2 (North) + CCW (90 deg) -> 0 (East)?
|
|
||||||
# Actual behavior results in pi (West).
|
|
||||||
assert p.ports["start"].rotation is not None
|
|
||||||
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_dead_ports() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer=(1, 0), width=1)
|
|
||||||
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
|
|
||||||
p.set_dead()
|
|
||||||
|
|
||||||
# Path with negative length (impossible for PathTool, would normally raise BuildError)
|
|
||||||
p.straight("in", -10)
|
|
||||||
|
|
||||||
# Port 'in' should be updated by dummy extension despite tool failure
|
|
||||||
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
|
|
||||||
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
# Downstream path should work correctly using the dummy port location
|
|
||||||
p.straight("in", 20)
|
|
||||||
# 10 + (-20) = -10
|
|
||||||
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
# Verify no geometry
|
|
||||||
assert not p.pattern.has_shapes()
|
|
||||||
|
|
@ -1,936 +0,0 @@
|
||||||
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, Tool
|
|
||||||
from masque.error import BuildError, PortError, PatternError
|
|
||||||
|
|
||||||
def test_pather_trace_basic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
|
|
||||||
# Port rotation 0 points in +x (INTO device).
|
|
||||||
# To extend it, we move in -x direction.
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
# Trace single port
|
|
||||||
p.at('A').trace(None, 5000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
|
||||||
|
|
||||||
# Trace with bend
|
|
||||||
p.at('A').trace(True, 5000) # CCW bend
|
|
||||||
# Port was at (-5000, 0) rot 0.
|
|
||||||
# New wire starts at (-5000, 0) rot 0.
|
|
||||||
# Output port of wire before rotation: (5000, 500) rot -pi/2
|
|
||||||
# Rotate by pi (since dev port rot is 0 and tool port rot is 0):
|
|
||||||
# (-5000, -500) rot pi - pi/2 = pi/2
|
|
||||||
# Add to start: (-10000, -500) rot pi/2
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2)
|
|
||||||
|
|
||||||
def test_pather_trace_to() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
# Trace to x=-10000
|
|
||||||
p.at('A').trace_to(None, x=-10000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
|
||||||
|
|
||||||
# Trace to position=-20000
|
|
||||||
p.at('A').trace_to(None, p=-20000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0))
|
|
||||||
|
|
||||||
def test_pather_bundle_trace() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
|
||||||
|
|
||||||
# Straight bundle - all should align to same x
|
|
||||||
p.at(['A', 'B']).straight(xmin=-10000)
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
|
|
||||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
|
|
||||||
|
|
||||||
# Bundle with bend
|
|
||||||
p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
|
|
||||||
# Traveling in -x direction. CCW turn turns towards -y.
|
|
||||||
# A is at y=0, B is at y=2000.
|
|
||||||
# Rotation center is at y = -R.
|
|
||||||
# A is closer to center than B. So A is inner, B is outer.
|
|
||||||
# xmin is coordinate of innermost bend (A).
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000)
|
|
||||||
# B's bend is further out (more negative x)
|
|
||||||
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000)
|
|
||||||
|
|
||||||
def test_pather_each_bound() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Each should move by 5000 (towards -x)
|
|
||||||
p.at(['A', 'B']).trace(None, each=5000)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000))
|
|
||||||
|
|
||||||
def test_selection_management() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
pp = p.at('A')
|
|
||||||
assert pp.ports == ['A']
|
|
||||||
|
|
||||||
pp.select('B')
|
|
||||||
assert pp.ports == ['A', 'B']
|
|
||||||
|
|
||||||
pp.deselect('A')
|
|
||||||
assert pp.ports == ['B']
|
|
||||||
|
|
||||||
pp.select(['A'])
|
|
||||||
assert pp.ports == ['B', 'A']
|
|
||||||
|
|
||||||
pp.drop()
|
|
||||||
assert 'A' not in p.pattern.ports
|
|
||||||
assert 'B' not in p.pattern.ports
|
|
||||||
assert pp.ports == []
|
|
||||||
|
|
||||||
def test_mark_fork() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib)
|
|
||||||
p.pattern.ports['A'] = Port((100, 200), rotation=1)
|
|
||||||
|
|
||||||
pp = p.at('A')
|
|
||||||
pp.mark('B')
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200))
|
|
||||||
assert p.pattern.ports['B'].rotation == 1
|
|
||||||
assert pp.ports == ['A'] # mark keeps current selection
|
|
||||||
|
|
||||||
pp.fork('C')
|
|
||||||
assert 'C' in p.pattern.ports
|
|
||||||
assert pp.ports == ['C'] # fork switches to new name
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None:
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
p_mark = Pather(lib, pattern=Pattern(ports={
|
|
||||||
'A': Port((0, 0), rotation=0),
|
|
||||||
'C': Port((2, 0), rotation=0),
|
|
||||||
}))
|
|
||||||
with pytest.raises(PortError, match='overwrite existing ports'):
|
|
||||||
p_mark.at('A').mark('C')
|
|
||||||
assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0))
|
|
||||||
|
|
||||||
p_fork = Pather(lib, pattern=Pattern(ports={
|
|
||||||
'A': Port((0, 0), rotation=0),
|
|
||||||
'B': Port((1, 0), rotation=0),
|
|
||||||
}))
|
|
||||||
pp = p_fork.at(['A', 'B'])
|
|
||||||
with pytest.raises(PortError, match='targets would collide'):
|
|
||||||
pp.fork({'A': 'X', 'B': 'X'})
|
|
||||||
assert set(p_fork.pattern.ports) == {'A', 'B'}
|
|
||||||
assert pp.ports == ['A', 'B']
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib, pattern=Pattern(ports={
|
|
||||||
'A': Port((0, 0), rotation=0),
|
|
||||||
'B': Port((1, 0), rotation=0),
|
|
||||||
'C': Port((2, 0), rotation=0),
|
|
||||||
}))
|
|
||||||
p.set_dead()
|
|
||||||
|
|
||||||
p.at('A').mark('C')
|
|
||||||
assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0))
|
|
||||||
|
|
||||||
pp = p.at(['A', 'B'])
|
|
||||||
pp.fork({'A': 'X', 'B': 'X'})
|
|
||||||
assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0))
|
|
||||||
assert pp.ports == ['X']
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_fork_reject_missing_sources() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib, pattern=Pattern(ports={
|
|
||||||
'A': Port((0, 0), rotation=0),
|
|
||||||
'B': Port((1, 0), rotation=0),
|
|
||||||
}))
|
|
||||||
|
|
||||||
with pytest.raises(PortError, match='selected ports'):
|
|
||||||
p.at(['A', 'B']).mark({'Z': 'C'})
|
|
||||||
|
|
||||||
with pytest.raises(PortError, match='selected ports'):
|
|
||||||
p.at(['A', 'B']).fork({'Z': 'C'})
|
|
||||||
|
|
||||||
def test_rename() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
p.at('A').rename('B')
|
|
||||||
assert 'A' not in p.pattern.ports
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
|
|
||||||
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
|
||||||
pp = p.at(['B', 'C'])
|
|
||||||
pp.rename({'B': 'D', 'C': 'E'})
|
|
||||||
assert 'B' not in p.pattern.ports
|
|
||||||
assert 'C' not in p.pattern.ports
|
|
||||||
assert 'D' in p.pattern.ports
|
|
||||||
assert 'E' in p.pattern.ports
|
|
||||||
assert set(pp.ports) == {'D', 'E'}
|
|
||||||
|
|
||||||
def test_renderpather_uturn_fallback() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
rp = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
rp.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
# PathTool doesn't implement planU, so it should fall back to two planL calls
|
|
||||||
rp.at('A').uturn(offset=10000, length=5000)
|
|
||||||
|
|
||||||
# Two steps should be added
|
|
||||||
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
|
|
||||||
assert numpy.isclose(rp.pattern.ports['A'].rotation, pi)
|
|
||||||
|
|
||||||
def test_autotool_uturn() -> None:
|
|
||||||
from masque.builder.tools import AutoTool
|
|
||||||
lib = Library()
|
|
||||||
|
|
||||||
# Setup AutoTool with a simple straight and a bend
|
|
||||||
def make_straight(length: float) -> Pattern:
|
|
||||||
pat = Pattern()
|
|
||||||
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
|
|
||||||
pat.ports['in'] = Port((0, 0), 0)
|
|
||||||
pat.ports['out'] = Port((length, 0), pi)
|
|
||||||
return pat
|
|
||||||
|
|
||||||
bend_pat = Pattern()
|
|
||||||
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
|
|
||||||
bend_pat.ports['in'] = Port((0, 0), 0)
|
|
||||||
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
|
||||||
lib['bend'] = bend_pat
|
|
||||||
|
|
||||||
tool = AutoTool(
|
|
||||||
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, auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), 0)
|
|
||||||
|
|
||||||
# CW U-turn (jog < 0)
|
|
||||||
# R = 500. jog = -2000. length = 1000.
|
|
||||||
# p0 = planL(length=1000) -> out at (1000, -500) rot pi/2
|
|
||||||
# R2 = 500.
|
|
||||||
# l2_length = abs(-2000) - abs(-500) - 500 = 1000.
|
|
||||||
p.at('A').uturn(offset=-2000, length=1000)
|
|
||||||
|
|
||||||
# Final port should be at (-1000, 2000) rot pi
|
|
||||||
# Start: (0,0) rot 0. Wire direction is rot + pi = pi (West, -x).
|
|
||||||
# Tool planU returns (length, jog) = (1000, -2000) relative to (0,0) rot 0.
|
|
||||||
# Rotation of pi transforms (1000, -2000) to (-1000, 2000).
|
|
||||||
# Final rotation: 0 + pi = pi.
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
|
||||||
|
|
||||||
def test_pather_trace_into() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
|
|
||||||
# 1. Straight connector
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((-10000, 0), rotation=pi)
|
|
||||||
p.at('A').trace_into('B', plug_destination=False)
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
assert 'A' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
|
||||||
|
|
||||||
# 2. Single bend
|
|
||||||
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2)
|
|
||||||
p.at('C').trace_into('D', plug_destination=False)
|
|
||||||
assert 'D' in p.pattern.ports
|
|
||||||
assert 'C' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000))
|
|
||||||
|
|
||||||
# 3. Jog (S-bend)
|
|
||||||
p.pattern.ports['E'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi)
|
|
||||||
p.at('E').trace_into('F', plug_destination=False)
|
|
||||||
assert 'F' in p.pattern.ports
|
|
||||||
assert 'E' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000))
|
|
||||||
|
|
||||||
# 4. U-bend (0 deg angle)
|
|
||||||
p.pattern.ports['G'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['H'] = Port((-10000, 2000), rotation=0)
|
|
||||||
p.at('G').trace_into('H', plug_destination=False)
|
|
||||||
assert 'H' in p.pattern.ports
|
|
||||||
assert 'G' in p.pattern.ports
|
|
||||||
# A U-bend with length=-travel=10000 and jog=-2000 from (0,0) rot 0
|
|
||||||
# ends up at (-10000, 2000) rot pi.
|
|
||||||
assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000))
|
|
||||||
assert p.pattern.ports['G'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['G'].rotation, pi)
|
|
||||||
|
|
||||||
# 5. Vertical straight connector
|
|
||||||
p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2)
|
|
||||||
p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2)
|
|
||||||
p.at('I').trace_into('J', plug_destination=False)
|
|
||||||
assert 'J' in p.pattern.ports
|
|
||||||
assert 'I' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000))
|
|
||||||
assert p.pattern.ports['I'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
|
||||||
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()
|
|
||||||
|
|
||||||
p.trace_into('A', 'B', plug_destination=False)
|
|
||||||
|
|
||||||
assert set(p.pattern.ports) == {'A', 'B'}
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
assert not p.pattern.has_shapes()
|
|
||||||
assert not p.pattern.has_refs()
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_dead_fallback_preserves_out_ptype() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
|
||||||
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')
|
|
||||||
|
|
||||||
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_place_overwrites_colliding_ports_last_wins() -> None:
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib, pattern=Pattern(ports={
|
|
||||||
'A': Port((5, 5), rotation=0),
|
|
||||||
'keep': Port((9, 9), rotation=0),
|
|
||||||
}))
|
|
||||||
p.set_dead()
|
|
||||||
|
|
||||||
other = Pattern()
|
|
||||||
other.ports['X'] = Port((1, 0), rotation=0)
|
|
||||||
other.ports['Y'] = Port((2, 0), rotation=pi / 2)
|
|
||||||
|
|
||||||
p.place(other, port_map={'X': 'A', 'Y': 'A'})
|
|
||||||
|
|
||||||
assert set(p.pattern.ports) == {'A', 'keep'}
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool, pattern=Pattern(ports={
|
|
||||||
'A': Port((0, 0), rotation=0, ptype='wire'),
|
|
||||||
'B': Port((99, 99), rotation=0, ptype='wire'),
|
|
||||||
}))
|
|
||||||
p.set_dead()
|
|
||||||
|
|
||||||
other = Pattern()
|
|
||||||
other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire')
|
|
||||||
other.ports['X'] = Port((10, 0), rotation=0, ptype='wire')
|
|
||||||
other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'})
|
|
||||||
|
|
||||||
assert 'A' not in p.pattern.ports
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0))
|
|
||||||
assert p.pattern.ports['B'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['B'].rotation, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None:
|
|
||||||
p = Pather(Library(), pattern=Pattern(ports={
|
|
||||||
'A': Port((0, 0), rotation=0),
|
|
||||||
'B': Port((1, 0), rotation=0),
|
|
||||||
'C': Port((2, 0), rotation=0),
|
|
||||||
}))
|
|
||||||
p.set_dead()
|
|
||||||
|
|
||||||
p.rename_ports({'A': 'C', 'B': 'C'})
|
|
||||||
|
|
||||||
assert set(p.pattern.ports) == {'C'}
|
|
||||||
assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_jog_failed_fallback_is_atomic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='shorter than required bend'):
|
|
||||||
p.jog('A', 1.5, length=1.5)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.jog('A', 1.5, length=5)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
|
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_jog_length_solved_from_single_position_bound() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.jog('A', 2, x=-6)
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
|
||||||
|
|
||||||
q = Pather(Library(), tools=tool)
|
|
||||||
q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
q.jog('A', 2, p=-6)
|
|
||||||
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_jog_requires_length_or_one_position_bound() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='requires either length'):
|
|
||||||
p.jog('A', 2)
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='exactly one positional bound'):
|
|
||||||
p.jog('A', 2, x=-6, p=-6)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
|
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
|
||||||
|
|
||||||
for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}):
|
|
||||||
p = Pather(Library(), tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
with pytest.raises(BuildError, match='exactly one positional bound'):
|
|
||||||
p.trace_to('A', None, **kwargs)
|
|
||||||
|
|
||||||
p = Pather(Library(), tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
with pytest.raises(BuildError, match='length cannot be combined'):
|
|
||||||
p.trace_to('A', None, x=-5, length=3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_trace_rejects_length_with_bundle_bound() -> None:
|
|
||||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='length cannot be combined'):
|
|
||||||
p.trace('A', None, length=5, xmin=-100)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}))
|
|
||||||
def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None:
|
|
||||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='exactly one bundle bound'):
|
|
||||||
p.trace(['A', 'B'], None, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_jog_rejects_length_with_position_bound() -> None:
|
|
||||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='length cannot be combined'):
|
|
||||||
p.jog('A', 2, length=5, x=-999)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10}))
|
|
||||||
def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
|
|
||||||
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
|
|
||||||
p.uturn('A', 4, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_uturn_none_length_defaults_to_zero() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.uturn('A', 4)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4))
|
|
||||||
assert p.pattern.ports['A'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_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, 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
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire')
|
|
||||||
p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(PortError, match='overwritten'):
|
|
||||||
p.trace_into('A', 'B', plug_destination=False, thru='other')
|
|
||||||
|
|
||||||
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'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,
|
|
||||||
kwargs: dict[str, Any],
|
|
||||||
match: str,
|
|
||||||
) -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
p.pattern.ports['B'] = dst
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match=match):
|
|
||||||
p.trace_into('A', 'B', plug_destination=False, **kwargs)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
|
||||||
assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset)
|
|
||||||
assert dst.rotation is not None
|
|
||||||
assert p.pattern.ports['B'].rotation is not None
|
|
||||||
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None:
|
|
||||||
class OutPtypeSensitiveTool(Tool):
|
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
|
|
||||||
radius = 1 if out_ptype is None else 2
|
|
||||||
if ccw is None:
|
|
||||||
rotation = pi
|
|
||||||
jog = 0
|
|
||||||
elif bool(ccw):
|
|
||||||
rotation = -pi / 2
|
|
||||||
jog = radius
|
|
||||||
else:
|
|
||||||
rotation = pi / 2
|
|
||||||
jog = -radius
|
|
||||||
ptype = out_ptype or in_ptype or 'wire'
|
|
||||||
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
|
||||||
|
|
||||||
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='fallback via two planL'):
|
|
||||||
p.jog('A', 5, length=10, out_ptype='wide')
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None:
|
|
||||||
class OutPtypeSensitiveTool(Tool):
|
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
|
|
||||||
radius = 1 if out_ptype is None else 2
|
|
||||||
if ccw is None:
|
|
||||||
rotation = pi
|
|
||||||
jog = 0
|
|
||||||
elif bool(ccw):
|
|
||||||
rotation = -pi / 2
|
|
||||||
jog = radius
|
|
||||||
else:
|
|
||||||
rotation = pi / 2
|
|
||||||
jog = -radius
|
|
||||||
ptype = out_ptype or in_ptype or 'wire'
|
|
||||||
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
|
||||||
|
|
||||||
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='fallback via two planL'):
|
|
||||||
p.uturn('A', 5, length=10, out_ptype='wide')
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
|
|
||||||
class DummyTool(Tool):
|
|
||||||
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
|
|
||||||
pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire')
|
|
||||||
lib['top'] = pat
|
|
||||||
return lib
|
|
||||||
|
|
||||||
out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y'))
|
|
||||||
assert numpy.allclose(out_port.offset, (5, 0))
|
|
||||||
assert numpy.isclose(out_port.rotation, pi)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_planS_fallback_accepts_custom_port_names() -> None:
|
|
||||||
class DummyTool(Tool):
|
|
||||||
def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
|
||||||
lib = Library()
|
|
||||||
pat = Pattern()
|
|
||||||
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
|
|
||||||
pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire')
|
|
||||||
lib['top'] = pat
|
|
||||||
return lib
|
|
||||||
|
|
||||||
out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y'))
|
|
||||||
assert numpy.allclose(out_port.offset, (5, 2))
|
|
||||||
assert numpy.isclose(out_port.rotation, pi)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_uturn_failed_fallback_is_atomic() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='shorter than required bend'):
|
|
||||||
p.uturn('A', 1.5, length=0)
|
|
||||||
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
|
||||||
assert p.pattern.ports['A'].rotation == 0
|
|
||||||
assert len(p.paths['A']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
|
||||||
class FullTreeTool(Tool):
|
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
|
||||||
ptype = out_ptype or in_ptype or 'wire'
|
|
||||||
return Port((length, 0), rotation=pi, ptype=ptype), {'length': 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((1, 0), pi, ptype='wire'),
|
|
||||||
})
|
|
||||||
child = Pattern(annotations={'batch': [len(batch)]})
|
|
||||||
top.ref('_seg')
|
|
||||||
tree['_top'] = top
|
|
||||||
tree['_seg'] = child
|
|
||||||
return tree
|
|
||||||
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib, tools=FullTreeTool(), auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.straight('A', 10)
|
|
||||||
p.render()
|
|
||||||
p.straight('A', 10)
|
|
||||||
p.render()
|
|
||||||
|
|
||||||
assert len(lib) == 2
|
|
||||||
assert set(lib.keys()) == set(p.pattern.refs.keys())
|
|
||||||
assert len(set(p.pattern.refs.keys())) == 2
|
|
||||||
assert all(name.startswith('_seg') for name in lib)
|
|
||||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_render_fallback_preserves_segment_subtrees() -> None:
|
|
||||||
class TraceTreeTool(Tool):
|
|
||||||
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'),
|
|
||||||
})
|
|
||||||
child = Pattern(annotations={'length': [length]})
|
|
||||||
top.ref('_seg')
|
|
||||||
tree['_top'] = top
|
|
||||||
tree['_seg'] = child
|
|
||||||
return tree
|
|
||||||
|
|
||||||
lib = Library()
|
|
||||||
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.straight('A', 10)
|
|
||||||
p.render()
|
|
||||||
|
|
||||||
assert '_seg' in lib
|
|
||||||
assert '_seg' in p.pattern.refs
|
|
||||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
|
|
||||||
class MissingSingleUseTool(Tool):
|
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
|
||||||
ptype = out_ptype or in_ptype or 'wire'
|
|
||||||
return Port((length, 0), rotation=pi, ptype=ptype), {'length': 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((1, 0), pi, ptype='wire'),
|
|
||||||
})
|
|
||||||
top.ref('_seg')
|
|
||||||
tree['_top'] = top
|
|
||||||
return tree
|
|
||||||
|
|
||||||
lib = Library()
|
|
||||||
lib['_seg'] = Pattern(annotations={'stale': [1]})
|
|
||||||
p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
p.straight('A', 10)
|
|
||||||
|
|
||||||
with pytest.raises(BuildError, match='missing single-use refs'):
|
|
||||||
p.render()
|
|
||||||
|
|
||||||
assert list(lib.keys()) == ['_seg']
|
|
||||||
assert not p.pattern.refs
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
|
||||||
class SharedRefTool(Tool):
|
|
||||||
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
|
||||||
ptype = out_ptype or in_ptype or 'wire'
|
|
||||||
return Port((length, 0), rotation=pi, ptype=ptype), {'length': 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((1, 0), pi, ptype='wire'),
|
|
||||||
})
|
|
||||||
top.ref('shared')
|
|
||||||
tree['_top'] = top
|
|
||||||
return tree
|
|
||||||
|
|
||||||
lib = Library()
|
|
||||||
lib['shared'] = Pattern(annotations={'shared': [1]})
|
|
||||||
p = Pather(lib, tools=SharedRefTool(), auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
|
||||||
|
|
||||||
p.straight('A', 10)
|
|
||||||
p.render()
|
|
||||||
|
|
||||||
assert 'shared' in p.pattern.refs
|
|
||||||
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_rename_to_none_keeps_pending_geometry_without_port() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
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
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.has_shapes()
|
|
||||||
assert 'A' not in rp.pattern.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_place_treeview_resolves_once() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
|
|
||||||
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
|
|
||||||
|
|
||||||
p.place(tree)
|
|
||||||
|
|
||||||
assert len(lib) == 1
|
|
||||||
assert 'child' in lib
|
|
||||||
assert 'child' in p.pattern.refs
|
|
||||||
assert 'B' in p.pattern.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_plug_treeview_resolves_once() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
|
|
||||||
|
|
||||||
p.plug(tree, {'A': 'B'})
|
|
||||||
|
|
||||||
assert len(lib) == 1
|
|
||||||
assert 'child' in lib
|
|
||||||
assert 'child' in p.pattern.refs
|
|
||||||
assert 'A' not in p.pattern.ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_failed_plug_does_not_add_break_marker() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, 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']
|
|
||||||
|
|
||||||
other = Pattern(
|
|
||||||
annotations={'k': [2]},
|
|
||||||
ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(PatternError, match='Annotation keys overlap'):
|
|
||||||
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
|
|
||||||
|
|
||||||
assert [step.opcode for step in p.paths['A']] == ['L']
|
|
||||||
assert set(p.pattern.ports) == {'A'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
p.at('A').straight(5000)
|
|
||||||
p.rename_ports({'A': None})
|
|
||||||
|
|
||||||
other = Pattern(ports={'X': Port((-5000, 0), rotation=0)})
|
|
||||||
p.place(other, port_map={'X': 'A'}, append=True)
|
|
||||||
p.at('A').straight(2000)
|
|
||||||
|
|
||||||
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
|
|
||||||
|
|
||||||
p.render()
|
|
||||||
assert p.pattern.has_shapes()
|
|
||||||
assert 'A' in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
|
||||||
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
|
||||||
|
|
||||||
p.at('A').straight(5000)
|
|
||||||
p.rename_ports({'A': None})
|
|
||||||
|
|
||||||
other = Pattern(
|
|
||||||
ports={
|
|
||||||
'X': Port((0, 0), rotation=pi),
|
|
||||||
'Y': Port((-5000, 0), rotation=0),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
|
|
||||||
p.at('A').straight(2000)
|
|
||||||
|
|
||||||
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
|
|
||||||
|
|
||||||
p.render()
|
|
||||||
assert p.pattern.has_shapes()
|
|
||||||
assert 'A' in p.pattern.ports
|
|
||||||
assert 'B' not in p.pattern.ports
|
|
||||||
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer='M1', width=1000)
|
|
||||||
p = Pather(lib, tools=tool, 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']
|
|
||||||
|
|
||||||
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'}
|
|
||||||
127
masque/test/test_pather_autotool.py
Normal file
127
masque/test/test_pather_autotool.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from masque import Pather, Library, Pattern, Port
|
||||||
|
from masque.builder.tools import AutoTool
|
||||||
|
|
||||||
|
|
||||||
|
def make_straight(length, 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(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=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((R, -R), pi/2, ptype=ptype)
|
||||||
|
else:
|
||||||
|
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((R, R), -pi/2, ptype=ptype)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def multi_bend_tool():
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
lib["b1"] = make_bend(2, ptype="wire")
|
||||||
|
b1_abs = lib.abstract("b1")
|
||||||
|
lib["b2"] = make_bend(5, ptype="wire")
|
||||||
|
b2_abs = lib.abstract("b2")
|
||||||
|
|
||||||
|
tool = AutoTool(
|
||||||
|
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:
|
||||||
|
from masque.builder.tools import AutoTool
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
def make_straight(length: float) -> Pattern:
|
||||||
|
pat = Pattern()
|
||||||
|
pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000)
|
||||||
|
pat.ports['in'] = Port((0, 0), 0)
|
||||||
|
pat.ports['out'] = Port((length, 0), pi)
|
||||||
|
return pat
|
||||||
|
|
||||||
|
bend_pat = Pattern()
|
||||||
|
bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)])
|
||||||
|
bend_pat.ports['in'] = Port((0, 0), 0)
|
||||||
|
bend_pat.ports['out'] = Port((500, -500), pi/2)
|
||||||
|
lib['bend'] = bend_pat
|
||||||
|
|
||||||
|
tool = AutoTool(
|
||||||
|
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, auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), 0)
|
||||||
|
|
||||||
|
p.at('A').uturn(offset=-2000, length=1000)
|
||||||
|
|
||||||
|
# U-turn plan output is transformed into the port extension frame.
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||||
|
|
||||||
|
def test_deferred_render_autotool_double_L(multi_bend_tool) -> None:
|
||||||
|
tool, lib = multi_bend_tool
|
||||||
|
rp = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
rp.ports["A"] = Port((0,0), 0, ptype="wire")
|
||||||
|
|
||||||
|
rp.jog("A", 10, length=20)
|
||||||
|
|
||||||
|
assert_allclose(rp.ports["A"].offset, [-20, -10])
|
||||||
|
assert_allclose(rp.ports["A"].rotation, 0)
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert len(rp.pattern.refs) > 0
|
||||||
|
|
||||||
|
def test_pather_uturn_fallback_no_heuristic(multi_bend_tool) -> None:
|
||||||
|
tool, lib = multi_bend_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)
|
||||||
|
|
||||||
|
# Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames,
|
||||||
|
# yielding a global endpoint at (-5, -10).
|
||||||
|
assert_allclose(p.ports["A"].offset, [-5, -10])
|
||||||
|
assert_allclose(p.ports["A"].rotation, pi)
|
||||||
213
masque/test/test_pather_constraints.py
Normal file
213
masque/test/test_pather_constraints.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
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, Tool
|
||||||
|
from masque.error import BuildError, PortError, PatternError
|
||||||
|
|
||||||
|
|
||||||
|
def test_pather_jog_failed_fallback_is_atomic() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='shorter than required bend'):
|
||||||
|
p.jog('A', 1.5, length=1.5)
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
|
assert p.pattern.ports['A'].rotation == 0
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
|
|
||||||
|
def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.jog('A', 1.5, length=5)
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5))
|
||||||
|
assert p.pattern.ports['A'].rotation == 0
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
|
|
||||||
|
def test_pather_jog_length_solved_from_single_position_bound() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.jog('A', 2, x=-6)
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||||
|
|
||||||
|
q = Pather(Library(), tools=tool)
|
||||||
|
q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
q.jog('A', 2, p=-6)
|
||||||
|
assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2))
|
||||||
|
|
||||||
|
def test_pather_jog_requires_length_or_one_position_bound() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='requires either length'):
|
||||||
|
p.jog('A', 2)
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='exactly one positional bound'):
|
||||||
|
p.jog('A', 2, x=-6, p=-6)
|
||||||
|
|
||||||
|
def test_pather_trace_to_rejects_conflicting_position_bounds() -> None:
|
||||||
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
|
|
||||||
|
for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}):
|
||||||
|
p = Pather(Library(), tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
with pytest.raises(BuildError, match='exactly one positional bound'):
|
||||||
|
p.trace_to('A', None, **kwargs)
|
||||||
|
|
||||||
|
p = Pather(Library(), tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||||
|
p.trace_to('A', None, x=-5, length=3)
|
||||||
|
|
||||||
|
def test_pather_trace_rejects_length_with_bundle_bound() -> None:
|
||||||
|
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||||
|
p.trace('A', None, length=5, xmin=-100)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kwargs', ({'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}))
|
||||||
|
def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None:
|
||||||
|
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='exactly one bundle bound'):
|
||||||
|
p.trace(['A', 'B'], None, **kwargs)
|
||||||
|
|
||||||
|
def test_pather_jog_rejects_length_with_position_bound() -> None:
|
||||||
|
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='length cannot be combined'):
|
||||||
|
p.jog('A', 2, length=5, x=-999)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('kwargs', ({'x': -999}, {'xmin': -10}))
|
||||||
|
def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None:
|
||||||
|
p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'))
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'):
|
||||||
|
p.uturn('A', 4, **kwargs)
|
||||||
|
|
||||||
|
def test_pather_uturn_none_length_defaults_to_zero() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.uturn('A', 4)
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, pi)
|
||||||
|
|
||||||
|
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_jog() -> None:
|
||||||
|
class OutPtypeSensitiveTool(Tool):
|
||||||
|
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
|
||||||
|
radius = 1 if out_ptype is None else 2
|
||||||
|
if ccw is None:
|
||||||
|
rotation = pi
|
||||||
|
jog = 0
|
||||||
|
elif bool(ccw):
|
||||||
|
rotation = -pi / 2
|
||||||
|
jog = radius
|
||||||
|
else:
|
||||||
|
rotation = pi / 2
|
||||||
|
jog = -radius
|
||||||
|
ptype = out_ptype or in_ptype or 'wire'
|
||||||
|
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
||||||
|
|
||||||
|
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='fallback via two planL'):
|
||||||
|
p.jog('A', 5, length=10, out_ptype='wide')
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
|
|
||||||
|
def test_pather_two_l_fallback_validation_rejects_out_ptype_sensitive_uturn() -> None:
|
||||||
|
class OutPtypeSensitiveTool(Tool):
|
||||||
|
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs):
|
||||||
|
radius = 1 if out_ptype is None else 2
|
||||||
|
if ccw is None:
|
||||||
|
rotation = pi
|
||||||
|
jog = 0
|
||||||
|
elif bool(ccw):
|
||||||
|
rotation = -pi / 2
|
||||||
|
jog = radius
|
||||||
|
else:
|
||||||
|
rotation = pi / 2
|
||||||
|
jog = -radius
|
||||||
|
ptype = out_ptype or in_ptype or 'wire'
|
||||||
|
return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length}
|
||||||
|
|
||||||
|
p = Pather(Library(), tools=OutPtypeSensitiveTool())
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='fallback via two planL'):
|
||||||
|
p.uturn('A', 5, length=10, out_ptype='wide')
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
|
|
||||||
|
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
|
||||||
|
class DummyTool(Tool):
|
||||||
|
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
||||||
|
lib = Library()
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
|
||||||
|
pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire')
|
||||||
|
lib['top'] = pat
|
||||||
|
return lib
|
||||||
|
|
||||||
|
out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y'))
|
||||||
|
assert numpy.allclose(out_port.offset, (5, 0))
|
||||||
|
assert numpy.isclose(out_port.rotation, pi)
|
||||||
|
|
||||||
|
def test_tool_planS_fallback_accepts_custom_port_names() -> None:
|
||||||
|
class DummyTool(Tool):
|
||||||
|
def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
||||||
|
lib = Library()
|
||||||
|
pat = Pattern()
|
||||||
|
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
|
||||||
|
pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire')
|
||||||
|
lib['top'] = pat
|
||||||
|
return lib
|
||||||
|
|
||||||
|
out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y'))
|
||||||
|
assert numpy.allclose(out_port.offset, (5, 2))
|
||||||
|
assert numpy.isclose(out_port.rotation, pi)
|
||||||
|
|
||||||
|
def test_pather_uturn_failed_fallback_is_atomic() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='shorter than required bend'):
|
||||||
|
p.uturn('A', 1.5, length=0)
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
|
assert p.pattern.ports['A'].rotation == 0
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
305
masque/test/test_pather_core.py
Normal file
305
masque/test/test_pather_core.py
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_allclose, assert_equal
|
||||||
|
|
||||||
|
from masque import Pather, Library, Pattern, Port
|
||||||
|
from masque.builder.tools import PathTool, Tool
|
||||||
|
from masque.error import BuildError, PortError, PatternError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pather_setup() -> tuple[Pather, PathTool, Library]:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
# Port rotation points into the device, so path extension moves in the opposite direction.
|
||||||
|
p.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||||
|
return p, tool, lib
|
||||||
|
|
||||||
|
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, tool, lib = pather_setup
|
||||||
|
p.straight("start", 10)
|
||||||
|
|
||||||
|
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
|
||||||
|
assert p.ports["start"].rotation is not None
|
||||||
|
assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10)
|
||||||
|
|
||||||
|
def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, tool, lib = pather_setup
|
||||||
|
p.cw("start", 10)
|
||||||
|
|
||||||
|
assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10)
|
||||||
|
assert p.ports["start"].rotation is not None
|
||||||
|
assert_allclose(p.ports["start"].rotation, 0, atol=1e-10)
|
||||||
|
|
||||||
|
def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, tool, lib = pather_setup
|
||||||
|
p.straight("start", y=-50)
|
||||||
|
assert_equal(p.ports["start"].offset, [0, -50])
|
||||||
|
|
||||||
|
def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, tool, lib = pather_setup
|
||||||
|
p.ports["A"] = Port((0, 0), pi / 2, ptype="wire")
|
||||||
|
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
|
||||||
|
|
||||||
|
p.straight(["A", "B"], ymin=-20)
|
||||||
|
assert_equal(p.ports["A"].offset, [0, -20])
|
||||||
|
assert_equal(p.ports["B"].offset, [10, -20])
|
||||||
|
|
||||||
|
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, tool, lib = pather_setup
|
||||||
|
p.at("start").straight(10).ccw(10)
|
||||||
|
assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10)
|
||||||
|
assert p.ports["start"].rotation is not None
|
||||||
|
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10)
|
||||||
|
|
||||||
|
def test_pather_dead_ports() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer=(1, 0), width=1)
|
||||||
|
p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool)
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
p.straight("in", -10)
|
||||||
|
|
||||||
|
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
|
||||||
|
|
||||||
|
p.straight("in", 20)
|
||||||
|
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)
|
||||||
|
|
||||||
|
assert not p.pattern.has_shapes()
|
||||||
|
|
||||||
|
def test_pather_trace_basic() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
|
||||||
|
# Routing extends opposite the port's inward-facing rotation.
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').trace(None, 5000)
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
||||||
|
|
||||||
|
p.at('A').trace(True, 5000) # CCW bend
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2)
|
||||||
|
|
||||||
|
def test_pather_trace_to() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').trace_to(None, x=-10000)
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||||
|
|
||||||
|
p.at('A').trace_to(None, p=-20000)
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0))
|
||||||
|
|
||||||
|
def test_pather_bundle_trace() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['B'] = Port((0, 2000), rotation=0)
|
||||||
|
|
||||||
|
p.at(['A', 'B']).straight(xmin=-10000)
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000)
|
||||||
|
assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000)
|
||||||
|
|
||||||
|
p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000)
|
||||||
|
# The lower port is on the inner bend, so `xmin` applies to that route.
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000)
|
||||||
|
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000)
|
||||||
|
|
||||||
|
def test_pather_each_bound() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
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)
|
||||||
|
|
||||||
|
p.at(['A', 'B']).trace(None, each=5000)
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0))
|
||||||
|
assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000))
|
||||||
|
|
||||||
|
def test_selection_management() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
pp = p.at('A')
|
||||||
|
assert pp.ports == ['A']
|
||||||
|
|
||||||
|
pp.select('B')
|
||||||
|
assert pp.ports == ['A', 'B']
|
||||||
|
|
||||||
|
pp.deselect('A')
|
||||||
|
assert pp.ports == ['B']
|
||||||
|
|
||||||
|
pp.select(['A'])
|
||||||
|
assert pp.ports == ['B', 'A']
|
||||||
|
|
||||||
|
pp.drop()
|
||||||
|
assert 'A' not in p.pattern.ports
|
||||||
|
assert 'B' not in p.pattern.ports
|
||||||
|
assert pp.ports == []
|
||||||
|
|
||||||
|
def test_mark_fork() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib)
|
||||||
|
p.pattern.ports['A'] = Port((100, 200), rotation=1)
|
||||||
|
|
||||||
|
pp = p.at('A')
|
||||||
|
pp.mark('B')
|
||||||
|
assert 'B' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200))
|
||||||
|
assert p.pattern.ports['B'].rotation == 1
|
||||||
|
assert pp.ports == ['A']
|
||||||
|
|
||||||
|
pp.fork('C')
|
||||||
|
assert 'C' in p.pattern.ports
|
||||||
|
assert pp.ports == ['C']
|
||||||
|
|
||||||
|
def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None:
|
||||||
|
lib = Library()
|
||||||
|
|
||||||
|
p_mark = Pather(lib, pattern=Pattern(ports={
|
||||||
|
'A': Port((0, 0), rotation=0),
|
||||||
|
'C': Port((2, 0), rotation=0),
|
||||||
|
}))
|
||||||
|
with pytest.raises(PortError, match='overwrite existing ports'):
|
||||||
|
p_mark.at('A').mark('C')
|
||||||
|
assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0))
|
||||||
|
|
||||||
|
p_fork = Pather(lib, pattern=Pattern(ports={
|
||||||
|
'A': Port((0, 0), rotation=0),
|
||||||
|
'B': Port((1, 0), rotation=0),
|
||||||
|
}))
|
||||||
|
pp = p_fork.at(['A', 'B'])
|
||||||
|
with pytest.raises(PortError, match='targets would collide'):
|
||||||
|
pp.fork({'A': 'X', 'B': 'X'})
|
||||||
|
assert set(p_fork.pattern.ports) == {'A', 'B'}
|
||||||
|
assert pp.ports == ['A', 'B']
|
||||||
|
|
||||||
|
def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib, pattern=Pattern(ports={
|
||||||
|
'A': Port((0, 0), rotation=0),
|
||||||
|
'B': Port((1, 0), rotation=0),
|
||||||
|
'C': Port((2, 0), rotation=0),
|
||||||
|
}))
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
p.at('A').mark('C')
|
||||||
|
assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0))
|
||||||
|
|
||||||
|
pp = p.at(['A', 'B'])
|
||||||
|
pp.fork({'A': 'X', 'B': 'X'})
|
||||||
|
assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0))
|
||||||
|
assert pp.ports == ['X']
|
||||||
|
|
||||||
|
def test_mark_fork_reject_missing_sources() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib, pattern=Pattern(ports={
|
||||||
|
'A': Port((0, 0), rotation=0),
|
||||||
|
'B': Port((1, 0), rotation=0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
with pytest.raises(PortError, match='selected ports'):
|
||||||
|
p.at(['A', 'B']).mark({'Z': 'C'})
|
||||||
|
|
||||||
|
with pytest.raises(PortError, match='selected ports'):
|
||||||
|
p.at(['A', 'B']).fork({'Z': 'C'})
|
||||||
|
|
||||||
|
def test_rename() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').rename('B')
|
||||||
|
assert 'A' not in p.pattern.ports
|
||||||
|
assert 'B' in p.pattern.ports
|
||||||
|
|
||||||
|
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
||||||
|
pp = p.at(['B', 'C'])
|
||||||
|
pp.rename({'B': 'D', 'C': 'E'})
|
||||||
|
assert 'B' not in p.pattern.ports
|
||||||
|
assert 'C' not in p.pattern.ports
|
||||||
|
assert 'D' in p.pattern.ports
|
||||||
|
assert 'E' in p.pattern.ports
|
||||||
|
assert set(pp.ports) == {'D', 'E'}
|
||||||
|
|
||||||
|
def test_pather_dead_fallback_preserves_out_ptype() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
p.straight('A', -1000, out_ptype='other')
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0))
|
||||||
|
assert p.pattern.ports['A'].ptype == 'other'
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
|
|
||||||
|
def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None:
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib, pattern=Pattern(ports={
|
||||||
|
'A': Port((5, 5), rotation=0),
|
||||||
|
'keep': Port((9, 9), rotation=0),
|
||||||
|
}))
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
other = Pattern()
|
||||||
|
other.ports['X'] = Port((1, 0), rotation=0)
|
||||||
|
other.ports['Y'] = Port((2, 0), rotation=pi / 2)
|
||||||
|
|
||||||
|
p.place(other, port_map={'X': 'A', 'Y': 'A'})
|
||||||
|
|
||||||
|
assert set(p.pattern.ports) == {'A', 'keep'}
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2)
|
||||||
|
|
||||||
|
def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool, pattern=Pattern(ports={
|
||||||
|
'A': Port((0, 0), rotation=0, ptype='wire'),
|
||||||
|
'B': Port((99, 99), rotation=0, ptype='wire'),
|
||||||
|
}))
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
other = Pattern()
|
||||||
|
other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire')
|
||||||
|
other.ports['X'] = Port((10, 0), rotation=0, ptype='wire')
|
||||||
|
other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'})
|
||||||
|
|
||||||
|
assert 'A' not in p.pattern.ports
|
||||||
|
assert 'B' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0))
|
||||||
|
assert p.pattern.ports['B'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['B'].rotation, 0)
|
||||||
|
|
||||||
|
def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None:
|
||||||
|
p = Pather(Library(), pattern=Pattern(ports={
|
||||||
|
'A': Port((0, 0), rotation=0),
|
||||||
|
'B': Port((1, 0), rotation=0),
|
||||||
|
'C': Port((2, 0), rotation=0),
|
||||||
|
}))
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
p.rename_ports({'A': 'C', 'B': 'C'})
|
||||||
|
|
||||||
|
assert set(p.pattern.ports) == {'C'}
|
||||||
|
assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0))
|
||||||
122
masque/test/test_pather_place_plug.py
Normal file
122
masque/test/test_pather_place_plug.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
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, 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)
|
||||||
|
|
||||||
|
tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})}
|
||||||
|
|
||||||
|
p.place(tree)
|
||||||
|
|
||||||
|
assert len(lib) == 1
|
||||||
|
assert 'child' in lib
|
||||||
|
assert 'child' in p.pattern.refs
|
||||||
|
assert 'B' in p.pattern.ports
|
||||||
|
|
||||||
|
def test_pather_plug_treeview_resolves_once() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})}
|
||||||
|
|
||||||
|
p.plug(tree, {'A': 'B'})
|
||||||
|
|
||||||
|
assert len(lib) == 1
|
||||||
|
assert 'child' in lib
|
||||||
|
assert 'child' in p.pattern.refs
|
||||||
|
assert 'A' not in p.pattern.ports
|
||||||
|
|
||||||
|
def test_pather_failed_plug_does_not_add_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, 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']
|
||||||
|
|
||||||
|
other = Pattern(
|
||||||
|
annotations={'k': [2]},
|
||||||
|
ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PatternError, match='Annotation keys overlap'):
|
||||||
|
p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True)
|
||||||
|
|
||||||
|
assert [step.opcode for step in p.paths['A']] == ['L']
|
||||||
|
assert set(p.pattern.ports) == {'A'}
|
||||||
|
|
||||||
|
def test_pather_place_reused_deleted_name_keeps_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').straight(5000)
|
||||||
|
p.rename_ports({'A': None})
|
||||||
|
|
||||||
|
other = Pattern(ports={'X': Port((-5000, 0), rotation=0)})
|
||||||
|
p.place(other, port_map={'X': 'A'}, append=True)
|
||||||
|
p.at('A').straight(2000)
|
||||||
|
|
||||||
|
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
|
||||||
|
|
||||||
|
p.render()
|
||||||
|
assert p.pattern.has_shapes()
|
||||||
|
assert 'A' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
||||||
|
|
||||||
|
def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['B'] = Port((0, 0), rotation=0)
|
||||||
|
|
||||||
|
p.at('A').straight(5000)
|
||||||
|
p.rename_ports({'A': None})
|
||||||
|
|
||||||
|
other = Pattern(
|
||||||
|
ports={
|
||||||
|
'X': Port((0, 0), rotation=pi),
|
||||||
|
'Y': Port((-5000, 0), rotation=0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True)
|
||||||
|
p.at('A').straight(2000)
|
||||||
|
|
||||||
|
assert [step.opcode for step in p.paths['A']] == ['L', 'P', 'L']
|
||||||
|
|
||||||
|
p.render()
|
||||||
|
assert p.pattern.has_shapes()
|
||||||
|
assert 'A' in p.pattern.ports
|
||||||
|
assert 'B' not in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0))
|
||||||
|
|
||||||
|
def test_pather_failed_plugged_does_not_add_break_marker() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
p = Pather(lib, tools=tool, 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']
|
||||||
|
|
||||||
|
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'}
|
||||||
312
masque/test/test_pather_rendering.py
Normal file
312
masque/test/test_pather_rendering.py
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_allclose
|
||||||
|
|
||||||
|
from ..builder import Pather
|
||||||
|
from ..builder.tools import PathTool, Tool
|
||||||
|
from ..error import BuildError
|
||||||
|
from ..library import Library
|
||||||
|
from ..pattern import Pattern
|
||||||
|
from ..ports import Port
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..shapes import Path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def deferred_render_setup() -> tuple[Pather, PathTool, Library]:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||||
|
rp = Pather(lib, tools=tool, auto_render=False)
|
||||||
|
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
||||||
|
return rp, tool, lib
|
||||||
|
|
||||||
|
def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
rp, tool, lib = deferred_render_setup
|
||||||
|
rp.at("start").straight(10).straight(10)
|
||||||
|
|
||||||
|
assert not rp.pattern.has_shapes()
|
||||||
|
assert len(rp.paths["start"]) == 2
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert rp.pattern.has_shapes()
|
||||||
|
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||||
|
|
||||||
|
# PathTool renders length steps in the port extension direction.
|
||||||
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
|
assert len(path_shape.vertices) == 3
|
||||||
|
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
rp, tool, lib = deferred_render_setup
|
||||||
|
rp.at("start").straight(10).cw(10)
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
|
# 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_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 len(rp.paths["start"]) == 1
|
||||||
|
assert rp.paths["start"][0].opcode == "S"
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
|
# 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:
|
||||||
|
rp, tool, lib = deferred_render_setup
|
||||||
|
rp.at("start").straight(10).cw(10)
|
||||||
|
|
||||||
|
rp.mirror(0)
|
||||||
|
rp.render()
|
||||||
|
|
||||||
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
|
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
rp, tool1, lib = deferred_render_setup
|
||||||
|
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
|
||||||
|
|
||||||
|
rp.at("start").straight(10)
|
||||||
|
rp.retool(tool2, keys=["start"])
|
||||||
|
rp.at("start").straight(10)
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||||
|
assert len(rp.pattern.shapes[(2, 0)]) == 1
|
||||||
|
|
||||||
|
def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
rp, tool, lib = deferred_render_setup
|
||||||
|
pp = rp.at("start")
|
||||||
|
pp.straight(10)
|
||||||
|
pp.translate((5, 0))
|
||||||
|
pp.straight(10)
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
|
||||||
|
shapes = rp.pattern.shapes[(1, 0)]
|
||||||
|
assert len(shapes) == 2
|
||||||
|
assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10)
|
||||||
|
assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10)
|
||||||
|
assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10)
|
||||||
|
|
||||||
|
def test_deferred_render_dead_ports() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer=(1, 0), width=1)
|
||||||
|
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False)
|
||||||
|
rp.set_dead()
|
||||||
|
|
||||||
|
rp.straight("in", -10)
|
||||||
|
|
||||||
|
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
|
||||||
|
|
||||||
|
assert len(rp.paths["in"]) == 0
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert not rp.pattern.has_shapes()
|
||||||
|
|
||||||
|
def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
rp, tool, lib = deferred_render_setup
|
||||||
|
rp.at("start").straight(10)
|
||||||
|
rp.rename_ports({"start": "new_start"})
|
||||||
|
rp.at("new_start").straight(10)
|
||||||
|
|
||||||
|
assert "start" not in rp.paths
|
||||||
|
assert len(rp.paths["new_start"]) == 2
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert rp.pattern.has_shapes()
|
||||||
|
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
||||||
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
|
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
||||||
|
assert "new_start" in rp.ports
|
||||||
|
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
|
||||||
|
|
||||||
|
def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
rp, tool, lib = deferred_render_setup
|
||||||
|
rp.at("start").straight(10).drop()
|
||||||
|
|
||||||
|
assert "start" not in rp.ports
|
||||||
|
assert len(rp.paths["start"]) == 1
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert rp.pattern.has_shapes()
|
||||||
|
assert "start" not in rp.ports
|
||||||
|
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
||||||
|
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
|
||||||
|
|
||||||
|
def test_pathtool_traceL_bend_geometry_matches_ports() -> None:
|
||||||
|
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||||
|
|
||||||
|
tree = tool.traceL(True, 10)
|
||||||
|
pat = tree.top_pattern()
|
||||||
|
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
||||||
|
|
||||||
|
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_traceS_geometry_matches_ports() -> None:
|
||||||
|
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||||
|
|
||||||
|
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], [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, 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']) == 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
|
||||||
|
assert numpy.isclose(rp.pattern.ports['A'].rotation, pi)
|
||||||
|
|
||||||
|
def test_pather_render_auto_renames_single_use_tool_children() -> None:
|
||||||
|
class FullTreeTool(Tool):
|
||||||
|
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
||||||
|
ptype = out_ptype or in_ptype or 'wire'
|
||||||
|
return Port((length, 0), rotation=pi, ptype=ptype), {'length': 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((1, 0), pi, ptype='wire'),
|
||||||
|
})
|
||||||
|
child = Pattern(annotations={'batch': [len(batch)]})
|
||||||
|
top.ref('_seg')
|
||||||
|
tree['_top'] = top
|
||||||
|
tree['_seg'] = child
|
||||||
|
return tree
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib, tools=FullTreeTool(), auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.straight('A', 10)
|
||||||
|
p.render()
|
||||||
|
p.straight('A', 10)
|
||||||
|
p.render()
|
||||||
|
|
||||||
|
assert len(lib) == 2
|
||||||
|
assert set(lib.keys()) == set(p.pattern.refs.keys())
|
||||||
|
assert len(set(p.pattern.refs.keys())) == 2
|
||||||
|
assert all(name.startswith('_seg') for name in lib)
|
||||||
|
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
||||||
|
|
||||||
|
def test_tool_render_fallback_preserves_segment_subtrees() -> None:
|
||||||
|
class TraceTreeTool(Tool):
|
||||||
|
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'),
|
||||||
|
})
|
||||||
|
child = Pattern(annotations={'length': [length]})
|
||||||
|
top.ref('_seg')
|
||||||
|
tree['_top'] = top
|
||||||
|
tree['_seg'] = child
|
||||||
|
return tree
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
p = Pather(lib, tools=TraceTreeTool(), auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.straight('A', 10)
|
||||||
|
p.render()
|
||||||
|
|
||||||
|
assert '_seg' in lib
|
||||||
|
assert '_seg' in p.pattern.refs
|
||||||
|
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
||||||
|
|
||||||
|
def test_pather_render_rejects_missing_single_use_tool_refs() -> None:
|
||||||
|
class MissingSingleUseTool(Tool):
|
||||||
|
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
||||||
|
ptype = out_ptype or in_ptype or 'wire'
|
||||||
|
return Port((length, 0), rotation=pi, ptype=ptype), {'length': 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((1, 0), pi, ptype='wire'),
|
||||||
|
})
|
||||||
|
top.ref('_seg')
|
||||||
|
tree['_top'] = top
|
||||||
|
return tree
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
lib['_seg'] = Pattern(annotations={'stale': [1]})
|
||||||
|
p = Pather(lib, tools=MissingSingleUseTool(), auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.straight('A', 10)
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match='missing single-use refs'):
|
||||||
|
p.render()
|
||||||
|
|
||||||
|
assert list(lib.keys()) == ['_seg']
|
||||||
|
assert not p.pattern.refs
|
||||||
|
|
||||||
|
def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
|
||||||
|
class SharedRefTool(Tool):
|
||||||
|
def planL(self, ccw, length, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202
|
||||||
|
ptype = out_ptype or in_ptype or 'wire'
|
||||||
|
return Port((length, 0), rotation=pi, ptype=ptype), {'length': 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((1, 0), pi, ptype='wire'),
|
||||||
|
})
|
||||||
|
top.ref('shared')
|
||||||
|
tree['_top'] = top
|
||||||
|
return tree
|
||||||
|
|
||||||
|
lib = Library()
|
||||||
|
lib['shared'] = Pattern(annotations={'shared': [1]})
|
||||||
|
p = Pather(lib, tools=SharedRefTool(), auto_render=False)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.straight('A', 10)
|
||||||
|
p.render()
|
||||||
|
|
||||||
|
assert 'shared' in p.pattern.refs
|
||||||
|
assert p.pattern.referenced_patterns() <= set(lib.keys())
|
||||||
|
|
||||||
|
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, 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
|
||||||
|
|
||||||
|
rp.render()
|
||||||
|
assert rp.pattern.has_shapes()
|
||||||
|
assert 'A' not in rp.pattern.ports
|
||||||
189
masque/test/test_pather_trace_into.py
Normal file
189
masque/test/test_pather_trace_into.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_equal
|
||||||
|
|
||||||
|
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, 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")
|
||||||
|
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||||
|
|
||||||
|
p.trace_into("src", "dst")
|
||||||
|
|
||||||
|
assert "src" not in p.ports
|
||||||
|
assert "dst" not in p.ports
|
||||||
|
assert len(p.pattern.refs) == 1
|
||||||
|
|
||||||
|
def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, _tool, _lib = trace_into_setup
|
||||||
|
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||||
|
p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire")
|
||||||
|
|
||||||
|
p.trace_into("src", "dst")
|
||||||
|
|
||||||
|
assert "src" not in p.ports
|
||||||
|
assert "dst" not in p.ports
|
||||||
|
# `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")
|
||||||
|
p.ports["dst"] = Port((-20, -10), pi, ptype="wire")
|
||||||
|
|
||||||
|
p.trace_into("src", "dst")
|
||||||
|
|
||||||
|
assert "src" not in p.ports
|
||||||
|
assert "dst" not in p.ports
|
||||||
|
|
||||||
|
def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None:
|
||||||
|
p, _tool, _lib = trace_into_setup
|
||||||
|
p.ports["src"] = Port((0, 0), 0, ptype="wire")
|
||||||
|
p.ports["dst"] = Port((-20, 0), pi, ptype="wire")
|
||||||
|
p.ports["other"] = Port((10, 10), 0)
|
||||||
|
|
||||||
|
p.trace_into("src", "dst", thru="other")
|
||||||
|
|
||||||
|
assert "src" in p.ports
|
||||||
|
assert_equal(p.ports["src"].offset, [10, 10])
|
||||||
|
assert "other" not in p.ports
|
||||||
|
|
||||||
|
def test_pather_trace_into() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000)
|
||||||
|
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)
|
||||||
|
p.at('A').trace_into('B', plug_destination=False)
|
||||||
|
assert 'B' in p.pattern.ports
|
||||||
|
assert 'A' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||||
|
|
||||||
|
p.pattern.ports['C'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi/2)
|
||||||
|
p.at('C').trace_into('D', plug_destination=False)
|
||||||
|
assert 'D' in p.pattern.ports
|
||||||
|
assert 'C' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000))
|
||||||
|
|
||||||
|
p.pattern.ports['E'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi)
|
||||||
|
p.at('E').trace_into('F', plug_destination=False)
|
||||||
|
assert 'F' in p.pattern.ports
|
||||||
|
assert 'E' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000))
|
||||||
|
|
||||||
|
p.pattern.ports['G'] = Port((0, 0), rotation=0)
|
||||||
|
p.pattern.ports['H'] = Port((-10000, 2000), rotation=0)
|
||||||
|
p.at('G').trace_into('H', plug_destination=False)
|
||||||
|
assert 'H' in p.pattern.ports
|
||||||
|
assert 'G' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000))
|
||||||
|
assert p.pattern.ports['G'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['G'].rotation, pi)
|
||||||
|
|
||||||
|
p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2)
|
||||||
|
p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2)
|
||||||
|
p.at('I').trace_into('J', plug_destination=False)
|
||||||
|
assert 'J' in p.pattern.ports
|
||||||
|
assert 'I' in p.pattern.ports
|
||||||
|
assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000))
|
||||||
|
assert p.pattern.ports['I'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
|
||||||
|
|
||||||
|
def test_pather_trace_into_dead_updates_ports_without_geometry() -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1000, ptype='wire')
|
||||||
|
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()
|
||||||
|
|
||||||
|
p.trace_into('A', 'B', plug_destination=False)
|
||||||
|
|
||||||
|
assert set(p.pattern.ports) == {'A', 'B'}
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0))
|
||||||
|
assert p.pattern.ports['A'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
|
assert not p.pattern.has_shapes()
|
||||||
|
assert not p.pattern.has_refs()
|
||||||
|
|
||||||
|
def test_pather_trace_into_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, 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
|
||||||
|
|
||||||
|
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)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire')
|
||||||
|
p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(PortError, match='overwritten'):
|
||||||
|
p.trace_into('A', 'B', plug_destination=False, thru='other')
|
||||||
|
|
||||||
|
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'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,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
match: str,
|
||||||
|
) -> None:
|
||||||
|
lib = Library()
|
||||||
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
||||||
|
p = Pather(lib, tools=tool)
|
||||||
|
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.pattern.ports['B'] = dst
|
||||||
|
|
||||||
|
with pytest.raises(BuildError, match=match):
|
||||||
|
p.trace_into('A', 'B', plug_destination=False, **kwargs)
|
||||||
|
|
||||||
|
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))
|
||||||
|
assert numpy.isclose(p.pattern.ports['A'].rotation, 0)
|
||||||
|
assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset)
|
||||||
|
assert dst.rotation is not None
|
||||||
|
assert p.pattern.ports['B'].rotation is not None
|
||||||
|
assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation)
|
||||||
|
assert len(p.paths['A']) == 0
|
||||||
89
masque/test/test_poly_collection.py
Normal file
89
masque/test/test_poly_collection.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import pytest
|
||||||
|
from numpy.testing import assert_equal
|
||||||
|
|
||||||
|
from ..error import PatternError
|
||||||
|
from ..shapes import Circle, Ellipse, Polygon, PolyCollection
|
||||||
|
|
||||||
|
|
||||||
|
def test_poly_collection_init() -> None:
|
||||||
|
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||||
|
offsets = [0, 4]
|
||||||
|
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||||
|
assert len(list(pc.polygon_vertices)) == 2
|
||||||
|
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
|
||||||
|
|
||||||
|
def test_poly_collection_to_polygons() -> None:
|
||||||
|
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||||
|
offsets = [0, 4]
|
||||||
|
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
||||||
|
polys = pc.to_polygons()
|
||||||
|
assert len(polys) == 2
|
||||||
|
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||||
|
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
|
||||||
|
|
||||||
|
def test_poly_collection_holes() -> None:
|
||||||
|
# PolyCollection represents separate polygon boundaries, including nested boundaries.
|
||||||
|
verts = [
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[10, 10],
|
||||||
|
[0, 10], # Poly 1
|
||||||
|
[2, 2],
|
||||||
|
[2, 8],
|
||||||
|
[8, 8],
|
||||||
|
[8, 2], # Poly 2
|
||||||
|
]
|
||||||
|
offsets = [0, 4]
|
||||||
|
pc = PolyCollection(verts, offsets)
|
||||||
|
polys = pc.to_polygons()
|
||||||
|
assert len(polys) == 2
|
||||||
|
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||||
|
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
|
||||||
|
|
||||||
|
def test_poly_collection_constituent_empty() -> None:
|
||||||
|
# Duplicate offsets create an empty constituent slice between valid polygons.
|
||||||
|
verts = [
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[0, 1], # Tri
|
||||||
|
[10, 10],
|
||||||
|
[11, 10],
|
||||||
|
[11, 11],
|
||||||
|
[10, 11], # Square
|
||||||
|
]
|
||||||
|
offsets = [0, 3, 3]
|
||||||
|
pc = PolyCollection(verts, offsets)
|
||||||
|
with pytest.raises(PatternError):
|
||||||
|
pc.to_polygons()
|
||||||
|
|
||||||
|
def test_poly_collection_valid() -> None:
|
||||||
|
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
||||||
|
offsets = [0, 3]
|
||||||
|
pc = PolyCollection(verts, offsets)
|
||||||
|
assert len(pc.to_polygons()) == 2
|
||||||
|
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
|
||||||
|
sorted_shapes = sorted(shapes)
|
||||||
|
assert len(sorted_shapes) == 4
|
||||||
|
assert sorted(sorted_shapes) == sorted_shapes
|
||||||
|
|
||||||
|
def test_poly_collection_normalized_form_reconstruction_is_independent() -> None:
|
||||||
|
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
||||||
|
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
||||||
|
|
||||||
|
clone = rebuild()
|
||||||
|
clone.vertex_offsets[:] = [5]
|
||||||
|
|
||||||
|
assert_equal(pc.vertex_offsets, [0])
|
||||||
|
assert_equal(clone.vertex_offsets, [5])
|
||||||
|
|
||||||
|
def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None:
|
||||||
|
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
||||||
|
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
||||||
|
|
||||||
|
first = rebuild()
|
||||||
|
second = rebuild()
|
||||||
|
first.vertex_offsets[:] = [7]
|
||||||
|
|
||||||
|
assert_equal(first.vertex_offsets, [7])
|
||||||
|
assert_equal(second.vertex_offsets, [0])
|
||||||
|
assert_equal(pc.vertex_offsets, [0])
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
import pytest
|
|
||||||
from typing import cast, TYPE_CHECKING
|
|
||||||
from numpy.testing import assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..builder import Pather
|
|
||||||
from ..builder.tools import PathTool
|
|
||||||
from ..library import Library
|
|
||||||
from ..ports import Port
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..shapes import Path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def rpather_setup() -> tuple[Pather, PathTool, Library]:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
rp = Pather(lib, tools=tool, auto_render=False)
|
|
||||||
rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire")
|
|
||||||
return rp, tool, lib
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_basic(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
# Plan two segments
|
|
||||||
rp.at("start").straight(10).straight(10)
|
|
||||||
|
|
||||||
# Before rendering, no shapes in pattern
|
|
||||||
assert not rp.pattern.has_shapes()
|
|
||||||
assert len(rp.paths["start"]) == 2
|
|
||||||
|
|
||||||
# Render
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.has_shapes()
|
|
||||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
|
||||||
|
|
||||||
# Path vertices should be (0,0), (0,-10), (0,-20)
|
|
||||||
# transformed by start port (rot pi/2 -> 270 deg transform)
|
|
||||||
# wait, PathTool.render for opcode L uses rotation_matrix_2d(port_rot + pi)
|
|
||||||
# start_port rot pi/2. pi/2 + pi = 3pi/2.
|
|
||||||
# (10, 0) rotated 3pi/2 -> (0, -10)
|
|
||||||
# So vertices: (0,0), (0,-10), (0,-20)
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
assert len(path_shape.vertices) == 3
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_bend(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
# Plan straight then bend
|
|
||||||
rp.at("start").straight(10).cw(10)
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
# Path vertices:
|
|
||||||
# 1. Start (0,0)
|
|
||||||
# 2. Straight end: (0, -10)
|
|
||||||
# 3. Bend end: (-1, -20)
|
|
||||||
# PathTool.planL(ccw=False, length=10) returns data=[10, -1]
|
|
||||||
# start_port for 2nd segment is at (0, -10) with rotation pi/2
|
|
||||||
# dxy = rot(pi/2 + pi) @ (10, 0) = (0, -10). So vertex at (0, -20).
|
|
||||||
# and final end_port.offset is (-1, -20).
|
|
||||||
assert len(path_shape.vertices) == 4
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_jog_uses_native_pathtool_planS(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
rp.at("start").jog(4, length=10)
|
|
||||||
|
|
||||||
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])
|
|
||||||
# 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_renderpather_mirror_preserves_planned_bend_geometry(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
rp.at("start").straight(10).cw(10)
|
|
||||||
|
|
||||||
rp.mirror(0)
|
|
||||||
rp.render()
|
|
||||||
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 20], [-1, 20]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_retool(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool1, lib = rpather_setup
|
|
||||||
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
|
|
||||||
|
|
||||||
rp.at("start").straight(10)
|
|
||||||
rp.retool(tool2, keys=["start"])
|
|
||||||
rp.at("start").straight(10)
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
# Different tools should cause different batches/shapes
|
|
||||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
|
||||||
assert len(rp.pattern.shapes[(2, 0)]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_portpather_translate_only_affects_future_steps(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
pp = rp.at("start")
|
|
||||||
pp.straight(10)
|
|
||||||
pp.translate((5, 0))
|
|
||||||
pp.straight(10)
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
|
|
||||||
shapes = rp.pattern.shapes[(1, 0)]
|
|
||||||
assert len(shapes) == 2
|
|
||||||
assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10)
|
|
||||||
assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10)
|
|
||||||
assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_dead_ports() -> None:
|
|
||||||
lib = Library()
|
|
||||||
tool = PathTool(layer=(1, 0), width=1)
|
|
||||||
rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, auto_render=False)
|
|
||||||
rp.set_dead()
|
|
||||||
|
|
||||||
# Impossible path
|
|
||||||
rp.straight("in", -10)
|
|
||||||
|
|
||||||
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
|
|
||||||
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)
|
|
||||||
|
|
||||||
# Verify no render steps were added
|
|
||||||
assert len(rp.paths["in"]) == 0
|
|
||||||
|
|
||||||
# Verify no geometry
|
|
||||||
rp.render()
|
|
||||||
assert not rp.pattern.has_shapes()
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_rename_port(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
rp.at("start").straight(10)
|
|
||||||
# Rename port while path is planned
|
|
||||||
rp.rename_ports({"start": "new_start"})
|
|
||||||
# Continue path on new name
|
|
||||||
rp.at("new_start").straight(10)
|
|
||||||
|
|
||||||
assert "start" not in rp.paths
|
|
||||||
assert len(rp.paths["new_start"]) == 2
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.has_shapes()
|
|
||||||
assert len(rp.pattern.shapes[(1, 0)]) == 1
|
|
||||||
# Total length 20. start_port rot pi/2 -> 270 deg transform.
|
|
||||||
# Vertices (0,0), (0,-10), (0,-20)
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10)
|
|
||||||
assert "new_start" in rp.ports
|
|
||||||
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderpather_drop_keeps_pending_geometry_without_port(rpather_setup: tuple[Pather, PathTool, Library]) -> None:
|
|
||||||
rp, tool, lib = rpather_setup
|
|
||||||
rp.at("start").straight(10).drop()
|
|
||||||
|
|
||||||
assert "start" not in rp.ports
|
|
||||||
assert len(rp.paths["start"]) == 1
|
|
||||||
|
|
||||||
rp.render()
|
|
||||||
assert rp.pattern.has_shapes()
|
|
||||||
assert "start" not in rp.ports
|
|
||||||
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
|
|
||||||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pathtool_traceL_bend_geometry_matches_ports() -> None:
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
|
|
||||||
tree = tool.traceL(True, 10)
|
|
||||||
pat = tree.top_pattern()
|
|
||||||
path_shape = cast("Path", pat.shapes[(1, 0)][0])
|
|
||||||
|
|
||||||
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_traceS_geometry_matches_ports() -> None:
|
|
||||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
|
||||||
|
|
||||||
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], [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)
|
|
||||||
|
|
@ -7,7 +7,6 @@ from ..error import PatternError
|
||||||
|
|
||||||
|
|
||||||
def test_grid_displacements() -> None:
|
def test_grid_displacements() -> None:
|
||||||
# 2x2 grid
|
|
||||||
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
|
grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2)
|
||||||
disps = sorted([tuple(d) for d in grid.displacements])
|
disps = sorted([tuple(d) for d in grid.displacements])
|
||||||
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
|
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
|
||||||
|
|
@ -34,7 +33,6 @@ def test_grid_get_bounds() -> None:
|
||||||
def test_arbitrary_displacements() -> None:
|
def test_arbitrary_displacements() -> None:
|
||||||
pts = [[0, 0], [10, 20], [-5, 30]]
|
pts = [[0, 0], [10, 20], [-5, 30]]
|
||||||
arb = Arbitrary(pts)
|
arb = Arbitrary(pts)
|
||||||
# They should be sorted by displacements.setter
|
|
||||||
disps = arb.displacements
|
disps = arb.displacements
|
||||||
assert len(disps) == 3
|
assert len(disps) == 3
|
||||||
assert any((disps == [0, 0]).all(axis=1))
|
assert any((disps == [0, 0]).all(axis=1))
|
||||||
|
|
@ -47,9 +45,7 @@ def test_arbitrary_transform() -> None:
|
||||||
arb.rotate(pi / 2)
|
arb.rotate(pi / 2)
|
||||||
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
|
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
|
||||||
|
|
||||||
arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is:
|
arb.mirror(0)
|
||||||
# self.displacements[:, 1 - axis] *= -1
|
|
||||||
# if axis=0, 1-axis=1, so y *= -1
|
|
||||||
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)
|
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..shapes import Arc, Ellipse, Circle, Polygon, Path as MPath, Text, PolyCollection
|
|
||||||
from ..error import PatternError
|
|
||||||
|
|
||||||
|
|
||||||
# 1. Text shape tests
|
|
||||||
def test_text_to_polygons() -> None:
|
|
||||||
pytest.importorskip("freetype")
|
|
||||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
|
||||||
if not Path(font_path).exists():
|
|
||||||
pytest.skip("Font file not found")
|
|
||||||
|
|
||||||
t = Text("Hi", height=10, font_path=font_path)
|
|
||||||
polys = t.to_polygons()
|
|
||||||
assert len(polys) > 0
|
|
||||||
assert all(isinstance(p, Polygon) for p in polys)
|
|
||||||
|
|
||||||
# Check that it advances
|
|
||||||
# Character 'H' and 'i' should have different vertices
|
|
||||||
# Each character is a set of polygons. We check the mean x of vertices for each character.
|
|
||||||
char_x_means = [p.vertices[:, 0].mean() for p in polys]
|
|
||||||
assert len(set(char_x_means)) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_bounds_and_normalized_form() -> None:
|
|
||||||
pytest.importorskip("freetype")
|
|
||||||
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
|
||||||
if not Path(font_path).exists():
|
|
||||||
pytest.skip("Font file not found")
|
|
||||||
|
|
||||||
text = Text("Hi", height=10, font_path=font_path)
|
|
||||||
_intrinsic, extrinsic, ctor = text.normalized_form(5)
|
|
||||||
normalized = ctor()
|
|
||||||
|
|
||||||
assert extrinsic[1] == 2
|
|
||||||
assert normalized.height == 5
|
|
||||||
|
|
||||||
bounds = text.get_bounds_single()
|
|
||||||
assert bounds is not None
|
|
||||||
assert numpy.isfinite(bounds).all()
|
|
||||||
assert numpy.all(bounds[1] > bounds[0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_mirroring_affects_comparison() -> None:
|
|
||||||
text = Text("A", height=10, font_path="dummy.ttf")
|
|
||||||
mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True)
|
|
||||||
|
|
||||||
assert text != mirrored
|
|
||||||
assert (text < mirrored) != (mirrored < text)
|
|
||||||
|
|
||||||
|
|
||||||
# 2. Manhattanization tests
|
|
||||||
def test_manhattanize() -> None:
|
|
||||||
pytest.importorskip("float_raster")
|
|
||||||
pytest.importorskip("skimage.measure")
|
|
||||||
# Diamond shape
|
|
||||||
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
|
|
||||||
grid = numpy.arange(0, 11, 1)
|
|
||||||
|
|
||||||
manhattan_polys = poly.manhattanize(grid, grid)
|
|
||||||
assert len(manhattan_polys) >= 1
|
|
||||||
for mp in manhattan_polys:
|
|
||||||
# Check that all edges are axis-aligned
|
|
||||||
dv = numpy.diff(mp.vertices, axis=0)
|
|
||||||
# For each segment, either dx or dy must be zero
|
|
||||||
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))
|
|
||||||
|
|
||||||
|
|
||||||
# 3. Comparison and Sorting tests
|
|
||||||
def test_shape_comparisons() -> None:
|
|
||||||
c1 = Circle(radius=10)
|
|
||||||
c2 = Circle(radius=20)
|
|
||||||
assert c1 < c2
|
|
||||||
assert not (c2 < c1)
|
|
||||||
|
|
||||||
p1 = Polygon([[0, 0], [10, 0], [10, 10]])
|
|
||||||
p2 = Polygon([[0, 0], [10, 0], [10, 11]]) # Different vertex
|
|
||||||
assert p1 < p2
|
|
||||||
|
|
||||||
# Different types
|
|
||||||
assert c1 < p1 or p1 < c1
|
|
||||||
assert (c1 < p1) != (p1 < c1)
|
|
||||||
|
|
||||||
|
|
||||||
# 4. Arc/Path Edge Cases
|
|
||||||
def test_arc_edge_cases() -> None:
|
|
||||||
# Wrapped arc (> 360 deg)
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2)
|
|
||||||
a.to_polygons(num_vertices=64)
|
|
||||||
# Should basically be a ring
|
|
||||||
bounds = a.get_bounds_single()
|
|
||||||
assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
|
|
||||||
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
|
|
||||||
bounds = ellipse.get_bounds_single()
|
|
||||||
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
|
||||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
|
||||||
arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200))
|
|
||||||
bounds = arc.get_bounds_single()
|
|
||||||
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
|
||||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
|
||||||
for shape in (
|
|
||||||
Circle(radius=10),
|
|
||||||
Ellipse(radii=(10, 20)),
|
|
||||||
Arc(radii=(10, 20), angles=(0, 1), width=2),
|
|
||||||
):
|
|
||||||
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert len(polys[0].vertices) >= 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
|
||||||
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
|
|
||||||
with pytest.raises(PatternError, match='valid max_arclen'):
|
|
||||||
arc.to_polygons(num_vertices=24)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
|
||||||
ellipse = Ellipse(radii=(10, 20))
|
|
||||||
ellipse.scale_by(0.5)
|
|
||||||
assert_allclose(ellipse.radii, [5, 10])
|
|
||||||
|
|
||||||
|
|
||||||
def test_arc_rejects_zero_radii_up_front() -> None:
|
|
||||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
|
||||||
Arc(radii=(0, 5), angles=(0, 1), width=1)
|
|
||||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
|
||||||
Arc(radii=(5, 0), angles=(0, 1), width=1)
|
|
||||||
with pytest.raises(PatternError, match='Radii must be positive'):
|
|
||||||
Arc(radii=(0, 0), angles=(0, 1), width=1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_path_edge_cases() -> None:
|
|
||||||
# Zero-length segments
|
|
||||||
p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2)
|
|
||||||
polys = p.to_polygons()
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]])
|
|
||||||
|
|
||||||
|
|
||||||
# 5. PolyCollection with holes
|
|
||||||
def test_poly_collection_holes() -> None:
|
|
||||||
# Outer square, inner square hole
|
|
||||||
# PolyCollection doesn't explicitly support holes, but its constituents (Polygons) do?
|
|
||||||
# wait, Polygon in masque is just a boundary. Holes are usually handled by having multiple
|
|
||||||
# polygons or using specific winding rules.
|
|
||||||
# masque.shapes.Polygon doc says "specify an implicitly-closed boundary".
|
|
||||||
# Pyclipper is used in connectivity.py for holes.
|
|
||||||
|
|
||||||
# Let's test PolyCollection with multiple polygons
|
|
||||||
verts = [
|
|
||||||
[0, 0],
|
|
||||||
[10, 0],
|
|
||||||
[10, 10],
|
|
||||||
[0, 10], # Poly 1
|
|
||||||
[2, 2],
|
|
||||||
[2, 8],
|
|
||||||
[8, 8],
|
|
||||||
[8, 2], # Poly 2
|
|
||||||
]
|
|
||||||
offsets = [0, 4]
|
|
||||||
pc = PolyCollection(verts, offsets)
|
|
||||||
polys = pc.to_polygons()
|
|
||||||
assert len(polys) == 2
|
|
||||||
assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
||||||
assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_constituent_empty() -> None:
|
|
||||||
# One real triangle, one "empty" polygon (0 vertices), one real square
|
|
||||||
# Note: Polygon requires 3 vertices, so "empty" here might mean just some junk
|
|
||||||
# that to_polygons should handle.
|
|
||||||
# Actually PolyCollection doesn't check vertex count per polygon.
|
|
||||||
verts = [
|
|
||||||
[0, 0],
|
|
||||||
[1, 0],
|
|
||||||
[0, 1], # Tri
|
|
||||||
# Empty space
|
|
||||||
[10, 10],
|
|
||||||
[11, 10],
|
|
||||||
[11, 11],
|
|
||||||
[10, 11], # Square
|
|
||||||
]
|
|
||||||
offsets = [0, 3, 3] # Index 3 is start of "empty", Index 3 is also start of Square?
|
|
||||||
# No, offsets should be strictly increasing or handle 0-length slices.
|
|
||||||
# vertex_slices uses zip(offsets, chain(offsets[1:], [len(verts)]))
|
|
||||||
# if offsets = [0, 3, 3], slices are [0:3], [3:3], [3:7]
|
|
||||||
offsets = [0, 3, 3]
|
|
||||||
pc = PolyCollection(verts, offsets)
|
|
||||||
# Polygon(vertices=[]) will fail because of the setter check.
|
|
||||||
# Let's see if pc.to_polygons() handles it.
|
|
||||||
# It calls Polygon(vertices=vv) for each slice.
|
|
||||||
# slice [3:3] gives empty vv.
|
|
||||||
with pytest.raises(PatternError):
|
|
||||||
pc.to_polygons()
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_valid() -> None:
|
|
||||||
verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
|
||||||
offsets = [0, 3]
|
|
||||||
pc = PolyCollection(verts, offsets)
|
|
||||||
assert len(pc.to_polygons()) == 2
|
|
||||||
shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))]
|
|
||||||
sorted_shapes = sorted(shapes)
|
|
||||||
assert len(sorted_shapes) == 4
|
|
||||||
# Just verify it doesn't crash and is stable
|
|
||||||
assert sorted(sorted_shapes) == sorted_shapes
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_normalized_form_reconstruction_is_independent() -> None:
|
|
||||||
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
|
||||||
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
|
||||||
|
|
||||||
clone = rebuild()
|
|
||||||
clone.vertex_offsets[:] = [5]
|
|
||||||
|
|
||||||
assert_equal(pc.vertex_offsets, [0])
|
|
||||||
assert_equal(clone.vertex_offsets, [5])
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None:
|
|
||||||
pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0])
|
|
||||||
_intrinsic, _extrinsic, rebuild = pc.normalized_form(1)
|
|
||||||
|
|
||||||
first = rebuild()
|
|
||||||
second = rebuild()
|
|
||||||
first.vertex_offsets[:] = [7]
|
|
||||||
|
|
||||||
assert_equal(first.vertex_offsets, [7])
|
|
||||||
assert_equal(second.vertex_offsets, [0])
|
|
||||||
assert_equal(pc.vertex_offsets, [0])
|
|
||||||
15
masque/test/test_shape_ordering.py
Normal file
15
masque/test/test_shape_ordering.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from ..shapes import Circle, Ellipse, Polygon
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_comparisons() -> None:
|
||||||
|
c1 = Circle(radius=10)
|
||||||
|
c2 = Circle(radius=20)
|
||||||
|
assert c1 < c2
|
||||||
|
assert not (c2 < c1)
|
||||||
|
|
||||||
|
p1 = Polygon([[0, 0], [10, 0], [10, 10]])
|
||||||
|
p2 = Polygon([[0, 0], [10, 0], [10, 11]])
|
||||||
|
assert p1 < p2
|
||||||
|
|
||||||
|
assert c1 < p1 or p1 < c1
|
||||||
|
assert (c1 < p1) != (p1 < c1)
|
||||||
44
masque/test/test_shape_transforms.py
Normal file
44
masque/test/test_shape_transforms.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from numpy import pi
|
||||||
|
from numpy.testing import assert_equal, assert_allclose
|
||||||
|
|
||||||
|
from ..shapes import Arc, Ellipse
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_mirror() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||||
|
e.mirror(0)
|
||||||
|
assert_equal(e.offset, [10, 20])
|
||||||
|
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
||||||
|
|
||||||
|
a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20))
|
||||||
|
a.mirror(0)
|
||||||
|
assert_equal(a.offset, [10, 20])
|
||||||
|
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
|
||||||
|
|
||||||
|
a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
a.mirror(1)
|
||||||
|
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)
|
||||||
|
assert a.angle_ref == Arc.AngleRef.FocusNeg
|
||||||
|
|
||||||
|
def test_shape_flip_across() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||||
|
e.flip_across(axis=0)
|
||||||
|
assert_equal(e.offset, [10, -20])
|
||||||
|
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
||||||
|
|
||||||
|
e = Ellipse(radii=(10, 5), offset=(10, 20))
|
||||||
|
e.flip_across(y=10)
|
||||||
|
assert_equal(e.offset, [10, 0])
|
||||||
|
|
||||||
|
def test_shape_scale() -> None:
|
||||||
|
e = Ellipse(radii=(10, 5))
|
||||||
|
e.scale_by(2)
|
||||||
|
assert_equal(e.radii, [20, 10])
|
||||||
|
|
||||||
|
a = Arc(radii=(10, 5), angles=(0, pi), width=2)
|
||||||
|
a.scale_by(0.5)
|
||||||
|
assert_equal(a.radii, [5, 2.5])
|
||||||
|
assert a.width == 1
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import numpy
|
|
||||||
from numpy.testing import assert_equal, assert_allclose
|
|
||||||
from numpy import pi
|
|
||||||
|
|
||||||
from ..shapes import Arc, Ellipse, Circle, Polygon, PolyCollection
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_init() -> None:
|
|
||||||
# Two squares: [[0,0], [1,0], [1,1], [0,1]] and [[10,10], [11,10], [11,11], [10,11]]
|
|
||||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
|
||||||
offsets = [0, 4]
|
|
||||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
|
||||||
assert len(list(pc.polygon_vertices)) == 2
|
|
||||||
assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_poly_collection_to_polygons() -> None:
|
|
||||||
verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]]
|
|
||||||
offsets = [0, 4]
|
|
||||||
pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets)
|
|
||||||
polys = pc.to_polygons()
|
|
||||||
assert len(polys) == 2
|
|
||||||
assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]])
|
|
||||||
assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]])
|
|
||||||
|
|
||||||
|
|
||||||
def test_circle_init() -> None:
|
|
||||||
c = Circle(radius=10, offset=(5, 5))
|
|
||||||
assert c.radius == 10
|
|
||||||
assert_equal(c.offset, [5, 5])
|
|
||||||
|
|
||||||
|
|
||||||
def test_circle_to_polygons() -> None:
|
|
||||||
c = Circle(radius=10)
|
|
||||||
polys = c.to_polygons(num_vertices=32)
|
|
||||||
assert len(polys) == 1
|
|
||||||
assert isinstance(polys[0], Polygon)
|
|
||||||
# A circle with 32 vertices should have vertices distributed around (0,0)
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_ellipse_init() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
|
|
||||||
assert_equal(e.radii, [10, 5])
|
|
||||||
assert_equal(e.offset, [1, 2])
|
|
||||||
assert e.rotation == pi / 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_ellipse_to_polygons() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5))
|
|
||||||
polys = e.to_polygons(num_vertices=64)
|
|
||||||
assert len(polys) == 1
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_arc_init() -> None:
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
|
|
||||||
assert_equal(a.radii, [10, 10])
|
|
||||||
assert_equal(a.angles, [0, pi / 2])
|
|
||||||
assert a.width == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_arc_to_polygons() -> None:
|
|
||||||
# Quarter circle arc
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
|
||||||
polys = a.to_polygons(num_vertices=32)
|
|
||||||
assert len(polys) == 1
|
|
||||||
# Outer radius 11, inner radius 9
|
|
||||||
# Quarter circle from 0 to 90 deg
|
|
||||||
bounds = polys[0].get_bounds_single()
|
|
||||||
# Min x should be 0 (inner edge start/stop or center if width is large)
|
|
||||||
# But wait, the arc is centered at 0,0.
|
|
||||||
# Outer edge goes from (11, 0) to (0, 11)
|
|
||||||
# Inner edge goes from (9, 0) to (0, 9)
|
|
||||||
# So x ranges from 0 to 11, y ranges from 0 to 11.
|
|
||||||
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_mirror() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
|
||||||
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
|
||||||
assert_equal(e.offset, [10, 20])
|
|
||||||
# rotation was pi/4, mirrored(0) -> -pi/4 == 3pi/4 (mod pi)
|
|
||||||
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
|
||||||
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20))
|
|
||||||
a.mirror(0)
|
|
||||||
assert_equal(a.offset, [10, 20])
|
|
||||||
# For Arc, mirror(0) negates rotation and angles
|
|
||||||
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_flip_across() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
|
||||||
e.flip_across(axis=0) # Mirror across y=0: flips y-offset
|
|
||||||
assert_equal(e.offset, [10, -20])
|
|
||||||
# rotation also flips: -pi/4 == 3pi/4 (mod pi)
|
|
||||||
assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10)
|
|
||||||
# Mirror across specific y
|
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20))
|
|
||||||
e.flip_across(y=10) # Mirror across y=10
|
|
||||||
# y=20 mirrored across y=10 -> y=0
|
|
||||||
assert_equal(e.offset, [10, 0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_scale() -> None:
|
|
||||||
e = Ellipse(radii=(10, 5))
|
|
||||||
e.scale_by(2)
|
|
||||||
assert_equal(e.radii, [20, 10])
|
|
||||||
|
|
||||||
a = Arc(radii=(10, 5), angles=(0, pi), width=2)
|
|
||||||
a.scale_by(0.5)
|
|
||||||
assert_equal(a.radii, [5, 2.5])
|
|
||||||
assert a.width == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_shape_arclen() -> None:
|
|
||||||
# Test that max_arclen correctly limits segment lengths
|
|
||||||
|
|
||||||
# Ellipse
|
|
||||||
e = Ellipse(radii=(10, 5))
|
|
||||||
# Approximate perimeter is ~48.4
|
|
||||||
# With max_arclen=5, should have > 10 segments
|
|
||||||
polys = e.to_polygons(max_arclen=5)
|
|
||||||
v = polys[0].vertices
|
|
||||||
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
|
||||||
assert numpy.all(dist <= 5.000001)
|
|
||||||
assert len(v) > 10
|
|
||||||
|
|
||||||
# Arc
|
|
||||||
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
|
||||||
# Outer perimeter is 11 * pi/2 ~ 17.27
|
|
||||||
# Inner perimeter is 9 * pi/2 ~ 14.14
|
|
||||||
# With max_arclen=2, should have > 8 segments on outer edge
|
|
||||||
polys = a.to_polygons(max_arclen=2)
|
|
||||||
v = polys[0].vertices
|
|
||||||
# Arc polygons are closed, but contain both inner and outer edges and caps
|
|
||||||
# Let's just check that all segment lengths are within limit
|
|
||||||
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
|
||||||
assert numpy.all(dist <= 2.000001)
|
|
||||||
47
masque/test/test_text.py
Normal file
47
masque/test/test_text.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
from ..shapes import Polygon, Text
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_to_polygons() -> None:
|
||||||
|
pytest.importorskip("freetype")
|
||||||
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
||||||
|
if not Path(font_path).exists():
|
||||||
|
pytest.skip("Font file not found")
|
||||||
|
|
||||||
|
t = Text("Hi", height=10, font_path=font_path)
|
||||||
|
polys = t.to_polygons()
|
||||||
|
assert len(polys) > 0
|
||||||
|
assert all(isinstance(p, Polygon) for p in polys)
|
||||||
|
|
||||||
|
# Each character produces polygons with distinct horizontal placement.
|
||||||
|
char_x_means = [p.vertices[:, 0].mean() for p in polys]
|
||||||
|
assert len(set(char_x_means)) >= 2
|
||||||
|
|
||||||
|
def test_text_bounds_and_normalized_form() -> None:
|
||||||
|
pytest.importorskip("freetype")
|
||||||
|
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf"
|
||||||
|
if not Path(font_path).exists():
|
||||||
|
pytest.skip("Font file not found")
|
||||||
|
|
||||||
|
text = Text("Hi", height=10, font_path=font_path)
|
||||||
|
_intrinsic, extrinsic, ctor = text.normalized_form(5)
|
||||||
|
normalized = ctor()
|
||||||
|
|
||||||
|
assert extrinsic[1] == 2
|
||||||
|
assert normalized.height == 5
|
||||||
|
|
||||||
|
bounds = text.get_bounds_single()
|
||||||
|
assert bounds is not None
|
||||||
|
assert numpy.isfinite(bounds).all()
|
||||||
|
assert numpy.all(bounds[1] > bounds[0])
|
||||||
|
|
||||||
|
def test_text_mirroring_affects_comparison() -> None:
|
||||||
|
text = Text("A", height=10, font_path="dummy.ttf")
|
||||||
|
mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True)
|
||||||
|
|
||||||
|
assert text != mirrored
|
||||||
|
assert (text < mirrored) != (mirrored < text)
|
||||||
|
|
@ -33,19 +33,13 @@ def test_remove_colinear_vertices() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_remove_colinear_vertices_exhaustive() -> None:
|
def test_remove_colinear_vertices_exhaustive() -> None:
|
||||||
# U-turn
|
|
||||||
v = [[0, 0], [10, 0], [0, 0]]
|
v = [[0, 0], [10, 0], [0, 0]]
|
||||||
v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True)
|
v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True)
|
||||||
# Open path should keep ends. [10,0] is between [0,0] and [0,0]?
|
|
||||||
# They are colinear, but it's a 180 degree turn.
|
|
||||||
# We preserve 180 degree turns if preserve_uturns is True.
|
|
||||||
assert len(v_clean) == 3
|
assert len(v_clean) == 3
|
||||||
|
|
||||||
v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False)
|
v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False)
|
||||||
# If not preserving u-turns, it should collapse to just the endpoints
|
|
||||||
assert len(v_collapsed) == 2
|
assert len(v_collapsed) == 2
|
||||||
|
|
||||||
# 180 degree U-turn in closed path
|
|
||||||
v = [[0, 0], [10, 0], [5, 0]]
|
v = [[0, 0], [10, 0], [5, 0]]
|
||||||
v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False)
|
v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False)
|
||||||
assert len(v_clean) == 2
|
assert len(v_clean) == 2
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ def test_visualize_noninteractive(tmp_path) -> None:
|
||||||
def test_visualize_empty() -> None:
|
def test_visualize_empty() -> None:
|
||||||
""" Test visualizing an empty pattern. """
|
""" Test visualizing an empty pattern. """
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
# Should not raise
|
|
||||||
pat.visualize(overdraw=True)
|
pat.visualize(overdraw=True)
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
|
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
|
||||||
|
|
@ -51,5 +50,4 @@ def test_visualize_no_refs() -> None:
|
||||||
""" Test visualizing a pattern with only local shapes (no library needed). """
|
""" Test visualizing a pattern with only local shapes (no library needed). """
|
||||||
pat = Pattern()
|
pat = Pattern()
|
||||||
pat.polygon('L1', [[0, 0], [1, 0], [0, 1]])
|
pat.polygon('L1', [[0, 0], [1, 0], [0, 1]])
|
||||||
# Should not raise even if library is None
|
|
||||||
pat.visualize(overdraw=True)
|
pat.visualize(overdraw=True)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue