Compare commits

..

15 commits

43 changed files with 2748 additions and 4624 deletions

View file

@ -20,10 +20,10 @@ Contents
* Use `Pather` to snap ports together into a circuit
* Check for dangling references
- [library](library.py)
* Continue from `devices.py` by declaring a mixed library with `BuildLibrary`
* Import source-backed GDS cells and register python-generated recipes together
* Call `build()` to produce a normal library for downstream `Pather` usage and writing
* Continue from `devices.py` using a lazy library
* Create a `LazyLibrary`, which loads / generates patterns only when they are first used
* Explore alternate ways of specifying a pattern for `.plug()` and `.place()`
* Design a pattern which is meant to plug into an existing pattern (via `.interface()`)
- [pather](pather.py)
* Use `Pather` to route individual wires and wire bundles
* Use `AutoTool` to generate paths

View file

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

View file

@ -63,15 +63,10 @@ from .library import (
ILibrary as ILibrary,
LibraryView as LibraryView,
Library as Library,
BuiltLibrary as BuiltLibrary,
BuildLibrary as BuildLibrary,
BuildReport as BuildReport,
CellProvenance as CellProvenance,
LazyLibrary as LazyLibrary,
AbstractView as AbstractView,
TreeView as TreeView,
Tree as Tree,
cell as cell,
)
from .ports import (
Port as Port,

View file

@ -1,19 +1,9 @@
"""
Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides)
The `Tool` interface has two layers:
* `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.
Concrete tools may implement native planning/rendering for `L`, `S`, or `U` routes.
Any unimplemented planning method falls back to the corresponding `trace*()` method,
and `Pather` may further synthesize some routes from simpler primitives when needed.
"""
from typing import Literal, Any, Self, cast
from collections.abc import Sequence, Callable, Iterator
@ -35,11 +25,8 @@ from ..error import BuildError
@dataclass(frozen=True, slots=True)
class RenderStep:
"""
A single deferred routing operation.
`Pather(auto_render=False)` stores these records while routing and later
passes batches of compatible steps to `Tool.render()` when `Pather.render()`
is called.
Representation of a single saved operation, used by deferred `Pather`
instances and passed to `Tool.render()` when `Pather.render()` is called.
"""
opcode: Literal['L', 'S', 'U', 'P']
""" What operation is being performed.
@ -50,13 +37,10 @@ class RenderStep:
"""
tool: 'Tool | None'
""" Tool that produced this step, or `None` for `opcode='P'`. """
""" The current tool. May be `None` if `opcode='P'` """
start_port: Port
""" Input-side port before this step is rendered. """
end_port: Port
""" Output-side port after this step is rendered. """
data: Any
""" Arbitrary tool-specific data"""
@ -117,9 +101,7 @@ class RenderStep:
def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port, Any]:
"""
Measure generated geometry for the base `Tool.plan*()` fallbacks.
Returns the calculated output port and the original tree as render data.
Extracts a Port and returns the tree (as data) for tool planning fallbacks.
"""
pat = tree.top_pattern()
in_p = pat[port_names[0]]
@ -131,13 +113,6 @@ def measure_tool_plan(tree: ILibrary, port_names: tuple[str, str]) -> tuple[Port
class Tool:
"""
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(
self,
@ -197,7 +172,7 @@ class Tool:
"""
Create a wire or waveguide that travels exactly `length` distance along the 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 output port).
`jog` is positive when moving left of the direction of travel (from input to ouput port).
Used by `Pather`.
@ -261,7 +236,7 @@ class Tool:
Returns:
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises:
BuildError if an impossible or unsupported geometry is requested.
@ -309,7 +284,7 @@ class Tool:
Returns:
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises:
BuildError if an impossible or unsupported geometry is requested.
@ -337,9 +312,8 @@ class Tool:
**kwargs,
) -> Library:
"""
Create a wire or waveguide whose output is displaced by `length` along
the input axis and `jog` along the perpendicular axis, while preserving
the input orientation (i.e. a U-bend or jogged U-turn).
Create a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
Used by `Pather`. Tools may leave this unimplemented if they
do not support a native U-bend primitive.
@ -354,7 +328,6 @@ class Tool:
jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a leftwards shift (i.e. counterclockwise 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.
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
@ -378,9 +351,8 @@ class Tool:
**kwargs,
) -> tuple[Port, Any]:
"""
Plan a wire or waveguide whose output is displaced by optional `length`
along the input axis and `jog` along the perpendicular axis, while
preserving the input orientation (i.e. a U-bend or jogged U-turn).
Plan a wire or waveguide that travels exactly `jog` distance along the axis
perpendicular to its input port (i.e. a U-bend).
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
@ -402,7 +374,7 @@ class Tool:
Returns:
The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0.
Any tool-specific data, to be stored in `RenderStep.data`, for use during rendering.
Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering.
Raises:
BuildError if an impossible or unsupported geometry is requested.
@ -432,11 +404,6 @@ class Tool:
Render the provided `batch` of `RenderStep`s into geometry, returning a tree
(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:
batch: A sequence of `RenderStep` objects containing the ports and data
provided by this tool's `planL`/`planS`/`planU` functions.
@ -497,18 +464,15 @@ abstract_tuple_t = tuple[Abstract, str, str]
@dataclass
class SimpleTool(Tool, metaclass=ABCMeta):
"""
Minimal L-route tool built from one straight generator and one bend.
`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.
A simple tool which relies on a single pre-rendered `bend` pattern, a function
for generating straight paths, and a table of pre-rendered `transitions` for converting
from non-native ptypes.
"""
straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str]
""" `(create_straight, in_port_name, out_port_name)` for straight segments. """
""" `create_straight(length: float), in_port_name, out_port_name` """
bend: abstract_tuple_t # Assumed to be clockwise
""" `(clockwise_bend_abstract, in_port_name, out_port_name)` for L turns. """
""" `clockwise_bend_abstract, in_port_name, out_port_name` """
default_out_ptype: str
""" Default value for out_ptype """
@ -518,7 +482,7 @@ class SimpleTool(Tool, metaclass=ABCMeta):
@dataclass(frozen=True, slots=True)
class LData:
""" Deferred render data returned by `planL()`. """
""" Data for planL """
straight_length: float
straight_kwargs: dict[str, Any]
ccw: SupportsBool | None
@ -644,114 +608,43 @@ class SimpleTool(Tool, metaclass=ABCMeta):
@dataclass
class AutoTool(Tool, metaclass=ABCMeta):
"""
A routing tool assembled from reusable path primitives.
`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*()`.
A simple tool which relies on a single pre-rendered `bend` pattern, a function
for generating straight paths, and a table of pre-rendered `transitions` for converting
from non-native ptypes.
"""
@dataclass(frozen=True, slots=True)
class Straight:
"""
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.
"""
""" Description of a straight-path generator """
ptype: str
""" Port type produced by this straight segment. """
fn: Callable[[float], Pattern] | Callable[[float], Library]
""" Generator function called as `fn(length, **kwargs)`. """
in_port_name: str
""" Name of the input port on the generated pattern. """
out_port_name: str
""" Name of the output port on the generated pattern. """
length_range: tuple[float, float] = (0, numpy.inf)
""" Valid generated lengths, as `(inclusive_min, exclusive_max)`. """
@dataclass(frozen=True, slots=True)
class SBend:
"""
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.
"""
""" Description of an s-bend generator """
ptype: str
""" Port type produced by this S-bend. """
fn: Callable[[float], Pattern] | Callable[[float], Library]
"""
Generator function called as `fn(abs(jog), **kwargs)`. The generated
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.
Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel
and may be negative for a jog in the opposite direction. Won't be called if jog=0.
"""
in_port_name: str
""" Name of the input port on the generated pattern. """
out_port_name: str
""" Name of the output port on the generated pattern. """
jog_range: tuple[float, float] = (0, numpy.inf)
""" Valid residual jog magnitudes, as `(inclusive_min, exclusive_max)`. """
@dataclass(frozen=True, slots=True)
class 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.
"""
""" Description of a pre-rendered bend """
abstract: Abstract
""" Abstract for the reusable bend pattern. """
in_port_name: str
""" Name of the bend input port. """
out_port_name: str
""" Name of the bend output port. """
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?
""" Whether to mirror the stored bend to produce the opposite turn. """
@property
def in_port(self) -> Port:
@ -763,22 +656,10 @@ class AutoTool(Tool, metaclass=ABCMeta):
@dataclass(frozen=True, slots=True)
class 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)`.
"""
""" Description of a pre-rendered transition """
abstract: Abstract
""" Abstract for the reusable transition pattern. """
their_port_name: str
""" Name of the external-side port. """
our_port_name: str
""" Name of the internal primitive-side port. """
@property
def our_port(self) -> Port:
@ -793,7 +674,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
@dataclass(frozen=True, slots=True)
class LPlan:
""" Candidate L-route configuration before final straight length is known. """
""" Template for an L-path configuration """
straight: 'AutoTool.Straight'
bend: 'AutoTool.Bend | None'
in_trans: 'AutoTool.Transition | None'
@ -806,7 +687,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
@dataclass(frozen=True, slots=True)
class LData:
""" Deferred render data returned by `planL()`. """
""" Data for planL """
straight_length: float
straight: 'AutoTool.Straight'
straight_kwargs: dict[str, Any]
@ -877,7 +758,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
@dataclass(frozen=True, slots=True)
class SData:
""" Deferred render data for native-S routes returned by `planS()`. """
""" Data for planS """
straight_length: float
straight: 'AutoTool.Straight'
gen_kwargs: dict[str, Any]
@ -889,7 +770,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
@dataclass(frozen=True, slots=True)
class UData:
""" Deferred render data for `planU()` or double-L `planS()` routes. """
""" Data for planU or planS (double-L) """
ldata0: 'AutoTool.LData'
ldata1: 'AutoTool.LData'
straight2: 'AutoTool.Straight'
@ -953,27 +834,21 @@ class AutoTool(Tool, metaclass=ABCMeta):
raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}")
straights: list[Straight]
""" Straight generators to choose from, in priority order. """
""" List of straight-generators to choose from, in order of priority """
bends: list[Bend]
""" L-bend primitives to choose from, in priority order. """
""" List of bends to choose from, in order of priority """
sbends: list[SBend]
""" Native S-bend generators to choose from, in priority order. """
""" List of S-bend generators to choose from, in order of priority """
transitions: dict[tuple[str, str], Transition]
""" Mapping from `(external_ptype, internal_ptype)` to transition primitive. """
""" `{(external_ptype, internal_ptype): Transition, ...}` """
default_out_ptype: str
""" Output port type used when a zero-length route provides no primitive ptype. """
""" Default value for out_ptype """
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()):
ooii = (iioo[1], iioo[0])
self.transitions.setdefault(ooii, self.transitions[iioo].reversed())
@ -1014,7 +889,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
return numpy.zeros(2)
orot = out_transition.our_port.rotation
assert orot is not None
otrans_dxy = rotation_matrix_2d(bend_angle - orot - pi) @ (out_transition.their_port.offset - out_transition.our_port.offset)
otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset)
return otrans_dxy
def planL(
@ -1053,7 +928,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
straight_kwargs: dict[str, Any],
) -> ILibrary:
"""
Render an L step into an existing tree.
Render an L step into a preexisting tree
"""
pat = tree.top_pattern()
if data.in_transition:
@ -1186,7 +1061,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
gen_kwargs: dict[str, Any],
) -> ILibrary:
"""
Render a native-S step into an existing tree.
Render an L step into a preexisting tree
"""
pat = tree.top_pattern()
if data.in_transition:
@ -1332,21 +1207,19 @@ class AutoTool(Tool, metaclass=ABCMeta):
@dataclass
class PathTool(Tool, metaclass=ABCMeta):
"""
Tool that renders routes directly as `Pattern.path()` geometry.
A tool which draws `Path` geometry elements.
`PathTool` supports L and S routes. Immediate `traceL()` and `traceS()`
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.
If `planL` / `render` are used, the `Path` elements can cover >2 vertices;
with `path` only individual rectangles will be drawn.
"""
layer: layer_t
""" Layer to draw generated path geometry on. """
""" Layer to draw on """
width: float
""" Width of generated path geometry. """
""" `Path` width """
ptype: str = 'unk'
""" Port type for generated input and output ports. """
""" ptype for any ports in patterns generated by this tool """
#@dataclass(frozen=True, slots=True)
#class LData:

View file

@ -22,6 +22,8 @@ Notes:
from typing import IO, cast, Any
from collections.abc import Iterable, Mapping, Callable
from types import MappingProxyType
import io
import mmap
import logging
import pathlib
import gzip
@ -38,7 +40,7 @@ from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape
from ..shapes import Polygon, Path, RectCollection
from ..repetition import Grid
from ..utils import layer_t, annotations_t
from ..library import Library, ILibrary
from ..library import LazyLibrary, Library, ILibrary, ILibraryView
logger = logging.getLogger(__name__)
@ -540,6 +542,117 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element
return texts
def load_library(
stream: IO[bytes],
*,
full_load: bool = False,
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Scan a GDSII stream to determine what structures are present, and create
a library from them. This enables deferred reading of structures
on an as-needed basis.
All structures are loaded as secondary
Args:
stream: Seekable stream. Position 0 should be the start of the file.
The caller should leave the stream open while the library
is still in use, since the library will need to access it
in order to read the structure contents.
full_load: If True, force all structures to be read immediately rather
than as-needed. Since data is read sequentially from the file, this
will be faster than using the resulting library's `precache` method.
postprocess: If given, this function is used to post-process each
pattern *upon first load only*.
Returns:
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
stream.seek(0)
lib = LazyLibrary()
if full_load:
# Full load approach (immediately load everything)
patterns, library_info = read(stream)
for name, pattern in patterns.items():
if postprocess is not None:
lib[name] = postprocess(lib, name, pattern)
else:
lib[name] = pattern
return lib, library_info
# Normal approach (scan and defer load)
library_info = _read_header(stream)
structs = klamath.library.scan_structs(stream)
for name_bytes, pos in structs.items():
name = name_bytes.decode('ASCII')
def mkstruct(pos: int = pos, name: str = name) -> Pattern:
stream.seek(pos)
pat = read_elements(stream, raw_mode=True)
if postprocess is not None:
pat = postprocess(lib, name, pat)
return pat
lib[name] = mkstruct
return lib, library_info
def load_libraryfile(
filename: str | pathlib.Path,
*,
use_mmap: bool = True,
full_load: bool = False,
postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None
) -> tuple[LazyLibrary, dict[str, Any]]:
"""
Wrapper for `load_library()` that takes a filename or path instead of a stream.
Will automatically decompress the file if it is gzipped.
NOTE that any streams/mmaps opened will remain open until ALL of the
`PatternGenerator` objects in the library are garbage collected.
Args:
path: filename or path to read from
use_mmap: If `True`, will attempt to memory-map the file instead
of buffering. In the case of gzipped files, the file
is decompressed into a python `bytes` object in memory
and reopened as an `io.BytesIO` stream.
full_load: If `True`, immediately loads all data. See `load_library`.
postprocess: Passed to `load_library`
Returns:
LazyLibrary object, allowing for deferred load of structures.
Additional library info (dict, same format as from `read`).
"""
path = pathlib.Path(filename)
stream: IO[bytes]
if is_gzipped(path):
if use_mmap:
logger.info('Asked to mmap a gzipped file, reading into memory instead...')
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BytesIO(gz_stream.read()) # type: ignore
else:
gz_stream = gzip.open(path, mode='rb') # noqa: SIM115
stream = io.BufferedReader(gz_stream) # type: ignore
else: # noqa: PLR5501
if use_mmap:
base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115
stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore
else:
stream = path.open(mode='rb') # noqa: SIM115
try:
return load_library(stream, full_load=full_load, postprocess=postprocess)
finally:
if full_load:
stream.close()
def check_valid_names(
names: Iterable[str],
max_length: int = 32,

View file

@ -1,388 +0,0 @@
"""
Classic source-backed lazy GDSII reader built on the pure-python klamath path.
This module provides the non-Arrow half of Masque's lazy GDS architecture:
- `GdsLibrarySource` scans a GDS stream once to discover library metadata,
struct order, and child edges without materializing every cell.
- cells are materialized on demand through the classic `gdsii` decoder
whenever a caller indexes the lazy view
- 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

View file

@ -9,7 +9,8 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import IO, Any, cast
from collections import defaultdict
from collections.abc import Iterator, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
import copy
import gzip
import logging
import mmap
@ -18,12 +19,13 @@ import pathlib
import numpy
from numpy.typing import NDArray
import pyarrow
import klamath
from . import gdsii_arrow
from .utils import is_gzipped
from .gdsii_lazy_core import OverlayLibrary, PortsLibraryView, _pattern_children, write, writefile
from ..library import ILibraryView, LibraryView, dangling_mode_t
from ..pattern import Pattern
from . import gdsii, gdsii_arrow
from .utils import is_gzipped, tmpfile
from ..error import LibraryError
from ..library import ILibrary, ILibraryView, Library, LibraryView, dangling_mode_t
from ..pattern import Pattern, map_targets
from ..utils import apply_transforms
@ -77,6 +79,22 @@ class _ScanPayload:
cells: dict[str, _CellScan]
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:
return gdsii_arrow.is_available()
@ -156,6 +174,30 @@ def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload:
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(
xy: NDArray[numpy.integer[Any]],
angle_rad: NDArray[numpy.floating[Any]],
@ -243,9 +285,6 @@ class ArrowLibrary(ILibraryView):
struct_range = self._payload.cells[name].struct_range
return self._source.raw_slice(struct_range.start, struct_range.end)
def can_copy_raw_struct(self, name: str) -> bool:
return name not in self._cache
def materialize_many(
self,
names: Sequence[str],
@ -396,34 +435,6 @@ class ArrowLibrary(ILibraryView):
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 close(self) -> None:
data = self._source.data
if isinstance(data, mmap.mmap):
data.close()
if self._source.handle is not None:
self._source.handle.close()
self._source.handle = None
def __enter__(self) -> ArrowLibrary:
return self
def __exit__(self, *_args: object) -> None:
self.close()
def find_refs_local(
self,
name: str,
@ -506,6 +517,304 @@ class ArrowLibrary(ILibraryView):
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(
filename: str | pathlib.Path,
) -> tuple[ArrowLibrary, dict[str, Any]]:
@ -517,3 +826,135 @@ def load_libraryfile(
filename: str | pathlib.Path,
) -> tuple[ArrowLibrary, dict[str, Any]]:
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()

View file

@ -1,706 +0,0 @@
"""
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()

View file

@ -14,7 +14,7 @@ Classes include:
- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked
library. Generated with `ILibraryView.abstract_view()`.
"""
from typing import Self, TYPE_CHECKING, Any, cast, TypeAlias, Protocol, Literal
from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal
from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable
import logging
import re
@ -22,14 +22,12 @@ import copy
from pprint import pformat
from collections import defaultdict
from abc import ABCMeta, abstractmethod
from contextvars import ContextVar
from dataclasses import dataclass, replace
from graphlib import TopologicalSorter, CycleError
import numpy
from numpy.typing import ArrayLike, NDArray
from .error import BuildError, LibraryError, PatternError
from .error import LibraryError, PatternError
from .utils import layer_t, apply_transforms
from .shapes import Shape, Polygon
from .label import Label
@ -42,11 +40,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_ACTIVE_BUILD_SESSIONS: ContextVar[dict[int, '_BuildSessionLibrary'] | None] = ContextVar(
'masque_active_build_sessions',
default=None,
)
class visitor_function_t(Protocol):
""" Signature for `Library.dfs()` visitor functions. """
@ -69,69 +62,6 @@ Tree: TypeAlias = MutableMapping[str, 'Pattern']
dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include']
""" 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 = '_'
"""
@ -201,15 +131,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta):
"""
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(
self,
tops: str | Sequence[str] | None = None,
@ -1467,819 +1388,6 @@ class Library(ILibrary):
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):
"""
This class is usually used to create a library of Patterns by mapping names to

View file

@ -1,7 +1,6 @@
from typing import Any, cast
import copy
import functools
from enum import Enum
import numpy
from numpy import pi
@ -14,37 +13,18 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
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
class Arc(PositionableImpl, Shape):
"""
An elliptical arc, formed by cutting off an elliptical ring with two rays.
By default the rays exit from its center, but they can optionally exit from one of the
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
a rotation, and a width.
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
The radii define an ellipse; the ring is formed with radii +/- width/2.
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
The start and stop angle are measured counterclockwise from the first (x) radius.
"""
__slots__ = (
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
'_radii', '_angles', '_width', '_rotation',
# Inherited
'_offset', '_repetition', '_annotations',
)
@ -61,11 +41,6 @@ class Arc(PositionableImpl, Shape):
_width: float
""" Width of the arc """
_angle_ref: ArcAngleRef
""" Origin used by start/stop rays """
AngleRef = ArcAngleRef
# radius properties
@property
def radii(self) -> NDArray[numpy.float64]:
@ -138,18 +113,6 @@ class Arc(PositionableImpl, Shape):
def stop_angle(self, val: float) -> None:
self.angles = (self.angles[0], val)
# Angle reference property
@property
def angle_ref(self) -> ArcAngleRef:
"""
Origin used to interpret start and stop angle rays.
"""
return self._angle_ref
@angle_ref.setter
def angle_ref(self, val: ArcAngleRef | str) -> None:
self._angle_ref = ArcAngleRef(val)
# Rotation property
@property
def rotation(self) -> float:
@ -196,14 +159,12 @@ class Arc(PositionableImpl, Shape):
rotation: float = 0,
repetition: Repetition | None = None,
annotations: annotations_t = None,
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
) -> None:
self.radii = radii
self.angles = angles
self.width = width
self.offset = offset
self.rotation = rotation
self.angle_ref = angle_ref
self.repetition = repetition
self.annotations = annotations
@ -225,7 +186,6 @@ class Arc(PositionableImpl, Shape):
new._width = width
new._offset = offset
new._rotation = rotation % (2 * pi)
new._angle_ref = ArcAngleRef(angle_ref)
new._repetition = repetition
new._annotations = annotations
return new
@ -248,7 +208,6 @@ class Arc(PositionableImpl, Shape):
and numpy.array_equal(self.angles, other.angles)
and self.width == other.width
and self.rotation == other.rotation
and self.angle_ref == other.angle_ref
and self.repetition == other.repetition
and annotations_eq(self.annotations, other.annotations)
)
@ -265,8 +224,6 @@ class Arc(PositionableImpl, Shape):
return tuple(self.radii) < tuple(other.radii)
if not numpy.array_equal(self.angles, other.angles):
return tuple(self.angles) < tuple(other.angles)
if self.angle_ref != other.angle_ref:
return self.angle_ref < other.angle_ref
if not numpy.array_equal(self.offset, other.offset):
return tuple(self.offset) < tuple(other.offset)
if self.rotation != other.rotation:
@ -422,11 +379,6 @@ class Arc(PositionableImpl, Shape):
return self
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 += axis * pi
self.angles *= -1
@ -438,7 +390,6 @@ class Arc(PositionableImpl, Shape):
return self
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
angle_ref = self.angle_ref
if self.radius_x < self.radius_y:
radii = self.radii / self.radius_x
scale = self.radius_x
@ -449,26 +400,23 @@ class Arc(PositionableImpl, Shape):
scale = self.radius_y
rotation = self.rotation + pi / 2
angles = self.angles - pi / 2
angle_ref = _swapped_focus_ref(angle_ref)
delta_angle = angles[1] - angles[0]
start_angle = angles[0] % (2 * pi)
if start_angle >= pi:
start_angle -= pi
rotation += pi
angle_ref = _swapped_focus_ref(angle_ref)
norm_angles = (start_angle, start_angle + delta_angle)
rotation %= 2 * pi
width = self.width
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value),
(self.offset, scale / norm_value, rotation, False),
lambda: Arc(
radii=radii * norm_value,
angles=norm_angles,
width=width * norm_value,
angle_ref=angle_ref,
))
def get_cap_edges(self) -> NDArray[numpy.float64]:
@ -479,16 +427,27 @@ class Arc(PositionableImpl, Shape):
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
```
"""
a_ranges = self._angles_to_parameters()
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
cuts = []
for index in range(2):
edge = []
mins = []
maxs = []
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
wh = sgn * self.width / 2
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
cuts.append(edge)
return numpy.array(cuts) + self.offset
rx = self.radius_x + wh
ry = self.radius_y + wh
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]:
"""
@ -509,7 +468,7 @@ class Arc(PositionableImpl, Shape):
rx = self.radius_x + wh
ry = self.radius_y + wh
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
sign = numpy.sign(d_angle)
if sign != numpy.sign(a1 - a0):
a1 += sign * 2 * pi
@ -517,93 +476,9 @@ class Arc(PositionableImpl, Shape):
aa.append((a0, a1))
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:
angles = f'{numpy.rad2deg(self.angles)}'
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
"""
Return the opposite focus reference, or center for center-referenced arcs.
"""
if angle_ref == ArcAngleRef.FocusPos:
return ArcAngleRef.FocusNeg
if angle_ref == ArcAngleRef.FocusNeg:
return ArcAngleRef.FocusPos
return angle_ref
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]

View file

@ -1,27 +0,0 @@
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)

View file

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

View file

@ -1,87 +0,0 @@
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_equal, assert_allclose
from ..error import PatternError
from ..shapes import Arc
from .helpers import assert_closed_edges_within
def test_arc_init() -> None:
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0))
assert_equal(a.radii, [10, 10])
assert_equal(a.angles, [0, pi / 2])
assert a.width == 2
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)

View file

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

View file

@ -1,61 +1,12 @@
import pytest
from numpy import pi
from numpy.testing import assert_allclose
from numpy import pi
from masque.builder.tools import AutoTool
from masque.builder.pather import Pather
from masque.library import Library
from masque.pattern import Pattern
from masque.ports import Port
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"
from masque.library import Library
from masque.builder.pather import Pather
def make_straight(length, width=2, ptype="wire"):
pat = Pattern()
@ -66,13 +17,15 @@ def make_straight(length, width=2, ptype="wire"):
def make_bend(R, width=2, ptype="wire", clockwise=True):
pat = Pattern()
# Rectangular approximation of a 90 degree bend.
# 90 degree arc approximation (just two rects for start and end)
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), 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:
# (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), xctr=R, lx=width, ymin=0, ymax=R)
pat.ports["A"] = Port((0, 0), 0, ptype=ptype)
@ -83,14 +36,18 @@ def make_bend(R, width=2, ptype="wire", clockwise=True):
def multi_bend_tool():
lib = Library()
# Bend 1: R=2
lib["b1"] = make_bend(2, ptype="wire")
b1_abs = lib.abstract("b1")
# Bend 2: R=5
lib["b2"] = make_bend(5, ptype="wire")
b2_abs = lib.abstract("b2")
tool = AutoTool(
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)),
# 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))
],
bends=[
@ -103,6 +60,7 @@ def multi_bend_tool():
)
return tool, lib
@pytest.fixture
def asymmetric_transition_tool() -> AutoTool:
lib = Library()
@ -144,6 +102,7 @@ def asymmetric_transition_tool() -> AutoTool:
default_out_ptype="core",
).add_complementary_transitions()
def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None:
pat = tree.top_pattern()
out_port = pat[port_names[1]]
@ -154,66 +113,33 @@ def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[
assert_allclose(rot, plan_port.rotation)
assert out_port.ptype == plan_port.ptype
def test_autotool_planL_selection(multi_bend_tool) -> None:
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)
assert data.straight.length_range == (0, 10)
assert data.straight_length == 3
assert data.bend.abstract.name == "b1"
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)
assert data.straight.length_range == (10, 1e8)
assert data.straight_length == 13
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:
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)
assert data.ldata0.straight_length == 7
assert data.ldata0.bend.abstract.name == "b2"
@ -221,6 +147,7 @@ def test_autotool_planU_consistency(multi_bend_tool) -> None:
assert data.ldata1.straight_length == 0
assert data.ldata1.bend.abstract.name == "b1"
def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
tool = asymmetric_transition_tool
@ -232,9 +159,14 @@ def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_tran
tree = tool.traceU(12, length=0, in_ptype="core")
assert_trace_matches_plan(plan_port, tree)
def test_autotool_planS_double_L(multi_bend_tool) -> None:
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)
assert_allclose(p.offset, [20, 10])
assert_allclose(p.rotation, pi)
@ -243,6 +175,7 @@ def test_autotool_planS_double_L(multi_bend_tool) -> None:
assert data.ldata1.straight_length == 0
assert data.l2_length == 6
def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
tool = asymmetric_transition_tool
@ -255,6 +188,7 @@ def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymme
tree = tool.traceS(4, 10, in_ptype="core")
assert_trace_matches_plan(plan_port, tree)
def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
lib = Library()
@ -308,3 +242,65 @@ def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
assert data.straight_length == 0
assert data.jog_remaining == 4
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)

View file

@ -48,7 +48,12 @@ def test_layer_as_polygons_flatten() -> None:
polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib)
assert len(polys) == 1
# Child vertices are rotated by the ref and then translated by the ref offset.
# Original child at (0,0) with rot pi/2 is still at (0,0) in its own space?
# 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]])
assert_allclose(polys[0].vertices, expected, atol=1e-10)

View file

@ -1,315 +0,0 @@
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()

View file

@ -1,17 +0,0 @@
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Circle, Polygon
def test_circle_init() -> None:
c = Circle(radius=10, offset=(5, 5))
assert c.radius == 10
assert_equal(c.offset, [5, 5])
def test_circle_to_polygons() -> None:
c = Circle(radius=10)
polys = c.to_polygons(num_vertices=32)
assert len(polys) == 1
assert isinstance(polys[0], Polygon)
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10)

View file

@ -1,26 +0,0 @@
from numpy import pi
from ..shapes import Arc, Circle, Ellipse
from .helpers import assert_closed_edges_within
def test_shape_arclen() -> None:
e = Ellipse(radii=(10, 5))
polys = e.to_polygons(max_arclen=5)
v = polys[0].vertices
assert_closed_edges_within(v, 5)
assert len(v) > 10
a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
polys = a.to_polygons(max_arclen=2)
assert_closed_edges_within(polys[0].vertices, 2)
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
for shape in (
Circle(radius=10),
Ellipse(radii=(10, 20)),
Arc(radii=(10, 20), angles=(0, 1), width=2),
):
polys = shape.to_polygons(num_vertices=None, max_arclen=1e9)
assert len(polys) == 1
assert len(polys[0].vertices) >= 3

View file

@ -26,16 +26,19 @@ def test_dxf_roundtrip(tmp_path: Path):
lib = Library()
pat = Pattern()
# 1. Polygon (closed)
poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]])
pat.polygon("1", vertices=poly_verts)
# 2. Path (open, 3 points)
path_verts = numpy.array([[20, 0], [30, 0], [30, 10]])
pat.path("2", vertices=path_verts, width=2)
# Two-point paths remain paths rather than being polygonized.
# 3. Path (open, 2 points) - Testing the fix for 2-point polylines
path2_verts = numpy.array([[40, 0], [50, 10]])
pat.path("3", vertices=path2_verts, width=0)
pat.path("3", vertices=path2_verts, width=0) # width 0 to be sure it's not a polygonized path if we're not careful
# 4. Ref with Grid repetition (Manhattan)
subpat = Pattern()
subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = subpat
@ -49,29 +52,38 @@ def test_dxf_roundtrip(tmp_path: Path):
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]
# Verify Polygon
polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)]
assert len(polys) >= 1
poly_read = polys[0]
assert _matches_closed_vertices(poly_read.vertices, poly_verts)
# Verify 3-point Path
paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)]
assert len(paths) >= 1
path_read = paths[0]
assert _matches_open_path(path_read.vertices, path_verts)
assert path_read.width == 2
# Verify 2-point Path
paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)]
assert len(paths2) >= 1
path2_read = paths2[0]
assert _matches_open_path(path2_read.vertices, path2_verts)
assert path2_read.width == 0
# 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
# Check refs in the top pattern
found_grid = False
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":
for ref in reflist:
if isinstance(ref.repetition, Grid):
@ -83,12 +95,16 @@ def test_dxf_roundtrip(tmp_path: Path):
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):
# Test that float precision doesn't break Manhattan grid detection
lib = Library()
sub = Pattern()
sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]])
lib["sub"] = sub
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
top.ref("sub", offset=(0, 0), rotation=angle,
repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2))
@ -98,7 +114,7 @@ def test_dxf_manhattan_precision(tmp_path: Path):
dxf_file = tmp_path / "precision.dxf"
dxf.writefile(lib, "top", dxf_file)
# Near-integer rotated basis vectors round-trip as a Manhattan Grid.
# If the isclose() fix works, this should still be a Grid when read back
read_lib, _ = dxf.readfile(dxf_file)
read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0]

View file

@ -1,29 +0,0 @@
from numpy import pi
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Ellipse
def test_ellipse_init() -> None:
e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4)
assert_equal(e.radii, [10, 5])
assert_equal(e.offset, [1, 2])
assert e.rotation == pi / 4
def test_ellipse_to_polygons() -> None:
e = Ellipse(radii=(10, 5))
polys = e.to_polygons(num_vertices=64)
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10)
def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None:
ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200))
bounds = ellipse.get_bounds_single()
poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single()
assert_allclose(bounds, poly_bounds, atol=1e-3)
def test_ellipse_integer_radii_scale_cleanly() -> None:
ellipse = Ellipse(radii=(10, 20))
ellipse.scale_by(0.5)
assert_allclose(ellipse.radii, [5, 10])

View file

@ -11,31 +11,45 @@ from ..repetition import Grid, Arbitrary
def create_test_library(for_gds: bool = False) -> Library:
lib = Library()
# 1. Polygons
pat_poly = Pattern()
pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]])
lib["polygons"] = pat_poly
# 2. Paths with different endcaps
pat_paths = Pattern()
# 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)
# Circle (Only for GDS)
if for_gds:
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))
lib["paths"] = pat_paths
# 3. Circles (only for OASIS or polygonized for GDS)
pat_circles = Pattern()
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])
else:
pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)))
lib["circles"] = pat_circles
# 4. Refs with repetitions
pat_refs = Pattern()
# Simple Ref
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))
# Ref with Arbitrary repetition
pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]]))
lib["refs"] = pat_refs
# 5. Shapes with repetitions (OASIS only, must be wrapped for GDS)
pat_rep_shapes = Pattern()
poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5))
pat_rep_shapes.shapes[(4, 0)].append(poly_rep)
@ -54,10 +68,16 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
read_lib, _ = gdsii.readfile(gds_file)
# Check existence
for name in lib:
assert name in read_lib
# Check 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])
assert p_flush.cap == MPath.Cap.Flush
@ -72,16 +92,20 @@ def test_gdsii_full_roundtrip(tmp_path: Path) -> None:
assert p_custom.cap_extensions is not None
assert_allclose(p_custom.cap_extensions, (1, 5))
# Check Refs with repetitions
read_refs = read_lib["refs"]
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]
assert len(arefs) == 1
assert isinstance(arefs[0].repetition, Grid)
assert arefs[0].repetition.a_count == 3
assert arefs[0].repetition.b_count == 2
# GDS stores repeated shapes through refs created by wrap_repeated_shapes().
# Check wrapped 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
def test_oasis_full_roundtrip(tmp_path: Path) -> None:
@ -93,17 +117,34 @@ def test_oasis_full_roundtrip(tmp_path: Path) -> None:
read_lib, _ = oasis.readfile(oas_file)
# Check existence
for name in lib:
assert name in read_lib
# Check Circle
read_circles = read_lib["circles"]
assert isinstance(read_circles.shapes[(3, 0)][0], Circle)
assert read_circles.shapes[(3, 0)][0].radius == 5
# Check Path caps
read_paths = read_lib["paths"]
assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush
assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square
# 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"]
poly = read_rep_shapes.shapes[(4, 0)][0]
assert poly.repetition is not None

View file

@ -1,101 +0,0 @@
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')

View file

@ -1,17 +0,0 @@
import pytest
import numpy
from ..shapes import Polygon
def test_manhattanize() -> None:
pytest.importorskip("float_raster")
pytest.importorskip("skimage.measure")
poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]])
grid = numpy.arange(0, 11, 1)
manhattan_polys = poly.manhattanize(grid, grid)
assert len(manhattan_polys) >= 1
for mp in manhattan_polys:
dv = numpy.diff(mp.vertices, axis=0)
assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0))

View file

@ -1,6 +1,6 @@
from numpy.testing import assert_equal, assert_allclose
from ..shapes import Path, Path as MPath
from ..shapes import Path
def test_path_init() -> None:
@ -14,6 +14,7 @@ def test_path_to_polygons_flush() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush)
polys = p.to_polygons()
assert len(polys) == 1
# Rectangle from (0, -1) to (10, 1)
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[0, -1], [10, 1]])
@ -22,6 +23,8 @@ def test_path_to_polygons_square() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square)
polys = p.to_polygons()
assert len(polys) == 1
# Square cap adds width/2 = 1 to each end
# Rectangle from (-1, -1) to (11, 1)
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-1, -1], [11, 1]])
@ -29,8 +32,11 @@ def test_path_to_polygons_square() -> None:
def test_path_to_polygons_circle() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle)
polys = p.to_polygons(num_vertices=32)
# Path.to_polygons for Circle cap returns 1 polygon for the path + polygons for the caps
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()
assert_equal(bounds, [[-1, -1], [11, 1]])
@ -39,21 +45,32 @@ def test_path_custom_cap() -> None:
p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10))
polys = p.to_polygons()
assert len(polys) == 1
# Extends 5 units at start, 10 at end
# Starts at -5, ends at 20
bounds = polys[0].get_bounds_single()
assert_equal(bounds, [[-5, -1], [20, 1]])
def test_path_bend() -> None:
# L-shaped path
p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2)
polys = p.to_polygons()
assert len(polys) == 1
bounds = polys[0].get_bounds_single()
# 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]])
def test_path_mirror() -> None:
p = Path(vertices=[[10, 5], [20, 10]], width=2)
p.mirror(0)
p.mirror(0) # Mirror across x axis (y -> -y)
assert_equal(p.vertices, [[10, -5], [20, -10]])
@ -92,10 +109,3 @@ 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))
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]])

108
masque/test/test_pather.py Normal file
View file

@ -0,0 +1,108 @@
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()

View file

@ -0,0 +1,936 @@
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'}

View file

@ -1,127 +0,0 @@
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose
from masque import Pather, Library, Pattern, Port
from masque.builder.tools import AutoTool
def make_straight(length, 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)

View file

@ -1,213 +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_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

View file

@ -1,305 +0,0 @@
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))

View file

@ -1,122 +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_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'}

View file

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

View file

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

View file

@ -1,89 +0,0 @@
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])

View file

@ -0,0 +1,199 @@
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)

View file

@ -7,6 +7,7 @@ from ..error import PatternError
def test_grid_displacements() -> None:
# 2x2 grid
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])
assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)]
@ -33,6 +34,7 @@ def test_grid_get_bounds() -> None:
def test_arbitrary_displacements() -> None:
pts = [[0, 0], [10, 20], [-5, 30]]
arb = Arbitrary(pts)
# They should be sorted by displacements.setter
disps = arb.displacements
assert len(disps) == 3
assert any((disps == [0, 0]).all(axis=1))
@ -45,7 +47,9 @@ def test_arbitrary_transform() -> None:
arb.rotate(pi / 2)
assert_allclose(arb.displacements, [[0, 10]], atol=1e-10)
arb.mirror(0)
arb.mirror(0) # Mirror x across y axis? Wait, mirror(axis=0) in repetition.py is:
# self.displacements[:, 1 - axis] *= -1
# if axis=0, 1-axis=1, so y *= -1
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)

View file

@ -0,0 +1,244 @@
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])

View file

@ -1,15 +0,0 @@
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)

View file

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

142
masque/test/test_shapes.py Normal file
View file

@ -0,0 +1,142 @@
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)

View file

@ -1,47 +0,0 @@
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)

View file

@ -33,13 +33,19 @@ def test_remove_colinear_vertices() -> None:
def test_remove_colinear_vertices_exhaustive() -> None:
# U-turn
v = [[0, 0], [10, 0], [0, 0]]
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
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
# 180 degree U-turn in closed path
v = [[0, 0], [10, 0], [5, 0]]
v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False)
assert len(v_clean) == 2

View file

@ -43,6 +43,7 @@ def test_visualize_noninteractive(tmp_path) -> None:
def test_visualize_empty() -> None:
""" Test visualizing an empty pattern. """
pat = Pattern()
# Should not raise
pat.visualize(overdraw=True)
@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed")
@ -50,4 +51,5 @@ def test_visualize_no_refs() -> None:
""" Test visualizing a pattern with only local shapes (no library needed). """
pat = Pattern()
pat.polygon('L1', [[0, 0], [1, 0], [0, 1]])
# Should not raise even if library is None
pat.visualize(overdraw=True)