diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index ea4471f..6e5730b 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,12 +1,6 @@ masque Tutorial =============== -These examples are meant to be read roughly in order. - -- Start with `basic_shapes.py` for the core `Pattern` / GDS concepts. -- Then read `devices.py` and `library.py` for hierarchical composition and libraries. -- Read the `pather*` tutorials separately when you want routing helpers. - Contents -------- @@ -14,13 +8,11 @@ Contents * Draw basic geometry * Export to GDS - [devices](devices.py) - * Build hierarchical photonic-crystal example devices * Reference other patterns * Add ports to a pattern - * Use `Builder` to snap ports together into a circuit + * Snap ports together to build a circuit * Check for dangling references - [library](library.py) - * 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()`) @@ -36,8 +28,7 @@ Contents * Advanced port manipulation and connections -Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating -photonic-crystal lattices; it is support code rather than a step-by-step tutorial. +Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. Running @@ -49,6 +40,3 @@ cd examples/tutorial python3 basic_shapes.py klayout -e basic_shapes.gds ``` - -Some tutorials depend on outputs from earlier ones. In particular, `library.py` -expects `circuit.gds`, which is generated by `devices.py`. diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index d6beb2a..79d318a 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,11 +1,3 @@ -""" -Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Builder`. - -This file uses photonic-crystal components as the concrete example, so some of -the geometry-generation code is domain-specific. The tutorial value is in the -Masque patterns around it: creating reusable cells, annotating ports, composing -hierarchy with references, and snapping ports together to build a larger circuit. -""" from collections.abc import Sequence, Mapping import numpy @@ -72,9 +64,9 @@ def perturbed_l3( Provided sequence should have same length as `shifts_a`. xy_size: `(x, y)` number of mirror periods in each direction; total size is `2 * n + 1` holes in each direction. Default (10, 10). - perturbed_radius: radius of holes perturbed to form an upwards-directed beam + perturbed_radius: radius of holes perturbed to form an upwards-driected beam (multiplicative factor). Default 1.1. - trench_width: Width of the undercut trenches. Default 1200. + trench width: Width of the undercut trenches. Default 1200. Returns: `Pattern` object representing the L3 design. @@ -87,15 +79,14 @@ def perturbed_l3( shifts_a=shifts_a, shifts_r=shifts_r) - # Build the cavity by instancing the supplied `hole` pattern many times. - # Using references keeps the pattern compact even though it contains many holes. + # Build L3 cavity, using references to the provided hole pattern pat = Pattern() pat.refs[hole] += [ Ref(scale=r, offset=(lattice_constant * x, lattice_constant * y)) for x, y, r in xyr] - # Add rectangular undercut aids based on the referenced hole extents. + # Add rectangular undercut aids min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) trench_dx = max_xy[0] - min_xy[0] @@ -104,7 +95,7 @@ def perturbed_l3( Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), ] - # Define the interface in Masque terms: two ports at the left/right extents. + # Ports are at outer extents of the device (with y=0) extent = lattice_constant * xy_size[0] pat.ports = dict( input=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -134,17 +125,17 @@ def waveguide( Returns: `Pattern` object representing the waveguide. """ - # Generate the normalized lattice locations for the line defect. + # Generate hole locations xy = pcgen.waveguide(length=length, num_mirror=mirror_periods) - # Build the pattern by placing repeated references to the same hole cell. + # Build the pattern pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Publish the device interface as two ports at the outer edges. + # Ports are at outer edges, with y=0 extent = lattice_constant * length / 2 pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -173,17 +164,17 @@ def bend( `Pattern` object representing the waveguide bend. Ports are named 'left' (input) and 'right' (output). """ - # Generate the normalized lattice locations for the bend. + # Generate hole locations xy = pcgen.wgbend(num_mirror=mirror_periods) - # Build the pattern by instancing the shared hole cell. - pat = Pattern() + # Build the pattern + pat= Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Publish the bend interface as two ports. + # Figure out port locations. extent = lattice_constant * mirror_periods pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -212,17 +203,17 @@ def y_splitter( `Pattern` object representing the y-splitter. Ports are named 'in', 'top', and 'bottom'. """ - # Generate the normalized lattice locations for the splitter. + # Generate hole locations xy = pcgen.y_splitter(num_mirror=mirror_periods) - # Build the pattern by instancing the shared hole cell. + # Build pattern pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Publish the splitter interface as one input and two outputs. + # Determine port locations extent = lattice_constant * mirror_periods pat.ports = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -236,13 +227,13 @@ def y_splitter( def main(interactive: bool = True) -> None: - # First make a couple of reusable primitive cells. + # Generate some basic hole patterns shape_lib = { 'smile': basic_shapes.smile(RADIUS), 'hole': basic_shapes.hole(RADIUS), } - # Then build a small library of higher-level devices from those primitives. + # Build some devices a = LATTICE_CONSTANT devices = {} @@ -254,23 +245,22 @@ def main(interactive: bool = True) -> None: devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5) devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :) - # Turn the device mapping into a `Library`. - # That gives us convenience helpers for hierarchy inspection and abstract views. + # Turn our dict of devices into a Library. + # This provides some convenience functions in the future! lib = Library(devices) # # Build a circuit # - # Create a `Builder`, and register the resulting top cell as "my_circuit". + # Create a `Builder`, and add the circuit to our library as "my_circuit". circ = Builder(library=lib, name='my_circuit') - # Start by placing a waveguide and renaming its ports to match the circuit-level - # names we want to use while assembling the design. + # Start by placing a waveguide. Call its ports "in" and "signal". circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) - # Extend the signal path by attaching another waveguide. - # Because `wg10` only has one unattached port left after the plug, Masque can - # infer that it should keep the name `signal`. + # Extend the signal path by attaching the "left" port of a waveguide. + # Since there is only one other port ("right") on the waveguide we + # are attaching (wg10), it automatically inherits the name "signal". circ.plug('wg10', {'signal': 'left'}) # We could have done the following instead: @@ -278,8 +268,8 @@ def main(interactive: bool = True) -> None: # lib['my_circuit'] = circ_pat # circ_pat.place(lib.abstract('wg10'), ...) # circ_pat.plug(lib.abstract('wg10'), ...) - # but `Builder` removes some repeated `lib.abstract(...)` boilerplate and keeps - # the assembly code focused on port-level intent. + # but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar + # syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality. # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the @@ -291,10 +281,13 @@ def main(interactive: bool = True) -> None: circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # Add a bend to both branches. - # Our bend primitive is defined with a specific orientation, so choosing which - # port to plug determines whether the path turns clockwise or counterclockwise. - # We could also mirror one instance instead of using opposite ports. + # Add a bend to both ports. + # Our bend's ports "left" and "right" refer to the original counterclockwise + # orientation. We want the bends to turn in opposite directions, so we attach + # the "right" port to "signal1" to bend clockwise, and the "left" port + # to "signal2" to bend counterclockwise. + # We could also use `mirrored=(True, False)` to mirror one of the devices + # and then use same device port on both paths. circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) @@ -303,26 +296,29 @@ def main(interactive: bool = True) -> None: circ.plug('l3cav', {'signal1': 'input'}) circ.plug('wg10', {'signal1': 'left'}) - # `signal2` gets a single waveguide of equivalent overall length. + # "signal2" just gets a single of equivalent length circ.plug('wg28', {'signal2': 'left'}) - # Now bend both branches back towards each other. + # Now we bend both waveguides back towards each other circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # To join the branches, attach a second y-junction. - # This succeeds only if both chosen ports agree on the same translation and - # rotation for the inserted device; otherwise Masque raises an exception. + # To join the waveguides, we attach a second y-junction. + # We plug "signal1" into the "bot" port, and "signal2" into the "top" port. + # The remaining port gets named "signal_out". + # This operation would raise an exception if the ports did not line up + # correctly (i.e. they required different rotations or translations of the + # y-junction device). circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) # Finally, add some more waveguide to "signal_out". circ.plug('wg10', {'signal_out': 'left'}) - # Bake the top-level port metadata into labels so it survives GDS export. - # These labels appear on the circuit cell; individual child devices keep their - # own port labels in their own cells. + # We can also add text labels for our circuit's ports. + # They will appear at the uppermost hierarchy level, while the individual + # device ports will appear further down, in their respective cells. ports_to_data(circ.pattern) # Check if we forgot to include any patterns... ooops! @@ -334,12 +330,12 @@ def main(interactive: bool = True) -> None: lib.add(shape_lib) assert not lib.dangling_refs() - # We can visualize the design directly, though opening the written GDS is often easier. + # We can visualize the design. Usually it's easier to just view the GDS. if interactive: print('Visualizing... this step may be slow') circ.pattern.visualize(lib) - # Write out only the subtree reachable from our top cell. + #Write out to GDS, only keeping patterns referenced by our circuit (including itself) subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use check_valid_names(subtree.keys()) writefile(subtree, 'circuit.gds', **GDS_OPTS) diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index faaa5a1..abfbbf1 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,11 +1,3 @@ -""" -Tutorial: using `LazyLibrary` and `Builder.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 mix lazily loaded GDS content with -python-generated devices inside one library. -""" from typing import Any from pprint import pformat @@ -20,9 +12,8 @@ from basic_shapes import GDS_OPTS def main() -> None: - # 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. + # Define a `LazyLibrary`, which provides lazy evaluation for generating + # patterns and lazy-loading of GDS contents. lib = LazyLibrary() # @@ -32,9 +23,9 @@ def main() -> None: # Scan circuit.gds and prepare to lazy-load its contents gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports) - # 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. + # Add it into the device library by providing a way to read port info + # This maintains the lazy evaluation from above, so no patterns + # are actually read yet. lib.add(gds_lib) print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys()))) @@ -49,8 +40,8 @@ def main() -> None: hole = 'triangle', ) - # Triangle-based variants. These lambdas are only recipes for building the - # patterns; they do not execute until someone asks for the cell. + # Triangle-based variants. These are defined here, but they won't run until they're + # retrieved from the library. 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) @@ -62,22 +53,22 @@ def main() -> None: # Build a mixed waveguide with an L3 cavity in the middle # - # Start a new design by copying the ports from an existing library cell. - # This gives `circ2` the same external interface as `tri_l3cav`. + # Immediately start building from an instance of the L3 cavity circ2 = Builder(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 `Builder`. + # First way to get abstracts is `lib.abstract(name)` + # We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`. circ2.plug(lib.abstract('wg10'), {'input': 'right'}) - # Second way: use an `AbstractView`, which behaves like a mapping of names - # to abstracts. + # Second way to get abstracts is to use an AbstractView + # This also works directly with `Pattern.plug()` / `Pattern.place()`. abstracts = lib.abstract_view() circ2.plug(abstracts['wg10'], {'output': 'left'}) - # Third way: let `Builder` resolve a pattern name through its own library. - # This shorthand is convenient, but it is specific to helpers that already - # carry a library reference. + # Third way to specify an abstract works by automatically getting + # it from the library already within the Builder object. + # This wouldn't work if we only had a `Pattern` (not a `Builder`). + # Just pass the pattern name! circ2.plug('tri_wg10', {'input': 'right'}) circ2.plug('tri_wg10', {'output': 'left'}) @@ -86,15 +77,13 @@ def main() -> None: # - # Build a second device that is explicitly designed to mate with `circ2`. + # Build a device that could plug into our mixed_wg_cav and joins the two ports # - # `Builder.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. + # We'll be designing against an existing device's interface... circ3 = Builder.interface(source=circ2) - # Continue routing outward from those inherited ports. + # ... that lets us continue from where we left off. 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'}) diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index 386384a..f7bbdb2 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -243,7 +243,7 @@ def main() -> None: # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here # and achieve the same result without having to define any transitions in M1_tool. # Note that even though we have changed the tool used for GND, the via doesn't get placed until - # the next time we route GND (the `pather.ccw()` call below). + # the next time we draw a path on GND (the pather.mpath() statement below). pather.retool(M1_tool, keys='GND') # Bundle together GND and VCC, and path the bundle forward and counterclockwise. diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py index 6d41a39..3fad6e7 100644 --- a/examples/tutorial/port_pather.py +++ b/examples/tutorial/port_pather.py @@ -27,14 +27,14 @@ def main() -> None: # and remembers the selected port(s). This allows method chaining. # Route VCC: 6um South, then West to x=0. - # (Note: since the port points North into the pad, trace() moves South by default) + # (Note: since the port points North into the pad, path() moves South by default) (rpather.at('VCC') - .trace(False, length=6_000) # Move South, turn West (Clockwise) - .trace_to(None, x=0) # Continue West to x=0 + .path(ccw=False, length=6_000) # Move South, turn West (Clockwise) + .path_to(ccw=None, x=0) # Continue West to x=0 ) # Route GND: 5um South, then West to match VCC's x-coordinate. - rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x) + rpather.at('GND').path(ccw=False, length=5_000).path_to(ccw=None, x=rpather['VCC'].x) # @@ -49,45 +49,45 @@ def main() -> None: .retool(M1_tool) # this only retools the 'GND' port ) - # We can also pass multiple ports to .at(), and then route them together. + # We can also pass multiple ports to .at(), and then use .mpath() on them. # Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via). (rpather.at(['GND', 'VCC']) - .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South - .retool(M1_tool) # Retools both GND and VCC - .trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension - .trace(False, emin=1_000, spacing=1_200) # U-turn back South - .trace(False, emin=2_000, spacing=4_500) # U-turn back West + .mpath(ccw=True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South + .retool(M1_tool) # Retools both GND and VCC + .mpath(ccw=True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension + .mpath(ccw=False, emin=1_000, spacing=1_200) # U-turn back South + .mpath(ccw=False, emin=2_000, spacing=4_500) # U-turn back West ) # Retool VCC back to M2 and move both to x=-28k rpather.at('VCC').retool(M2_tool) - rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000) + rpather.at(['GND', 'VCC']).mpath(ccw=None, xmin=-28_000) # Final segments to -50k - rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire') + rpather.at('VCC').path_to(ccw=None, x=-50_000, out_ptype='m1wire') with rpather.at('GND').toolctx(M2_tool): - rpather.at('GND').trace_to(None, x=-40_000) - rpather.at('GND').trace_to(None, x=-50_000) + rpather.at('GND').path_to(ccw=None, x=-40_000) + rpather.at('GND').path_to(ccw=None, x=-50_000) # - # Branching with mark and fork + # Branching with save_copy and into_copy # - # .mark(new_name) creates a port copy and keeps the original selected. - # .fork(new_name) creates a port copy and selects the new one. + # .save_copy(new_name) creates a port copy and keeps the original selected. + # .into_copy(new_name) creates a port copy and selects the new one. # Create a tap on GND (rpather.at('GND') - .trace(None, length=5_000) # Move GND further West - .mark('GND_TAP') # Mark this location for a later branch - .jog(offset=-10_000, length=10_000) # Continue GND with an S-bend + .path(ccw=None, length=5_000) # Move GND further West + .save_copy('GND_TAP') # Mark this location for a later branch + .pathS(length=10_000, jog=-10_000) # Continue GND with an S-bend ) # Branch VCC and follow the new branch (rpather.at('VCC') - .trace(None, length=5_000) - .fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' - .trace(True, length=5_000) # VCC_BRANCH turns South + .path(ccw=None, length=5_000) + .into_copy('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' + .path(ccw=True, length=5_000) # VCC_BRANCH turns South ) # The original 'VCC' port remains at x=-55k, y=VCC.y @@ -99,25 +99,27 @@ def main() -> None: # Route the GND_TAP we saved earlier. (rpather.at('GND_TAP') .retool(M1_tool) - .trace(True, length=10_000) # Turn South - .rename('GND_FEED') # Give it a more descriptive name + .path(ccw=True, length=10_000) # Turn South + .rename_to('GND_FEED') # Give it a more descriptive name .retool(M1_tool) # Re-apply tool to the new name ) # We can manage the active set of ports in a PortPather pp = rpather.at(['VCC_BRANCH', 'GND_FEED']) - pp.select('GND') # Now tracking 3 ports - pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND - pp.trace(None, each=5_000) # Move both 5um forward (length > transition size) + pp.add_port('GND') # Now tracking 3 ports + pp.drop_port('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND + pp.path_each(ccw=None, length=5_000) # Move both 5um forward (length > transition size) # We can also delete ports from the pather entirely rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead) # - # Advanced Connections: trace_into + # Advanced Connections: path_into and path_from # - # trace_into routes FROM the selected port TO a target port. + + # path_into routes FROM the selected port TO a target port. + # path_from routes TO the selected port FROM a source port. # Create a destination component dest_ports = { @@ -131,10 +133,10 @@ def main() -> None: # Connect GND_FEED to DEST_A # Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice. - rpather.at('GND_FEED').trace_into('DEST_A') + rpather.at('GND_FEED').path_into('DEST_A') - # Connect VCC_BRANCH to DEST_B - rpather.at('VCC_BRANCH').trace_into('DEST_B') + # Connect VCC_BRANCH to DEST_B using path_from + rpather.at('DEST_B').path_from('VCC_BRANCH') # diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 0f2eebc..49ad3be 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -2,7 +2,7 @@ Unified Pattern assembly and routing (`Pather`) """ from typing import Self, Literal, Any, overload -from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence, Callable +from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence import copy import logging from collections import defaultdict @@ -93,14 +93,6 @@ class Pather(PortList): PROBE_LENGTH: float = 1e6 """ Large length used when probing tools for their lateral displacement """ - _POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position') - """ Single-port position bounds accepted by `trace_to()` and `jog()` """ - - _BUNDLE_BOUND_KEYS: tuple[str, ...] = ( - 'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest', - ) - """ Bounds accepted by `trace()` / `trace_to()` when solving bundle extensions """ - @property def ports(self) -> dict[str, Port]: return self.pattern.ports @@ -178,23 +170,6 @@ class Pather(PortList): port = self.ports[n] self.paths[n].append(RenderStep('P', None, port.copy(), port.copy(), None)) - def _prepare_breaks(self, names: Iterable[str | None]) -> list[tuple[str, RenderStep]]: - """ Snapshot break markers to be committed after a successful mutation. """ - if self._dead: - return [] - - prepared: list[tuple[str, RenderStep]] = [] - for n in names: - if n is not None and n in self.paths: - port = self.ports[n] - prepared.append((n, RenderStep('P', None, port.copy(), port.copy(), None))) - return prepared - - def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None: - """ Append previously prepared break markers. """ - for name, step in prepared: - self.paths[name].append(step) - @logged_op(lambda args: list(args['map_in'].keys())) def plug( self, @@ -203,11 +178,9 @@ class Pather(PortList): map_out: dict[str, str | None] | None = None, **kwargs, ) -> Self: - other = self.library.resolve(other, append=kwargs.get('append', False)) - - prepared_breaks: list[tuple[str, RenderStep]] = [] if not self._dead: - other_ports = other.ports + other_res = self.library.resolve(other, append=kwargs.get('append', False)) + other_ports = other_res.ports affected = set(map_in.keys()) plugged = set(map_in.values()) for name in other_ports: @@ -215,10 +188,12 @@ class Pather(PortList): new_name = (map_out or {}).get(name, name) if new_name is not None: affected.add(new_name) - prepared_breaks = self._prepare_breaks(affected) + self._record_break(affected) + + # Resolve into Abstract or Pattern + other = self.library.resolve(other, append=kwargs.get('append', False)) self.pattern.plug(other=other, map_in=map_in, map_out=map_out, skip_geometry=self._dead, **kwargs) - self._commit_breaks(prepared_breaks) return self @logged_op() @@ -228,20 +203,20 @@ class Pather(PortList): port_map: dict[str, str | None] | None = None, **kwargs, ) -> Self: - other = self.library.resolve(other, append=kwargs.get('append', False)) - - prepared_breaks: list[tuple[str, RenderStep]] = [] if not self._dead: - other_ports = other.ports + other_res = self.library.resolve(other, append=kwargs.get('append', False)) + other_ports = other_res.ports affected = set() for name in other_ports: new_name = (port_map or {}).get(name, name) if new_name is not None: affected.add(new_name) - prepared_breaks = self._prepare_breaks(affected) + self._record_break(affected) + + # Resolve into Abstract or Pattern + other = self.library.resolve(other, append=kwargs.get('append', False)) self.pattern.place(other=other, port_map=port_map, skip_geometry=self._dead, **kwargs) - self._commit_breaks(prepared_breaks) return self @logged_op(lambda args: list(args['connections'].keys())) @@ -362,375 +337,6 @@ class Pather(PortList): if self._auto_render: self.render(append=self._auto_render_append) - def _transform_relative_port(self, start_port: Port, out_port: Port) -> Port: - """ Transform a tool-planned output port into layout coordinates without mutating state. """ - port_rot = start_port.rotation - assert port_rot is not None - - transformed = out_port.copy() - transformed.rotate_around((0, 0), pi + port_rot) - transformed.translate(start_port.offset) - return transformed - - def _resolved_position_bound( - self, - portspec: str, - bounds: Mapping[str, Any], - *, - allow_length: bool, - ) -> tuple[str, Any, float] | None: - """ - Resolve a single positional bound for a single port into a travel length. - """ - present = [(key, bounds[key]) for key in self._POSITION_KEYS if bounds.get(key) is not None] - if not present: - return None - if len(present) > 1: - keys = ', '.join(key for key, _value in present) - raise BuildError(f'Provide exactly one positional bound; got {keys}') - if not allow_length and bounds.get('length') is not None: - raise BuildError('length cannot be combined with a positional bound') - - key, value = present[0] - port = self.pattern[portspec] - assert port.rotation is not None - is_horiz = numpy.isclose(port.rotation % pi, 0) - if is_horiz: - if key == 'y': - raise BuildError('Port is horizontal') - target = Port((value, port.offset[1]), rotation=None) - else: - if key == 'x': - raise BuildError('Port is vertical') - target = Port((port.offset[0], value), rotation=None) - (travel, _jog), _ = port.measure_travel(target) - return key, value, -float(travel) - - @staticmethod - def _format_route_key_list(keys: Sequence[str]) -> str: - return ', '.join(keys) - - @staticmethod - def _present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]: - return [key for key in keys if bounds.get(key) is not None] - - def _present_bundle_bounds(self, bounds: Mapping[str, Any]) -> list[str]: - return self._present_keys(bounds, self._BUNDLE_BOUND_KEYS) - - def _validate_trace_args( - self, - portspec: Sequence[str], - *, - length: float | None, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - bundle_bounds = self._present_bundle_bounds(bounds) - if len(bundle_bounds) > 1: - args = self._format_route_key_list(bundle_bounds) - raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}') - - invalid_with_length = self._present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds - invalid_with_each = self._present_keys(bounds, ('set_rotation',)) + bundle_bounds - - if length is not None: - if len(portspec) > 1: - raise BuildError('length only allowed with a single port') - if spacing is not None: - invalid_with_length.append('spacing') - if invalid_with_length: - args = self._format_route_key_list(invalid_with_length) - raise BuildError(f'length cannot be combined with other routing bounds: {args}') - return - - if bounds.get('each') is not None: - if spacing is not None: - invalid_with_each.append('spacing') - if invalid_with_each: - args = self._format_route_key_list(invalid_with_each) - raise BuildError(f'each cannot be combined with other routing bounds: {args}') - return - - if not bundle_bounds: - raise BuildError('No bound type specified for trace()') - - def _validate_trace_to_positional_args( - self, - *, - spacing: float | ArrayLike | None, - bounds: Mapping[str, Any], - ) -> None: - invalid = self._present_keys(bounds, ('each', 'set_rotation')) + self._present_bundle_bounds(bounds) - if spacing is not None: - invalid.append('spacing') - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}') - - def _validate_jog_args(self, *, length: float | None, bounds: Mapping[str, Any]) -> None: - invalid = self._present_keys(bounds, ('each', 'set_rotation')) + self._present_bundle_bounds(bounds) - if length is not None: - invalid = self._present_keys(bounds, self._POSITION_KEYS) + invalid - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}') - return - - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'Unsupported routing bounds for jog(): {args}') - - def _validate_uturn_args(self, bounds: Mapping[str, Any]) -> None: - invalid = self._present_keys(bounds, self._POSITION_KEYS + ('each', 'set_rotation')) + self._present_bundle_bounds(bounds) - if invalid: - args = self._format_route_key_list(invalid) - raise BuildError(f'Unsupported routing bounds for uturn(): {args}') - - def _validate_fallback_endpoint( - self, - portspec: str, - actual_end: Port, - *, - length: float, - jog: float, - out_rotation: float, - requested_out_ptype: str | None, - route_name: str, - ) -> None: - """ - Ensure a synthesized fallback route still satisfies the public routing contract. - """ - start_port = self.pattern[portspec] - expected_local = Port((length, jog), rotation=out_rotation, ptype=actual_end.ptype) - expected_end = self._transform_relative_port(start_port, expected_local) - - offsets_match = bool(numpy.allclose(actual_end.offset, expected_end.offset)) - rotations_match = ( - actual_end.rotation is not None - and expected_end.rotation is not None - and bool(numpy.isclose(actual_end.rotation, expected_end.rotation)) - ) - ptype_matches = requested_out_ptype is None or actual_end.ptype == requested_out_ptype - - if offsets_match and rotations_match and ptype_matches: - return - - raise BuildError( - f'{route_name} fallback via two planL() steps is unsupported for this tool/kwargs combination. ' - f'Expected offset={tuple(expected_end.offset)}, rotation={expected_end.rotation}, ' - f'ptype={requested_out_ptype or actual_end.ptype}; got offset={tuple(actual_end.offset)}, ' - f'rotation={actual_end.rotation}, ptype={actual_end.ptype}' - ) - - def _apply_validated_double_l( - self, - portspec: str, - tool: Tool, - first: tuple[Port, Any], - second: tuple[Port, Any], - *, - length: float, - jog: float, - out_rotation: float, - requested_out_ptype: str | None, - route_name: str, - plug_into: str | None, - ) -> None: - out_port0, data0 = first - out_port1, data1 = second - staged_port0 = self._transform_relative_port(self.pattern[portspec], out_port0) - staged_port1 = self._transform_relative_port(staged_port0, out_port1) - self._validate_fallback_endpoint( - portspec, - staged_port1, - length = length, - jog = jog, - out_rotation = out_rotation, - requested_out_ptype = requested_out_ptype, - route_name = route_name, - ) - self._apply_step('L', portspec, out_port0, data0, tool) - self._apply_step('L', portspec, out_port1, data1, tool, plug_into) - - def _plan_s_fallback( - self, - tool: Tool, - portspec: str, - in_ptype: str, - length: float, - jog: float, - **kwargs: Any, - ) -> tuple[tuple[Port, Any], tuple[Port, Any]]: - ccw0 = jog > 0 - R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs) - R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs) - L1, L2 = length - R2, abs(jog) - R1 - if L1 < 0 or L2 < 0: - raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback") - - first = tool.planL(ccw0, L1, in_ptype = in_ptype, **(kwargs | {'out_ptype': None})) - second = tool.planL(not ccw0, L2, in_ptype = first[0].ptype, **kwargs) - return first, second - - def _plan_u_fallback( - self, - tool: Tool, - in_ptype: str, - length: float, - jog: float, - **kwargs: Any, - ) -> tuple[tuple[Port, Any], tuple[Port, Any]]: - ccw = jog > 0 - R = self._get_tool_R(tool, ccw, in_ptype, **kwargs) - L1, L2 = length + R, abs(jog) - R - first = tool.planL(ccw, L1, in_ptype = in_ptype, **(kwargs | {'out_ptype': None})) - second = tool.planL(ccw, L2, in_ptype = first[0].ptype, **kwargs) - return first, second - - def _run_route_transaction(self, callback: Callable[[], None]) -> None: - """ Run a routing mutation atomically, rendering once at the end if auto-render is enabled. """ - saved_ports = copy.deepcopy(self.pattern.ports) - saved_paths = defaultdict(list, copy.deepcopy(dict(self.paths))) - saved_auto_render = self._auto_render - self._auto_render = False - try: - callback() - except Exception: - self.pattern.ports = saved_ports - self.paths = saved_paths - raise - finally: - self._auto_render = saved_auto_render - if saved_auto_render and any(self.paths.values()): - self.render(append = self._auto_render_append) - - def _execute_route_op(self, op_name: str, kwargs: dict[str, Any]) -> None: - if op_name == 'trace_to': - self.trace_to(**kwargs) - elif op_name == 'jog': - self.jog(**kwargs) - elif op_name == 'uturn': - self.uturn(**kwargs) - elif op_name == 'rename_ports': - self.rename_ports(**kwargs) - else: - raise BuildError(f'Unrecognized routing op {op_name}') - - def _execute_route_ops(self, ops: Sequence[tuple[str, dict[str, Any]]]) -> None: - for op_name, op_kwargs in ops: - self._execute_route_op(op_name, op_kwargs) - - def _merge_trace_into_op_kwargs( - self, - op_name: str, - user_kwargs: Mapping[str, Any], - **reserved: Any, - ) -> dict[str, Any]: - """ Merge tool kwargs with internally computed op kwargs, rejecting collisions. """ - collisions = sorted(set(user_kwargs) & set(reserved)) - if collisions: - args = ', '.join(collisions) - raise BuildError(f'trace_into() kwargs cannot override {op_name}() arguments: {args}') - return {**user_kwargs, **reserved} - - def _plan_trace_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None, - plug_destination: bool, - thru: str | None, - **kwargs: Any, - ) -> list[tuple[str, dict[str, Any]]]: - port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst] - if out_ptype is None: - out_ptype = port_dst.ptype - if port_src.rotation is None or port_dst.rotation is None: - raise PortError('Ports must have rotation') - - src_horiz = numpy.isclose(port_src.rotation % pi, 0) - dst_horiz = numpy.isclose(port_dst.rotation % pi, 0) - xd, yd = port_dst.offset - angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - dst_args = {'out_ptype': out_ptype} - if plug_destination: - dst_args['plug_into'] = portspec_dst - - ops: list[tuple[str, dict[str, Any]]] = [] - if src_horiz and not dst_horiz: - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = angle > pi, - x = xd, - ))) - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = None, - y = yd, - **dst_args, - ))) - elif dst_horiz and not src_horiz: - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = angle > pi, - y = yd, - ))) - ops.append(('trace_to', self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = None, - x = xd, - **dst_args, - ))) - elif numpy.isclose(angle, pi): - (travel, jog), _ = port_src.measure_travel(port_dst) - if numpy.isclose(jog, 0): - ops.append(( - 'trace_to', - self._merge_trace_into_op_kwargs( - 'trace_to', - kwargs, - portspec = portspec_src, - ccw = None, - x = xd if src_horiz else None, - y = yd if not src_horiz else None, - **dst_args, - ), - )) - else: - ops.append(('jog', self._merge_trace_into_op_kwargs( - 'jog', - kwargs, - portspec = portspec_src, - offset = -jog, - length = -travel, - **dst_args, - ))) - elif numpy.isclose(angle, 0): - (travel, jog), _ = port_src.measure_travel(port_dst) - ops.append(('uturn', self._merge_trace_into_op_kwargs( - 'uturn', - kwargs, - portspec = portspec_src, - offset = -jog, - length = -travel, - **dst_args, - ))) - else: - raise BuildError(f"Cannot route relative angle {angle}") - - if thru: - ops.append(('rename_ports', {'mapping': {thru: portspec_src}})) - return ops - def _get_tool_R(self, tool: Tool, ccw: SupportsBool, in_ptype: str | None, **kwargs) -> float: """ Probe a tool to find the lateral displacement (radius) of its bend. """ kwargs_no_out = kwargs | {'out_ptype': None} @@ -801,26 +407,35 @@ class Pather(PortList): try: out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) except (BuildError, NotImplementedError): + # Try S-bend fallback (two L-bends) + ccw0 = jog > 0 try: - first, second = self._plan_s_fallback(tool, portspec, in_ptype, length, jog, **kwargs) + R1 = self._get_tool_R(tool, ccw0, in_ptype, **kwargs) + R2 = self._get_tool_R(tool, not ccw0, in_ptype, **kwargs) + L1, L2 = length - R2, abs(jog) - R1 except (BuildError, NotImplementedError): if not self._dead: raise self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) return self - self._apply_validated_double_l( - portspec, - tool, - first, - second, - length = length, - jog = jog, - out_rotation = pi, - requested_out_ptype = kwargs.get('out_ptype'), - route_name = 'S-bend', - plug_into = plug_into, - ) + if L1 < 0 or L2 < 0: + if not self._dead: + raise BuildError(f"Jog {jog} or length {length} too small for double-L fallback") from None + self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) + return self + + try: + out_port0, data0 = tool.planL(ccw0, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None})) + out_port1, data1 = tool.planL(not ccw0, L2, in_ptype=out_port0.ptype, **kwargs) + except (BuildError, NotImplementedError): + if not self._dead: + raise + self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=pi) + return self + + self._apply_step('L', portspec, out_port0, data0, tool) + self._apply_step('L', portspec, out_port1, data1, tool, plug_into) return self if out_port is not None: self._apply_step('S', portspec, out_port, data, tool, plug_into) @@ -835,27 +450,22 @@ class Pather(PortList): try: out_port, data = tool.planU(jog, length=length, in_ptype=in_ptype, **kwargs) except (BuildError, NotImplementedError): + # Try U-turn fallback (two L-bends) + ccw = jog > 0 try: - first, second = self._plan_u_fallback(tool, in_ptype, length, jog, **kwargs) + R = self._get_tool_R(tool, ccw, in_ptype, **kwargs) + L1, L2 = length + R, abs(jog) - R + out_port0, data0 = tool.planL(ccw, L1, in_ptype=in_ptype, **(kwargs | {'out_ptype': None})) + out_port1, data1 = tool.planL(ccw, L2, in_ptype=out_port0.ptype, **kwargs) except (BuildError, NotImplementedError): if not self._dead: raise self._apply_dead_fallback(portspec, length, jog, None, in_ptype, plug_into, out_rot=0) return self - - self._apply_validated_double_l( - portspec, - tool, - first, - second, - length = length, - jog = jog, - out_rotation = 0, - requested_out_ptype = kwargs.get('out_ptype'), - route_name = 'U-turn', - plug_into = plug_into, - ) - return self + else: + self._apply_step('L', portspec, out_port0, data0, tool) + self._apply_step('L', portspec, out_port1, data1, tool, plug_into) + return self if out_port is not None: self._apply_step('U', portspec, out_port, data, tool, plug_into) return self @@ -872,29 +482,23 @@ class Pather(PortList): spacing: float | ArrayLike | None = None, **bounds: Any, ) -> Self: - """ - Route one or more ports using straight segments or single 90-degree bends. - - Provide exactly one routing mode: - - `length` for a single port, - - `each` to extend each selected port independently by the same amount, or - - one bundle bound such as `xmin`, `emax`, or `min_past_furthest`. - - `spacing` and `set_rotation` are only valid when using a bundle bound. - """ with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - self._validate_trace_args(portspec, length=length, spacing=spacing, bounds=bounds) if length is not None: + if len(portspec) > 1: + raise BuildError('length only allowed with a single port') return self._traceL(portspec[0], ccw, length, **bounds) - if bounds.get('each') is not None: + if 'each' in bounds: each = bounds.pop('each') for p in portspec: self._traceL(p, ccw, each, **bounds) return self # Bundle routing - bt = self._present_bundle_bounds(bounds)[0] + bt_keys = {'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'} + bt = next((k for k in bounds if k in bt_keys), None) + if not bt: + raise BuildError('No bound type specified for trace()') bval = bounds.pop(bt) set_rot = bounds.pop('set_rotation', None) exts = ell(self.pattern[tuple(portspec)], ccw, spacing=spacing, bound=bval, bound_type=bt, set_rotation=set_rot) @@ -910,30 +514,29 @@ class Pather(PortList): spacing: float | ArrayLike | None = None, **bounds: Any, ) -> Self: - """ - Route until a single positional bound is reached, or delegate to `trace()` for length/bundle bounds. - - Exactly one of `p`, `pos`, `position`, `x`, or `y` may be used as a positional - bound. Positional bounds are only valid for a single port and may not be combined - with `length`, `spacing`, `each`, or bundle-bound keywords such as `xmin`/`emax`. - """ with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - if len(portspec) == 1: - resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=False) - else: - resolved = None - pos_count = sum(bounds.get(key) is not None for key in self._POSITION_KEYS) - if pos_count: - raise BuildError('Position bounds only allowed with a single port') - if resolved is not None: + pos_keys = {'p', 'x', 'y', 'pos', 'position'} + pb = {k: bounds[k] for k in bounds if k in pos_keys} + if pb: if len(portspec) > 1: raise BuildError('Position bounds only allowed with a single port') - self._validate_trace_to_positional_args(spacing=spacing, bounds=bounds) - _key, _value, length = resolved - other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS and bk != 'length'} - return self._traceL(portspec[0], ccw, length, **other_bounds) + k, v = next(iter(pb.items())) + port = self.pattern[portspec[0]] + assert port.rotation is not None + is_horiz = numpy.isclose(port.rotation % pi, 0) + if is_horiz: + if k == 'y': + raise BuildError('Port is horizontal') + target = Port((v, port.offset[1]), rotation=None) + else: + if k == 'x': + raise BuildError('Port is vertical') + target = Port((port.offset[0], v), rotation=None) + (travel, jog), _ = port.measure_travel(target) + other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in pos_keys and bk != 'length'} + return self._traceL(portspec[0], ccw, -travel, **other_bounds) return self.trace(portspec, ccw, spacing=spacing, **bounds) def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: @@ -949,42 +552,17 @@ class Pather(PortList): return self.bend(portspec, False, length, **bounds) def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self: - """ - Route an S-bend. - - `length` is the along-travel displacement. If omitted, exactly one positional - bound (`p`, `pos`, `position`, `x`, or `y`) must be provided for a single port, - and the required travel distance is derived from that bound. When `length` is - provided, no other routing-bound keywords are accepted. - """ with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds): if isinstance(portspec, str): portspec = [portspec] - self._validate_jog_args(length=length, bounds=bounds) - other_bounds = dict(bounds) - if length is None: - if len(portspec) != 1: - raise BuildError('Positional length solving for jog() is only allowed with a single port') - resolved = self._resolved_position_bound(portspec[0], bounds, allow_length=True) - if resolved is None: - raise BuildError('jog() requires either length=... or exactly one positional bound') - _key, _value, length = resolved - other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in self._POSITION_KEYS} for p in portspec: - self._traceS(p, length, offset, **other_bounds) + self._traceS(p, length, offset, **bounds) return self def uturn(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds: Any) -> Self: - """ - Route a U-turn. - - `length` is the along-travel displacement to the final port. If omitted, it defaults - to 0. Positional and bundle-bound keywords are not supported for this operation. - """ with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds): if isinstance(portspec, str): portspec = [portspec] - self._validate_uturn_args(bounds) for p in portspec: self._traceU(p, offset, length=length if length else 0, **bounds) return self @@ -999,13 +577,6 @@ class Pather(PortList): thru: str | None = None, **kwargs: Any, ) -> Self: - """ - Route one port into another using the shortest supported combination of trace primitives. - - If `plug_destination` is `True`, the destination port is consumed by the final step. - If `thru` is provided, that port is renamed to the source name after the route is complete. - The operation is transactional for live port state and deferred routing steps. - """ with self._logger.log_operation( self, 'trace_into', @@ -1017,15 +588,43 @@ class Pather(PortList): ): if self._dead: return self - ops = self._plan_trace_into( - portspec_src, - portspec_dst, - out_ptype = out_ptype, - plug_destination = plug_destination, - thru = thru, - **kwargs, - ) - self._run_route_transaction(lambda: self._execute_route_ops(ops)) + port_src, port_dst = self.pattern[portspec_src], self.pattern[portspec_dst] + if out_ptype is None: + out_ptype = port_dst.ptype + if port_src.rotation is None or port_dst.rotation is None: + raise PortError('Ports must have rotation') + src_horiz = numpy.isclose(port_src.rotation % pi, 0) + dst_horiz = numpy.isclose(port_dst.rotation % pi, 0) + xd, yd = port_dst.offset + angle = (port_dst.rotation - port_src.rotation) % (2 * pi) + dst_args = {**kwargs, 'out_ptype': out_ptype} + if plug_destination: + dst_args['plug_into'] = portspec_dst + if src_horiz and not dst_horiz: + self.trace_to(portspec_src, angle > pi, x=xd, **kwargs) + self.trace_to(portspec_src, None, y=yd, **dst_args) + elif dst_horiz and not src_horiz: + self.trace_to(portspec_src, angle > pi, y=yd, **kwargs) + self.trace_to(portspec_src, None, x=xd, **dst_args) + elif numpy.isclose(angle, pi): + (travel, jog), _ = port_src.measure_travel(port_dst) + if numpy.isclose(jog, 0): + self.trace_to( + portspec_src, + None, + x=xd if src_horiz else None, + y=yd if not src_horiz else None, + **dst_args, + ) + else: + self.jog(portspec_src, -jog, -travel, **dst_args) + elif numpy.isclose(angle, 0): + (travel, jog), _ = port_src.measure_travel(port_dst) + self.uturn(portspec_src, -jog, length=-travel, **dst_args) + else: + raise BuildError(f"Cannot route relative angle {angle}") + if thru: + self.rename_ports({thru: portspec_src}) return self # @@ -1258,46 +857,20 @@ class PortPather: self.ports = [pp for pp in self.ports if pp not in ports_set] return self - def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]: - if isinstance(name, str): - if len(self.ports) > 1: - raise BuildError(f'Use a mapping to {action} >1 port') - name_map = {self.ports[0]: name} - else: - name_map = dict(name) - - missing_selected = set(name_map) - set(self.ports) - if missing_selected: - raise PortError(f'Can only {action} selected ports: {missing_selected}') - - missing_pattern = set(name_map) - set(self.pather.pattern.ports) - if missing_pattern: - raise PortError(f'Ports to {action} were not found: {missing_pattern}') - - targets = list(name_map.values()) - duplicate_targets = {vv for vv in targets if targets.count(vv) > 1} - if duplicate_targets: - raise PortError(f'{action.capitalize()} targets would collide: {duplicate_targets}') - - overwritten = { - dst for src, dst in name_map.items() - if dst in self.pather.pattern.ports and dst != src - } - if overwritten: - raise PortError(f'{action.capitalize()} would overwrite existing ports: {overwritten}') - - return name_map - def mark(self, name: str | Mapping[str, str]) -> Self: """ Bookmark current port(s). """ - name_map = self._normalize_copy_map(name, 'mark') + name_map: Mapping[str, str] = {self.ports[0]: name} if isinstance(name, str) else name + if isinstance(name, str) and len(self.ports) > 1: + raise BuildError('Use a mapping to mark >1 port') for src, dst in name_map.items(): self.pather.pattern.ports[dst] = self.pather.pattern[src].copy() return self def fork(self, name: str | Mapping[str, str]) -> Self: """ Split and follow new name. """ - name_map = self._normalize_copy_map(name, 'fork') + name_map: Mapping[str, str] = {self.ports[0]: name} if isinstance(name, str) else name + if isinstance(name, str) and len(self.ports) > 1: + raise BuildError('Use a mapping to fork >1 port') for src, dst in name_map.items(): self.pather.pattern.ports[dst] = self.pather.pattern[src].copy() self.ports = [(dst if pp == src else pp) for pp in self.ports] diff --git a/masque/builder/tools.py b/masque/builder/tools.py index f0772a1..5b1a0a9 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,9 +1,7 @@ """ Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) -Concrete tools may implement native planning/rendering for `L`, `S`, or `U` routes. -Any unimplemented planning method falls back to the corresponding `trace*()` method, -and `Pather` may further synthesize some routes from simpler primitives when needed. +# TODO document all tools """ from typing import Literal, Any, Self, cast from collections.abc import Sequence, Callable, Iterator @@ -276,8 +274,8 @@ class Tool: Args: length: The total distance from input to output, along the input's axis only. jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftward shift (i.e. counterclockwise bend followed - by a clockwise bend) + A positive number implies a rightwards shift (i.e. clockwise bend followed + by a counterclockwise bend) 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. kwargs: Custom tool-specific parameters. @@ -315,8 +313,7 @@ class Tool: 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` and `RenderPather`. Tools may leave this unimplemented if they - do not support a native U-bend primitive. + Used by `Pather` and `RenderPather`. The output port must have an orientation identical to the input port. @@ -351,12 +348,12 @@ class Tool: **kwargs, ) -> tuple[Port, Any]: """ + # NOTE: TODO: U-bend is WIP; this interface may change in the future. + 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 `RenderPather`. This is an optional native-planning hook: tools may - implement it when they can represent a U-turn directly, otherwise they may rely - on `traceU()` or let `Pather` synthesize the route from simpler primitives. + Used by `RenderPather`. The output port must have an orientation identical to the input port. @@ -369,8 +366,7 @@ class Tool: followed by a clockwise bend) 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. - kwargs: Custom tool-specific parameters. `length` may be supplied here to - request a U-turn whose final port is displaced along both axes. + kwargs: Custom tool-specific parameters. Returns: The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. diff --git a/masque/builder/utils.py b/masque/builder/utils.py index ca36fff..4de6dbb 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -46,7 +46,7 @@ def ell( ccw: Turn direction. `True` means counterclockwise, `False` means clockwise, and `None` means no bend. If `None`, spacing must remain `None` or `0` (default), Otherwise, spacing must be set to a non-`None` value. - bound_type: Method used for determining the travel distance; see diagram above. + bound_method: Method used for determining the travel distance; see diagram above. Valid values are: - 'min_extension' or 'emin': The total extension value for the furthest-out port (B in the diagram). @@ -64,7 +64,7 @@ def ell( the x- and y- axes. If specifying a position, it is projected onto the extension direction. - bound: Value associated with `bound_type`, see above. + bound_value: Value associated with `bound_type`, see above. spacing: Distance between adjacent channels. Can be scalar, resulting in evenly spaced channels, or a vector with length one less than `ports`, allowing non-uniform spacing. diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 237b1d8..301910d 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -192,37 +192,8 @@ def read( top_name, top_pat = _read_block(msp) mlib = Library({top_name: top_pat}) - - blocks_by_name = { - bb.name: bb - for bb in lib.blocks - if not bb.is_any_layout - } - - referenced: set[str] = set() - pending = [msp] - seen_blocks: set[str] = set() - while pending: - block = pending.pop() - block_name = getattr(block, 'name', None) - if block_name is not None and block_name in seen_blocks: - continue - if block_name is not None: - seen_blocks.add(block_name) - for element in block: - if not isinstance(element, Insert): - continue - target = element.dxfattribs().get('name') - if target is None or target in referenced: - continue - referenced.add(target) - if target in blocks_by_name: - pending.append(blocks_by_name[target]) - for bb in lib.blocks: - if bb.is_any_layout: - continue - if bb.name.startswith('_') and bb.name not in referenced: + if bb.name == '*Model_Space': continue name, pat = _read_block(bb) mlib[name] = pat diff --git a/masque/library.py b/masque/library.py index e98d98d..825dbf0 100644 --- a/masque/library.py +++ b/masque/library.py @@ -180,8 +180,6 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): if isinstance(tops, str): tops = (tops,) - tops = set(tops) - skip |= tops # don't re-visit tops # Get referenced patterns for all tops targets = set() @@ -828,11 +826,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ - if old_name not in self: - raise LibraryError(f'"{old_name}" does not exist in the library.') - if old_name == new_name: - return self - self[new_name] = self[old_name] del self[old_name] if move_references: @@ -857,9 +850,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ - if old_target == new_target: - return self - for pattern in self.values(): if old_target in pattern.refs: pattern.refs[new_target].extend(pattern.refs[old_target]) @@ -1489,11 +1479,6 @@ class LazyLibrary(ILibrary): Returns: self """ - if old_name not in self.mapping: - raise LibraryError(f'"{old_name}" does not exist in the library.') - if old_name == new_name: - return self - self[new_name] = self.mapping[old_name] # copy over function if old_name in self.cache: self.cache[new_name] = self.cache[old_name] @@ -1515,9 +1500,6 @@ class LazyLibrary(ILibrary): Returns: self """ - if old_target == new_target: - return self - self.precache() for pattern in self.cache.values(): if old_target in pattern.refs: diff --git a/masque/pattern.py b/masque/pattern.py index 9586140..b670493 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1387,10 +1387,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if append: if isinstance(other, Abstract): raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') - if other.annotations is not None and self.annotations is not None: - annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') else: if isinstance(other, Pattern): raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' @@ -1566,10 +1562,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if append: if isinstance(other, Abstract): raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') - if other.annotations is not None and self.annotations is not None: - annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') elif isinstance(other, Pattern): raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' 'Use `append=True` if you intended to append the full geometry.') diff --git a/masque/ports.py b/masque/ports.py index ab1b93b..3a695fb 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -581,8 +581,6 @@ class PortList(metaclass=ABCMeta): The rotation should be performed before the translation. """ - if not map_in: - raise PortError('Must provide at least one port connection') s_ports = self[map_in.keys()] o_ports = other[map_in.values()] return self.find_port_transform( @@ -634,8 +632,6 @@ class PortList(metaclass=ABCMeta): The rotation should be performed before the translation. """ - if not map_in: - raise PortError('Must provide at least one port connection') s_offsets = numpy.array([p.offset for p in s_ports.values()]) o_offsets = numpy.array([p.offset for p in o_ports.values()]) s_types = [p.ptype for p in s_ports.values()] diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py index 0008172..91d6c3b 100644 --- a/masque/test/test_advanced_routing.py +++ b/masque/test/test_advanced_routing.py @@ -47,9 +47,8 @@ def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> No 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 + # Single bend should result in 2 segments (one for x move, one for y move) + assert len(p.pattern.refs) == 2 def test_path_into_sbend(advanced_pather: tuple[Pather, PathTool, Library]) -> None: diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index 0249c64..1e44e4d 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -245,3 +245,54 @@ def test_boolean_invalid_inputs_raise_pattern_error() -> None: for bad in (123, object(), [123]): with pytest.raises(PatternError, match='Unsupported type'): boolean([rect], bad, operation='intersection') + + +def test_bridge_holes() -> None: + from masque.utils.boolean import _bridge_holes + + # Outer: 10x10 square + outer = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) + # Hole: 2x2 square in the middle + hole = numpy.array([[4, 4], [6, 4], [6, 6], [4, 6]]) + + bridged = _bridge_holes(outer, [hole]) + + # We expect more vertices than outer + hole + # Original outer has 4, hole has 4. Bridge adds 2 (to hole) and 2 (back to outer) + 1 to close hole loop? + # Our implementation: + # 1. outer up to bridge edge (best_edge_idx) + # 2. bridge point on outer + # 3. hole reordered starting at max X + # 4. close hole loop (repeat max X) + # 5. bridge point on outer again + # 6. rest of outer + + # max X of hole is 6 at (6,4) or (6,6). argmax will pick first one. + # hole vertices: [4,4], [6,4], [6,6], [4,6]. argmax(x) is index 1: (6,4) + # roll hole to start at (6,4): [6,4], [6,6], [4,6], [4,4] + + # intersection of ray from (6,4) to right: + # edges of outer: (0,0)-(10,0), (10,0)-(10,10), (10,10)-(0,10), (0,10)-(0,0) + # edge (10,0)-(10,10) spans y=4. + # intersection at (10,4). best_edge_idx = 1 (edge from index 1 to 2) + + # vertices added: + # outer[0:2]: (0,0), (10,0) + # bridge pt: (10,4) + # hole: (6,4), (6,6), (4,6), (4,4) + # hole close: (6,4) + # bridge pt back: (10,4) + # outer[2:]: (10,10), (0,10) + + expected_len = 11 + assert len(bridged) == expected_len + + # verify it wraps around the hole and back + # index 2 is bridge_pt + assert_allclose(bridged[2], [10, 4]) + # index 3 is hole reordered max X + assert_allclose(bridged[3], [6, 4]) + # index 7 is hole closed at max X + assert_allclose(bridged[7], [6, 4]) + # index 8 is bridge_pt back + assert_allclose(bridged[8], [10, 4]) diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py index f6dd177..5b038c6 100644 --- a/masque/test/test_dxf.py +++ b/masque/test/test_dxf.py @@ -10,18 +10,6 @@ from ..shapes import Path as MPath, Polygon from ..repetition import Grid from ..file import dxf - -def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool: - return bool( - numpy.allclose(actual, expected) - or numpy.allclose(actual, expected[::-1]) - ) - - -def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool: - return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()} - - def test_dxf_roundtrip(tmp_path: Path): lib = Library() pat = Pattern() @@ -59,20 +47,21 @@ def test_dxf_roundtrip(tmp_path: Path): 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) + # DXF polyline might be shifted or vertices reordered, but here they should be simple + assert_allclose(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_allclose(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_allclose(path2_read.vertices, path2_verts) assert path2_read.width == 0 # Verify Ref with Grid @@ -169,16 +158,4 @@ def test_dxf_read_legacy_polyline() -> None: polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)] assert len(polys) == 1 - assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]])) - - -def test_dxf_read_ignores_unreferenced_setup_blocks() -> None: - lib = Library({"top": Pattern()}) - stream = io.StringIO() - - dxf.write(lib, "top", stream) - stream.seek(0) - - read_lib, _ = dxf.read(stream) - - assert set(read_lib) == {"Model"} + assert_allclose(polys[0].vertices, [[0, 0], [10, 0], [10, 10]]) diff --git a/masque/test/test_library.py b/masque/test/test_library.py index ce564aa..3b731ad 100644 --- a/masque/test/test_library.py +++ b/masque/test/test_library.py @@ -221,52 +221,6 @@ def test_library_rename() -> None: assert "old" not in lib["parent"].refs -@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) -def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("top") - - lib.rename("top", "top", move_references=True) - - assert set(lib.keys()) == {"top", "parent"} - assert "top" in lib["parent"].refs - assert len(lib["parent"].refs["top"]) == 1 - - -@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) -def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - - lib.rename_top("top") - - assert list(lib.keys()) == ["top"] - - -@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) -def test_library_rename_missing_raises_library_error(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - - with pytest.raises(LibraryError, match="does not exist"): - lib.rename("missing", "new") - - -@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) -def test_library_move_references_same_target_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: - lib = library_cls() - lib["top"] = Pattern() - lib["parent"] = Pattern() - lib["parent"].ref("top") - - lib.move_references("top", "top") - - assert "top" in lib["parent"].refs - assert len(lib["parent"].refs["top"]) == 1 - - def test_library_dfs_can_replace_existing_patterns() -> None: lib = Library() child = Pattern() diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index a187ec6..495d305 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -1,11 +1,9 @@ -from typing import Any - import pytest import numpy from numpy import pi from masque import Pather, RenderPather, Library, Pattern, Port from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError +from masque.error import BuildError def test_pather_trace_basic() -> None: lib = Library() @@ -123,42 +121,6 @@ def test_mark_fork() -> None: 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_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) @@ -281,16 +243,6 @@ def test_pather_trace_into() -> None: 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_jog_failed_fallback_is_atomic() -> None: lib = Library() @@ -306,219 +258,6 @@ def test_pather_jog_failed_fallback_is_atomic() -> None: 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: @@ -578,56 +317,3 @@ def test_renderpather_rename_to_none_keeps_pending_geometry_without_port() -> No 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) - 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'} diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py index c100079..6048bf1 100644 --- a/masque/test/test_pattern.py +++ b/masque/test/test_pattern.py @@ -148,17 +148,6 @@ def test_pattern_place_append_requires_pattern_atomically() -> None: assert not parent.ports -def test_pattern_place_append_annotation_conflict_is_atomic() -> None: - parent = Pattern(annotations={"k": [1]}) - child = Pattern(annotations={"k": [2]}, ports={"A": Port((1, 2), 0)}) - - with pytest.raises(PatternError, match="Annotation keys overlap"): - parent.place(child, append=True) - - assert not parent.ports - assert parent.annotations == {"k": [1]} - - def test_pattern_interface() -> None: source = Pattern() source.ports["A"] = Port((10, 20), 0, ptype="test") @@ -203,25 +192,6 @@ def test_pattern_plug_requires_abstract_for_reference_atomically() -> None: assert set(parent.ports) == {"X"} -def test_pattern_plug_append_annotation_conflict_is_atomic() -> None: - parent = Pattern( - annotations={"k": [1]}, - ports={"X": Port((0, 0), 0), "Q": Port((9, 9), 0)}, - ) - child = Pattern( - annotations={"k": [2]}, - ports={"A": Port((0, 0), pi), "B": Port((5, 0), 0)}, - ) - - with pytest.raises(PatternError, match="Annotation keys overlap"): - parent.plug(child, {"X": "A"}, map_out={"B": "Y"}, append=True) - - assert set(parent.ports) == {"X", "Q"} - assert_allclose(parent.ports["X"].offset, (0, 0)) - assert_allclose(parent.ports["Q"].offset, (9, 9)) - assert parent.annotations == {"k": [1]} - - def test_pattern_append_port_conflict_is_atomic() -> None: pat1 = Pattern() pat1.ports["A"] = Port((0, 0), 0) diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py index fa19bab..4e7d097 100644 --- a/masque/test/test_ports.py +++ b/masque/test/test_ports.py @@ -257,14 +257,3 @@ def test_pattern_plug_rejects_map_out_on_connected_ports_atomically() -> None: host.plug(other, {"A": "X"}, map_out={"X": "renamed", "Y": "out"}, append=True) assert set(host.ports) == {"A"} - - -def test_find_transform_requires_connection_map() -> None: - host = Pattern(ports={"A": Port((0, 0), 0)}) - other = Pattern(ports={"X": Port((0, 0), pi)}) - - with pytest.raises(PortError, match="at least one port connection"): - host.find_transform(other, {}) - - with pytest.raises(PortError, match="at least one port connection"): - Pattern.find_port_transform({}, {}, {}) diff --git a/masque/test/test_svg.py b/masque/test/test_svg.py index c0dcd97..b637853 100644 --- a/masque/test/test_svg.py +++ b/masque/test/test_svg.py @@ -90,11 +90,9 @@ def test_svg_uses_unique_ids_for_colliding_mangled_names(tmp_path: Path) -> None root = ET.fromstring(svg_path.read_text()) ids = [group.attrib["id"] for group in root.iter(f"{SVG_NS}g")] - top_group = next(group for group in root.iter(f"{SVG_NS}g") if group.attrib["id"] == "top") - hrefs = [use.attrib[XLINK_HREF] for use in top_group.iter(f"{SVG_NS}use")] + hrefs = [use.attrib[XLINK_HREF] for use in root.iter(f"{SVG_NS}use")] + assert ids.count("a_b") == 1 assert len(set(ids)) == len(ids) - assert len(hrefs) == 2 - assert len(set(hrefs)) == 2 - assert all(href.startswith("#") for href in hrefs) - assert all(href[1:] in ids for href in hrefs) + assert "#a_b" in hrefs + assert "#a_b_2" in hrefs