[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']) (rpather.at(['GND', 'VCC'])
.trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South
.retool(M1_tool) # Retools both GND and VCC .retool(M1_tool) # Retools both GND and VCC
.trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension .set_spacing(1_200) # Default bundle spacing for later bends
.trace(False, emin=1_000, spacing=1_200) # U-turn back South .trace(True, emax=50_000) # Turn East, moves 50um extension
.trace(False, emin=2_000, spacing=4_500) # U-turn back West .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 # 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) self.pattern.flatten(self.library)
return self return self
def at(self, portspec: str | Iterable[str]) -> 'PortPather': def at(
return PortPather(portspec, self) self,
portspec: str | Iterable[str],
*,
spacing: float | ArrayLike | None = None,
) -> 'PortPather':
return PortPather(portspec, self, default_spacing=spacing)
class PortPather: class PortPather:
""" Port state manager for fluent pathing. """ """ 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.ports = [ports] if isinstance(ports, str) else list(ports)
self.pather = pather self.pather = pather
self.default_spacing = default_spacing
def retool(self, tool: Tool) -> Self: def retool(self, tool: Tool) -> Self:
self.pather.retool(tool, self.ports) self.pather.retool(tool, self.ports)
return self return self
def set_spacing(self, spacing: float | ArrayLike | None) -> Self:
self.default_spacing = spacing
return self
@contextmanager @contextmanager
def toolctx(self, tool: Tool) -> Iterator[Self]: def toolctx(self, tool: Tool) -> Iterator[Self]:
with self.pather.toolctx(tool, keys=self.ports): with self.pather.toolctx(tool, keys=self.ports):
yield self yield self
def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> 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) self.pather.trace(self.ports, ccw, length, **kw)
return self return self
def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> 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) self.pather.trace_to(self.ports, ccw, **kw)
return self return self

View file

@ -1,13 +1,11 @@
from typing import Any
import pytest import pytest
import numpy import numpy
from numpy import pi from numpy import pi
from numpy.testing import assert_allclose, assert_equal from numpy.testing import assert_allclose, assert_equal
from masque import Pather, Library, Pattern, Port from masque import Pather, Library, Pattern, Port
from masque.builder.tools import PathTool, Tool from masque.builder.tools import PathTool
from masque.error import BuildError, PortError, PatternError from masque.error import BuildError, PortError
@pytest.fixture @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['A'].offset[0], -20000)
assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) 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: def test_pather_each_bound() -> None:
lib = Library() lib = Library()
tool = PathTool(layer='M1', width=1000) tool = PathTool(layer='M1', width=1000)