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)
This commit is contained in:
NiNjA-CodE
2026-05-19 19:17:25 -06:00
committed by GitHub
parent f03a74a93f
commit 76e644e4ef
6 changed files with 1041 additions and 0 deletions

View File

@@ -42,6 +42,42 @@ All notable changes to the KiCAD MCP Server project are documented here.
### New MCP Tools ### New MCP Tools
- `add_gnd_stitching_vias` — 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.
Combines three placement strategies, freely composable:
- `grid` — regular grid across the board interior.
- `around_refs` — densify around named footprints (good for tucking
extra ground under MCUs, switching regulators, or RF parts).
- `in_zones` — restrict candidates to points inside the filled
polygons of GND copper zones, so each new via actually stitches
real ground polygons together rather than floating on silkscreen.
Also supports per-via geometry control (`viaSize`, `viaDrill`,
`clearance`, `edgeMargin`), an `maxVias` cap for incremental work,
auto-detection of the GND net (tries `GND` / `GROUND` / `VSS` /
`/GND`), and a `dryRun` mode that returns the placements that
*would* be made without modifying the board — useful for previewing
before committing.
Returns `{ placed: [{x, y, unit}, ...], summary: {placed_count,
candidates_evaluated, skipped_by_zone_membership,
skipped_by_collision, ...} }`.
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 new vias by string concatenation; this
port reads obstacles via the pcbnew API so it handles rotated
footprints correctly, integrates with the in-memory board (two
sequential calls see each other's placements), picks up net codes
from the live board, and adds the `in_zones` strategy + the
`maxVias` cap + dry-run.
- `check_courtyard_overlaps` — Detect courtyard overlaps between footprints - `check_courtyard_overlaps` — Detect courtyard overlaps between footprints
and (optionally) flag courtyards that extend past the board outline. and (optionally) flag courtyards that extend past the board outline.
Returns overlap pairs with intersection extents (mm), per-component Returns overlap pairs with intersection extents (mm), per-component

View File

@@ -1649,3 +1649,391 @@ class RoutingCommands:
dx = p1.x - p2.x dx = p1.x - p2.x
dy = p1.y - p2.y dy = p1.y - p2.y
return (dx * dx + dy * dy) ** 0.5 return (dx * dx + dy * dy) ** 0.5
# -----------------------------------------------------------------------
# add_gnd_stitching_vias
#
# Originally prototyped in morningfire-pcb-automation:
# https://github.com/NiNjA-CodE/morningfire-pcb-automation
# (scripts/ground/add_gnd_vias.py — regex-on-PCB-text version)
#
# The version here uses the pcbnew API so it handles arbitrary
# rotations, gets net IDs / clearances from the loaded board, and
# works against the live in-memory board state (so two calls in
# sequence — e.g. "around U1" then "across the board" — both see
# the first call's placements). All copper layers are checked
# because a through-hole via penetrates the full stackup; missing a
# B.Cu collision check is the classic way GND-stitching tools
# create silent shorts.
# -----------------------------------------------------------------------
def add_gnd_stitching_vias(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Drop GND stitching vias across the board, collision-checked on every copper layer.
Strategies (combine freely):
- ``grid`` Place candidates on a regular grid across the board
interior. Each candidate is accepted only if its
full keep-out radius is clear of every non-GND
segment / via / pad on every copper layer.
- ``around_refs`` For each named footprint, try a small radius of
grid points around its anchor. Good for densifying
ground around noisy ICs (MCUs, switching
regulators, RF parts).
- ``in_zones`` Restrict candidates to points actually inside the
filled polygons of GND copper zones, so each new
via lands on copper that's already a GND
equipotential. Highly recommended on boards where
the GND zone is fragmented — these vias
actually stitch the zones, not just float on
silkscreen.
Args:
gndNet: name of the ground net. Default: auto-detect from
``GND`` / ``GROUND`` / ``VSS`` in that order, else error.
strategies: list of strategy names. Default ``["grid"]``.
Pass ``["grid", "around_refs", "in_zones"]`` for the kitchen
sink — collision check + intra-call dedupe means the
strategies compose safely.
viaSize: pad diameter mm. Default 0.6.
viaDrill: drill diameter mm. Default 0.3.
clearance: extra clearance beyond required mm. Default 0.2.
spacing: grid spacing mm for ``grid`` and ``around_refs``.
Default 5.0.
densifyRefs: list of refs for ``around_refs``. Default [].
densifyRadius: how many grid cells around each ref to try.
Default 2 (5x5 candidate field per ref).
edgeMargin: distance from board edge mm. Default 0.5.
maxVias: maximum total placements (across all strategies).
Default unlimited.
dryRun: don't write, just return placements.
Returns:
``{"success": True, "placed": [{"x", "y", "unit"}, ...],
"summary": {...}}``
"""
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
try:
return self._do_add_gnd_stitching(params)
except Exception as e:
import traceback
logger.error(
f"add_gnd_stitching_vias failed: {e}\n{traceback.format_exc()}"
)
return {
"success": False,
"message": "add_gnd_stitching_vias failed",
"errorDetails": str(e),
}
def _do_add_gnd_stitching(self, params: Dict[str, Any]) -> Dict[str, Any]:
# --- Parse params ---
gnd_net_name = params.get("gndNet")
strategies = list(params.get("strategies") or ["grid"])
for s in strategies:
if s not in ("grid", "around_refs", "in_zones"):
return {
"success": False,
"message": f"Unknown strategy '{s}'",
"errorDetails": "Valid strategies: grid, around_refs, in_zones",
}
via_size_mm = float(params.get("viaSize", 0.6))
via_drill_mm = float(params.get("viaDrill", 0.3))
if via_drill_mm >= via_size_mm:
return {
"success": False,
"message": "Invalid via geometry",
"errorDetails": "viaDrill must be smaller than viaSize",
}
clearance_mm = float(params.get("clearance", 0.2))
spacing_mm = float(params.get("spacing", 5.0))
densify_refs = list(params.get("densifyRefs") or [])
densify_radius = int(params.get("densifyRadius", 2))
edge_margin_mm = float(params.get("edgeMargin", 0.5))
max_vias_raw = params.get("maxVias")
max_vias = int(max_vias_raw) if max_vias_raw is not None else None
dry_run = bool(params.get("dryRun", False))
scale = 1_000_000 # mm -> nm
via_size_nm = int(via_size_mm * scale)
via_drill_nm = int(via_drill_mm * scale)
via_radius_nm = via_size_nm // 2
clearance_nm = int(clearance_mm * scale)
spacing_nm = int(spacing_mm * scale)
edge_margin_nm = int(edge_margin_mm * scale)
# --- Resolve GND net ---
netinfo = self.board.GetNetInfo()
nets_by_name = netinfo.NetsByName()
gnd_net = None
if gnd_net_name:
if nets_by_name.has_key(gnd_net_name):
gnd_net = nets_by_name[gnd_net_name]
else:
return {
"success": False,
"message": f"Net '{gnd_net_name}' not found",
"errorDetails": "Pass a net that exists on this board",
}
else:
for candidate in ("GND", "GROUND", "VSS", "/GND"):
if nets_by_name.has_key(candidate):
gnd_net = nets_by_name[candidate]
gnd_net_name = candidate
break
if gnd_net is None:
return {
"success": False,
"message": "No GND net detected",
"errorDetails": (
"Pass gndNet explicitly. Auto-detect tries "
"GND / GROUND / VSS / /GND."
),
}
gnd_net_code = gnd_net.GetNetCode()
# --- Board outline bbox (for the grid + edge guard) ---
edge_bb = self.board.GetBoardEdgesBoundingBox()
if edge_bb.GetWidth() <= 0 or edge_bb.GetHeight() <= 0:
return {
"success": False,
"message": "Board outline is missing or empty",
"errorDetails": "Define Edge.Cuts before stitching vias",
}
x_min = edge_bb.GetLeft() + edge_margin_nm
y_min = edge_bb.GetTop() + edge_margin_nm
x_max = edge_bb.GetRight() - edge_margin_nm
y_max = edge_bb.GetBottom() - edge_margin_nm
if x_max <= x_min or y_max <= y_min:
return {
"success": False,
"message": "Edge margin too large for this board",
"errorDetails": "Reduce edgeMargin or increase the outline",
}
# --- Gather obstacles (everything on a non-GND net we must dodge) ---
# Tracks: list of (x1, y1, x2, y2, half_width)
# Vias: list of (cx, cy, radius)
# Pads: list of (cx, cy, half_extent) — bbox-circle approximation
obstacle_tracks: List[tuple] = []
obstacle_vias: List[tuple] = []
obstacle_pads: List[tuple] = []
for track in self.board.GetTracks():
if track.GetNetCode() == gnd_net_code:
continue
# The rest of this module uses the string-class check rather
# than `isinstance(track, pcbnew.PCB_VIA)` — match that for
# consistency and because isinstance against the SWIG type
# works unreliably under test stubs.
is_via = False
try:
is_via = track.GetClass() == "PCB_VIA"
except Exception:
is_via = False
if is_via:
pos = track.GetPosition()
width = track.GetWidth()
drill = 0
try:
drill = track.GetDrill()
except Exception:
pass
obstacle_vias.append((pos.x, pos.y, max(width, drill) // 2))
else:
s, e = track.GetStart(), track.GetEnd()
obstacle_tracks.append((s.x, s.y, e.x, e.y, track.GetWidth() // 2))
for fp in self.board.GetFootprints():
for pad in fp.Pads():
pad_net = pad.GetNetCode()
if pad_net == gnd_net_code:
continue
p = pad.GetPosition()
sz = pad.GetSize()
half_extent = max(sz.x, sz.y) // 2
# Inflate for pad-shape variation (round vs rect)
obstacle_pads.append((p.x, p.y, half_extent))
logger.info(
f"add_gnd_stitching_vias: {len(obstacle_tracks)} tracks, "
f"{len(obstacle_vias)} vias, {len(obstacle_pads)} pads to avoid"
)
# --- In-zone test (cached per call) ---
gnd_zones = [
z for z in self.board.Zones()
if z.GetNetCode() == gnd_net_code
]
def in_any_gnd_zone(x_nm: int, y_nm: int) -> bool:
pt = pcbnew.VECTOR2I(x_nm, y_nm)
for z in gnd_zones:
try:
if z.HitTestFilledArea(z.GetLayer(), pt, 0):
return True
except Exception:
# API variant: take any zone in whose bbox we sit
bb = z.GetBoundingBox()
if (bb.GetLeft() <= x_nm <= bb.GetRight()
and bb.GetTop() <= y_nm <= bb.GetBottom()):
return True
return False
# --- Collision check closure (all-layer) ---
placed_via_centres: List[tuple] = [] # nm coords of vias placed this call
def can_place(x_nm: int, y_nm: int) -> bool:
# Boundary
if not (x_min <= x_nm <= x_max and y_min <= y_nm <= y_max):
return False
# Distance against placed-this-call vias (avoid clumping)
min_self = via_size_nm + clearance_nm
for ox, oy in placed_via_centres:
dx = x_nm - ox
dy = y_nm - oy
if dx * dx + dy * dy < min_self * min_self:
return False
# Tracks
for x1, y1, x2, y2, hw in obstacle_tracks:
min_dist = via_radius_nm + hw + clearance_nm
if _point_to_segment_distance_nm(x_nm, y_nm, x1, y1, x2, y2) < min_dist:
return False
# Vias
for vx, vy, vr in obstacle_vias:
min_dist = via_radius_nm + vr + clearance_nm
dx = x_nm - vx
dy = y_nm - vy
if dx * dx + dy * dy < min_dist * min_dist:
return False
# Pads (bbox-circle approximation, intentionally conservative)
for px, py, ph in obstacle_pads:
min_dist = via_radius_nm + ph + clearance_nm
dx = x_nm - px
dy = y_nm - py
if dx * dx + dy * dy < min_dist * min_dist:
return False
return True
# --- Build candidate list per strategy ---
candidates: List[tuple] = []
if "around_refs" in strategies:
if not densify_refs:
logger.warning(
"around_refs strategy requested but densifyRefs is empty"
)
fps_by_ref = {fp.GetReference(): fp for fp in self.board.GetFootprints()}
for ref in densify_refs:
fp = fps_by_ref.get(ref)
if not fp:
logger.warning(f"densifyRefs: {ref!r} not found")
continue
cx = fp.GetPosition().x
cy = fp.GetPosition().y
for dx in range(-densify_radius, densify_radius + 1):
for dy in range(-densify_radius, densify_radius + 1):
candidates.append((cx + dx * spacing_nm, cy + dy * spacing_nm))
if "grid" in strategies or "in_zones" in strategies:
x = x_min
while x <= x_max:
y = y_min
while y <= y_max:
candidates.append((x, y))
y += spacing_nm
x += spacing_nm
# --- Filter + place ---
in_zones_only = "in_zones" in strategies
skipped_by_zone = 0
skipped_by_collision = 0
placed_meta: List[Dict[str, Any]] = []
for cx, cy in candidates:
if max_vias is not None and len(placed_meta) >= max_vias:
break
if in_zones_only and not in_any_gnd_zone(cx, cy):
skipped_by_zone += 1
continue
if not can_place(cx, cy):
skipped_by_collision += 1
continue
placed_via_centres.append((cx, cy))
placed_meta.append({
"x": round(cx / scale, 3),
"y": round(cy / scale, 3),
"unit": "mm",
})
# --- Write to board ---
if not dry_run:
f_cu = self.board.GetLayerID("F.Cu")
b_cu = self.board.GetLayerID("B.Cu")
for cx, cy in placed_via_centres:
via = pcbnew.PCB_VIA(self.board)
via.SetPosition(pcbnew.VECTOR2I(cx, cy))
via.SetWidth(via_size_nm)
via.SetDrill(via_drill_nm)
via.SetLayerPair(f_cu, b_cu)
via.SetNet(gnd_net)
self.board.Add(via)
return {
"success": True,
"placed": placed_meta,
"summary": {
"gnd_net": gnd_net_name,
"placed_count": len(placed_meta),
"candidates_evaluated": len(candidates),
"skipped_by_zone_membership": skipped_by_zone,
"skipped_by_collision": skipped_by_collision,
"strategies": strategies,
"dry_run": dry_run,
"via_size_mm": via_size_mm,
"via_drill_mm": via_drill_mm,
"clearance_mm": clearance_mm,
"spacing_mm": spacing_mm,
},
}
# ---------------------------------------------------------------------------
# Module-level geometry helper (used by add_gnd_stitching_vias collision check)
# ---------------------------------------------------------------------------
def _point_to_segment_distance_nm(
px: int, py: int, x1: int, y1: int, x2: int, y2: int
) -> float:
"""Shortest distance (nm) from point (px,py) to segment (x1,y1)-(x2,y2).
Pure integer-friendly variant of the standard projection formula;
used in the hot loop of GND-stitching collision detection so we
avoid building VECTOR2I objects per call.
"""
dx = x2 - x1
dy = y2 - y1
if dx == 0 and dy == 0:
ex = px - x1
ey = py - y1
return (ex * ex + ey * ey) ** 0.5
denom = dx * dx + dy * dy
t = ((px - x1) * dx + (py - y1) * dy) / denom
if t < 0:
t = 0
elif t > 1:
t = 1
proj_x = x1 + t * dx
proj_y = y1 + t * dy
ex = px - proj_x
ey = py - proj_y
return (ex * ex + ey * ey) ** 0.5

View File

@@ -370,6 +370,7 @@ class KiCADInterface:
"delete_trace": self.routing_commands.delete_trace, "delete_trace": self.routing_commands.delete_trace,
"query_traces": self.routing_commands.query_traces, "query_traces": self.routing_commands.query_traces,
"query_zones": self.routing_commands.query_zones, "query_zones": self.routing_commands.query_zones,
"add_gnd_stitching_vias": self.routing_commands.add_gnd_stitching_vias,
"modify_trace": self.routing_commands.modify_trace, "modify_trace": self.routing_commands.modify_trace,
"copy_routing_pattern": self.routing_commands.copy_routing_pattern, "copy_routing_pattern": self.routing_commands.copy_routing_pattern,
"get_nets_list": self.routing_commands.get_nets_list, "get_nets_list": self.routing_commands.get_nets_list,

View File

@@ -1027,6 +1027,122 @@ ROUTING_TOOLS = [
}, },
}, },
}, },
{
"name": "add_gnd_stitching_vias",
"title": "Add GND Stitching Vias",
"description": (
"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 missing one layer is the classic silent-short failure "
"mode that other GND-stitching tools have). Combines three "
"strategies: a regular `grid` across the interior, "
"`around_refs` (densify around named ICs like an MCU or "
"switching regulator), and `in_zones` (only place vias "
"where they actually land on a GND copper zone so they "
"stitch real polygons together rather than floating on "
"silkscreen). Supports `dryRun` to preview placements "
"without writing to the board. "
"Approach ported from morningfire-pcb-automation "
"(https://github.com/NiNjA-CodE/morningfire-pcb-automation, "
"scripts/ground/add_gnd_vias.py); this version reads "
"obstacles via the pcbnew API (handles rotation, picks up "
"net classes, integrates with the live in-memory board) "
"and adds the in-zones strategy + maxVias cap + dry-run."
),
"inputSchema": {
"type": "object",
"properties": {
"gndNet": {
"type": "string",
"description": (
"Name of the ground net (default: auto-detect "
"GND / GROUND / VSS / /GND)."
),
},
"strategies": {
"type": "array",
"description": (
"Which placement strategies to combine (default: "
"['grid']). Pass ['grid', 'around_refs', "
"'in_zones'] for full coverage."
),
"items": {
"type": "string",
"enum": ["grid", "around_refs", "in_zones"],
},
},
"viaSize": {
"type": "number",
"description": "Via pad diameter in mm (default 0.6).",
"default": 0.6,
},
"viaDrill": {
"type": "number",
"description": (
"Via drill diameter in mm (default 0.3). "
"Must be smaller than viaSize."
),
"default": 0.3,
},
"clearance": {
"type": "number",
"description": (
"Extra clearance beyond required between each new "
"via and existing copper, in mm. Default 0.2."
),
"default": 0.2,
},
"spacing": {
"type": "number",
"description": (
"Grid spacing in mm for the `grid` and "
"`around_refs` strategies. Default 5.0."
),
"default": 5.0,
},
"densifyRefs": {
"type": "array",
"description": (
"Reference designators to densify ground around "
"(used by `around_refs` strategy). Good targets: "
"MCUs, switching regulators, RF parts."
),
"items": {"type": "string"},
},
"densifyRadius": {
"type": "integer",
"description": (
"How many grid cells around each ref to try "
"(default 2 = 5x5 candidate field per ref)."
),
"default": 2,
},
"edgeMargin": {
"type": "number",
"description": (
"Keep-out from the board edge in mm. Default 0.5."
),
"default": 0.5,
},
"maxVias": {
"type": "integer",
"description": (
"Cap on total placements across all strategies "
"(default unlimited). Useful when iterating."
),
},
"dryRun": {
"type": "boolean",
"description": (
"If true, return the placements that would be "
"made but don't modify the board. Default false."
),
"default": False,
},
},
},
},
{ {
"name": "modify_trace", "name": "modify_trace",
"title": "Modify Trace", "title": "Modify Trace",

View File

@@ -259,6 +259,96 @@ export function registerRoutingTools(server: McpServer, callKicadScript: Functio
}, },
); );
// ------------------------------------------------------
// Add GND Stitching Vias Tool
//
// Drops GND stitching vias across the board with full-stackup
// collision detection: every non-GND segment, via, and pad on every
// copper layer is checked, because a PTH via penetrates the whole
// board. Three combinable strategies: regular grid, around named
// refs (densify under MCUs / regulators / RF parts), and in-zones
// only (vias land on actual GND copper, not silkscreen). Supports
// dryRun to preview placements without writing.
//
// Approach ported from morningfire-pcb-automation:
// https://github.com/NiNjA-CodE/morningfire-pcb-automation
// (scripts/ground/add_gnd_vias.py)
// ------------------------------------------------------
server.tool(
"add_gnd_stitching_vias",
"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 missing any one layer is the classic silent-short failure mode). Three combinable strategies: `grid` (regular grid across the interior), `around_refs` (densify around named ICs), and `in_zones` (only place vias inside an actual GND copper zone). Supports `dryRun` to preview placements without writing.",
{
gndNet: z
.string()
.optional()
.describe(
"Name of the ground net (default: auto-detect GND / GROUND / VSS / /GND).",
),
strategies: z
.array(z.enum(["grid", "around_refs", "in_zones"]))
.optional()
.describe(
"Which placement strategies to combine (default: ['grid']). Pass ['grid', 'around_refs', 'in_zones'] for full coverage.",
),
viaSize: z.number().optional().describe("Via pad diameter in mm (default 0.6)."),
viaDrill: z
.number()
.optional()
.describe("Via drill diameter in mm (default 0.3). Must be smaller than viaSize."),
clearance: z
.number()
.optional()
.describe(
"Extra clearance beyond required between each new via and existing copper, in mm (default 0.2).",
),
spacing: z
.number()
.optional()
.describe(
"Grid spacing in mm for `grid` and `around_refs` strategies (default 5.0).",
),
densifyRefs: z
.array(z.string())
.optional()
.describe(
"Reference designators to densify ground around (used by `around_refs`). Targets: MCUs, switching regulators, RF parts.",
),
densifyRadius: z
.number()
.int()
.optional()
.describe(
"How many grid cells around each ref to try (default 2 = 5x5 candidate field per ref).",
),
edgeMargin: z
.number()
.optional()
.describe("Keep-out from the board edge in mm (default 0.5)."),
maxVias: z
.number()
.int()
.optional()
.describe("Cap on total placements across all strategies (default unlimited)."),
dryRun: z
.boolean()
.optional()
.describe(
"If true, return the placements that would be made but don't modify the board (default false).",
),
},
async (args: any) => {
const result = await callKicadScript("add_gnd_stitching_vias", args);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
},
);
// Get nets list tool // Get nets list tool
server.tool( server.tool(
"get_nets_list", "get_nets_list",

View File

@@ -0,0 +1,410 @@
"""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)