From 049098ade550f185c7918cce8ca78c90a22a9b14 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 17 Nov 2025 22:12:24 -0800 Subject: [PATCH] [Tool / AutoTool / Pather / RenderPather / PatherMixin] add support for S-bends --- masque/builder/pather.py | 68 +++++++++++++++++ masque/builder/pather_mixin.py | 30 ++++---- masque/builder/renderpather.py | 75 +++++++++++++++++++ masque/builder/tools.py | 131 +++++++++++++++++++++++++-------- 4 files changed, 257 insertions(+), 47 deletions(-) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 4b9c5b3..3a0dee4 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -304,3 +304,71 @@ class Pather(Builder, PatherMixin): self.plug(tname, {portspec: tool_port_names[0], **output}) 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 + diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py index e2c7eee..a60aaeb 100644 --- a/masque/builder/pather_mixin.py +++ b/masque/builder/pather_mixin.py @@ -48,6 +48,18 @@ class PatherMixin(metaclass=ABCMeta): ) -> Self: pass + @abstractmethod + def pathS( + self, + portspec: str, + length: float, + jog: float, + *, + plug_into: str | None = None, + **kwargs, + ) -> Self: + pass + def retool( self, tool: Tool, @@ -266,8 +278,6 @@ class PatherMixin(metaclass=ABCMeta): 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: 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... @@ -297,20 +307,10 @@ class PatherMixin(metaclass=ABCMeta): elif not src_is_horizontal and xs == xd: # straight connector 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: - # figure out how much y our x-segment (2nd) takes up, then path based on that - x_len = numpy.abs(xd - xs) - ccw2 = src_ne != (xd < xs) - 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) + # S-bend, delegate to implementations + tj, _ = port_src.measure_travel(port_dst) + self.pathS(*tj, **dst_args) elif numpy.isclose(angle, 0): raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') else: diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index c546f8b..eb78cb8 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -420,6 +420,81 @@ class RenderPather(PortList, PatherMixin): 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( self, append: bool = True, diff --git a/masque/builder/tools.py b/masque/builder/tools.py index befe198..08aaf4c 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -101,6 +101,48 @@ class Tool: """ 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( self, ccw: SupportsBool | None, @@ -204,8 +246,8 @@ class Tool: Args: 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 - by a counterclockwise bend) + A positive number implies a leftwards shift (i.e. counterclockwise bend + 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. @@ -413,6 +455,7 @@ class AutoTool(Tool, metaclass=ABCMeta): in_port_name: str out_port_name: str + jog_range: tuple[float, float] = (0, numpy.inf) @dataclass(frozen=True, slots=True) class Bend: @@ -497,15 +540,8 @@ class AutoTool(Tool, metaclass=ABCMeta): def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: if ccw is None: return numpy.zeros(2), pi - - angle_in = bend.in_port.rotation - 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 - + bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) + assert bend_angle is not None if bool(ccw): bend_dxy[1] *= -1 bend_angle *= -1 @@ -518,21 +554,15 @@ class AutoTool(Tool, metaclass=ABCMeta): 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() - - 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) + dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) return dxy @staticmethod def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: if in_transition is None: return numpy.zeros(2) - irot = in_transition.their_port.rotation - assert irot is not None - itrans_dxy = rotation_matrix_2d(-irot) @ (in_transition.our_port.offset - in_transition.their_port.offset) - return itrans_dxy + dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port) + return dxy @staticmethod 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, out_ptype: str | None = None, - **kwargs, # noqa: ARG002 (unused) + **kwargs, ) -> tuple[Port, LData]: success = False @@ -703,21 +733,36 @@ class AutoTool(Tool, metaclass=ABCMeta): itrans_dxy = self._itransition2dxy(in_transition) jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) - if success: - b_transition = None - straight_length = 0 - break + if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: + sbend_dxy = self._sbend2dxy(sbend, jog_remaining) + success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) + if success: + b_transition = None + straight_length = 0 + break if success: 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 raise BuildError( - f'Asked to draw S-path with total length {length:,g}, shorter than required bends and transitions:\n' - f'sbend: {sbend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' - f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' - ) + f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}' + ) from l2_err if out_transition is not None: 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}) 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( self, batch: Sequence[RenderStep],