Files
kicad-mcp-server/tests/test_add_gnd_stitching_vias.py
NiNjA-CodE 76e644e4ef feat(routing): add add_gnd_stitching_vias MCP tool with all-layer collision (#191)
Drop GND stitching vias across the board with collision checking
against every non-GND segment, via, and pad on every copper layer.
PTH vias penetrate the full stackup, so an F.Cu-only check (the most
common shortcut) silently creates shorts on inner / B.Cu copper —
this implementation explicitly walks all layers.

  grid          Regular grid across the board interior. Default
                spacing 5mm.

  around_refs   Densify around specified footprints (e.g. MCUs,
                switching regulators, RF parts). Configurable
                density via densifyRadius.

  in_zones      Restrict placements to candidates inside the filled
                polygons of GND copper zones, so each new via lands
                on copper that's already a GND equipotential.
                Recommended on boards where the GND zone is fragmented:
                these vias actually stitch real polygons rather than
                floating on silkscreen.

All three strategies use the same collision check + intra-call
clump-prevention, so passing `["grid", "around_refs", "in_zones"]`
is a safe kitchen-sink configuration.

  - Auto-detect GND net (tries GND / GROUND / VSS / /GND in order)
    OR explicit `gndNet` parameter.
  - Per-via geometry control: viaSize, viaDrill, clearance.
  - edgeMargin: keep-out distance from board edge.
  - maxVias: cap on total placements (useful for incremental work).
  - dryRun: return placements without modifying the board — for
    previewing before committing.
  - Validates viaDrill < viaSize, rejects unknown strategy names,
    surfaces clear errors when GND net can't be resolved or the
    board outline is missing.

Approach ported from morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/ground/add_gnd_vias.py). The original parses the PCB text
with regex and writes vias by string concatenation; this port reads
obstacles via the pcbnew API (handles rotated footprints, integrates
with the live in-memory board so two sequential calls see each
other's placements, picks up net codes from the loaded board) and
adds the in_zones strategy, the maxVias cap, and dry-run mode.

Credit is in the docstring, the TypeScript wrapper comment, the MCP
tool description (visible to clients), and the CHANGELOG entry.

tests/test_add_gnd_stitching_vias.py — 18 cases, all passing.
Uses mocked pcbnew objects so the suite runs under both the conftest
stub and a real pcbnew install.

  - grid strategy fills empty board with correct count
  - collision blocks via near a signal track (with extent assertion)
  - GND-net obstacles are correctly ignored
  - around_refs densifies near footprints with bounded extent
  - in_zones rejects candidates outside HitTestFilledArea
  - dryRun does NOT call board.Add
  - actual run calls board.Add per placement
  - maxVias caps total placements
  - intra-call clump prevention (asserts pairwise distance)
  - viaDrill >= viaSize is rejected
  - unknown strategy name is rejected
  - missing GND net returns clear error payload
  - no board loaded returns clear error
  - named GND net (e.g. VSS) is honoured even when GND also exists
  - direct unit tests for _point_to_segment_distance_nm helper

Real-board smoke test on TuneForge_TF001 (4-layer, 44 footprints):
  - GND net auto-detected
  - grid spacing 4mm: 141 placements, 129 blocked by collision
  - grid + in_zones: 140 placed, 15 rejected by zone membership,
    115 blocked by collision

  python/commands/routing.py         (+impl, ~370 LOC)
  python/kicad_interface.py          (+handler registration)
  python/schemas/tool_schemas.py     (+MCP schema)
  src/tools/routing.ts               (+TypeScript surface, builds clean)
  tests/test_add_gnd_stitching_vias.py (+18 tests)
  CHANGELOG.md                       (+Unreleased -> New MCP Tools)
2026-05-19 21:17:25 -04:00

411 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the add_gnd_stitching_vias MCP tool.
Uses mocked pcbnew objects so the suite runs under both the conftest
stub and a real pcbnew install. The math/orchestration is what we want
to lock in — the actual KiCad SWIG calls are wafer-thin wrappers.
Approach ported from morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/ground/add_gnd_vias.py). These tests pin the placement
contract: all-layer collision check, in-zones membership filtering,
clump prevention, geometry validation.
"""
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
# Need pcbnew imported (real or stubbed) before RoutingCommands.
import pcbnew # noqa: F401, E402
from commands.routing import RoutingCommands, _point_to_segment_distance_nm # noqa: E402
# ---------------------------------------------------------------------------
# Fixture helpers — build a fake board with controllable obstacles.
# ---------------------------------------------------------------------------
def _mm(v):
return int(round(v * 1_000_000))
def _bbox(left_mm, top_mm, right_mm, bottom_mm):
bb = MagicMock()
bb.GetLeft.return_value = _mm(left_mm)
bb.GetTop.return_value = _mm(top_mm)
bb.GetRight.return_value = _mm(right_mm)
bb.GetBottom.return_value = _mm(bottom_mm)
bb.GetWidth.return_value = _mm(right_mm - left_mm)
bb.GetHeight.return_value = _mm(bottom_mm - top_mm)
return bb
def _vector(x_mm, y_mm):
v = MagicMock()
v.x = _mm(x_mm)
v.y = _mm(y_mm)
return v
def _track(net_code, x1, y1, x2, y2, width_mm=0.2):
"""A segment-style track on the given net."""
t = MagicMock()
t.GetNetCode.return_value = net_code
t.GetStart.return_value = _vector(x1, y1)
t.GetEnd.return_value = _vector(x2, y2)
t.GetWidth.return_value = _mm(width_mm)
t.GetClass.return_value = "PCB_TRACK" # routing code checks via GetClass()
return t
def _via(net_code, x, y, size_mm=0.8, drill_mm=0.4):
"""A through-hole via on the given net."""
v = MagicMock()
v.GetNetCode.return_value = net_code
v.GetPosition.return_value = _vector(x, y)
v.GetWidth.return_value = _mm(size_mm)
v.GetDrill.return_value = _mm(drill_mm)
v.GetClass.return_value = "PCB_VIA" # routing code checks via GetClass()
return v
def _pad(net_code, x, y, size_x_mm=1.0, size_y_mm=1.0):
p = MagicMock()
p.GetNetCode.return_value = net_code
p.GetPosition.return_value = _vector(x, y)
sz = MagicMock()
sz.x = _mm(size_x_mm)
sz.y = _mm(size_y_mm)
p.GetSize.return_value = sz
return p
def _footprint(ref, x_mm, y_mm, pads=()):
fp = MagicMock()
fp.GetReference.return_value = ref
fp.GetPosition.return_value = _vector(x_mm, y_mm)
fp.Pads.return_value = list(pads)
return fp
def _net(code, name):
n = MagicMock()
n.GetNetCode.return_value = code
n.GetNetname.return_value = name
return n
def _board(
*, width_mm=60.0, height_mm=40.0, gnd_code=1, gnd_name="GND",
tracks=(), pads=(), footprints=(), other_vias=(), zones=(),
extra_nets=None,
):
"""Build a fake pcbnew BOARD with the supplied obstacles."""
board = MagicMock()
nets_by_name = MagicMock()
name_lookup = {gnd_name: _net(gnd_code, gnd_name)}
if extra_nets:
for code, name in extra_nets.items():
name_lookup[name] = _net(code, name)
nets_by_name.has_key.side_effect = lambda k: k in name_lookup # noqa: W601
nets_by_name.__getitem__.side_effect = lambda k: name_lookup[k]
netinfo = MagicMock()
netinfo.NetsByName.return_value = nets_by_name
board.GetNetInfo.return_value = netinfo
board.GetBoardEdgesBoundingBox.return_value = _bbox(0, 0, width_mm, height_mm)
board.GetTracks.return_value = list(tracks) + list(other_vias)
board.GetFootprints.return_value = list(footprints)
board.Zones.return_value = list(zones)
# Layer IDs (just need stable numbers)
layer_map = {"F.Cu": 0, "B.Cu": 31}
board.GetLayerID.side_effect = lambda n: layer_map.get(n, -1)
return board
def _cmd(board):
cc = RoutingCommands.__new__(RoutingCommands)
cc.board = board
return cc
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_grid_strategy_fills_empty_board():
board = _board(width_mm=20, height_mm=20)
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["grid"],
"spacing": 5.0,
"edgeMargin": 0.5,
"dryRun": True,
})
assert out["success"], out
placed = out["placed"]
# Grid from 0.5 to 19.5 stepping 5 -> {0.5, 5.5, 10.5, 15.5} -> 4*4 = 16
assert len(placed) == 16
assert out["summary"]["placed_count"] == 16
# All placements inside the edge bounds
for p in placed:
assert 0.5 <= p["x"] <= 19.5 and 0.5 <= p["y"] <= 19.5
@pytest.mark.unit
def test_collision_blocks_via_near_signal_track():
# Signal track on B.Cu (net code 2) crossing the middle of the board.
track = _track(net_code=2, x1=0, y1=10, x2=20, y2=10, width_mm=0.5)
board = _board(width_mm=20, height_mm=20, tracks=[track])
no_collision = _cmd(_board(width_mm=20, height_mm=20)).add_gnd_stitching_vias({
"strategies": ["grid"], "spacing": 5.0, "edgeMargin": 0.5,
"dryRun": True,
})
with_collision = _cmd(board).add_gnd_stitching_vias({
"strategies": ["grid"], "spacing": 5.0, "edgeMargin": 0.5,
"dryRun": True,
})
assert len(with_collision["placed"]) < len(no_collision["placed"]), (
"track at y=10 must block at least one grid point"
)
# Specifically the row at y=10.5 should be blocked (the only one within
# the track's collision distance for a 5mm grid).
for p in with_collision["placed"]:
# via radius 0.3 + track half-width 0.25 + clearance 0.2 = 0.75mm
# the track is at y=10, so any via with |y - 10| < 0.75 should be blocked
assert abs(p["y"] - 10) >= 0.749, (
f"via at y={p['y']} should have been blocked by track at y=10"
)
@pytest.mark.unit
def test_gnd_net_obstacles_are_ignored():
"""Vias and tracks already on GND should NOT block new stitching vias."""
gnd_track = _track(net_code=1, x1=0, y1=10, x2=20, y2=10, width_mm=2.0)
board_gnd_only = _board(width_mm=20, height_mm=20, tracks=[gnd_track])
out = _cmd(board_gnd_only).add_gnd_stitching_vias({
"strategies": ["grid"], "spacing": 5.0, "edgeMargin": 0.5,
"dryRun": True,
})
# No non-GND obstacles → identical to empty-board layout (16 vias)
assert len(out["placed"]) == 16
@pytest.mark.unit
def test_around_refs_densifies_near_footprint():
fp = _footprint("U1", 10.0, 10.0)
board = _board(width_mm=30, height_mm=30, footprints=[fp])
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["around_refs"],
"densifyRefs": ["U1"],
"densifyRadius": 2,
"spacing": 2.0,
"edgeMargin": 0.5,
"dryRun": True,
})
assert out["success"]
# 5x5 candidate field around U1 = 25 vias if all clear
assert out["summary"]["placed_count"] == 25
# All placements should be within 2*2.0mm of U1's centre
for p in out["placed"]:
assert abs(p["x"] - 10.0) <= 4.0 + 0.001
assert abs(p["y"] - 10.0) <= 4.0 + 0.001
@pytest.mark.unit
def test_in_zones_filter_rejects_candidates_outside_zone(monkeypatch):
"""When in_zones strategy is selected, only candidates inside a GND
zone's HitTestFilledArea are placed."""
# Patch pcbnew.VECTOR2I to return a real SimpleNamespace so the
# zone's HitTestFilledArea side_effect can read pt.x as an int.
monkeypatch.setattr(
pcbnew, "VECTOR2I",
lambda x, y: SimpleNamespace(x=x, y=y),
)
# Build a zone whose HitTestFilledArea reports True only for the LEFT
# half of the board (x < 10mm).
zone = MagicMock()
zone.GetNetCode.return_value = 1
zone.GetLayer.return_value = 0
def _hit(layer, pt, tol):
return pt.x < _mm(10)
zone.HitTestFilledArea.side_effect = _hit
# Defensive fallback: also give the zone a bbox in case the API
# variant gets used instead.
zone.GetBoundingBox.return_value = _bbox(0, 0, 10, 20)
board = _board(width_mm=20, height_mm=20, zones=[zone])
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["in_zones"],
"spacing": 5.0,
"edgeMargin": 0.5,
"dryRun": True,
})
assert out["success"], out
# Only candidates with x < 10mm should be placed → {0.5, 5.5} -> 2 columns × 4 rows = 8
assert all(p["x"] < 10 for p in out["placed"])
assert out["summary"]["placed_count"] == 8
assert out["summary"]["skipped_by_zone_membership"] > 0
@pytest.mark.unit
def test_dry_run_does_not_modify_board():
board = _board(width_mm=20, height_mm=20)
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["grid"], "spacing": 5.0, "dryRun": True,
})
assert out["success"]
assert out["summary"]["dry_run"] is True
# No vias added: board.Add not called
board.Add.assert_not_called()
@pytest.mark.unit
def test_actual_run_writes_vias_to_board():
board = _board(width_mm=20, height_mm=20)
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["grid"], "spacing": 5.0, "dryRun": False,
})
assert out["success"]
# Should have called board.Add once per placed via
assert board.Add.call_count == out["summary"]["placed_count"]
@pytest.mark.unit
def test_max_vias_caps_total_placements():
board = _board(width_mm=40, height_mm=40)
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["grid"], "spacing": 5.0, "edgeMargin": 0.5,
"maxVias": 5, "dryRun": True,
})
assert out["summary"]["placed_count"] == 5
@pytest.mark.unit
def test_intra_call_clump_prevention():
"""Two passes' worth of candidates near each other should NOT clump."""
fp1 = _footprint("U1", 10.0, 10.0)
fp2 = _footprint("U2", 10.5, 10.0) # very close to U1
board = _board(width_mm=30, height_mm=30, footprints=[fp1, fp2])
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["around_refs"],
"densifyRefs": ["U1", "U2"],
"densifyRadius": 1,
"spacing": 0.5, # ridiculously tight: forces self-collision
"viaSize": 0.6,
"clearance": 0.2,
"edgeMargin": 0.5,
"dryRun": True,
})
placed = out["placed"]
# Each pair must respect viaSize + clearance separation
min_centre = 0.6 + 0.2 # 0.8mm
for i in range(len(placed)):
for j in range(i + 1, len(placed)):
dx = placed[i]["x"] - placed[j]["x"]
dy = placed[i]["y"] - placed[j]["y"]
d = (dx * dx + dy * dy) ** 0.5
assert d >= min_centre - 1e-3, (
f"vias clumped: ({placed[i]['x']},{placed[i]['y']}) and "
f"({placed[j]['x']},{placed[j]['y']}) distance={d:.3f}mm"
)
@pytest.mark.unit
def test_invalid_via_geometry_rejected():
board = _board()
out = _cmd(board).add_gnd_stitching_vias({
"viaSize": 0.4,
"viaDrill": 0.4, # equal to size — invalid
})
assert out["success"] is False
assert "Invalid via geometry" in out["message"]
@pytest.mark.unit
def test_unknown_strategy_rejected():
board = _board()
out = _cmd(board).add_gnd_stitching_vias({
"strategies": ["random_walk"],
})
assert out["success"] is False
assert "Unknown strategy" in out["message"]
@pytest.mark.unit
def test_missing_gnd_net_returns_clear_error():
board = _board(gnd_name="NOT_GND", gnd_code=1)
# The build above creates a single net called NOT_GND, no GND.
# auto-detect should fail.
out = _cmd(board).add_gnd_stitching_vias({})
assert out["success"] is False
assert "No GND net detected" in out["message"]
@pytest.mark.unit
def test_no_board_returns_clear_error():
cc = RoutingCommands.__new__(RoutingCommands)
cc.board = None
out = cc.add_gnd_stitching_vias({})
assert out["success"] is False
assert "No board" in out["message"]
@pytest.mark.unit
def test_named_gnd_net_used_when_specified():
board = _board(
gnd_name="VSS", gnd_code=7,
extra_nets={1: "GND"}, # also has a GND net
)
out = _cmd(board).add_gnd_stitching_vias({
"gndNet": "VSS", "strategies": ["grid"], "spacing": 10,
"edgeMargin": 5, "dryRun": True,
})
assert out["success"]
assert out["summary"]["gnd_net"] == "VSS"
# ---------------------------------------------------------------------------
# Direct tests for the geometry helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_point_to_segment_distance_endpoint():
# Point exactly at one endpoint -> distance 0
d = _point_to_segment_distance_nm(0, 0, 0, 0, 100, 0)
assert d == 0
@pytest.mark.unit
def test_point_to_segment_distance_perpendicular():
# Point above the midpoint of a horizontal segment
d = _point_to_segment_distance_nm(50, 100, 0, 0, 100, 0)
assert d == pytest.approx(100)
@pytest.mark.unit
def test_point_to_segment_distance_beyond_endpoint():
# Point well past one endpoint -> distance to that endpoint
d = _point_to_segment_distance_nm(200, 0, 0, 0, 100, 0)
assert d == pytest.approx(100)
@pytest.mark.unit
def test_point_to_segment_distance_zero_length_segment():
# Degenerate segment (start == end) -> distance to that point
d = _point_to_segment_distance_nm(3, 4, 0, 0, 0, 0)
assert d == pytest.approx(5)