[Pather/RenderPather/PortPather] Rework pathing verbs *BREAKING CHANGE*

This commit is contained in:
jan 2026-03-06 22:58:03 -08:00
commit babbe78daa
5 changed files with 468 additions and 416 deletions

View file

@ -255,7 +255,7 @@ class Pather(Builder, PatherMixin):
return s
def path(
def _path(
self,
portspec: str,
ccw: SupportsBool | None,
@ -296,7 +296,7 @@ class Pather(Builder, PatherMixin):
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.warning('Skipping geometry for path() since device is dead')
logger.warning('Skipping geometry for _path() since device is dead')
tool_port_names = ('A', 'B')
@ -335,7 +335,7 @@ class Pather(Builder, PatherMixin):
self.plug(tname, {portspec: tool_port_names[0], **output})
return self
def pathS(
def _pathS(
self,
portspec: str,
length: float,
@ -346,20 +346,17 @@ class Pather(Builder, PatherMixin):
) -> Self:
"""
Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim
of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is
left of direction of travel).
of traveling exactly `length` distance.
The output port will have the same orientation as the source port (`portspec`).
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
raises a NotImplementedError.
The wire will travel `length` distance along the port's axis, and exactly `jog`
distance in the perpendicular direction. The output port will have an orientation
identical to the input port.
Args:
portspec: The name of the port into which the wire will be plugged.
jog: Total manhattan distance perpendicular to the direction of travel.
Positive values are to the left of the direction of travel.
length: The total manhattan distance from input to output, along the input's axis only.
(There may be a tool-dependent offset along the other axis.)
length: The total distance from input to output, along the input's axis only.
jog: Total distance perpendicular to the direction of travel. Positive values
are to the left of the direction of travel.
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
@ -377,7 +374,7 @@ class Pather(Builder, PatherMixin):
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.warning('Skipping geometry for pathS() since device is dead')
logger.warning('Skipping geometry for _pathS() since device is dead')
tool_port_names = ('A', 'B')
@ -398,8 +395,8 @@ class Pather(Builder, PatherMixin):
(_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]])
kwargs_plug = kwargs | {'plug_into': plug_into}
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
self._path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self._path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
except (BuildError, NotImplementedError):
if not self._dead:
raise
@ -433,4 +430,3 @@ class Pather(Builder, PatherMixin):
output = {}
self.plug(tname, {portspec: tool_port_names[0], **output})
return self

View file

@ -1,5 +1,5 @@
from typing import Self, overload
from collections.abc import Sequence, Iterator, Iterable
from collections.abc import Sequence, Iterator, Iterable, Mapping
import logging
from contextlib import contextmanager
from abc import abstractmethod, ABCMeta
@ -37,8 +37,323 @@ class PatherMixin(PortList, metaclass=ABCMeta):
(e.g wires or waveguides) to be plugged into this device.
"""
def trace(
self,
portspec: str | Sequence[str],
ccw: SupportsBool | None,
length: float | None = None,
*,
spacing: float | ArrayLike | None = None,
**bounds,
) -> Self:
"""
Create a "wire"/"waveguide" extending from the port(s) `portspec`.
Args:
portspec: The name(s) of the port(s) into which the wire(s) will be plugged.
ccw: If `None`, the output should be along the same axis as the input.
Otherwise, cast to bool and turn counterclockwise if True
and clockwise otherwise.
length: The total distance from input to output, along the input's axis only.
Length is only allowed with a single port.
spacing: Center-to-center distance between output ports along the input port's axis.
Only used when routing multiple ports with a bend.
bounds: Boundary constraints for the trace.
- each: results in each port being extended by `each` distance.
- emin, emax, pmin, pmax, xmin, xmax, ymin, ymax: bundle routing via `ell()`.
- set_rotation: explicit rotation for ports without one.
Returns:
self
"""
if isinstance(portspec, str):
portspec = [portspec]
if length is not None:
if len(portspec) > 1:
raise BuildError('length is only allowed with a single port in trace()')
if bounds:
raise BuildError('length and bounds are mutually exclusive in trace()')
return self._path(portspec[0], ccw, length)
if 'each' in bounds:
each = bounds.pop('each')
if bounds:
raise BuildError('each and other bounds are mutually exclusive in trace()')
for port in portspec:
self._path(port, ccw, each)
return self
# Bundle routing (formerly mpath logic)
bound_types = set()
if 'bound_type' in bounds:
bound_types.add(bounds.pop('bound_type'))
bound = bounds.pop('bound')
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in bounds:
bound_types.add(bt)
bound = bounds.pop(bt)
if not bound_types:
raise BuildError('No bound type specified for trace()')
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified: {bound_types}')
bound_type = tuple(bound_types)[0]
ports = self.pattern[tuple(portspec)]
set_rotation = bounds.pop('set_rotation', None)
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
for port_name, ext_len in extensions.items():
self._path(port_name, ccw, ext_len, **bounds)
return self
def trace_to(
self,
portspec: str | Sequence[str],
ccw: SupportsBool | None,
*,
spacing: float | ArrayLike | None = None,
**bounds,
) -> Self:
"""
Create a "wire"/"waveguide" extending from the port(s) `portspec` to a target position.
Args:
portspec: The name(s) of the port(s) into which the wire(s) will be plugged.
ccw: If `None`, the output should be along the same axis as the input.
Otherwise, cast to bool and turn counterclockwise if True
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Only used when routing multiple ports with a bend.
bounds: Boundary constraints for the target position.
- p, x, y, pos, position: Coordinate of the target position. Error if used with multiple ports.
- pmin, pmax, xmin, xmax, ymin, ymax, emin, emax: bundle routing via `ell()`.
Returns:
self
"""
if isinstance(portspec, str):
portspec = [portspec]
pos_bounds = {kk: bounds[kk] for kk in ('p', 'x', 'y', 'pos', 'position') if kk in bounds}
if pos_bounds:
if len(portspec) > 1:
raise BuildError(f'{tuple(pos_bounds.keys())} bounds are only allowed with a single port in trace_to()')
if len(pos_bounds) > 1:
raise BuildError(f'Too many position bounds: {tuple(pos_bounds.keys())}')
k, v = next(iter(pos_bounds.items()))
k = 'position' if k in ('p', 'pos') else k
# Logic hoisted from path_to()
port_name = portspec[0]
port = self.pattern[port_name]
if port.rotation is None:
raise PortError(f'Port {port_name} has no rotation and cannot be used for trace_to()')
if not numpy.isclose(port.rotation % (pi / 2), 0):
raise BuildError('trace_to was asked to route from non-manhattan port')
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if k == 'y':
raise BuildError('Asked to trace to y-coordinate, but port is horizontal')
target = v
else:
if k == 'x':
raise BuildError('Asked to trace to x-coordinate, but port is vertical')
target = v
x0, y0 = port.offset
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(target - x0):
raise BuildError(f'trace_to routing to behind source port: x0={x0:g} to {target:g}')
length = numpy.abs(target - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(target - y0):
raise BuildError(f'trace_to routing to behind source port: y0={y0:g} to {target:g}')
length = numpy.abs(target - y0)
other_bounds = {bk: bv for bk, bv in bounds.items() if bk not in pos_bounds and bk != 'length'}
if 'length' in bounds and bounds['length'] is not None:
raise BuildError('Cannot specify both relative length and absolute position in trace_to()')
return self._path(port_name, ccw, length, **other_bounds)
# Bundle routing (delegate to trace which handles ell)
return self.trace(portspec, ccw, spacing=spacing, **bounds)
def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
""" Straight extension. Replaces `path(ccw=None)` and `path_to(ccw=None)` """
return self.trace_to(portspec, None, length=length, **bounds)
def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self:
""" Bend extension. Replaces `path(ccw=True/False)` and `path_to(ccw=True/False)` """
return self.trace_to(portspec, ccw, length=length, **bounds)
def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
""" Counter-clockwise bend extension. """
return self.bend(portspec, True, length, **bounds)
def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self:
""" Clockwise bend extension. """
return self.bend(portspec, False, length, **bounds)
def jog(self, portspec: str | Sequence[str], offset: float, length: float | None = None, **bounds) -> Self:
""" Jog extension. Replaces `pathS`. """
if isinstance(portspec, str):
portspec = [portspec]
for port in portspec:
l_actual = length
if l_actual is None:
# TODO: use bounds to determine length?
raise BuildError('jog() currently requires a length')
self._pathS(port, l_actual, offset, **bounds)
return self
def trace_into(
self,
portspec_src: str,
portspec_dst: str,
*,
out_ptype: str | None = None,
plug_destination: bool = True,
thru: str | None = None,
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" traveling between the ports `portspec_src` and
`portspec_dst`, and `plug` it into both (or just the source port).
Only unambiguous scenarios are allowed:
- Straight connector between facing ports
- Single 90 degree bend
- Jog between facing ports
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
By default, the destination's `pytpe` will be used as the `out_ptype` for the
wire, and the `portspec_dst` will be plugged (i.e. removed).
Args:
portspec_src: The name of the starting port into which the wire will be plugged.
portspec_dst: The name of the destination port.
out_ptype: Passed to the pathing tool in order to specify the desired port type
to be generated at the destination end. If `None` (default), the destination
port's `ptype` will be used.
thru: If not `None`, the port by this name will be renamed to `portspec_src`.
This can be used when routing a signal through a pre-placed 2-port device.
Returns:
self
Raises:
PortError if either port does not have a specified rotation.
BuildError if an invalid port config is encountered:
- Non-manhattan ports
- U-bend
- Destination too close to (or behind) source
"""
if self._dead:
logger.error('Skipping trace_into() since device is dead')
return self
port_src = self.pattern[portspec_src]
port_dst = self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None:
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for trace_into()')
if port_dst.rotation is None:
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for trace_into()')
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
raise BuildError('trace_into was asked to route from non-manhattan port')
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
raise BuildError('trace_into was asked to route to non-manhattan port')
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
xs, ys = port_src.offset
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
dst_extra_args = {'out_ptype': out_ptype}
if plug_destination:
dst_extra_args['plug_into'] = portspec_dst
src_args = {**kwargs}
dst_args = {**src_args, **dst_extra_args}
if src_is_horizontal and not dst_is_horizontal:
# single bend should suffice
self.trace_to(portspec_src, angle > pi, x=xd, **src_args)
self.trace_to(portspec_src, None, y=yd, **dst_args)
elif dst_is_horizontal and not src_is_horizontal:
# single bend should suffice
self.trace_to(portspec_src, angle > pi, y=yd, **src_args)
self.trace_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
if src_is_horizontal and ys == yd:
# straight connector
self.trace_to(portspec_src, None, x=xd, **dst_args)
elif not src_is_horizontal and xs == xd:
# straight connector
self.trace_to(portspec_src, None, y=yd, **dst_args)
else:
# S-bend
(travel, jog), _ = port_src.measure_travel(port_dst)
self.jog(portspec_src, -jog, -travel, **dst_args)
elif numpy.isclose(angle, 0):
raise BuildError("Don't know how to route a U-bend yet (TODO)!")
else:
raise BuildError(f"Don't know how to route ports with relative angle {angle}")
if thru is not None:
self.rename_ports({thru: portspec_src})
return self
def _uturn_fallback(
self,
tool: Tool,
portspec: str,
jog: float,
length: float,
in_ptype: str | None,
plug_into: str | None,
**kwargs,
) -> bool:
"""
Attempt to perform a U-turn using two L-bends.
Returns True if successful, False if planL failed.
"""
# Fall back to drawing two L-bends
ccw = jog > 0
kwargs_no_out = kwargs | {'out_ptype': None}
try:
# First, find R by planning a minimal L-bend.
# Use a large length to ensure we don't hit tool-specific minimum length constraints.
dummy_port, _ = tool.planL(ccw, 1e9, in_ptype=in_ptype, **kwargs_no_out)
R = abs(dummy_port.y)
L1 = length + R
L2 = abs(jog) - R
kwargs_plug = kwargs | {'plug_into': plug_into}
self._path(portspec, ccw, L1, **kwargs_no_out)
self._path(portspec, ccw, L2, **kwargs_plug)
except (BuildError, NotImplementedError):
return False
else:
return True
@abstractmethod
def path(
def _path(
self,
portspec: str,
ccw: SupportsBool | None,
@ -50,7 +365,7 @@ class PatherMixin(PortList, metaclass=ABCMeta):
pass
@abstractmethod
def pathS(
def _pathS(
self,
portspec: str,
length: float,
@ -61,6 +376,16 @@ class PatherMixin(PortList, metaclass=ABCMeta):
) -> Self:
pass
def path(self, *args, **kwargs) -> Self:
import warnings
warnings.warn("path() is deprecated; use trace(), straight(), or bend() instead", DeprecationWarning, stacklevel=2)
return self._path(*args, **kwargs)
def pathS(self, *args, **kwargs) -> Self:
import warnings
warnings.warn("pathS() is deprecated; use jog() instead", DeprecationWarning, stacklevel=2)
return self._pathS(*args, **kwargs)
@abstractmethod
def plug(
self,
@ -76,6 +401,11 @@ class PatherMixin(PortList, metaclass=ABCMeta):
) -> Self:
pass
@abstractmethod
def plugged(self, connections: dict[str, str]) -> Self:
""" Manual connection acknowledgment. """
pass
def retool(
self,
tool: Tool,
@ -143,88 +473,13 @@ class PatherMixin(PortList, metaclass=ABCMeta):
**kwargs,
) -> Self:
"""
Build a "wire"/"waveguide" extending from the port `portspec`, with the aim
of ending exactly at a target position.
The wire will travel so that the output port will be placed at exactly the target
position along the input port's axis. There can be an unspecified (tool-dependent)
offset in the perpendicular direction. The output port will be rotated (or not)
based on the `ccw` parameter.
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec: The name of the port into which the wire will be plugged.
ccw: If `None`, the output should be along the same axis as the input.
Otherwise, cast to bool and turn counterclockwise if True
and clockwise otherwise.
position: The final port position, along the input's axis only.
(There may be a tool-dependent offset along the other axis.)
Only one of `position`, `x`, and `y` may be specified.
x: The final port position along the x axis.
`portspec` must refer to a horizontal port if `x` is passed, otherwise a
BuildError will be raised.
y: The final port position along the y axis.
`portspec` must refer to a vertical port if `y` is passed, otherwise a
BuildError will be raised.
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns:
self
Raises:
BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend
is present).
BuildError if `x` or `y` is specified but does not match the axis of `portspec`.
BuildError if more than one of `x`, `y`, and `position` is specified.
[DEPRECATED] use trace_to() instead.
"""
if self._dead:
logger.error('Skipping path_to() since device is dead')
return self
import warnings
warnings.warn("path_to() is deprecated; use trace_to() instead", DeprecationWarning, stacklevel=2)
pos_count = sum(vv is not None for vv in (position, x, y))
if pos_count > 1:
raise BuildError('Only one of `position`, `x`, and `y` may be specified at once')
if pos_count < 1:
raise BuildError('One of `position`, `x`, and `y` must be specified')
port = self.pattern[portspec]
if port.rotation is None:
raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()')
if not numpy.isclose(port.rotation % (pi / 2), 0):
raise BuildError('path_to was asked to route from non-manhattan port')
is_horizontal = numpy.isclose(port.rotation % pi, 0)
if is_horizontal:
if y is not None:
raise BuildError('Asked to path to y-coordinate, but port is horizontal')
if position is None:
position = x
else:
if x is not None:
raise BuildError('Asked to path to x-coordinate, but port is vertical')
if position is None:
position = y
x0, y0 = port.offset
if is_horizontal:
if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0):
raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}')
length = numpy.abs(position - x0)
else:
if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0):
raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}')
length = numpy.abs(position - y0)
return self.path(
portspec,
ccw,
length,
plug_into = plug_into,
**kwargs,
)
bounds = {kk: vv for kk, vv in (('position', position), ('x', x), ('y', y)) if vv is not None}
return self.trace_to(portspec, ccw, plug_into=plug_into, **bounds, **kwargs)
def path_into(
self,
@ -237,100 +492,19 @@ class PatherMixin(PortList, metaclass=ABCMeta):
**kwargs,
) -> Self:
"""
Create a "wire"/"waveguide" traveling between the ports `portspec_src` and
`portspec_dst`, and `plug` it into both (or just the source port).
Only unambiguous scenarios are allowed:
- Straight connector between facing ports
- Single 90 degree bend
- Jog between facing ports
(jog is done as late as possible, i.e. only 2 L-shaped segments are used)
By default, the destination's `pytpe` will be used as the `out_ptype` for the
wire, and the `portspec_dst` will be plugged (i.e. removed).
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec_src: The name of the starting port into which the wire will be plugged.
portspec_dst: The name of the destination port.
out_ptype: Passed to the pathing tool in order to specify the desired port type
to be generated at the destination end. If `None` (default), the destination
port's `ptype` will be used.
thru: If not `None`, the port by this name will be rename to `portspec_src`.
This can be used when routing a signal through a pre-placed 2-port device.
Returns:
self
Raises:
PortError if either port does not have a specified rotation.
BuildError if and invalid port config is encountered:
- Non-manhattan ports
- U-bend
- Destination too close to (or behind) source
[DEPRECATED] use trace_into() instead.
"""
if self._dead:
logger.error('Skipping path_into() since device is dead')
return self
import warnings
warnings.warn("path_into() is deprecated; use trace_into() instead", DeprecationWarning, stacklevel=2)
port_src = self.pattern[portspec_src]
port_dst = self.pattern[portspec_dst]
if out_ptype is None:
out_ptype = port_dst.ptype
if port_src.rotation is None:
raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()')
if port_dst.rotation is None:
raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()')
if not numpy.isclose(port_src.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route from non-manhattan port')
if not numpy.isclose(port_dst.rotation % (pi / 2), 0):
raise BuildError('path_into was asked to route to non-manhattan port')
src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0)
dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0)
xs, ys = port_src.offset
xd, yd = port_dst.offset
angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
dst_extra_args = {'out_ptype': out_ptype}
if plug_destination:
dst_extra_args['plug_into'] = portspec_dst
src_args = {**kwargs}
dst_args = {**src_args, **dst_extra_args}
if src_is_horizontal and not dst_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, x=xd, **src_args)
self.path_to(portspec_src, None, y=yd, **dst_args)
elif dst_is_horizontal and not src_is_horizontal:
# single bend should suffice
self.path_to(portspec_src, angle > pi, y=yd, **src_args)
self.path_to(portspec_src, None, x=xd, **dst_args)
elif numpy.isclose(angle, pi):
if src_is_horizontal and ys == yd:
# straight connector
self.path_to(portspec_src, None, x=xd, **dst_args)
elif not src_is_horizontal and xs == xd:
# straight connector
self.path_to(portspec_src, None, y=yd, **dst_args)
else:
# S-bend, delegate to implementations
(travel, jog), _ = port_src.measure_travel(port_dst)
self.pathS(portspec_src, -travel, -jog, **dst_args)
elif numpy.isclose(angle, 0):
raise BuildError('Don\'t know how to route a U-bend yet (TODO)!')
else:
raise BuildError(f'Don\'t know how to route ports with relative angle {angle}')
if thru is not None:
self.rename_ports({thru: portspec_src})
return self
return self.trace_into(
portspec_src,
portspec_dst,
out_ptype = out_ptype,
plug_destination = plug_destination,
thru = thru,
**kwargs,
)
def mpath(
self,
@ -342,109 +516,12 @@ class PatherMixin(PortList, metaclass=ABCMeta):
**kwargs,
) -> Self:
"""
`mpath` is a superset of `path` and `path_to` which can act on bundles or buses
of "wires or "waveguides".
The wires will travel so that the output ports will be placed at well-defined
locations along the axis of their input ports, but may have arbitrary (tool-
dependent) offsets in the perpendicular direction.
If `ccw` is not `None`, the wire bundle will turn 90 degres in either the
clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the
bundle, the center-to-center wire spacings after the turn are set by `spacing`,
which is required when `ccw` is not `None`. The final position of bundle as a
whole can be set in a number of ways:
=A>---------------------------V turn direction: `ccw=False`
=B>-------------V |
=C>-----------------------V |
=D=>----------------V |
|
x---x---x---x `spacing` (can be scalar or array)
<--------------> `emin=`
<------> `bound_type='min_past_furthest', bound=`
<--------------------------------> `emax=`
x `pmin=`
x `pmax=`
- `emin=`, equivalent to `bound_type='min_extension', bound=`
The total extension value for the furthest-out port (B in the diagram).
- `emax=`, equivalent to `bound_type='max_extension', bound=`:
The total extension value for the closest-in port (C in the diagram).
- `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`:
The coordinate of the innermost bend (D's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`:
The coordinate of the outermost bend (A's bend).
The x/y versions throw an error if they do not match the port axis (for debug)
- `bound_type='min_past_furthest', bound=`:
The distance between furthest out-port (B) and the innermost bend (D's bend).
If `ccw=None`, final output positions (along the input axis) of all wires will be
identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is
required. In this case, `emin=` and `emax=` are equivalent to each other, and
`pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other.
If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned.
Args:
portspec: The names of the ports which are to be routed.
ccw: If `None`, the outputs should be along the same axis as the inputs.
Otherwise, cast to bool and turn 90 degrees counterclockwise if `True`
and clockwise otherwise.
spacing: Center-to-center distance between output ports along the input port's axis.
Must be provided if (and only if) `ccw` is not `None`.
set_rotation: If the provided ports have `rotation=None`, this can be used
to set a rotation for them.
Returns:
self
Raises:
BuildError if the implied length for any wire is too close to fit the bend
(if a bend is requested).
BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not
match the axis of `portspec`.
BuildError if an incorrect bound type or spacing is specified.
[DEPRECATED] use trace() or trace_to() instead.
"""
if self._dead:
logger.error('Skipping mpath() since device is dead')
return self
import warnings
warnings.warn("mpath() is deprecated; use trace() or trace_to() instead", DeprecationWarning, stacklevel=2)
bound_types = set()
if 'bound_type' in kwargs:
bound_types.add(kwargs.pop('bound_type'))
bound = kwargs.pop('bound')
for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'):
if bt in kwargs:
bound_types.add(bt)
bound = kwargs.pop(bt)
if not bound_types:
raise BuildError('No bound type specified for mpath')
if len(bound_types) > 1:
raise BuildError(f'Too many bound types specified for mpath: {bound_types}')
bound_type = tuple(bound_types)[0]
if isinstance(portspec, str):
portspec = [portspec]
ports = self.pattern[tuple(portspec)]
extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation)
#if container:
# assert not getattr(self, 'render'), 'Containers not implemented for RenderPather'
# bld = self.interface(source=ports, library=self.library, tools=self.tools)
# for port_name, length in extensions.items():
# bld.path(port_name, ccw, length, **kwargs)
# self.library[container] = bld.pattern
# self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'?
#else:
for port_name, length in extensions.items():
self.path(port_name, ccw, length, **kwargs)
return self
return self.trace(portspec, ccw, spacing=spacing, set_rotation=set_rotation, **kwargs)
# TODO def bus_join()?
@ -488,61 +565,42 @@ class PortPather:
with self.pather.toolctx(tool, keys=self.ports):
yield self
def path(self, *args, **kwargs) -> Self:
def trace(self, ccw: SupportsBool | None, length: float | None = None, **kwargs) -> Self:
self.pather.trace(self.ports, ccw, length, **kwargs)
return self
def trace_to(self, ccw: SupportsBool | None, **kwargs) -> Self:
self.pather.trace_to(self.ports, ccw, **kwargs)
return self
def straight(self, length: float | None = None, **kwargs) -> Self:
self.pather.straight(self.ports, length, **kwargs)
return self
def bend(self, ccw: SupportsBool, length: float | None = None, **kwargs) -> Self:
self.pather.bend(self.ports, ccw, length, **kwargs)
return self
def ccw(self, length: float | None = None, **kwargs) -> Self:
self.pather.ccw(self.ports, length, **kwargs)
return self
def cw(self, length: float | None = None, **kwargs) -> Self:
self.pather.cw(self.ports, length, **kwargs)
return self
def jog(self, offset: float, length: float | None = None, **kwargs) -> Self:
self.pather.jog(self.ports, offset, length, **kwargs)
return self
def uturn(self, offset: float, length: float | None = None, **kwargs) -> Self:
self.pather.uturn(self.ports, offset, length, **kwargs)
return self
def trace_into(self, target_port: str, **kwargs) -> Self:
if len(self.ports) > 1:
logger.warning('Use path_each() when pathing multiple ports independently')
for port in self.ports:
self.pather.path(port, *args, **kwargs)
return self
def path_each(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather.path(port, *args, **kwargs)
return self
def pathS(self, *args, **kwargs) -> Self:
if len(self.ports) > 1:
logger.warning('Use pathS_each() when pathing multiple ports independently')
for port in self.ports:
self.pather.pathS(port, *args, **kwargs)
return self
def pathS_each(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather.pathS(port, *args, **kwargs)
return self
def path_to(self, *args, **kwargs) -> Self:
if len(self.ports) > 1:
logger.warning('Use path_each_to() when pathing multiple ports independently')
for port in self.ports:
self.pather.path_to(port, *args, **kwargs)
return self
def path_each_to(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather.path_to(port, *args, **kwargs)
return self
def mpath(self, *args, **kwargs) -> Self:
self.pather.mpath(self.ports, *args, **kwargs)
return self
def path_into(self, *args, **kwargs) -> Self:
""" Path_into, using the current port as the source """
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.')
self.pather.path_into(self.ports[0], *args, **kwargs)
return self
def path_from(self, *args, **kwargs) -> Self:
""" Path_into, using the current port as the destination """
if len(self.ports) > 1:
raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.')
thru = kwargs.pop('thru', None)
self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs)
if thru is not None:
self.rename_from(thru)
raise BuildError(f'Unable use implicit trace_into() with {len(self.ports)} (>1) ports.')
self.pather.trace_into(self.ports[0], target_port, **kwargs)
return self
def plug(
@ -558,10 +616,13 @@ class PortPather:
self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs)
return self
def plugged(self, other_port: str) -> Self:
if len(self.ports) > 1:
def plugged(self, other_port: str | Mapping[str, str]) -> Self:
if isinstance(other_port, Mapping):
self.pather.plugged(dict(other_port))
elif len(self.ports) > 1:
raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.')
self.pather.plugged({self.ports[0]: other_port})
else:
self.pather.plugged({self.ports[0]: other_port})
return self
#
@ -569,95 +630,91 @@ class PortPather:
#
def set_ptype(self, ptype: str) -> Self:
for port in self.ports:
self.pather[port].set_ptype(ptype)
self.pather.pattern[port].set_ptype(ptype)
return self
def translate(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather[port].translate(*args, **kwargs)
self.pather.pattern[port].translate(*args, **kwargs)
return self
def mirror(self, *args, **kwargs) -> Self:
for port in self.ports:
self.pather[port].mirror(*args, **kwargs)
self.pather.pattern[port].mirror(*args, **kwargs)
return self
def rotate(self, rotation: float) -> Self:
for port in self.ports:
self.pather[port].rotate(rotation)
self.pather.pattern[port].rotate(rotation)
return self
def set_rotation(self, rotation: float | None) -> Self:
for port in self.ports:
self.pather[port].set_rotation(rotation)
self.pather.pattern[port].set_rotation(rotation)
return self
def rename_to(self, new_name: str) -> Self:
if len(self.ports) > 1:
BuildError('Use rename_ports() for >1 port')
self.pather.rename_ports({self.ports[0]: new_name})
self.ports[0] = new_name
return self
def rename_from(self, old_name: str) -> Self:
if len(self.ports) > 1:
BuildError('Use rename_ports() for >1 port')
self.pather.rename_ports({old_name: self.ports[0]})
return self
def rename_ports(self, name_map: dict[str, str | None]) -> Self:
def rename(self, name: str | Mapping[str, str | None]) -> Self:
""" Rename active ports. Replaces `rename_to`. """
name_map: dict[str, str | None]
if isinstance(name, str):
if len(self.ports) > 1:
raise BuildError('Use a mapping to rename >1 port')
name_map = {self.ports[0]: name}
else:
name_map = dict(name)
self.pather.rename_ports(name_map)
self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None]
return self
def add_ports(self, ports: Iterable[str]) -> Self:
ports = list(ports)
conflicts = set(ports) & set(self.ports)
if conflicts:
raise BuildError(f'ports {conflicts} already selected')
self.ports += ports
def select(self, ports: str | Iterable[str]) -> Self:
""" Add ports to the selection. Replaces `add_ports`. """
if isinstance(ports, str):
ports = [ports]
for port in ports:
if port not in self.ports:
self.ports.append(port)
return self
def add_port(self, port: str, index: int | None = None) -> Self:
if port in self.ports:
raise BuildError(f'{port=} already selected')
if index is not None:
self.ports.insert(index, port)
def deselect(self, ports: str | Iterable[str]) -> Self:
""" Remove ports from the selection. Replaces `drop_port`. """
if isinstance(ports, str):
ports = [ports]
ports_set = set(ports)
self.ports = [pp for pp in self.ports if pp not in ports_set]
return self
def mark(self, name: str | Mapping[str, str]) -> Self:
""" Bookmark current port(s). Replaces `save_copy`. """
name_map: Mapping[str, str]
if isinstance(name, str):
if len(self.ports) > 1:
raise BuildError('Use a mapping to mark >1 port')
name_map = {self.ports[0]: name}
else:
self.ports.append(port)
name_map = name
for src, dst in name_map.items():
self.pather.pattern.ports[dst] = self.pather.pattern[src].copy()
return self
def drop_port(self, port: str) -> Self:
if port not in self.ports:
raise BuildError(f'{port=} already not selected')
self.ports = [pp for pp in self.ports if pp != port]
def fork(self, name: str | Mapping[str, str]) -> Self:
""" Split and follow new name. Replaces `into_copy`. """
name_map: Mapping[str, str]
if isinstance(name, str):
if len(self.ports) > 1:
raise BuildError('Use a mapping to fork >1 port')
name_map = {self.ports[0]: name}
else:
name_map = name
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]
return self
def into_copy(self, new_name: str, src: str | None = None) -> Self:
""" Copy a port and replace it with the copy """
if not self.ports:
raise BuildError('Have no ports to copy')
if len(self.ports) == 1:
src = self.ports[0]
elif src is None:
raise BuildError('Must specify src when >1 port is available')
if src not in self.ports:
raise BuildError(f'{src=} not available')
self.pather.ports[new_name] = self.pather[src].copy()
self.ports = [(new_name if pp == src else pp) for pp in self.ports]
return self
def save_copy(self, new_name: str, src: str | None = None) -> Self:
""" Copy a port and but keep using the original """
if not self.ports:
raise BuildError('Have no ports to copy')
if len(self.ports) == 1:
src = self.ports[0]
elif src is None:
raise BuildError('Must specify src when >1 port is available')
if src not in self.ports:
raise BuildError(f'{src=} not available')
self.pather.ports[new_name] = self.pather[src].copy()
def drop(self) -> Self:
""" Remove selected ports from the pattern and the PortPather. Replaces `delete(None)`. """
for pp in self.ports:
del self.pather.pattern.ports[pp]
self.ports = []
return self
@overload
@ -668,10 +725,9 @@ class PortPather:
def delete(self, name: str | None = None) -> Self | None:
if name is None:
for pp in self.ports:
del self.pather.ports[pp]
self.drop()
return None
del self.pather.ports[name]
del self.pather.pattern.ports[name]
self.ports = [pp for pp in self.ports if pp != name]
return self

View file

@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
class RenderPather(PatherMixin):
"""
`RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath`
`RenderPather` is an alternative to `Pather` which uses the `trace`/`trace_to`
functions to plan out wire paths without incrementally generating the layout. Instead,
it waits until `render` is called, at which point it draws all the planned segments
simultaneously. This allows it to e.g. draw each wire using a single `Path` or
@ -97,7 +97,7 @@ class RenderPather(PatherMixin):
in which case it is interpreted as a name in `library`.
Default `None` (no ports).
tools: A mapping of {port: tool} which specifies what `Tool` should be used
to generate waveguide or wire segments when `path`/`path_to`/`mpath`
to generate waveguide or wire segments when `trace`/`trace_to`
are called. Relies on `Tool.planL` and `Tool.render` implementations.
name: If specified, `library[name]` is set to `self.pattern`.
"""
@ -150,7 +150,7 @@ class RenderPather(PatherMixin):
and to which the new one should be added (if named). If not provided,
`source.library` must exist and will be used.
tools: `Tool`s which will be used by the pather for generating new wires
or waveguides (via `path`/`path_to`/`mpath`).
or waveguides (via `trace`/`trace_to`).
in_prefix: Prepended to port names for newly-created ports with
reversed directions compared to the current device.
out_prefix: Prepended to port names for ports which are directly
@ -377,7 +377,7 @@ class RenderPather(PatherMixin):
PortList.plugged(self, connections)
return self
def path(
def _path(
self,
portspec: str,
ccw: SupportsBool | None,
@ -420,7 +420,7 @@ class RenderPather(PatherMixin):
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.warning('Skipping geometry for path() since device is dead')
logger.warning('Skipping geometry for _path() since device is dead')
port = self.pattern[portspec]
in_ptype = port.ptype
@ -460,7 +460,7 @@ class RenderPather(PatherMixin):
return self
def pathS(
def _pathS(
self,
portspec: str,
length: float,
@ -504,7 +504,7 @@ class RenderPather(PatherMixin):
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.warning('Skipping geometry for pathS() since device is dead')
logger.warning('Skipping geometry for _pathS() since device is dead')
port = self.pattern[portspec]
in_ptype = port.ptype
@ -527,8 +527,8 @@ class RenderPather(PatherMixin):
jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1]
kwargs_plug = kwargs | {'plug_into': plug_into}
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
self._path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self._path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
except (BuildError, NotImplementedError):
if not self._dead:
raise
@ -564,7 +564,7 @@ class RenderPather(PatherMixin):
append: bool = True,
) -> Self:
"""
Generate the geometry which has been planned out with `path`/`path_to`/etc.
Generate the geometry which has been planned out with `trace`/`trace_to`/etc.
Args:
append: If `True`, the rendered geometry will be directly appended to

View file

@ -24,7 +24,7 @@ def pather_setup() -> tuple[Pather, PathTool, Library]:
def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Route 10um "forward"
p.path("start", ccw=None, length=10)
p.straight("start", 10)
# port rot pi/2 (North). Travel +pi relative to port -> South.
assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10)
@ -37,7 +37,7 @@ def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None:
# Start (0,0) rot pi/2 (North).
# Path 10um "forward" (South), then turn Clockwise (ccw=False).
# Facing South, turn Right -> West.
p.path("start", ccw=False, length=10)
p.cw("start", 10)
# PathTool.planL(ccw=False, length=10) returns out_port at (10, -1) relative to (0,0) rot 0.
# Transformed by port rot pi/2 (North) + pi (to move "forward" away from device):
@ -55,7 +55,7 @@ def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# start at (0,0) rot pi/2 (North)
# path "forward" (South) to y=-50
p.path_to("start", ccw=None, y=-50)
p.straight("start", y=-50)
assert_equal(p.ports["start"].offset, [0, -50])
@ -65,7 +65,7 @@ def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p.ports["B"] = Port((10, 0), pi / 2, ptype="wire")
# Path both "forward" (South) to y=-20
p.mpath(["A", "B"], ccw=None, ymin=-20)
p.straight(["A", "B"], ymin=-20)
assert_equal(p.ports["A"].offset, [0, -20])
assert_equal(p.ports["B"].offset, [10, -20])
@ -73,7 +73,7 @@ def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None:
def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup
# Fluent API test
p.at("start").path(ccw=None, length=10).path(ccw=True, length=10)
p.at("start").straight(10).ccw(10)
# 10um South -> (0, -10) rot pi/2
# then 10um South and turn CCW (Facing South, CCW is East)
# PathTool.planL(ccw=True, length=10) -> out_port=(10, 1) rot -pi/2 relative to rot 0
@ -93,14 +93,14 @@ def test_pather_dead_ports() -> None:
p.set_dead()
# Path with negative length (impossible for PathTool, would normally raise BuildError)
p.path("in", None, -10)
p.straight("in", -10)
# Port 'in' should be updated by dummy extension despite tool failure
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10)
# Downstream path should work correctly using the dummy port location
p.path("in", None, 20)
p.straight("in", 20)
# 10 + (-20) = -10
assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10)

View file

@ -24,7 +24,7 @@ def rpather_setup() -> tuple[RenderPather, PathTool, Library]:
def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
# Plan two segments
rp.at("start").path(ccw=None, length=10).path(ccw=None, length=10)
rp.at("start").straight(10).straight(10)
# Before rendering, no shapes in pattern
assert not rp.pattern.has_shapes()
@ -49,7 +49,7 @@ def test_renderpather_basic(rpather_setup: tuple[RenderPather, PathTool, Library
def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
rp, tool, lib = rpather_setup
# Plan straight then bend
rp.at("start").path(ccw=None, length=10).path(ccw=False, length=10)
rp.at("start").straight(10).cw(10)
rp.render()
path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0])
@ -69,9 +69,9 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar
rp, tool1, lib = rpather_setup
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
rp.at("start").path(ccw=None, length=10)
rp.at("start").straight(10)
rp.retool(tool2, keys=["start"])
rp.at("start").path(ccw=None, length=10)
rp.at("start").straight(10)
rp.render()
# Different tools should cause different batches/shapes
@ -86,7 +86,7 @@ def test_renderpather_dead_ports() -> None:
rp.set_dead()
# Impossible path
rp.path("in", None, -10)
rp.straight("in", -10)
# port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x.
assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10)