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)
2040 lines
79 KiB
Python
2040 lines
79 KiB
Python
"""
|
||
Routing-related command implementations for KiCAD interface
|
||
"""
|
||
|
||
import logging
|
||
import math
|
||
import os
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import pcbnew
|
||
|
||
logger = logging.getLogger("kicad_interface")
|
||
|
||
|
||
class RoutingCommands:
|
||
"""Handles routing-related KiCAD operations"""
|
||
|
||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||
"""Initialize with optional board instance"""
|
||
self.board = board
|
||
|
||
def add_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Add a new net to the PCB"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
name = params.get("name")
|
||
net_class = params.get("class")
|
||
|
||
if not name:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing net name",
|
||
"errorDetails": "name parameter is required",
|
||
}
|
||
|
||
# Create new net
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
if nets_map.has_key(name):
|
||
net = nets_map[name]
|
||
else:
|
||
net = pcbnew.NETINFO_ITEM(self.board, name)
|
||
self.board.Add(net)
|
||
|
||
# Set net class if provided — defensive against KiCad 6/7 vs KiCad 9/10 API.
|
||
if net_class:
|
||
net_classes = self.board.GetNetClasses()
|
||
resolved = None
|
||
if hasattr(net_classes, "Find"):
|
||
resolved = net_classes.Find(net_class)
|
||
else:
|
||
try:
|
||
if net_class in net_classes:
|
||
resolved = net_classes[net_class]
|
||
except Exception:
|
||
resolved = None
|
||
if resolved is not None:
|
||
net.SetClass(resolved)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Added net: {name}",
|
||
"net": {
|
||
"name": name,
|
||
"class": net_class if net_class else "Default",
|
||
"netcode": net.GetNetCode(),
|
||
},
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error adding net: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to add net",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def route_pad_to_pad(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Route a trace directly from one component pad to another.
|
||
|
||
Looks up pad positions automatically, then creates a trace.
|
||
Convenience wrapper around route_trace that eliminates the need
|
||
for separate get_pad_position calls.
|
||
"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
from_ref = params.get("fromRef")
|
||
from_pad = str(params.get("fromPad", ""))
|
||
to_ref = params.get("toRef")
|
||
to_pad = str(params.get("toPad", ""))
|
||
layer = params.get("layer", "F.Cu")
|
||
width = params.get("width")
|
||
net = params.get("net") # optional override
|
||
|
||
if not from_ref or not from_pad or not to_ref or not to_pad:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing parameters",
|
||
"errorDetails": "fromRef, fromPad, toRef, toPad are all required",
|
||
}
|
||
|
||
scale = 1000000 # nm to mm
|
||
|
||
# Find pads
|
||
footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()}
|
||
|
||
for ref in [from_ref, to_ref]:
|
||
if ref not in footprints:
|
||
return {
|
||
"success": False,
|
||
"message": f"Component not found: {ref}",
|
||
"errorDetails": f"'{ref}' does not exist on the board",
|
||
}
|
||
|
||
def find_pad(ref: str, pad_num: str) -> Any:
|
||
fp = footprints[ref]
|
||
for pad in fp.Pads():
|
||
if pad.GetNumber() == pad_num:
|
||
return pad
|
||
return None
|
||
|
||
start_pad = find_pad(from_ref, from_pad)
|
||
end_pad = find_pad(to_ref, to_pad)
|
||
|
||
if not start_pad:
|
||
return {
|
||
"success": False,
|
||
"message": f"Pad not found: {from_ref} pad {from_pad}",
|
||
"errorDetails": f"Check pad number for {from_ref}",
|
||
}
|
||
if not end_pad:
|
||
return {
|
||
"success": False,
|
||
"message": f"Pad not found: {to_ref} pad {to_pad}",
|
||
"errorDetails": f"Check pad number for {to_ref}",
|
||
}
|
||
|
||
start_pos = start_pad.GetPosition()
|
||
end_pos = end_pad.GetPosition()
|
||
|
||
# Use net from start pad if not overridden
|
||
if not net:
|
||
net = start_pad.GetNetname() or end_pad.GetNetname() or ""
|
||
|
||
# Detect if pads are on different copper layers → need via.
|
||
# SMD pad.GetLayer() reports F.Cu even on flipped B.Cu footprints in
|
||
# KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects
|
||
# the actual placed layer after Flip().
|
||
fp_start = footprints[from_ref]
|
||
fp_end = footprints[to_ref]
|
||
start_layer = self.board.GetLayerName(fp_start.GetLayer())
|
||
end_layer = self.board.GetLayerName(fp_end.GetLayer())
|
||
copper_layers = {"F.Cu", "B.Cu"}
|
||
needs_via = (
|
||
start_layer in copper_layers
|
||
and end_layer in copper_layers
|
||
and start_layer != end_layer
|
||
)
|
||
|
||
if needs_via:
|
||
# Place via directly below the start pad (same X).
|
||
# Using the geometric midpoint X causes all vias to stack at
|
||
# the same X when pads are back-to-back mirrored (e.g. J1/J2
|
||
# on F.Cu/B.Cu): midpoint is always the board center.
|
||
via_x = start_pos.x / scale
|
||
via_y = (start_pos.y + end_pos.y) / 2 / scale
|
||
|
||
# Trace on start layer: start_pad → via
|
||
r1 = self.route_trace(
|
||
{
|
||
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
|
||
"end": {"x": via_x, "y": via_y, "unit": "mm"},
|
||
"layer": start_layer,
|
||
"width": width,
|
||
"net": net,
|
||
}
|
||
)
|
||
# Via connecting both layers
|
||
self.add_via(
|
||
{
|
||
"position": {"x": via_x, "y": via_y, "unit": "mm"},
|
||
"net": net,
|
||
"from_layer": start_layer,
|
||
"to_layer": end_layer,
|
||
}
|
||
)
|
||
# Trace on end layer: via → end_pad
|
||
r2 = self.route_trace(
|
||
{
|
||
"start": {"x": via_x, "y": via_y, "unit": "mm"},
|
||
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
|
||
"layer": end_layer,
|
||
"width": width,
|
||
"net": net,
|
||
}
|
||
)
|
||
success = r1.get("success") and r2.get("success")
|
||
result = {
|
||
"success": success,
|
||
"message": f"Routed {from_ref}.{from_pad} → via → {to_ref}.{to_pad} (net: {net}, via at {via_x:.2f},{via_y:.2f})",
|
||
"via_added": True,
|
||
"via_position": {"x": via_x, "y": via_y},
|
||
}
|
||
else:
|
||
# Same layer — direct trace
|
||
result = self.route_trace(
|
||
{
|
||
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
|
||
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
|
||
"layer": layer if layer else start_layer,
|
||
"width": width,
|
||
"net": net,
|
||
}
|
||
)
|
||
|
||
if result.get("success"):
|
||
result["fromPad"] = {
|
||
"ref": from_ref,
|
||
"pad": from_pad,
|
||
"x": start_pos.x / scale,
|
||
"y": start_pos.y / scale,
|
||
}
|
||
result["toPad"] = {
|
||
"ref": to_ref,
|
||
"pad": to_pad,
|
||
"x": end_pos.x / scale,
|
||
"y": end_pos.y / scale,
|
||
}
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in route_pad_to_pad: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to route pad to pad",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Route a trace between two points or pads"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
start = params.get("start")
|
||
end = params.get("end")
|
||
layer = params.get("layer", "F.Cu")
|
||
width = params.get("width")
|
||
net = params.get("net")
|
||
via = params.get("via", False)
|
||
|
||
if not start or not end:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing parameters",
|
||
"errorDetails": "start and end points are required",
|
||
}
|
||
|
||
# Get layer ID
|
||
layer_id = self.board.GetLayerID(layer)
|
||
if layer_id < 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid layer",
|
||
"errorDetails": f"Layer '{layer}' does not exist",
|
||
}
|
||
|
||
# Get start point
|
||
start_point = self._get_point(start)
|
||
end_point = self._get_point(end)
|
||
|
||
# Create track segment
|
||
track = pcbnew.PCB_TRACK(self.board)
|
||
track.SetStart(start_point)
|
||
track.SetEnd(end_point)
|
||
track.SetLayer(layer_id)
|
||
|
||
# Set width (default to board's current track width)
|
||
if width:
|
||
track.SetWidth(int(width * 1000000)) # Convert mm to nm
|
||
else:
|
||
track.SetWidth(self.board.GetDesignSettings().GetCurrentTrackWidth())
|
||
|
||
# Set net if provided
|
||
if net:
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
if nets_map.has_key(net):
|
||
net_obj = nets_map[net]
|
||
track.SetNet(net_obj)
|
||
|
||
# Add track to board
|
||
self.board.Add(track)
|
||
|
||
# Add via if requested and net is specified
|
||
if via and net:
|
||
via_point = end_point
|
||
self.add_via(
|
||
{
|
||
"position": {
|
||
"x": via_point.x / 1000000,
|
||
"y": via_point.y / 1000000,
|
||
"unit": "mm",
|
||
},
|
||
"net": net,
|
||
}
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Added trace",
|
||
"trace": {
|
||
"start": {
|
||
"x": start_point.x / 1000000,
|
||
"y": start_point.y / 1000000,
|
||
"unit": "mm",
|
||
},
|
||
"end": {
|
||
"x": end_point.x / 1000000,
|
||
"y": end_point.y / 1000000,
|
||
"unit": "mm",
|
||
},
|
||
"layer": layer,
|
||
"width": track.GetWidth() / 1000000,
|
||
"net": net,
|
||
},
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error routing trace: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to route trace",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def route_arc_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Route a copper arc trace from start/mid/end points."""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
start = params.get("start")
|
||
mid = params.get("mid")
|
||
end = params.get("end")
|
||
layer = params.get("layer", "F.Cu")
|
||
width = params.get("width")
|
||
net = params.get("net")
|
||
|
||
if not start or not mid or not end:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing parameters",
|
||
"errorDetails": "start, mid and end points are required",
|
||
}
|
||
|
||
layer_id = self.board.GetLayerID(layer)
|
||
if layer_id < 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid layer",
|
||
"errorDetails": f"Layer '{layer}' does not exist",
|
||
}
|
||
|
||
start_point = self._get_point(start)
|
||
mid_point = self._get_point(mid)
|
||
end_point = self._get_point(end)
|
||
|
||
arc = pcbnew.PCB_ARC(self.board)
|
||
arc.SetStart(start_point)
|
||
arc.SetMid(mid_point)
|
||
arc.SetEnd(end_point)
|
||
arc.SetLayer(layer_id)
|
||
|
||
if width:
|
||
arc.SetWidth(int(width * 1000000))
|
||
else:
|
||
arc.SetWidth(self.board.GetDesignSettings().GetCurrentTrackWidth())
|
||
|
||
if net:
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
if nets_map.has_key(net):
|
||
arc.SetNet(nets_map[net])
|
||
|
||
self.board.Add(arc)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Added arc trace",
|
||
"arc": {
|
||
"start": {"x": start_point.x / 1000000, "y": start_point.y / 1000000, "unit": "mm"},
|
||
"mid": {"x": mid_point.x / 1000000, "y": mid_point.y / 1000000, "unit": "mm"},
|
||
"end": {"x": end_point.x / 1000000, "y": end_point.y / 1000000, "unit": "mm"},
|
||
"layer": layer,
|
||
"width": arc.GetWidth() / 1000000,
|
||
"net": net,
|
||
},
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Error routing arc trace: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to route arc trace",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def add_via(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Add a via at the specified location"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
position = params.get("position")
|
||
size = params.get("size")
|
||
drill = params.get("drill")
|
||
net = params.get("net")
|
||
from_layer = params.get("from_layer", "F.Cu")
|
||
to_layer = params.get("to_layer", "B.Cu")
|
||
|
||
if not position:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing position",
|
||
"errorDetails": "position parameter is required",
|
||
}
|
||
|
||
# Create via
|
||
via = pcbnew.PCB_VIA(self.board)
|
||
|
||
# Set position
|
||
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
|
||
x_nm = int(position["x"] * scale)
|
||
y_nm = int(position["y"] * scale)
|
||
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||
|
||
# Set size and drill (default to board's current via settings)
|
||
design_settings = self.board.GetDesignSettings()
|
||
via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
|
||
via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
|
||
|
||
# Set layers
|
||
from_id = self.board.GetLayerID(from_layer)
|
||
to_id = self.board.GetLayerID(to_layer)
|
||
if from_id < 0 or to_id < 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid layer",
|
||
"errorDetails": "Specified layers do not exist",
|
||
}
|
||
via.SetLayerPair(from_id, to_id)
|
||
|
||
# Set net if provided
|
||
if net:
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
if nets_map.has_key(net):
|
||
net_obj = nets_map[net]
|
||
via.SetNet(net_obj)
|
||
|
||
# Add via to board
|
||
self.board.Add(via)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Added via",
|
||
"via": {
|
||
"position": {
|
||
"x": position["x"],
|
||
"y": position["y"],
|
||
"unit": position["unit"],
|
||
},
|
||
"size": via.GetWidth(pcbnew.F_Cu) / 1000000,
|
||
"drill": via.GetDrill() / 1000000,
|
||
"from_layer": from_layer,
|
||
"to_layer": to_layer,
|
||
"net": net,
|
||
},
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error adding via: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to add via",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Delete a trace from the PCB"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
trace_uuid = params.get("traceUuid")
|
||
position = params.get("position")
|
||
net_name = params.get("net")
|
||
layer = params.get("layer")
|
||
include_vias = params.get("includeVias", False)
|
||
|
||
if not trace_uuid and not position and not net_name:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing parameters",
|
||
"errorDetails": "One of traceUuid, position, or net must be provided",
|
||
}
|
||
|
||
# Delete by net name (bulk delete), use "*" to delete all tracks
|
||
if net_name:
|
||
tracks_to_remove = []
|
||
for track in list(self.board.Tracks()):
|
||
if net_name != "*" and track.GetNetname() != net_name:
|
||
continue
|
||
|
||
# Skip vias if not requested
|
||
is_via = track.Type() == pcbnew.PCB_VIA_T
|
||
if is_via and not include_vias:
|
||
continue
|
||
|
||
# Filter by layer if specified (only for non-vias)
|
||
if layer and not is_via:
|
||
layer_id = self.board.GetLayerID(layer)
|
||
if track.GetLayer() != layer_id:
|
||
continue
|
||
|
||
tracks_to_remove.append(track)
|
||
|
||
deleted_count = len(tracks_to_remove)
|
||
for track in tracks_to_remove:
|
||
self.board.Remove(track)
|
||
tracks_to_remove.clear()
|
||
self.board.SetModified()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Deleted {deleted_count} traces on net '{net_name}'",
|
||
"deletedCount": deleted_count,
|
||
}
|
||
|
||
# Find track by UUID
|
||
if trace_uuid:
|
||
track = None
|
||
for item in list(self.board.Tracks()):
|
||
if item.m_Uuid.AsString() == trace_uuid:
|
||
track = item
|
||
break
|
||
|
||
if not track:
|
||
return {
|
||
"success": False,
|
||
"message": "Track not found",
|
||
"errorDetails": f"Could not find track with UUID: {trace_uuid}",
|
||
}
|
||
|
||
self.board.Remove(track)
|
||
track = None
|
||
self.board.SetModified()
|
||
return {"success": True, "message": f"Deleted track: {trace_uuid}"}
|
||
|
||
# No valid parameters provided
|
||
if not position:
|
||
return {
|
||
"success": False,
|
||
"message": "No valid search parameter provided",
|
||
"errorDetails": "Provide traceUuid, position, or net parameter",
|
||
}
|
||
|
||
# Find track by position
|
||
if position:
|
||
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
|
||
x_nm = int(position["x"] * scale)
|
||
y_nm = int(position["y"] * scale)
|
||
point = pcbnew.VECTOR2I(x_nm, y_nm)
|
||
|
||
# Find closest track
|
||
closest_track = None
|
||
min_distance = float("inf")
|
||
for track in list(self.board.Tracks()):
|
||
dist = self._point_to_track_distance(point, track)
|
||
if dist < min_distance:
|
||
min_distance = dist
|
||
closest_track = track
|
||
|
||
if closest_track and min_distance < 1000000: # Within 1mm
|
||
self.board.Remove(closest_track)
|
||
closest_track = None
|
||
self.board.SetModified()
|
||
return {
|
||
"success": True,
|
||
"message": "Deleted track at specified position",
|
||
}
|
||
else:
|
||
return {
|
||
"success": False,
|
||
"message": "No track found",
|
||
"errorDetails": "No track found near specified position",
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error deleting trace: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to delete trace",
|
||
"errorDetails": str(e),
|
||
}
|
||
return {
|
||
"success": False,
|
||
"message": "No action taken",
|
||
"errorDetails": "No matching trace found for given parameters",
|
||
}
|
||
|
||
def get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Get a list of all nets in the PCB"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
nets = []
|
||
netinfo = self.board.GetNetInfo()
|
||
for net_code in range(netinfo.GetNetCount()):
|
||
net = netinfo.GetNetItem(net_code)
|
||
if net:
|
||
nets.append(
|
||
{
|
||
"name": net.GetNetname(),
|
||
"code": net.GetNetCode(),
|
||
"class": net.GetNetClassName(),
|
||
}
|
||
)
|
||
|
||
return {"success": True, "nets": nets}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error getting nets list: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to get nets list",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def query_traces(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Query traces by net, layer, or bounding box"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
# Get filter parameters
|
||
net_name = params.get("net")
|
||
layer = params.get("layer")
|
||
bbox = params.get("boundingBox") # {x1, y1, x2, y2, unit}
|
||
include_vias = params.get("includeVias", False)
|
||
|
||
scale = 1000000 # nm to mm conversion factor
|
||
traces = []
|
||
vias = []
|
||
|
||
# Process tracks
|
||
for track in list(self.board.Tracks()):
|
||
try:
|
||
# Check if it's a via
|
||
is_via = track.Type() == pcbnew.PCB_VIA_T
|
||
|
||
if is_via and not include_vias:
|
||
continue
|
||
|
||
# Filter by net
|
||
if net_name and track.GetNetname() != net_name:
|
||
continue
|
||
|
||
# Filter by layer (only for tracks, not vias)
|
||
if layer and not is_via:
|
||
layer_id = self.board.GetLayerID(layer)
|
||
if track.GetLayer() != layer_id:
|
||
continue
|
||
|
||
# Filter by bounding box
|
||
if bbox:
|
||
bbox_unit = bbox.get("unit", "mm")
|
||
bbox_scale = scale if bbox_unit == "mm" else 25400000
|
||
x1 = int(bbox.get("x1", 0) * bbox_scale)
|
||
y1 = int(bbox.get("y1", 0) * bbox_scale)
|
||
x2 = int(bbox.get("x2", 0) * bbox_scale)
|
||
y2 = int(bbox.get("y2", 0) * bbox_scale)
|
||
|
||
if is_via:
|
||
pos = track.GetPosition()
|
||
if not (x1 <= pos.x <= x2 and y1 <= pos.y <= y2):
|
||
continue
|
||
else:
|
||
start = track.GetStart()
|
||
end = track.GetEnd()
|
||
# Check if either endpoint is within bbox
|
||
start_in = x1 <= start.x <= x2 and y1 <= start.y <= y2
|
||
end_in = x1 <= end.x <= x2 and y1 <= end.y <= y2
|
||
if not (start_in or end_in):
|
||
continue
|
||
|
||
if is_via:
|
||
pos = track.GetPosition()
|
||
vias.append(
|
||
{
|
||
"uuid": track.m_Uuid.AsString(),
|
||
"position": {
|
||
"x": pos.x / scale,
|
||
"y": pos.y / scale,
|
||
"unit": "mm",
|
||
},
|
||
"net": track.GetNetname(),
|
||
"netCode": track.GetNetCode(),
|
||
"diameter": track.GetWidth() / scale,
|
||
"drill": track.GetDrillValue() / scale,
|
||
}
|
||
)
|
||
else:
|
||
start = track.GetStart()
|
||
end = track.GetEnd()
|
||
traces.append(
|
||
{
|
||
"uuid": track.m_Uuid.AsString(),
|
||
"net": track.GetNetname(),
|
||
"netCode": track.GetNetCode(),
|
||
"layer": self.board.GetLayerName(track.GetLayer()),
|
||
"width": track.GetWidth() / scale,
|
||
"start": {
|
||
"x": start.x / scale,
|
||
"y": start.y / scale,
|
||
"unit": "mm",
|
||
},
|
||
"end": {
|
||
"x": end.x / scale,
|
||
"y": end.y / scale,
|
||
"unit": "mm",
|
||
},
|
||
"length": track.GetLength() / scale,
|
||
}
|
||
)
|
||
except Exception as track_err:
|
||
logger.warning(f"Skipping invalid track object: {track_err}")
|
||
continue
|
||
|
||
result = {"success": True, "traceCount": len(traces), "traces": traces}
|
||
|
||
if include_vias:
|
||
result["viaCount"] = len(vias)
|
||
result["vias"] = vias
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error querying traces: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to query traces",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def query_zones(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Query copper zones (filled pours) by net, layer, or bounding box.
|
||
|
||
Returns one entry per zone with its net, layers, priority, fill state,
|
||
and bounding box. Useful for auditing power planes / GND pours that
|
||
``query_traces`` does not report (zones are PCB_ZONE_T, not tracks).
|
||
"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
net_name = params.get("net")
|
||
layer = params.get("layer")
|
||
bbox = params.get("boundingBox")
|
||
|
||
scale = 1000000 # nm -> mm
|
||
target_layer_id = None
|
||
if layer:
|
||
target_layer_id = self.board.GetLayerID(layer)
|
||
|
||
bbox_box = None
|
||
if bbox:
|
||
bbox_unit = bbox.get("unit", "mm")
|
||
bbox_scale = scale if bbox_unit == "mm" else 25400000
|
||
bbox_box = (
|
||
int(bbox.get("x1", 0) * bbox_scale),
|
||
int(bbox.get("y1", 0) * bbox_scale),
|
||
int(bbox.get("x2", 0) * bbox_scale),
|
||
int(bbox.get("y2", 0) * bbox_scale),
|
||
)
|
||
|
||
zones_out = []
|
||
for zone in list(self.board.Zones()):
|
||
try:
|
||
z_net = zone.GetNetname()
|
||
if net_name and z_net != net_name:
|
||
continue
|
||
|
||
# A zone can span multiple copper layers; collect them.
|
||
layer_names = []
|
||
try:
|
||
layer_set = zone.GetLayerSet()
|
||
seq = layer_set.CuStack() if hasattr(layer_set, "CuStack") else layer_set.Seq()
|
||
for lid in seq:
|
||
layer_names.append(self.board.GetLayerName(lid))
|
||
except Exception:
|
||
layer_names = [self.board.GetLayerName(zone.GetLayer())]
|
||
|
||
if target_layer_id is not None:
|
||
if target_layer_id not in [self.board.GetLayerID(n) for n in layer_names]:
|
||
continue
|
||
|
||
bb = zone.GetBoundingBox()
|
||
bb_x1, bb_y1 = bb.GetLeft(), bb.GetTop()
|
||
bb_x2, bb_y2 = bb.GetRight(), bb.GetBottom()
|
||
|
||
if bbox_box is not None:
|
||
x1, y1, x2, y2 = bbox_box
|
||
# Reject if no overlap with filter bbox.
|
||
if bb_x2 < x1 or bb_x1 > x2 or bb_y2 < y1 or bb_y1 > y2:
|
||
continue
|
||
|
||
entry = {
|
||
"uuid": zone.m_Uuid.AsString(),
|
||
"net": z_net,
|
||
"netCode": zone.GetNetCode(),
|
||
"layers": layer_names,
|
||
"priority": zone.GetAssignedPriority() if hasattr(zone, "GetAssignedPriority") else 0,
|
||
"isFilled": bool(zone.IsFilled()),
|
||
"minThickness": zone.GetMinThickness() / scale,
|
||
"boundingBox": {
|
||
"x1": bb_x1 / scale,
|
||
"y1": bb_y1 / scale,
|
||
"x2": bb_x2 / scale,
|
||
"y2": bb_y2 / scale,
|
||
"unit": "mm",
|
||
},
|
||
}
|
||
# Area is only available when zone is filled.
|
||
try:
|
||
entry["filledArea"] = zone.GetFilledArea() / (scale * scale)
|
||
except Exception:
|
||
pass
|
||
|
||
zones_out.append(entry)
|
||
except Exception as zone_err:
|
||
logger.warning(f"Skipping invalid zone object: {zone_err}")
|
||
continue
|
||
|
||
return {
|
||
"success": True,
|
||
"zoneCount": len(zones_out),
|
||
"zones": zones_out,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error querying zones: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to query zones",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def modify_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Modify properties of an existing trace
|
||
|
||
Allows changing trace width, layer, and net assignment.
|
||
Find trace by UUID or position.
|
||
"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
# Identification parameters
|
||
trace_uuid = params.get("uuid")
|
||
position = params.get("position") # {x, y, unit}
|
||
|
||
# Modification parameters
|
||
new_width = params.get("width") # in mm
|
||
new_layer = params.get("layer")
|
||
new_net = params.get("net")
|
||
|
||
if not trace_uuid and not position:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing trace identifier",
|
||
"errorDetails": "Provide either 'uuid' or 'position' to identify the trace",
|
||
}
|
||
|
||
scale = 1000000 # nm to mm conversion
|
||
|
||
# Find the track
|
||
track = None
|
||
|
||
if trace_uuid:
|
||
for item in list(self.board.Tracks()):
|
||
if item.m_Uuid.AsString() == trace_uuid:
|
||
track = item
|
||
break
|
||
elif position:
|
||
pos_unit = position.get("unit", "mm")
|
||
pos_scale = scale if pos_unit == "mm" else 25400000
|
||
x_nm = int(position["x"] * pos_scale)
|
||
y_nm = int(position["y"] * pos_scale)
|
||
point = pcbnew.VECTOR2I(x_nm, y_nm)
|
||
|
||
# Find closest track
|
||
min_distance = float("inf")
|
||
for item in list(self.board.Tracks()):
|
||
dist = self._point_to_track_distance(point, item)
|
||
if dist < min_distance:
|
||
min_distance = dist
|
||
track = item
|
||
|
||
# Only accept if within 1mm
|
||
if min_distance >= 1000000:
|
||
track = None
|
||
|
||
if not track:
|
||
return {
|
||
"success": False,
|
||
"message": "Track not found",
|
||
"errorDetails": "Could not find track with specified identifier",
|
||
}
|
||
|
||
# Check if it's a via (some modifications don't apply)
|
||
is_via = track.Type() == pcbnew.PCB_VIA_T
|
||
modifications = []
|
||
|
||
# Apply modifications
|
||
if new_width is not None:
|
||
width_nm = int(new_width * scale)
|
||
track.SetWidth(width_nm)
|
||
modifications.append(f"width={new_width}mm")
|
||
|
||
if new_layer and not is_via:
|
||
layer_id = self.board.GetLayerID(new_layer)
|
||
if layer_id < 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid layer",
|
||
"errorDetails": f"Layer '{new_layer}' not found",
|
||
}
|
||
track.SetLayer(layer_id)
|
||
modifications.append(f"layer={new_layer}")
|
||
|
||
if new_net:
|
||
netinfo = self.board.GetNetInfo()
|
||
net = netinfo.GetNetItem(new_net)
|
||
if not net:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid net",
|
||
"errorDetails": f"Net '{new_net}' not found",
|
||
}
|
||
track.SetNet(net)
|
||
modifications.append(f"net={new_net}")
|
||
|
||
if not modifications:
|
||
return {
|
||
"success": False,
|
||
"message": "No modifications specified",
|
||
"errorDetails": "Provide at least one of: width, layer, net",
|
||
}
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Modified trace: {', '.join(modifications)}",
|
||
"uuid": track.m_Uuid.AsString(),
|
||
"modifications": modifications,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error modifying trace: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to modify trace",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def copy_routing_pattern(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Copy routing pattern from source components to target components
|
||
|
||
This enables routing replication between identical component groups.
|
||
The pattern is copied with a translation offset calculated from
|
||
the position difference between source and target components.
|
||
"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
source_refs = params.get("sourceRefs", []) # e.g., ["U1", "U2", "U3"]
|
||
target_refs = params.get("targetRefs", []) # e.g., ["U4", "U5", "U6"]
|
||
include_vias = params.get("includeVias", True)
|
||
trace_width = params.get("traceWidth") # Optional override
|
||
|
||
if not source_refs or not target_refs:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing component references",
|
||
"errorDetails": "Provide both 'sourceRefs' and 'targetRefs' arrays",
|
||
}
|
||
|
||
if len(source_refs) != len(target_refs):
|
||
return {
|
||
"success": False,
|
||
"message": "Mismatched component counts",
|
||
"errorDetails": f"sourceRefs has {len(source_refs)} items, targetRefs has {len(target_refs)}",
|
||
}
|
||
|
||
scale = 1000000 # nm to mm conversion
|
||
|
||
# Get footprints
|
||
footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()}
|
||
|
||
# Validate all references exist
|
||
for ref in source_refs + target_refs:
|
||
if ref not in footprints:
|
||
return {
|
||
"success": False,
|
||
"message": "Component not found",
|
||
"errorDetails": f"Component '{ref}' not found on board",
|
||
}
|
||
|
||
# Calculate offset from first source to first target component
|
||
source_fp = footprints[source_refs[0]]
|
||
target_fp = footprints[target_refs[0]]
|
||
source_pos = source_fp.GetPosition()
|
||
target_pos = target_fp.GetPosition()
|
||
|
||
offset_x = target_pos.x - source_pos.x
|
||
offset_y = target_pos.y - source_pos.y
|
||
|
||
# Build mapping from source refs to target refs
|
||
ref_mapping = dict(zip(source_refs, target_refs))
|
||
|
||
# Collect all nets connected to source components
|
||
source_nets = set()
|
||
source_pad_positions = [] # (x, y) in nm for geometric fallback
|
||
for ref in source_refs:
|
||
fp = footprints[ref]
|
||
for pad in fp.Pads():
|
||
net_name = pad.GetNetname()
|
||
if net_name and net_name != "":
|
||
source_nets.add(net_name)
|
||
pos = pad.GetPosition()
|
||
source_pad_positions.append((pos.x, pos.y))
|
||
|
||
# Build bounding box around source pads (with 5mm tolerance in nm)
|
||
TOLERANCE_NM = int(5 * scale)
|
||
if source_pad_positions:
|
||
xs = [p[0] for p in source_pad_positions]
|
||
ys = [p[1] for p in source_pad_positions]
|
||
bbox_x1 = min(xs) - TOLERANCE_NM
|
||
bbox_x2 = max(xs) + TOLERANCE_NM
|
||
bbox_y1 = min(ys) - TOLERANCE_NM
|
||
bbox_y2 = max(ys) + TOLERANCE_NM
|
||
else:
|
||
# Fall back to component position ± 25mm
|
||
sp = source_fp.GetPosition()
|
||
bbox_x1 = sp.x - int(25 * scale)
|
||
bbox_x2 = sp.x + int(25 * scale)
|
||
bbox_y1 = sp.y - int(25 * scale)
|
||
bbox_y2 = sp.y + int(25 * scale)
|
||
|
||
def point_in_bbox(px: int, py: int) -> bool:
|
||
return bbox_x1 <= px <= bbox_x2 and bbox_y1 <= py <= bbox_y2
|
||
|
||
# Collect traces: by net name (if available) OR by geometric proximity
|
||
use_net_filter = len(source_nets) > 0
|
||
traces_to_copy = []
|
||
vias_to_copy = []
|
||
|
||
for track in list(self.board.Tracks()):
|
||
is_via = track.Type() == pcbnew.PCB_VIA_T
|
||
|
||
if use_net_filter:
|
||
# Primary: net-based filter
|
||
if track.GetNetname() not in source_nets:
|
||
continue
|
||
else:
|
||
# Fallback: geometric filter – trace start OR end inside source bbox
|
||
if is_via:
|
||
pos = track.GetPosition()
|
||
if not point_in_bbox(pos.x, pos.y):
|
||
continue
|
||
else:
|
||
s = track.GetStart()
|
||
e = track.GetEnd()
|
||
if not (point_in_bbox(s.x, s.y) or point_in_bbox(e.x, e.y)):
|
||
continue
|
||
|
||
if is_via:
|
||
if include_vias:
|
||
vias_to_copy.append(track)
|
||
else:
|
||
traces_to_copy.append(track)
|
||
|
||
filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
|
||
logger.info(
|
||
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
|
||
f"{len(vias_to_copy)} vias selected via {filter_method}"
|
||
)
|
||
|
||
# Create new traces with offset
|
||
created_traces = 0
|
||
created_vias = 0
|
||
|
||
for track in traces_to_copy:
|
||
start = track.GetStart()
|
||
end = track.GetEnd()
|
||
|
||
# Create new track
|
||
new_track = pcbnew.PCB_TRACK(self.board)
|
||
new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y))
|
||
new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y))
|
||
new_track.SetLayer(track.GetLayer())
|
||
|
||
# Set width (use override or original)
|
||
if trace_width:
|
||
new_track.SetWidth(int(trace_width * scale))
|
||
else:
|
||
new_track.SetWidth(track.GetWidth())
|
||
|
||
# Try to find corresponding target net
|
||
# This is a simplification - more sophisticated mapping would be needed
|
||
# for complex designs
|
||
self.board.Add(new_track)
|
||
created_traces += 1
|
||
|
||
for via in vias_to_copy:
|
||
pos = via.GetPosition()
|
||
|
||
# Create new via
|
||
new_via = pcbnew.PCB_VIA(self.board)
|
||
new_via.SetPosition(pcbnew.VECTOR2I(pos.x + offset_x, pos.y + offset_y))
|
||
new_via.SetWidth(via.GetWidth(pcbnew.F_Cu))
|
||
new_via.SetDrill(via.GetDrillValue())
|
||
new_via.SetViaType(via.GetViaType())
|
||
|
||
self.board.Add(new_via)
|
||
created_vias += 1
|
||
|
||
result = {
|
||
"success": True,
|
||
"message": f"Copied routing pattern: {created_traces} traces, {created_vias} vias",
|
||
"filterMethod": filter_method,
|
||
"offset": {"x": offset_x / scale, "y": offset_y / scale, "unit": "mm"},
|
||
"createdTraces": created_traces,
|
||
"createdVias": created_vias,
|
||
"sourceComponents": source_refs,
|
||
"targetComponents": target_refs,
|
||
}
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error copying routing pattern: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to copy routing pattern",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def create_netclass(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Create a new net class with specified properties"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
name = params.get("name")
|
||
clearance = params.get("clearance")
|
||
# Schema exposes "traceWidth"; older callers may send "trackWidth". Accept both.
|
||
track_width = params.get("traceWidth", params.get("trackWidth"))
|
||
via_diameter = params.get("viaDiameter")
|
||
via_drill = params.get("viaDrill")
|
||
uvia_diameter = params.get("uviaDiameter")
|
||
uvia_drill = params.get("uviaDrill")
|
||
diff_pair_width = params.get("diffPairWidth")
|
||
diff_pair_gap = params.get("diffPairGap")
|
||
nets = params.get("nets", [])
|
||
|
||
if not name:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing netclass name",
|
||
"errorDetails": "name parameter is required",
|
||
}
|
||
|
||
# Get net classes — KiCad 6/7 returns NETCLASSES with .Find/.Add;
|
||
# KiCad 9/10 returns a netclasses_map (SWIG-wrapped std::map) that is dict-like.
|
||
net_classes = self.board.GetNetClasses()
|
||
|
||
existing = None
|
||
if hasattr(net_classes, "Find"):
|
||
existing = net_classes.Find(name)
|
||
else:
|
||
try:
|
||
if name in net_classes:
|
||
existing = net_classes[name]
|
||
except Exception:
|
||
existing = None
|
||
|
||
if existing is None:
|
||
netclass = pcbnew.NETCLASS(name)
|
||
if hasattr(net_classes, "Add"):
|
||
net_classes.Add(netclass)
|
||
else:
|
||
net_classes[name] = netclass
|
||
else:
|
||
netclass = existing
|
||
|
||
# Set properties
|
||
scale = 1000000 # mm to nm
|
||
|
||
# Defensive setters — KiCad 10's NETCLASS dropped some legacy mutators.
|
||
def _safe_set(method_name, value):
|
||
if value is None:
|
||
return
|
||
method = getattr(netclass, method_name, None)
|
||
if method is None:
|
||
return
|
||
try:
|
||
method(int(value * scale))
|
||
except Exception:
|
||
pass
|
||
|
||
_safe_set("SetClearance", clearance)
|
||
_safe_set("SetTrackWidth", track_width)
|
||
_safe_set("SetViaDiameter", via_diameter)
|
||
_safe_set("SetViaDrill", via_drill)
|
||
_safe_set("SetMicroViaDiameter", uvia_diameter)
|
||
_safe_set("SetMicroViaDrill", uvia_drill)
|
||
_safe_set("SetDiffPairWidth", diff_pair_width)
|
||
_safe_set("SetDiffPairGap", diff_pair_gap)
|
||
|
||
# Add nets to net class
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
for net_name in nets:
|
||
if nets_map.has_key(net_name):
|
||
net = nets_map[net_name]
|
||
net.SetClass(netclass)
|
||
|
||
# Defensive accessors — KiCad 10's NETCLASS dropped some legacy getters.
|
||
def _safe_get(method_name):
|
||
method = getattr(netclass, method_name, None)
|
||
if method is None:
|
||
return None
|
||
try:
|
||
return method() / scale
|
||
except Exception:
|
||
return None
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Created net class: {name}",
|
||
"netClass": {
|
||
"name": name,
|
||
"clearance": _safe_get("GetClearance"),
|
||
"trackWidth": _safe_get("GetTrackWidth"),
|
||
"viaDiameter": _safe_get("GetViaDiameter"),
|
||
"viaDrill": _safe_get("GetViaDrill"),
|
||
"uviaDiameter": _safe_get("GetMicroViaDiameter"),
|
||
"uviaDrill": _safe_get("GetMicroViaDrill"),
|
||
"diffPairWidth": _safe_get("GetDiffPairWidth"),
|
||
"diffPairGap": _safe_get("GetDiffPairGap"),
|
||
"nets": nets,
|
||
},
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error creating net class: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to create net class",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Add a copper pour (zone) to the PCB"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
layer = params.get("layer", "F.Cu")
|
||
net = params.get("net")
|
||
clearance = params.get("clearance")
|
||
min_width = params.get("minWidth", 0.2)
|
||
points = params.get("outline", params.get("points", []))
|
||
priority = params.get("priority", 0)
|
||
fill_type = params.get("fillType", "solid") # solid or hatched
|
||
|
||
# If no outline provided, use board outline
|
||
if not points or len(points) < 3:
|
||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||
if board_box.GetWidth() > 0 and board_box.GetHeight() > 0:
|
||
scale = 1000000 # nm to mm
|
||
x1 = board_box.GetX() / scale
|
||
y1 = board_box.GetY() / scale
|
||
x2 = (board_box.GetX() + board_box.GetWidth()) / scale
|
||
y2 = (board_box.GetY() + board_box.GetHeight()) / scale
|
||
|
||
# Detect corner radius from Edge.Cuts arcs so the zone rectangle
|
||
# stays inside the rounded board corners (avoids zone visually
|
||
# extending outside Edge.Cuts before refill)
|
||
corner_radius = 0.0
|
||
edge_layer_id = self.board.GetLayerID("Edge.Cuts")
|
||
for item in self.board.GetDrawings():
|
||
if item.GetLayer() == edge_layer_id and item.GetClass() == "PCB_ARC":
|
||
r = item.GetRadius() / scale
|
||
if r > corner_radius:
|
||
corner_radius = r
|
||
# Inset the zone rectangle by the corner radius so its corners
|
||
# lie on the straight portions of the board edge.
|
||
inset = corner_radius
|
||
points = [
|
||
{"x": x1 + inset, "y": y1 + inset},
|
||
{"x": x2 - inset, "y": y1 + inset},
|
||
{"x": x2 - inset, "y": y2 - inset},
|
||
{"x": x1 + inset, "y": y2 - inset},
|
||
]
|
||
else:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing outline",
|
||
"errorDetails": "Provide an outline array or add a board outline first",
|
||
}
|
||
|
||
# Get layer ID
|
||
layer_id = self.board.GetLayerID(layer)
|
||
if layer_id < 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid layer",
|
||
"errorDetails": f"Layer '{layer}' does not exist",
|
||
}
|
||
|
||
# Create zone
|
||
zone = pcbnew.ZONE(self.board)
|
||
zone.SetLayer(layer_id)
|
||
|
||
# Set net if provided
|
||
if net:
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
if nets_map.has_key(net):
|
||
net_obj = nets_map[net]
|
||
zone.SetNet(net_obj)
|
||
|
||
# Set zone properties
|
||
scale = 1000000 # mm to nm
|
||
zone.SetAssignedPriority(priority)
|
||
|
||
if clearance is not None:
|
||
zone.SetLocalClearance(int(clearance * scale))
|
||
|
||
zone.SetMinThickness(int(min_width * scale))
|
||
|
||
# Set fill type
|
||
if fill_type == "hatched":
|
||
zone.SetFillMode(pcbnew.ZONE_FILL_MODE_HATCH_PATTERN)
|
||
else:
|
||
zone.SetFillMode(pcbnew.ZONE_FILL_MODE_POLYGONS)
|
||
|
||
# Create outline
|
||
outline = zone.Outline()
|
||
outline.NewOutline() # Create a new outline contour first
|
||
|
||
# Add points to outline
|
||
for point in points:
|
||
scale = 1000000 if point.get("unit", "mm") == "mm" else 25400000
|
||
x_nm = int(point["x"] * scale)
|
||
y_nm = int(point["y"] * scale)
|
||
outline.Append(pcbnew.VECTOR2I(x_nm, y_nm)) # Add point to outline
|
||
|
||
# Add zone to board
|
||
self.board.Add(zone)
|
||
|
||
# Fill zone
|
||
# Note: Zone filling can cause issues with SWIG API
|
||
# Comment out for now - zones will be filled when board is saved/opened in KiCAD
|
||
# filler = pcbnew.ZONE_FILLER(self.board)
|
||
# filler.Fill(self.board.Zones())
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Added copper pour",
|
||
"pour": {
|
||
"layer": layer,
|
||
"net": net,
|
||
"clearance": clearance,
|
||
"minWidth": min_width,
|
||
"priority": priority,
|
||
"fillType": fill_type,
|
||
"pointCount": len(points),
|
||
},
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error adding copper pour: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to add copper pour",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def route_differential_pair(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Route a differential pair between two sets of points or pads"""
|
||
try:
|
||
if not self.board:
|
||
return {
|
||
"success": False,
|
||
"message": "No board is loaded",
|
||
"errorDetails": "Load or create a board first",
|
||
}
|
||
|
||
start_pos = params.get("startPos")
|
||
end_pos = params.get("endPos")
|
||
net_pos = params.get("netPos")
|
||
net_neg = params.get("netNeg")
|
||
layer = params.get("layer", "F.Cu")
|
||
width = params.get("width")
|
||
gap = params.get("gap")
|
||
|
||
if not start_pos or not end_pos or not net_pos or not net_neg:
|
||
return {
|
||
"success": False,
|
||
"message": "Missing parameters",
|
||
"errorDetails": "startPos, endPos, netPos, and netNeg are required",
|
||
}
|
||
|
||
# Get layer ID
|
||
layer_id = self.board.GetLayerID(layer)
|
||
if layer_id < 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid layer",
|
||
"errorDetails": f"Layer '{layer}' does not exist",
|
||
}
|
||
|
||
# Get nets
|
||
netinfo = self.board.GetNetInfo()
|
||
nets_map = netinfo.NetsByName()
|
||
|
||
net_pos_obj = nets_map[net_pos] if nets_map.has_key(net_pos) else None
|
||
net_neg_obj = nets_map[net_neg] if nets_map.has_key(net_neg) else None
|
||
|
||
if not net_pos_obj or not net_neg_obj:
|
||
return {
|
||
"success": False,
|
||
"message": "Nets not found",
|
||
"errorDetails": "One or both nets specified for the differential pair do not exist",
|
||
}
|
||
|
||
# Get start and end points
|
||
start_point = self._get_point(start_pos)
|
||
end_point = self._get_point(end_pos)
|
||
|
||
# Calculate offset vectors for the two traces
|
||
# First, get the direction vector from start to end
|
||
dx = end_point.x - start_point.x
|
||
dy = end_point.y - start_point.y
|
||
length = math.sqrt(dx * dx + dy * dy)
|
||
|
||
if length <= 0:
|
||
return {
|
||
"success": False,
|
||
"message": "Invalid points",
|
||
"errorDetails": "Start and end points must be different",
|
||
}
|
||
|
||
# Normalize direction vector
|
||
dx /= length
|
||
dy /= length
|
||
|
||
# Get perpendicular vector
|
||
px = -dy
|
||
py = dx
|
||
|
||
# Set default gap if not provided
|
||
if gap is None:
|
||
gap = 0.2 # mm
|
||
|
||
# Convert to nm
|
||
gap_nm = int(gap * 1000000)
|
||
|
||
# Calculate offsets
|
||
offset_x = int(px * gap_nm / 2)
|
||
offset_y = int(py * gap_nm / 2)
|
||
|
||
# Create positive and negative trace points
|
||
pos_start = pcbnew.VECTOR2I(
|
||
int(start_point.x + offset_x), int(start_point.y + offset_y)
|
||
)
|
||
pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
|
||
neg_start = pcbnew.VECTOR2I(
|
||
int(start_point.x - offset_x), int(start_point.y - offset_y)
|
||
)
|
||
neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
|
||
|
||
# Create positive trace
|
||
pos_track = pcbnew.PCB_TRACK(self.board)
|
||
pos_track.SetStart(pos_start)
|
||
pos_track.SetEnd(pos_end)
|
||
pos_track.SetLayer(layer_id)
|
||
pos_track.SetNet(net_pos_obj)
|
||
|
||
# Create negative trace
|
||
neg_track = pcbnew.PCB_TRACK(self.board)
|
||
neg_track.SetStart(neg_start)
|
||
neg_track.SetEnd(neg_end)
|
||
neg_track.SetLayer(layer_id)
|
||
neg_track.SetNet(net_neg_obj)
|
||
|
||
# Set width
|
||
if width:
|
||
trace_width_nm = int(width * 1000000)
|
||
pos_track.SetWidth(trace_width_nm)
|
||
neg_track.SetWidth(trace_width_nm)
|
||
else:
|
||
# Get default width from design rules or net class
|
||
trace_width = self.board.GetDesignSettings().GetCurrentTrackWidth()
|
||
pos_track.SetWidth(trace_width)
|
||
neg_track.SetWidth(trace_width)
|
||
|
||
# Add tracks to board
|
||
self.board.Add(pos_track)
|
||
self.board.Add(neg_track)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Added differential pair traces",
|
||
"diffPair": {
|
||
"posNet": net_pos,
|
||
"negNet": net_neg,
|
||
"layer": layer,
|
||
"width": pos_track.GetWidth() / 1000000,
|
||
"gap": gap,
|
||
"length": length / 1000000,
|
||
},
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error routing differential pair: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"message": "Failed to route differential pair",
|
||
"errorDetails": str(e),
|
||
}
|
||
|
||
def _get_point(self, point_spec: Dict[str, Any]) -> pcbnew.VECTOR2I:
|
||
"""Convert point specification to KiCAD point"""
|
||
if "x" in point_spec and "y" in point_spec:
|
||
scale = 1000000 if point_spec.get("unit", "mm") == "mm" else 25400000
|
||
x_nm = int(point_spec["x"] * scale)
|
||
y_nm = int(point_spec["y"] * scale)
|
||
return pcbnew.VECTOR2I(x_nm, y_nm)
|
||
elif "pad" in point_spec and "componentRef" in point_spec:
|
||
module = self.board.FindFootprintByReference(point_spec["componentRef"])
|
||
if module:
|
||
pad = module.FindPadByName(point_spec["pad"])
|
||
if pad:
|
||
return pad.GetPosition()
|
||
raise ValueError("Invalid point specification")
|
||
|
||
def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
|
||
"""Calculate distance from point to track segment"""
|
||
start = track.GetStart()
|
||
end = track.GetEnd()
|
||
|
||
# Vector from start to end
|
||
v = pcbnew.VECTOR2I(end.x - start.x, end.y - start.y)
|
||
# Vector from start to point
|
||
w = pcbnew.VECTOR2I(point.x - start.x, point.y - start.y)
|
||
|
||
# Length of track squared
|
||
c1 = v.x * v.x + v.y * v.y
|
||
if c1 == 0:
|
||
return self._point_distance(point, start)
|
||
|
||
# Projection coefficient
|
||
c2 = float(w.x * v.x + w.y * v.y) / c1
|
||
|
||
if c2 < 0:
|
||
return self._point_distance(point, start)
|
||
elif c2 > 1:
|
||
return self._point_distance(point, end)
|
||
|
||
# Point on line
|
||
proj = pcbnew.VECTOR2I(int(start.x + c2 * v.x), int(start.y + c2 * v.y))
|
||
return self._point_distance(point, proj)
|
||
|
||
def _point_distance(self, p1: pcbnew.VECTOR2I, p2: pcbnew.VECTOR2I) -> float:
|
||
"""Calculate distance between two points"""
|
||
dx = p1.x - p2.x
|
||
dy = p1.y - p2.y
|
||
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
|