diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 76e45b7..7e33b18 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 +from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence, Callable import copy import logging from collections import defaultdict @@ -93,6 +93,9 @@ 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()` """ + @property def ports(self) -> dict[str, Port]: return self.pattern.ports @@ -354,6 +357,295 @@ 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) + + 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} @@ -424,35 +716,26 @@ 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: - 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 + first, second = self._plan_s_fallback(tool, portspec, in_ptype, length, jog, **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 - 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) + 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, + ) return self if out_port is not None: self._apply_step('S', portspec, out_port, data, tool, plug_into) @@ -467,22 +750,27 @@ 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: - 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) + first, second = self._plan_u_fallback(tool, in_ptype, length, jog, **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 - else: - self._apply_step('L', portspec, out_port0, data0, tool) - self._apply_step('L', portspec, out_port1, data1, tool, plug_into) - 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 if out_port is not None: self._apply_step('U', portspec, out_port, data, tool, plug_into) return self @@ -531,29 +819,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`. + """ with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, **bounds): if isinstance(portspec, str): portspec = [portspec] - pos_keys = {'p', 'x', 'y', 'pos', 'position'} - pb = {k: bounds[k] for k in bounds if k in pos_keys and bounds[k] is not None} - if pb: + 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: if len(portspec) > 1: raise BuildError('Position bounds only allowed with a single port') - 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) + _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) return self.trace(portspec, ccw, spacing=spacing, **bounds) def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: @@ -569,14 +857,35 @@ 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. + """ with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, **bounds): if isinstance(portspec, str): portspec = [portspec] + 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, **bounds) + self._traceS(p, length, offset, **other_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. + """ with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, **bounds): if isinstance(portspec, str): portspec = [portspec] @@ -594,6 +903,13 @@ 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', @@ -605,43 +921,15 @@ class Pather(PortList): ): if self._dead: return self - 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}) + 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)) return self # diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 5b1a0a9..48f48ed 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,7 +1,9 @@ """ Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) -# TODO document all tools +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 @@ -313,7 +315,8 @@ 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`. + Used by `Pather` and `RenderPather`. Tools may leave this unimplemented if they + do not support a native U-bend primitive. The output port must have an orientation identical to the input port. @@ -348,12 +351,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`. + 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. The output port must have an orientation identical to the input port. @@ -366,7 +369,8 @@ 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. + kwargs: Custom tool-specific parameters. `length` may be supplied here to + request a U-turn whose final port is displaced along both axes. Returns: The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. diff --git a/masque/test/test_advanced_routing.py b/masque/test/test_advanced_routing.py index 91d6c3b..0008172 100644 --- a/masque/test/test_advanced_routing.py +++ b/masque/test/test_advanced_routing.py @@ -47,8 +47,9 @@ def test_path_into_bend(advanced_pather: tuple[Pather, PathTool, Library]) -> No assert "src" not in p.ports assert "dst" not in p.ports - # Single bend should result in 2 segments (one for x move, one for y move) - assert len(p.pattern.refs) == 2 + # `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: diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index 54a06be..a612400 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest import numpy from numpy import pi @@ -304,6 +306,184 @@ 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_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: