[PathTool] add native S-bend

This commit is contained in:
Jan Petykiewicz 2026-04-08 23:48:48 -07:00
commit e6f5136357
3 changed files with 125 additions and 20 deletions

View file

@ -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)

View file

@ -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')

View file

@ -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)