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

@@ -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
server.tool(
"get_nets_list",