From d36a1a53e61edc15b308e3cb271934a8e860498b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 20 Jun 2026 16:22:24 -0700 Subject: [PATCH] [PortPather] add default_spacing (.at(..., spacing=...)) --- examples/tutorial/port_pather.py | 7 +-- masque/builder/pather.py | 26 ++++++++-- masque/test/test_pather_core.py | 82 ++++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py index ab942d7..48e948e 100644 --- a/examples/tutorial/port_pather.py +++ b/examples/tutorial/port_pather.py @@ -54,9 +54,10 @@ def main() -> None: (rpather.at(['GND', 'VCC']) .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South .retool(M1_tool) # Retools both GND and VCC - .trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension - .trace(False, emin=1_000, spacing=1_200) # U-turn back South - .trace(False, emin=2_000, spacing=4_500) # U-turn back West + .set_spacing(1_200) # Default bundle spacing for later bends + .trace(True, emax=50_000) # Turn East, moves 50um extension + .trace(False, emin=1_000) # U-turn back South + .trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing ) # Retool VCC back to M2 and move both to x=-28k diff --git a/masque/builder/pather.py b/masque/builder/pather.py index b4c264d..8398e2e 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1173,30 +1173,50 @@ class Pather(PortList): self.pattern.flatten(self.library) return self - def at(self, portspec: str | Iterable[str]) -> 'PortPather': - return PortPather(portspec, self) + def at( + self, + portspec: str | Iterable[str], + *, + spacing: float | ArrayLike | None = None, + ) -> 'PortPather': + return PortPather(portspec, self, default_spacing=spacing) class PortPather: """ Port state manager for fluent pathing. """ - def __init__(self, ports: str | Iterable[str], pather: Pather) -> None: + def __init__( + self, + ports: str | Iterable[str], + pather: Pather, + *, + default_spacing: float | ArrayLike | None = None, + ) -> None: self.ports = [ports] if isinstance(ports, str) else list(ports) self.pather = pather + self.default_spacing = default_spacing def retool(self, tool: Tool) -> Self: self.pather.retool(tool, self.ports) return self + def set_spacing(self, spacing: float | ArrayLike | None) -> Self: + self.default_spacing = spacing + return self + @contextmanager def toolctx(self, tool: Tool) -> Iterator[Self]: with self.pather.toolctx(tool, keys=self.ports): yield self def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: + kw['spacing'] = self.default_spacing self.pather.trace(self.ports, ccw, length, **kw) return self def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: + kw['spacing'] = self.default_spacing self.pather.trace_to(self.ports, ccw, **kw) return self diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py index 396534e..1d67b90 100644 --- a/masque/test/test_pather_core.py +++ b/masque/test/test_pather_core.py @@ -1,13 +1,11 @@ -from typing import Any - import pytest import numpy from numpy import pi from numpy.testing import assert_allclose, assert_equal from masque import Pather, Library, Pattern, Port -from masque.builder.tools import PathTool, Tool -from masque.error import BuildError, PortError, PatternError +from masque.builder.tools import PathTool +from masque.error import BuildError, PortError @pytest.fixture @@ -117,6 +115,82 @@ def test_pather_bundle_trace() -> None: assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) +def test_portpather_default_spacing_matches_explicit_spacing() -> None: + lib_default = Library() + tool_default = PathTool(layer='M1', width=1000) + p_default = Pather(lib_default, tools=tool_default, auto_render=False) + p_default.pattern.ports['A'] = Port((0, 0), rotation=0) + p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) + + lib_explicit = Library() + tool_explicit = PathTool(layer='M1', width=1000) + p_explicit = Pather(lib_explicit, tools=tool_explicit, auto_render=False) + p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_default.at(['A', 'B'], spacing=2000).ccw(xmin=-20000) + p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) + + assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) + assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) + +def test_portpather_default_spacing_reused_and_overridden() -> None: + p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_default.pattern.ports['A'] = Port((0, 0), rotation=0) + p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + pp_default = p_default.at(['A', 'B'], spacing=2000) + pp_default.ccw(xmin=-20000) + pp_default.cw(emin=1000) + p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000).cw(emin=1000, spacing=2000) + + assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) + assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) + + p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_override.pattern.ports['A'] = Port((0, 0), rotation=0) + p_override.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000), auto_render=False) + p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_override.at(['A', 'B'], spacing=2000).ccw(xmin=-20000, spacing=3000) + p_override_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=3000) + + assert_allclose(p_override.pattern.ports['A'].offset, p_override_explicit.pattern.ports['A'].offset) + assert_allclose(p_override.pattern.ports['B'].offset, p_override_explicit.pattern.ports['B'].offset) + +def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p.at(['A', 'B'], spacing=2000).straight(xmin=-10000) + + assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) + +def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, auto_render=False) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + p.pattern.ports['C'] = Port((0, 4000), rotation=0) + + pp = p.at(['A', 'B', 'C'], spacing=[2000, 3000]) + pp.deselect('C') + + with pytest.raises(BuildError, match='spacing must be scalar or have length 1'): + pp.ccw(xmin=-20000) + def test_pather_each_bound() -> None: lib = Library() tool = PathTool(layer='M1', width=1000)