[Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends

This commit is contained in:
Jan Petykiewicz 2025-11-17 22:12:24 -08:00
parent dbaa6fc1f3
commit 049098ade5
4 changed files with 257 additions and 47 deletions

View File

@ -304,3 +304,71 @@ class Pather(Builder, PatherMixin):
self.plug(tname, {portspec: tool_port_names[0], **output}) self.plug(tname, {portspec: tool_port_names[0], **output})
return self return self
def pathS(
self,
portspec: str,
length: float,
jog: float,
*,
plug_into: str | None = None,
**kwargs,
) -> 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).
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.
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.)
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns:
self
Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping pathS() since device is dead')
return self
tool_port_names = ('A', 'B')
tool = self.tools.get(portspec, self.tools[None])
in_ptype = self.pattern[portspec].ptype
try:
tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs)
except NotImplementedError:
# Fall back to drawing two L-bends
ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None})
t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out)
t_pat0 = t_tree0.top_pattern()
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
t_tree1 = tool.path(not ccw0, jog - jog0, port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs)
t_pat1 = t_tree1.top_pattern()
(_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]])
self.path(portspec, ccw0, length - jog1, **kwargs_no_out)
self.path(portspec, not ccw0, jog - jog0, **kwargs)
return self
tname = self.library << tree
if plug_into is not None:
output = {plug_into: tool_port_names[1]}
else:
output = {}
self.plug(tname, {portspec: tool_port_names[0], **output})
return self

View File

@ -48,6 +48,18 @@ class PatherMixin(metaclass=ABCMeta):
) -> Self: ) -> Self:
pass pass
@abstractmethod
def pathS(
self,
portspec: str,
length: float,
jog: float,
*,
plug_into: str | None = None,
**kwargs,
) -> Self:
pass
def retool( def retool(
self, self,
tool: Tool, tool: Tool,
@ -266,8 +278,6 @@ class PatherMixin(metaclass=ABCMeta):
angle = (port_dst.rotation - port_src.rotation) % (2 * pi) angle = (port_dst.rotation - port_src.rotation) % (2 * pi)
src_ne = port_src.rotation % (2 * pi) > (3 * pi / 4) # path from src will go north or east
def get_jog(ccw: SupportsBool, length: float) -> float: def get_jog(ccw: SupportsBool, length: float) -> float:
tool = self.tools.get(portspec_src, self.tools[None]) tool = self.tools.get(portspec_src, self.tools[None])
in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already... in_ptype = 'unk' # Could use port_src.ptype, but we're assuming this is after one bend already...
@ -297,20 +307,10 @@ class PatherMixin(metaclass=ABCMeta):
elif not src_is_horizontal and xs == xd: elif not src_is_horizontal and xs == xd:
# straight connector # straight connector
self.path_to(portspec_src, None, y=yd, **dst_args) self.path_to(portspec_src, None, y=yd, **dst_args)
elif src_is_horizontal:
# figure out how much x our y-segment (2nd) takes up, then path based on that
y_len = numpy.abs(yd - ys)
ccw2 = src_ne != (yd > ys)
jog = get_jog(ccw2, y_len) * numpy.sign(xd - xs)
self.path_to(portspec_src, not ccw2, x=xd - jog, **src_args)
self.path_to(portspec_src, ccw2, y=yd, **dst_args)
else: else:
# figure out how much y our x-segment (2nd) takes up, then path based on that # S-bend, delegate to implementations
x_len = numpy.abs(xd - xs) tj, _ = port_src.measure_travel(port_dst)
ccw2 = src_ne != (xd < xs) self.pathS(*tj, **dst_args)
jog = get_jog(ccw2, x_len) * numpy.sign(yd - ys)
self.path_to(portspec_src, not ccw2, y=yd - jog, **src_args)
self.path_to(portspec_src, ccw2, x=xd, **dst_args)
elif numpy.isclose(angle, 0): elif numpy.isclose(angle, 0):
raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') raise BuildError('Don\'t know how to route a U-bend yet (TODO)!')
else: else:

View File

@ -420,6 +420,81 @@ class RenderPather(PortList, PatherMixin):
return self return self
def pathS(
self,
portspec: str,
length: float,
jog: float,
*,
plug_into: str | None = None,
**kwargs,
) -> 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).
The output port will have the same orientation as the source port (`portspec`).
`RenderPather.render` must be called after all paths have been fully planned.
This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former
raises a NotImplementedError.
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.)
plug_into: If not None, attempts to plug the wire's output port into the provided
port on `self`.
Returns:
self
Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog).
LibraryError if no valid name could be picked for the pattern.
"""
if self._dead:
logger.error('Skipping pathS() since device is dead')
return self
port = self.pattern[portspec]
in_ptype = port.ptype
port_rot = port.rotation
assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()?
tool = self.tools.get(portspec, self.tools[None])
# check feasibility, get output port and data
try:
out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs)
except NotImplementedError:
# Fall back to drawing two L-bends
ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None})
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out)
(_, jog0), _ = Port((0, 0), 0).measure_travel(t_port0)
t_port1, _ = tool.planL(not ccw0, jog - jog0, in_ptype=t_port0.ptype, **kwargs)
(_, jog1), _ = Port((0, 0), 0).measure_travel(t_port1)
self.path(portspec, ccw0, length - jog1, **kwargs_no_out)
self.path(portspec, not ccw0, jog - jog0, **kwargs)
return self
out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset)
step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy()
if plug_into is not None:
self.plugged({portspec: plug_into})
return self
def render( def render(
self, self,
append: bool = True, append: bool = True,

View File

@ -101,6 +101,48 @@ class Tool:
""" """
raise NotImplementedError(f'path() not implemented for {type(self)}') raise NotImplementedError(f'path() not implemented for {type(self)}')
def pathS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
"""
Create a wire or waveguide that travels exactly `length` distance along the axis
of its input port, and `jog` distance on the perpendicular axis.
`jog` is positive when moving left of the direction of travel (from input to ouput port).
Used by `Pather` and `RenderPather`.
The output port should be rotated to face the input port (i.e. plugging the device
into a port will move that port but keep its orientation).
The input and output ports should be compatible with `in_ptype` and
`out_ptype`, respectively. They should also be named `port_names[0]` and
`port_names[1]`, respectively.
Args:
length: The total distance from input to output, along the input's axis only.
jog: The total distance from input to output, along the second axis. Positive indicates
a leftward shift when moving from input to output port.
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
port_names: The output pattern will have its input port named `port_names[0]` and
its output named `port_names[1]`.
kwargs: Custom tool-specific parameters.
Returns:
A pattern tree containing the requested S-shaped (or straight) wire or waveguide
Raises:
BuildError if an impossible or unsupported geometry is requested.
"""
raise NotImplementedError(f'path() not implemented for {type(self)}')
def planL( def planL(
self, self,
ccw: SupportsBool | None, ccw: SupportsBool | None,
@ -204,8 +246,8 @@ class Tool:
Args: Args:
jog: The total offset from the input to output, along the perpendicular axis. jog: The total offset from the input to output, along the perpendicular axis.
A positive number implies a rightwards shift (i.e. clockwise bend followed A positive number implies a leftwards shift (i.e. counterclockwise bend
by a counterclockwise bend) followed by a clockwise bend)
in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged.
out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged.
kwargs: Custom tool-specific parameters. kwargs: Custom tool-specific parameters.
@ -413,6 +455,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
in_port_name: str in_port_name: str
out_port_name: str out_port_name: str
jog_range: tuple[float, float] = (0, numpy.inf)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Bend: class Bend:
@ -497,15 +540,8 @@ class AutoTool(Tool, metaclass=ABCMeta):
def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]:
if ccw is None: if ccw is None:
return numpy.zeros(2), pi return numpy.zeros(2), pi
bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port)
angle_in = bend.in_port.rotation assert bend_angle is not None
angle_out = bend.out_port.rotation
assert angle_in is not None
assert angle_out is not None
bend_dxy = rotation_matrix_2d(-angle_in) @ (bend.out_port.offset - bend.in_port.offset)
bend_angle = angle_out - angle_in
if bool(ccw): if bool(ccw):
bend_dxy[1] *= -1 bend_dxy[1] *= -1
bend_angle *= -1 bend_angle *= -1
@ -518,21 +554,15 @@ class AutoTool(Tool, metaclass=ABCMeta):
sbend_pat_or_tree = sbend.fn(jog) sbend_pat_or_tree = sbend.fn(jog)
sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern()
dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name])
angle_in = sbpat[sbend.in_port_name].rotation
assert angle_in is not None
dxy = rotation_matrix_2d(-angle_in) @ (sbpat[sbend.out_port_name].offset - sbpat[sbend.in_port_name].offset)
return dxy return dxy
@staticmethod @staticmethod
def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]:
if in_transition is None: if in_transition is None:
return numpy.zeros(2) return numpy.zeros(2)
irot = in_transition.their_port.rotation dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port)
assert irot is not None return dxy
itrans_dxy = rotation_matrix_2d(-irot) @ (in_transition.our_port.offset - in_transition.their_port.offset)
return itrans_dxy
@staticmethod @staticmethod
def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]:
@ -550,7 +580,7 @@ class AutoTool(Tool, metaclass=ABCMeta):
*, *,
in_ptype: str | None = None, in_ptype: str | None = None,
out_ptype: str | None = None, out_ptype: str | None = None,
**kwargs, # noqa: ARG002 (unused) **kwargs,
) -> tuple[Port, LData]: ) -> tuple[Port, LData]:
success = False success = False
@ -703,21 +733,36 @@ class AutoTool(Tool, metaclass=ABCMeta):
itrans_dxy = self._itransition2dxy(in_transition) itrans_dxy = self._itransition2dxy(in_transition)
jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1]
sbend_dxy = self._sbend2dxy(sbend, jog_remaining) if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]:
success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) sbend_dxy = self._sbend2dxy(sbend, jog_remaining)
if success: success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1])
b_transition = None if success:
straight_length = 0 b_transition = None
break straight_length = 0
break
if success: if success:
break break
else:
if not success:
try:
ccw0 = jog > 0
p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype)
p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype)
dx = p_test1.x - length / 2
p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype)
p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype)
success = True
except BuildError as err:
l2_err: BuildError | None = err
else:
l2_err = None
if not success:
# Failed to break # Failed to break
raise BuildError( raise BuildError(
f'Asked to draw S-path with total length {length:,g}, shorter than required bends and transitions:\n' f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}'
f'sbend: {sbend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' ) from l2_err
f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}'
)
if out_transition is not None: if out_transition is not None:
out_ptype_actual = out_transition.their_port.ptype out_ptype_actual = out_transition.their_port.ptype
@ -770,6 +815,28 @@ class AutoTool(Tool, metaclass=ABCMeta):
pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name})
return tree return tree
def pathS(
self,
length: float,
jog: float,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
port_names: tuple[str, str] = ('A', 'B'),
**kwargs,
) -> Library:
_out_port, data = self.planS(
length,
jog,
in_ptype = in_ptype,
out_ptype = out_ptype,
)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS')
pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype)
self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs)
return tree
def render( def render(
self, self,
batch: Sequence[RenderStep], batch: Sequence[RenderStep],