From e6f5136357d681ed3dcec7750c16897afbf26b4c Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 8 Apr 2026 23:48:48 -0700 Subject: [PATCH] [PathTool] add native S-bend --- masque/builder/tools.py | 104 +++++++++++++++++++++++++------ masque/test/test_pather_api.py | 15 ++++- masque/test/test_renderpather.py | 26 ++++++++ 3 files changed, 125 insertions(+), 20 deletions(-) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index ddd4bba..44318d1 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1238,6 +1238,39 @@ class PathTool(Tool, metaclass=ABCMeta): # self.width = width # self.ptype: str + def _check_out_ptype(self, out_ptype: str | None) -> None: + if out_ptype and out_ptype != self.ptype: + raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') + + def _bend_radius(self) -> float: + return self.width / 2 + + def _plan_l_vertices(self, length: float, bend_run: float) -> NDArray[numpy.float64]: + vertices = [(0.0, 0.0), (length, 0.0)] + if not numpy.isclose(bend_run, 0): + vertices.append((length, bend_run)) + return numpy.array(vertices, dtype=float) + + def _plan_s_vertices(self, length: float, jog: float) -> NDArray[numpy.float64]: + if numpy.isclose(jog, 0): + return numpy.array([(0.0, 0.0), (length, 0.0)], dtype=float) + + if length < self.width: + raise BuildError( + f'Asked to draw S-path with total length {length:,g}, shorter than required bend: {self.width:,g}' + ) + + # Match AutoTool's straight-then-s-bend placement so the jog happens + # width/2 before the end while still allowing smaller lateral offsets. + jog_x = length - self._bend_radius() + vertices = [ + (0.0, 0.0), + (jog_x, 0.0), + (jog_x, jog), + (length, jog), + ] + return numpy.array(vertices, dtype=float) + def traceL( self, ccw: SupportsBool | None, @@ -1248,7 +1281,7 @@ class PathTool(Tool, metaclass=ABCMeta): port_names: tuple[str, str] = ('A', 'B'), **kwargs, # noqa: ARG002 (unused) ) -> Library: - out_port, _data = self.planL( + out_port, data = self.planL( ccw, length, in_ptype=in_ptype, @@ -1256,12 +1289,7 @@ class PathTool(Tool, metaclass=ABCMeta): ) tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') - vertices: list[tuple[float, float]] - if ccw is None: - vertices = [(0.0, 0.0), (length, 0.0)] - else: - vertices = [(0.0, 0.0), (length, 0.0), tuple(out_port.offset)] - pat.path(layer=self.layer, width=self.width, vertices=vertices) + pat.path(layer=self.layer, width=self.width, vertices=self._plan_l_vertices(length, float(out_port.y))) if ccw is None: out_rot = pi @@ -1288,11 +1316,10 @@ class PathTool(Tool, metaclass=ABCMeta): ) -> tuple[Port, NDArray[numpy.float64]]: # TODO check all the math for L-shaped bends - if out_ptype and out_ptype != self.ptype: - raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') + self._check_out_ptype(out_ptype) if ccw is not None: - bend_dxy = numpy.array([1, -1]) * self.width / 2 + bend_dxy = numpy.array([1, -1]) * self._bend_radius() bend_angle = pi / 2 if bool(ccw): @@ -1313,6 +1340,46 @@ class PathTool(Tool, metaclass=ABCMeta): out_port = Port(data, rotation=bend_angle, ptype=self.ptype) return out_port, data + def traceS( + self, + length: float, + jog: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, # noqa: ARG002 (unused) + ) -> Library: + out_port, _data = self.planS( + length, + jog, + in_ptype=in_ptype, + out_ptype=out_ptype, + ) + + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceS') + pat.path(layer=self.layer, width=self.width, vertices=self._plan_s_vertices(length, jog)) + pat.ports = { + port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), + port_names[1]: out_port, + } + return tree + + def planS( + self, + length: float, + jog: float, + *, + in_ptype: str | None = None, # noqa: ARG002 (unused) + out_ptype: str | None = None, + **kwargs, # noqa: ARG002 (unused) + ) -> tuple[Port, NDArray[numpy.float64]]: + self._check_out_ptype(out_ptype) + self._plan_s_vertices(length, jog) + data = numpy.array((length, jog)) + out_port = Port((length, jog), rotation=pi, ptype=self.ptype) + return out_port, data + def render( self, batch: Sequence[RenderStep], @@ -1341,19 +1408,18 @@ class PathTool(Tool, metaclass=ABCMeta): # Masque convention: Port rotation points INTO the device. # So the direction of travel for the path is AWAY from the port, i.e., port_rot + pi. assert port_rot is not None - + transform = rotation_matrix_2d(port_rot + pi) + delta = step.end_port.offset - step.start_port.offset + local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta if step.opcode == 'L': - - length, _ = step.data - dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0) - path_vertices.append(step.start_port.offset + dxy) + local_vertices = self._plan_l_vertices(float(local_end[0]), float(local_end[1])) + elif step.opcode == 'S': + local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1])) else: raise BuildError(f'Unrecognized opcode "{step.opcode}"') - # Check if the last vertex added is already at the end port location - if not numpy.allclose(path_vertices[-1], local_batch[-1].end_port.offset): - # If the path ends in a bend, we need to add the final vertex - path_vertices.append(local_batch[-1].end_port.offset) + for vertex in local_vertices[1:]: + path_vertices.append(step.start_port.offset + transform @ vertex) tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') pat.path(layer=self.layer, width=self.width, vertices=path_vertices) diff --git a/masque/test/test_pather_api.py b/masque/test/test_pather_api.py index dd7dc8f..5f84101 100644 --- a/masque/test/test_pather_api.py +++ b/masque/test/test_pather_api.py @@ -407,13 +407,26 @@ def test_pather_jog_failed_fallback_is_atomic() -> None: p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') with pytest.raises(BuildError, match='shorter than required bend'): - p.jog('A', 1.5, length=5) + p.jog('A', 1.5, length=1.5) assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert p.pattern.ports['A'].rotation == 0 assert len(p.paths['A']) == 0 +def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 1.5, length=5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5)) + assert p.pattern.ports['A'].rotation == 0 + 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') diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index 9b5a8e3..b518a1f 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -65,6 +65,20 @@ def test_renderpather_bend(rpather_setup: tuple[Pather, PathTool, Library]) -> N assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10) +def test_renderpather_jog_uses_native_pathtool_planS(rpather_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = rpather_setup + rp.at("start").jog(4, length=10) + + assert len(rp.paths["start"]) == 1 + assert rp.paths["start"][0].opcode == "S" + + rp.render() + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + # Native PathTool S-bends place the jog width/2 before the route end. + assert_allclose(path_shape.vertices, [[0, 0], [0, -9], [4, -9], [4, -10]], atol=1e-10) + assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) + + def test_renderpather_mirror_preserves_planned_bend_geometry(rpather_setup: tuple[Pather, PathTool, Library]) -> None: rp, tool, lib = rpather_setup rp.at("start").straight(10).cw(10) @@ -171,3 +185,15 @@ def test_pathtool_traceL_bend_geometry_matches_ports() -> None: assert_allclose(path_shape.vertices, [[0, 0], [10, 0], [10, 1]], atol=1e-10) assert_allclose(pat.ports["B"].offset, [10, 1], atol=1e-10) + + +def test_pathtool_traceS_geometry_matches_ports() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + tree = tool.traceS(10, 4) + pat = tree.top_pattern() + path_shape = cast("Path", pat.shapes[(1, 0)][0]) + + assert_allclose(path_shape.vertices, [[0, 0], [9, 0], [9, 4], [10, 4]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [10, 4], atol=1e-10) + assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10)