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:
36
CHANGELOG.md
36
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
410
tests/test_add_gnd_stitching_vias.py
Normal file
410
tests/test_add_gnd_stitching_vias.py
Normal 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)
|
||||||
Reference in New Issue
Block a user