[PortPather] add default_spacing (.at(..., spacing=...))

This commit is contained in:
Jan Petykiewicz 2026-06-20 16:22:24 -07:00
commit d36a1a53e6
3 changed files with 105 additions and 10 deletions

View file

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

View file

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

View file

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