Merge pull request #57 from Kletternaut/demo/rpiCSI-videotest

fix: board outline, B.Cu routing + via auto-insert, schematic wiring, 5 new tools (KiCAD 9.0)

Thank you for your work, very appreciated
This commit is contained in:
mixelpixx
2026-03-09 13:50:22 -04:00
committed by GitHub
20 changed files with 1828 additions and 389 deletions

View File

@@ -50,6 +50,21 @@ All notable changes to the KiCAD MCP Server project are documented here.
**Action required for existing projects:** delete every line beginning with `;;` from any
`.kicad_sch` file created between upstream commit `b98c94b` and this fix.
- `add_schematic_component` / `inject_symbol_into_schematic`: symbol definition in
`lib_symbols` was never refreshed after editing via `create_symbol` / `edit_symbol`.
If the symbol was already present in the schematic's embedded `lib_symbols` section,
the function returned immediately — `delete + re-add` still pulled in the stale cached
definition. Fix: always read the current definition from the `.kicad_sym` file; if a
stale entry exists in `lib_symbols`, remove it first, then inject the fresh one.
Verified live. ✅
- `template_with_symbols_expanded.kicad_sch`: removed 13 legacy `_TEMPLATE_*` offscreen
instances (`_TEMPLATE_R`, `_TEMPLATE_C`, `_TEMPLATE_U`, etc.) that were placed at
`x=-100` as clone-sources for the old `ComponentManager` approach. `DynamicSymbolLoader`
(the current implementation) injects symbols directly and never needs these placeholders.
They appeared as dangling reference designators in KiCAD's component navigator and in
the schematic canvas when zoomed far out.
### Maintenance
- `.gitignore`: added `*.kicad_pcb.bak`, `*.kicad_pro.bak` alongside existing `-bak` variants;

View File

@@ -7,7 +7,8 @@
"NODE_ENV": "production",
"PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\bin\\Lib\\site-packages",
"LOG_LEVEL": "info",
"KICAD_AUTO_LAUNCH": "false"
"KICAD_AUTO_LAUNCH": "false",
"KICAD_MCP_DEV": "0"
},
"description": "KiCAD PCB Design Assistant - Note: PYTHONPATH auto-detected if venv exists"
}

View File

@@ -27,15 +27,43 @@ class BoardOutlineCommands:
"errorDetails": "Load or create a board first",
}
# Claude sends dimensions nested inside a "params" key:
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
# Unwrap the inner dict if present so we read dimensions from the right level.
inner = params.get("params", params)
shape = params.get("shape", "rectangle")
width = params.get("width")
height = params.get("height")
center_x = params.get("centerX", 0)
center_y = params.get("centerY", 0)
radius = params.get("radius")
corner_radius = params.get("cornerRadius", 0)
points = params.get("points", [])
unit = params.get("unit", "mm")
width = inner.get("width")
height = inner.get("height")
radius = inner.get("radius")
# Accept both "cornerRadius" and "radius" regardless of shape name.
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
if shape == "rectangle" and corner_radius > 0:
shape = "rounded_rectangle"
points = inner.get("points", [])
unit = inner.get("unit", "mm")
# Position: accept top-left corner (x/y) or center (centerX/centerY).
# Default: top-left at (0,0) so the board occupies positive coordinate space
# and is consistent with component placement coordinates.
x = inner.get("x")
y = inner.get("y")
if x is not None or y is not None:
ox = x if x is not None else 0.0
oy = y if y is not None else 0.0
center_x = ox + (width or 0) / 2.0
center_y = oy + (height or 0) / 2.0
else:
raw_cx = inner.get("centerX")
raw_cy = inner.get("centerY")
if raw_cx is not None or raw_cy is not None:
center_x = raw_cx if raw_cx is not None else 0.0
center_y = raw_cy if raw_cy is not None else 0.0
else:
# No position given → place top-left at (0,0)
center_x = (width or 0) / 2.0
center_y = (height or 0) / 2.0
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
return {

View File

@@ -125,14 +125,20 @@ class ComponentCommands:
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
module.SetOrientation(angle)
# Set layer
layer_id = self.board.GetLayerID(layer)
if layer_id >= 0:
module.SetLayer(layer_id)
# Set layer for F.Cu (or non-B.Cu) before adding to board
if layer != "B.Cu":
layer_id = self.board.GetLayerID(layer)
if layer_id >= 0:
module.SetLayer(layer_id)
# Add to board
# Add to board first — Flip() requires board context in KiCAD 9
self.board.Add(module)
# Flip to B.Cu after add (board context needed, otherwise hangs 30s)
if layer == "B.Cu":
if not module.IsFlipped():
module.Flip(module.GetPosition(), False)
return {
"success": True,
"message": f"Placed component: {component_id}",

View File

@@ -256,7 +256,16 @@ class ConnectionManager:
return False
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
stub_end = [pin_loc[0] + 2.54, pin_loc[1]]
# Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, '_last_pin_angle', 0)
try:
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
except Exception:
pin_angle_deg = 0
import math as _math
angle_rad = _math.radians(pin_angle_deg)
stub_end = [round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4)]
# Create wire stub using WireManager
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
@@ -282,6 +291,77 @@ class ConnectionManager:
logger.error(traceback.format_exc())
return False
@staticmethod
def connect_passthrough(
schematic_path: Path,
source_ref: str,
target_ref: str,
net_prefix: str = "PIN",
pin_offset: int = 0,
):
"""
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
Args:
schematic_path: Path to .kicad_sch file
source_ref: Reference of the first connector (e.g., "J1")
target_ref: Reference of the second connector (e.g., "J2")
net_prefix: Prefix for generated net names (default: "PIN" -> PIN_1, PIN_2, ...)
pin_offset: Add this value to the pin number when building the net name (default 0)
Returns:
dict with 'connected' list and 'failed' list
"""
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return {"connected": [], "failed": ["WireManager unavailable"]}
locator = ConnectionManager.get_pin_locator()
if not locator:
return {"connected": [], "failed": ["PinLocator unavailable"]}
# Get all pins of source and target
src_pins = locator.get_all_symbol_pins(schematic_path, source_ref) or {}
tgt_pins = locator.get_all_symbol_pins(schematic_path, target_ref) or {}
if not src_pins:
return {"connected": [], "failed": [f"No pins found on {source_ref}"]}
if not tgt_pins:
return {"connected": [], "failed": [f"No pins found on {target_ref}"]}
connected = []
failed = []
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
try:
net_name = f"{net_prefix}_{int(pin_num) + pin_offset}" if pin_num.isdigit() else f"{net_prefix}_{pin_num}"
ok_src = ConnectionManager.connect_to_net(
schematic_path, source_ref, pin_num, net_name
)
if not ok_src:
failed.append(f"{source_ref}/{pin_num}")
continue
if pin_num in tgt_pins:
ok_tgt = ConnectionManager.connect_to_net(
schematic_path, target_ref, pin_num, net_name
)
if not ok_tgt:
failed.append(f"{target_ref}/{pin_num}")
continue
else:
failed.append(f"{target_ref}/{pin_num} (pin not found)")
continue
connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]")
except Exception as e:
failed.append(f"{source_ref}/{pin_num}: {e}")
logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed")
return {"connected": connected, "failed": failed}
@staticmethod
def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None

View File

@@ -366,42 +366,30 @@ class DynamicSymbolLoader:
indented_lines.append(" " + line if line.strip() else line)
indented_block = "\n".join(indented_lines)
# Find the end of lib_symbols section to insert before closing )
lines = content.split("\n")
lib_sym_start = None
lib_sym_end = None
# Find the end of lib_symbols section using string search (format-independent,
# works even when sexpdata.dumps() has compacted the file to a single line)
lib_sym_start = content.find("(lib_symbols")
if lib_sym_start == -1:
raise ValueError("No lib_symbols section found in schematic")
depth = 0
for i, line in enumerate(lines):
if "(lib_symbols" in line and lib_sym_start is None:
lib_sym_start = i
depth = 0
for ch in line:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
continue
if lib_sym_start is not None and lib_sym_end is None:
for ch in line:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
lib_sym_end = i
break
if lib_sym_end is not None:
lib_sym_end = lib_sym_start
for i in range(lib_sym_start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
lib_sym_end = i
break
if lib_sym_end is None:
else:
raise ValueError("No lib_symbols section found in schematic")
# Insert the symbol block just before the closing ) of lib_symbols
lines.insert(lib_sym_end, indented_block)
content = content[:lib_sym_end] + "\n " + indented_block + "\n " + content[lib_sym_end:]
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
f.write(content)
# Handle both Path objects and strings
sch_name = (
@@ -450,29 +438,17 @@ class DynamicSymbolLoader:
with open(schematic_path, "r", encoding="utf-8") as f:
content = f.read()
# Insert before (sheet_instances or at end before final )
lines = content.split("\n")
insert_pos = None
for i, line in enumerate(lines):
if "(sheet_instances" in line:
insert_pos = i
break
if insert_pos is None:
# Insert before the last closing parenthesis
for i in range(len(lines) - 1, -1, -1):
if lines[i].strip() == ")":
insert_pos = i
break
if insert_pos is None:
# Insert before (sheet_instances using direct string search.
# This works for both pretty-printed and sexpdata-compacted single-line files.
insert_marker = "(sheet_instances"
insert_at = content.rfind(insert_marker)
if insert_at == -1:
raise ValueError("Could not find insertion point in schematic")
lines.insert(insert_pos, instance_block)
content = content[:insert_at] + instance_block + "\n " + content[insert_at:]
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
f.write(content)
logger.info(
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"

View File

@@ -7,6 +7,8 @@ import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import base64
import shutil
from datetime import datetime
logger = logging.getLogger("kicad_interface")
@@ -122,6 +124,13 @@ class ExportCommands:
else:
logger.warning("kicad-cli not available for drill file generation")
# DEV MODE: copy MCP server log into project folder for later analysis
if os.environ.get("KICAD_MCP_DEV") == "1":
try:
self._dev_copy_mcp_log(output_dir)
except Exception as dev_err:
logger.warning(f"[DEV] Could not copy MCP log: {dev_err}")
return {
"success": True,
"message": "Exported Gerber files",
@@ -643,3 +652,51 @@ class ExportCommands:
return path
return None
def _dev_copy_mcp_log(self, output_dir: str) -> None:
"""DEV MODE: Copy the MCP server log for the current session into the project folder.
Activated by env var KICAD_MCP_DEV=1.
The log is placed alongside the Gerber output as:
<project_dir>/mcp_log_<YYYYMMDD_HHMMSS>.txt
Only lines from the current server session (today's date) are included
to keep the file focused on the relevant run.
"""
import platform
# Resolve Claude log path per platform
system = platform.system()
if system == "Windows":
log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs")
elif system == "Darwin":
log_dir = os.path.expanduser("~/Library/Logs/Claude")
else:
log_dir = os.path.expanduser("~/.config/Claude/logs")
log_src = os.path.join(log_dir, "mcp-server-kicad.log")
if not os.path.exists(log_src):
logger.warning(f"[DEV] MCP log not found at: {log_src}")
return
# Project dir = parent of outputDir (the Gerber subfolder)
project_dir = os.path.dirname(output_dir)
# Extract only lines from the current session start (find last "Initializing server")
with open(log_src, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
# Find last occurrence of server start so we get only the current run
session_start = 0
for i, line in enumerate(all_lines):
if "Initializing server" in line:
session_start = i
session_lines = all_lines[session_start:]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dest = os.path.join(project_dir, f"mcp_log_{timestamp}.txt")
with open(dest, "w", encoding="utf-8") as f:
f.writelines(session_lines)
logger.info(f"[DEV] MCP session log saved to: {dest} ({len(session_lines)} lines)")

View File

@@ -469,7 +469,7 @@ class LibraryCommands:
def list_library_footprints(self, params: Dict) -> Dict:
"""List all footprints in a specific library"""
try:
library = params.get("library")
library = params.get("library") or params.get("library_name")
if not library:
return {"success": False, "message": "Missing library parameter"}

View File

@@ -179,6 +179,70 @@ class PinLocator:
return (rotated_x, rotated_y)
def _get_lib_id(self, schematic_path: Path, symbol_reference: str) -> Optional[str]:
"""Helper: return the lib_id string for a placed symbol"""
try:
sch_key = str(schematic_path)
if sch_key not in self._schematic_cache:
self._schematic_cache[sch_key] = Schematic(sch_key)
sch = self._schematic_cache[sch_key]
for symbol in sch.symbol:
if symbol.property.Reference.value == symbol_reference:
return symbol.lib_id.value if hasattr(symbol, "lib_id") else None
except Exception:
pass
return None
def get_pin_angle(
self, schematic_path: Path, symbol_reference: str, pin_number: str
) -> Optional[float]:
"""
Get the outward angle of a pin endpoint in degrees (0=right, 90=up, 180=left, 270=down).
This is the direction a wire stub must extend to stay connected to the pin.
Returns angle in degrees, or None if pin not found.
"""
try:
sch_key = str(schematic_path)
if sch_key not in self._schematic_cache:
self._schematic_cache[sch_key] = Schematic(sch_key)
sch = self._schematic_cache[sch_key]
target_symbol = None
for symbol in sch.symbol:
if symbol.property.Reference.value == symbol_reference:
target_symbol = symbol
break
if not target_symbol:
return None
symbol_at = target_symbol.at.value
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
return None
pins = self.get_symbol_pins(schematic_path, lib_id)
if pin_number not in pins:
matched_num = next(
(num for num, data in pins.items() if data.get("name") == pin_number),
None,
)
if matched_num:
pin_number = matched_num
else:
return None
# Pin definition angle + symbol rotation = absolute outward direction
pin_def_angle = pins[pin_number].get("angle", 0)
absolute_angle = (pin_def_angle + symbol_rotation) % 360
return absolute_angle
except Exception:
return None
def get_pin_location(
self, schematic_path: Path, symbol_reference: str, pin_number: str
) -> Optional[List[float]]:
@@ -237,12 +301,22 @@ class PinLocator:
logger.error(f"No pin definitions found for {lib_id}")
return None
# Find the requested pin
# Find the requested pin — match by number first, then by name
if pin_number not in pins:
logger.error(
f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}"
# Try matching by pin name (e.g. "VCC1", "SDA", "GND")
matched_num = next(
(num for num, data in pins.items() if data.get("name") == pin_number),
None,
)
return None
if matched_num:
logger.debug(f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}")
pin_number = matched_num
else:
logger.error(
f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())} "
f"(names: {[d.get('name','') for d in pins.values()]})"
)
return None
pin_data = pins[pin_number]

View File

@@ -144,40 +144,64 @@ class RoutingCommands:
if not net:
net = start_pad.GetNetname() or end_pad.GetNetname() or ""
# Delegate to route_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,
"width": width,
"net": net,
}
# Detect if pads are on different copper layers → need via
start_layer = start_pad.GetLayerName()
end_layer = end_pad.GetLayerName()
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 at midpoint between the two pads
via_x = (start_pos.x + end_pos.x) / 2 / 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["message"] = (
f"Routed {from_ref}.{from_pad}{to_ref}.{to_pad} (net: {net or 'none'})"
)
result["fromPad"] = {
"ref": from_ref,
"pad": from_pad,
"x": start_pos.x / scale,
"y": start_pos.y / scale,
"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,
"ref": to_ref, "pad": to_pad,
"x": end_pos.x / scale, "y": end_pos.y / scale,
}
return result
@@ -367,7 +391,7 @@ class RoutingCommands:
"y": position["y"],
"unit": position["unit"],
},
"size": via.GetWidth() / 1000000,
"size": via.GetWidth(pcbnew.F_Cu) / 1000000,
"drill": via.GetDrill() / 1000000,
"from_layer": from_layer,
"to_layer": to_layer,
@@ -950,7 +974,7 @@ class RoutingCommands:
# 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())
new_via.SetWidth(via.GetWidth(pcbnew.F_Cu))
new_via.SetDrill(via.GetDrillValue())
new_via.SetViaType(via.GetViaType())
@@ -1095,11 +1119,25 @@ class RoutingCommands:
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, "y": y1},
{"x": x2, "y": y1},
{"x": x2, "y": y2},
{"x": x1, "y": y2},
{"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 {

View File

@@ -0,0 +1,601 @@
"""
SVG Logo Import for KiCAD PCB
Converts an SVG file into KiCAD PCB graphic polygons (gr_poly) on the silkscreen
or any other given layer. Uses only Python standard library (xml, re, math).
No external dependencies required.
Supported SVG elements:
<path d="..."> M L H V Z C S Q T A commands (curves are linearised)
<rect> → 4-point polygon
<circle> → N-gon approximation
<polygon> / <polyline> → direct point list
<g> with transform → nested group transforms are applied
SVG coordinate system: Y increases downward (same as KiCAD mm), so no Y-flip needed.
"""
import re
import math
import uuid
import os
import logging
from typing import List, Tuple, Dict, Any, Optional
import xml.etree.ElementTree as ET
logger = logging.getLogger("kicad_interface")
# ---------------------------------------------------------------------------
# Type aliases
# ---------------------------------------------------------------------------
Point = Tuple[float, float]
Polygon = List[Point]
# ---------------------------------------------------------------------------
# SVG path tokenizer
# ---------------------------------------------------------------------------
_TOKEN_RE = re.compile(
r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)"
)
def _tokenize_path(d: str) -> List[str]:
tokens = []
for tok, num in _TOKEN_RE.findall(d):
if tok:
tokens.append(tok)
elif num:
tokens.append(num)
return tokens
def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
"""
Parse SVG path tokens into a list of closed and open subpaths.
Curves are linearised with ~0.5 mm step tolerance.
Returns a list of point-lists (each is a subpath/polygon).
"""
polygons: List[Polygon] = []
current: Polygon = []
cx, cy = 0.0, 0.0 # current point
sx, sy = 0.0, 0.0 # subpath start
last_ctrl = None # last bezier control point (for S/T commands)
last_cmd = ""
i = 0
cmd = "M"
num_tokens = []
# --- helpers ---
def consume(n: int) -> List[float]:
nonlocal i
vals = [float(tokens[i + k]) for k in range(n)]
i += n
return vals
def cubic_bezier_points(p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16) -> List[Point]:
pts = []
for k in range(1, steps + 1):
t = k / steps
mt = 1 - t
x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0]
y = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1]
pts.append((x, y))
return pts
def quad_bezier_points(p0: Point, p1: Point, p2: Point, steps: int = 12) -> List[Point]:
pts = []
for k in range(1, steps + 1):
t = k / steps
mt = 1 - t
x = mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0]
y = mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1]
pts.append((x, y))
return pts
def arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float,
large_arc: int, sweep: int, x2: float, y2: float, steps: int = 20) -> List[Point]:
"""Approximate SVG arc as polygon points (endpoint parameterization → centre)."""
if rx == 0 or ry == 0:
return [(x2, y2)]
phi = math.radians(phi_deg)
cos_phi, sin_phi = math.cos(phi), math.sin(phi)
dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy
rx, ry = abs(rx), abs(ry)
lam = (x1p / rx)**2 + (y1p / ry)**2
if lam > 1:
lam = math.sqrt(lam)
rx *= lam
ry *= lam
num = max(0.0, (rx*ry)**2 - (rx*y1p)**2 - (ry*x1p)**2)
den = (rx*y1p)**2 + (ry*x1p)**2
sq = math.sqrt(num / den) if den != 0 else 0
if large_arc == sweep:
sq = -sq
cxp = sq * rx * y1p / ry
cyp = -sq * ry * x1p / rx
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
def angle(ux, uy, vx, vy):
a = math.acos(max(-1, min(1, (ux*vx + uy*vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))))
if ux*vy - uy*vx < 0:
a = -a
return a
theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
dtheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry)
if not sweep and dtheta > 0:
dtheta -= 2 * math.pi
elif sweep and dtheta < 0:
dtheta += 2 * math.pi
pts = []
for k in range(1, steps + 1):
t = k / steps
angle_ = theta1 + t * dtheta
x_ = cos_phi * rx * math.cos(angle_) - sin_phi * ry * math.sin(angle_) + cx_
y_ = sin_phi * rx * math.cos(angle_) + cos_phi * ry * math.sin(angle_) + cy_
pts.append((x_, y_))
return pts
# --- main loop ---
while i < len(tokens):
tok = tokens[i]
if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \
re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok):
# implicit repeat of last command
pass
else:
cmd = tok
i += 1
last_ctrl = None # reset smooth control on new command letter
rel = cmd.islower()
if cmd in ('M', 'm'):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
else:
cx, cy = x, y
if current:
polygons.append(current)
current = [(cx, cy)]
sx, sy = cx, cy
# subsequent coordinates are implicit L/l
cmd = 'l' if rel else 'L'
elif cmd in ('L', 'l'):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
else:
cx, cy = x, y
current.append((cx, cy))
elif cmd in ('H', 'h'):
x = float(tokens[i]); i += 1
cx = cx + x if rel else x
current.append((cx, cy))
elif cmd in ('V', 'v'):
y = float(tokens[i]); i += 1
cy = cy + y if rel else y
current.append((cx, cy))
elif cmd in ('Z', 'z'):
current.append((sx, sy)) # close
polygons.append(current)
current = []
cx, cy = sx, sy
elif cmd in ('C', 'c'):
x1, y1, x2, y2, x, y = consume(6)
if rel:
x1 += cx; y1 += cy; x2 += cx; y2 += cy; x += cx; y += cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('S', 's'):
x2, y2, x, y = consume(4)
if rel:
x2 += cx; y2 += cy; x += cx; y += cy
if last_ctrl and last_cmd in ('C', 'c', 'S', 's'):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
x1, y1 = cx, cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('Q', 'q'):
x1, y1, x, y = consume(4)
if rel:
x1 += cx; y1 += cy; x += cx; y += cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('T', 't'):
x, y = consume(2)
if rel:
x += cx; y += cy
if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
x1, y1 = cx, cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('A', 'a'):
rx, ry, phi, large, sweep, x, y = consume(7)
large, sweep = int(large), int(sweep)
if rel:
x += cx; y += cy
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
current.extend(pts)
cx, cy = x, y
else:
# Unknown command — skip one token
i += 1
last_cmd = cmd.upper()
if current:
polygons.append(current)
return [p for p in polygons if len(p) >= 2]
# ---------------------------------------------------------------------------
# Transform parsing
# ---------------------------------------------------------------------------
def _parse_transform(transform_str: str) -> List[List[float]]:
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
def identity():
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
result = identity()
for m in re.finditer(
r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)',
transform_str
):
func = m.group(1)
args = [float(v) for v in re.split(r'[\s,]+', m.group(2).strip()) if v]
mat = identity()
if func == 'matrix' and len(args) == 6:
a, b, c, d, e, f = args
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
elif func == 'translate':
tx = args[0]
ty = args[1] if len(args) > 1 else 0
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
elif func == 'scale':
sx = args[0]
sy = args[1] if len(args) > 1 else sx
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
elif func == 'rotate':
angle = math.radians(args[0])
cos, sin = math.cos(angle), math.sin(angle)
if len(args) == 3:
cx_, cy_ = args[1], args[2]
t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]]
r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]]
mat = mat_mul(mat_mul(t1, r), t2)
else:
mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
elif func == 'skewX':
mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]]
elif func == 'skewY':
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
result = mat_mul(result, mat)
return result
def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
out = []
for x, y in pts:
nx = mat[0][0] * x + mat[0][1] * y + mat[0][2]
ny = mat[1][0] * x + mat[1][1] * y + mat[1][2]
out.append((nx, ny))
return out
def _mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
# ---------------------------------------------------------------------------
# SVG element → polygon extractor
# ---------------------------------------------------------------------------
SVG_NS = re.compile(r'\{[^}]+\}')
def _tag(el: ET.Element) -> str:
return SVG_NS.sub('', el.tag)
def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]:
for key in el.attrib:
if SVG_NS.sub('', key) == name:
return el.attrib[key]
return default
def _identity():
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
"""Recursively extract all polygons from an SVG element tree."""
tag = _tag(el)
display = _get_attr(el, 'display', 'inline')
visibility = _get_attr(el, 'visibility', 'visible')
if display == 'none' or visibility == 'hidden':
return []
# Accumulate transform
transform_str = _get_attr(el, 'transform', '')
if transform_str:
local_mat = _parse_transform(transform_str)
mat = _mat_mul(parent_mat, local_mat)
else:
mat = parent_mat
result: List[Polygon] = []
if tag == 'g' or tag == 'svg':
for child in el:
result.extend(_extract_polygons_from_element(child, mat))
elif tag == 'path':
d = _get_attr(el, 'd', '')
if d:
tokens = _tokenize_path(d)
polygons = _parse_path_tokens(tokens)
for poly in polygons:
result.append(_apply_transform(poly, mat))
elif tag == 'rect':
x = float(_get_attr(el, 'x', '0') or 0)
y = float(_get_attr(el, 'y', '0') or 0)
w = float(_get_attr(el, 'width', '0') or 0)
h = float(_get_attr(el, 'height', '0') or 0)
if w > 0 and h > 0:
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
result.append(_apply_transform(pts, mat))
elif tag == 'circle':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
r = float(_get_attr(el, 'r', '0') or 0)
if r > 0:
steps = 36
pts = [(cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
result.append(_apply_transform(pts, mat))
elif tag == 'ellipse':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
rx = float(_get_attr(el, 'rx', '0') or 0)
ry = float(_get_attr(el, 'ry', '0') or 0)
if rx > 0 and ry > 0:
steps = 36
pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
result.append(_apply_transform(pts, mat))
elif tag in ('polygon', 'polyline'):
points_str = _get_attr(el, 'points', '')
if points_str:
nums = [float(v) for v in re.split(r'[\s,]+', points_str.strip()) if v]
pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
if tag == 'polygon' and pts:
pts.append(pts[0]) # close
if pts:
result.append(_apply_transform(pts, mat))
elif tag == 'line':
x1 = float(_get_attr(el, 'x1', '0') or 0)
y1 = float(_get_attr(el, 'y1', '0') or 0)
x2 = float(_get_attr(el, 'x2', '0') or 0)
y2 = float(_get_attr(el, 'y2', '0') or 0)
pts = [(x1, y1), (x2, y2)]
result.append(_apply_transform(pts, mat))
return result
# ---------------------------------------------------------------------------
# Bounding box helper
# ---------------------------------------------------------------------------
def _bounding_box(polygons: List[Polygon]) -> Tuple[float, float, float, float]:
all_x = [p[0] for poly in polygons for p in poly]
all_y = [p[1] for poly in polygons for p in poly]
return min(all_x), min(all_y), max(all_x), max(all_y)
# ---------------------------------------------------------------------------
# gr_poly builder
# ---------------------------------------------------------------------------
def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled: bool) -> str:
pts_lines = []
row: List[str] = []
for i, (x, y) in enumerate(points):
row.append(f"(xy {x:.6f} {y:.6f})")
if len(row) == 4 or i == len(points) - 1:
pts_lines.append("\t\t\t" + " ".join(row))
row = []
fill_str = "yes" if filled else "none"
uid = str(uuid.uuid4())
lines = [
"\t(gr_poly",
"\t\t(pts",
] + pts_lines + [
"\t\t)",
"\t\t(stroke",
f"\t\t\t(width {stroke_width:.4f})",
"\t\t\t(type solid)",
"\t\t)",
f"\t\t(fill {fill_str})",
f'\t\t(layer "{layer}")',
f'\t\t(uuid "{uid}")',
"\t)",
]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main public function
# ---------------------------------------------------------------------------
def import_svg_to_pcb(
pcb_path: str,
svg_path: str,
x_mm: float,
y_mm: float,
width_mm: float,
layer: str = "F.SilkS",
stroke_width: float = 0.0,
filled: bool = True,
) -> Dict[str, Any]:
"""
Import an SVG file as graphic polygons into a KiCAD PCB file.
Args:
pcb_path: Path to .kicad_pcb file (will be edited in place)
svg_path: Path to SVG file
x_mm: X position of logo top-left in mm
y_mm: Y position of logo top-left in mm
width_mm: Desired width of the logo in mm (aspect ratio preserved)
layer: PCB layer name, e.g. "F.SilkS" or "B.SilkS"
stroke_width: Outline stroke width in mm (0 = no outline)
filled: Fill polygons (True) or outline only (False)
Returns:
dict with keys: success, message, polygon_count
"""
if not os.path.exists(pcb_path):
return {"success": False, "message": f"PCB file not found: {pcb_path}"}
if not os.path.exists(svg_path):
return {"success": False, "message": f"SVG file not found: {svg_path}"}
try:
# --- 1. Parse SVG ---
tree = ET.parse(svg_path)
root = tree.getroot()
# Determine SVG viewport
vb = _get_attr(root, 'viewBox')
if vb:
parts = [float(v) for v in re.split(r'[\s,]+', vb.strip()) if v]
svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3]
else:
w_str = _get_attr(root, 'width', '100') or '100'
h_str = _get_attr(root, 'height', '100') or '100'
svg_w = float(re.sub(r'[^\d.]', '', w_str) or 100)
svg_h = float(re.sub(r'[^\d.]', '', h_str) or 100)
svg_x0, svg_y0 = 0.0, 0.0
if svg_w == 0 or svg_h == 0:
return {"success": False, "message": "SVG has zero width or height"}
# --- 2. Extract all polygons ---
polygons = _extract_polygons_from_element(root, _identity())
if not polygons:
return {"success": False, "message": "No drawable shapes found in SVG"}
# --- 3. Compute bounding box of extracted polygons ---
bx_min, by_min, bx_max, by_max = _bounding_box(polygons)
poly_w = bx_max - bx_min
poly_h = by_max - by_min
if poly_w == 0:
return {"success": False, "message": "SVG shapes have zero width"}
# --- 4. Scale and translate to target position ---
scale = width_mm / poly_w
height_mm = poly_h * scale
scaled: List[Polygon] = []
for poly in polygons:
pts: List[Point] = []
for px, py in poly:
nx = x_mm + (px - bx_min) * scale
ny = y_mm + (py - by_min) * scale
pts.append((nx, ny))
scaled.append(pts)
# --- 5. Build gr_poly strings ---
gr_lines = []
for poly in scaled:
if len(poly) < 2:
continue
gr_lines.append(_build_gr_poly(poly, layer, stroke_width, filled))
if not gr_lines:
return {"success": False, "message": "No valid polygons after scaling"}
# --- 6. Inject into PCB file ---
with open(pcb_path, "r", encoding="utf-8") as f:
pcb_content = f.read()
# Insert before the final closing ')' of the kicad_pcb block
insert_block = "\n" + "\n".join(gr_lines) + "\n"
last_paren = pcb_content.rfind(")")
if last_paren == -1:
return {"success": False, "message": "PCB file format error: no closing parenthesis found"}
new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:]
with open(pcb_path, "w", encoding="utf-8") as f:
f.write(new_content)
logger.info(f"SVG logo import: wrote {len(gr_lines)} polygons to {pcb_path}")
return {
"success": True,
"message": (
f"Imported {len(gr_lines)} polygon(s) from SVG onto layer '{layer}'. "
f"Logo size: {width_mm:.2f} × {height_mm:.2f} mm at ({x_mm}, {y_mm})."
),
"polygon_count": len(gr_lines),
"logo_width_mm": round(width_mm, 4),
"logo_height_mm": round(height_mm, 4),
"position": {"x": x_mm, "y": y_mm},
"layer": layer,
}
except ET.ParseError as e:
logger.error(f"SVG parse error: {e}")
return {"success": False, "message": f"SVG parse error: {e}"}
except Exception as e:
logger.error(f"SVG import failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}

View File

@@ -26,7 +26,7 @@ log_file = os.path.join(log_dir, "kicad_interface.log")
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler(log_file), logging.StreamHandler(sys.stderr)],
handlers=[logging.FileHandler(log_file)],
)
logger = logging.getLogger("kicad_interface")
@@ -294,6 +294,7 @@ class KiCADInterface:
"create_project": self.project_commands.create_project,
"open_project": self.project_commands.open_project,
"save_project": self.project_commands.save_project,
"snapshot_project": self._handle_snapshot_project,
"get_project_info": self.project_commands.get_project_info,
# Board commands
"set_board_size": self.board_commands.set_board_size,
@@ -375,10 +376,15 @@ class KiCADInterface:
"add_schematic_connection": self._handle_add_schematic_connection,
"add_schematic_net_label": self._handle_add_schematic_net_label,
"connect_to_net": self._handle_connect_to_net,
"connect_passthrough": self._handle_connect_passthrough,
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
"get_net_connections": self._handle_get_net_connections,
"run_erc": self._handle_run_erc,
"generate_netlist": self._handle_generate_netlist,
"sync_schematic_to_board": self._handle_sync_schematic_to_board,
"list_schematic_libraries": self._handle_list_schematic_libraries,
"export_schematic_pdf": self._handle_export_schematic_pdf,
"import_svg_logo": self._handle_import_svg_logo,
# UI/Process management commands
"check_kicad_ui": self._handle_check_kicad_ui,
"launch_kicad_ui": self._handle_launch_kicad_ui,
@@ -490,6 +496,11 @@ class KiCADInterface:
# Get board from the project commands handler
self.board = self.project_commands.board
self._update_command_handlers()
elif command in self._BOARD_MUTATING_COMMANDS:
# Auto-save after every board mutation via SWIG.
# Prevents data loss if Claude hits context limit before
# an explicit save_project call.
self._auto_save_board()
return result
else:
@@ -510,6 +521,29 @@ class KiCADInterface:
"errorDetails": f"{str(e)}\n{traceback_str}",
}
# Board-mutating commands that trigger auto-save on SWIG path
_BOARD_MUTATING_COMMANDS = {
"place_component", "move_component", "rotate_component", "delete_component",
"route_trace", "route_pad_to_pad", "add_via", "delete_trace", "add_net",
"add_board_outline", "add_mounting_hole", "add_text", "add_board_text",
"add_copper_pour", "refill_zones", "import_svg_logo",
"sync_schematic_to_board", "connect_passthrough",
}
def _auto_save_board(self):
"""Save board to disk after SWIG mutations.
Called automatically after every board-mutating SWIG command so that
data is not lost if Claude hits the context limit before save_project.
"""
try:
if self.board:
board_path = self.board.GetFileName()
if board_path:
pcbnew.SaveBoard(board_path, self.board)
logger.debug(f"Auto-saved board to: {board_path}")
except Exception as e:
logger.warning(f"Auto-save failed: {e}")
def _update_command_handlers(self):
"""Update board reference in all command handlers"""
logger.debug("Updating board reference in command handlers")
@@ -582,11 +616,35 @@ class KiCADInterface:
return {"success": False, "message": str(e)}
def _handle_place_component(self, params):
"""Place a component on the PCB, with project-local fp-lib-table support."""
"""Place a component on the PCB, with project-local fp-lib-table support.
If boardPath is given and differs from the currently loaded board, the
board is reloaded from boardPath before placing — prevents silent failures
when Claude provides a boardPath that was not yet loaded.
"""
from pathlib import Path
board_path = params.get("boardPath")
if board_path:
board_path_norm = str(Path(board_path).resolve())
current_board_file = (
str(Path(self.board.GetFileName()).resolve()) if self.board else ""
)
if board_path_norm != current_board_file:
logger.info(
f"boardPath differs from current board — reloading: {board_path}"
)
try:
self.board = pcbnew.LoadBoard(board_path)
self._update_command_handlers()
logger.info("Board reloaded from boardPath")
except Exception as e:
logger.error(f"Failed to reload board from boardPath: {e}")
return {
"success": False,
"message": f"Could not load board from boardPath: {board_path}",
"errorDetails": str(e),
}
project_path = Path(board_path).parent
if project_path != getattr(self, "_current_project_path", None):
self._current_project_path = project_path
@@ -1271,6 +1329,80 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(),
}
def _handle_connect_passthrough(self, params):
"""Connect all pins of source connector to matching pins of target connector"""
logger.info("Connecting passthrough between two connectors")
try:
from pathlib import Path
schematic_path = params.get("schematicPath")
source_ref = params.get("sourceRef")
target_ref = params.get("targetRef")
net_prefix = params.get("netPrefix", "PIN")
pin_offset = int(params.get("pinOffset", 0))
if not all([schematic_path, source_ref, target_ref]):
return {"success": False, "message": "Missing required parameters: schematicPath, sourceRef, targetRef"}
result = ConnectionManager.connect_passthrough(
Path(schematic_path), source_ref, target_ref, net_prefix, pin_offset
)
n_ok = len(result["connected"])
n_fail = len(result["failed"])
return {
"success": n_fail == 0,
"message": f"Passthrough complete: {n_ok} connected, {n_fail} failed",
"connected": result["connected"],
"failed": result["failed"],
}
except Exception as e:
logger.error(f"Error in connect_passthrough: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_schematic_pin_locations(self, params):
"""Return exact pin endpoint coordinates for a schematic component"""
logger.info("Getting schematic pin locations")
try:
from pathlib import Path
from commands.pin_locator import PinLocator
schematic_path = params.get("schematicPath")
reference = params.get("reference")
if not all([schematic_path, reference]):
return {"success": False, "message": "Missing required parameters: schematicPath, reference"}
locator = PinLocator()
all_pins = locator.get_all_symbol_pins(Path(schematic_path), reference)
if not all_pins:
return {"success": False, "message": f"No pins found for {reference} — check reference and schematic path"}
# Enrich with pin names and angles from the symbol definition
pins_def = locator.get_symbol_pins(
Path(schematic_path),
locator._get_lib_id(Path(schematic_path), reference),
) if hasattr(locator, "_get_lib_id") else {}
result = {}
for pin_num, coords in all_pins.items():
entry = {"x": coords[0], "y": coords[1]}
if pin_num in pins_def:
entry["name"] = pins_def[pin_num].get("name", pin_num)
entry["angle"] = locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0
result[pin_num] = entry
return {"success": True, "reference": reference, "pins": result}
except Exception as e:
logger.error(f"Error getting pin locations: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_net_connections(self, params):
"""Get all connections for a named net"""
logger.info("Getting net connections")
@@ -1291,6 +1423,88 @@ class KiCADInterface:
logger.error(f"Error getting net connections: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_run_erc(self, params):
"""Run Electrical Rules Check on a schematic via kicad-cli"""
logger.info("Running ERC on schematic")
import subprocess
import tempfile
import os
try:
schematic_path = params.get("schematicPath")
if not schematic_path or not os.path.exists(schematic_path):
return {
"success": False,
"message": "Schematic file not found",
"errorDetails": f"Path does not exist: {schematic_path}",
}
kicad_cli = self.design_rule_commands._find_kicad_cli()
if not kicad_cli:
return {
"success": False,
"message": "kicad-cli not found",
"errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.",
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
cmd = [kicad_cli, "sch", "erc", "--format", "json", "--output", json_output, schematic_path]
logger.info(f"Running ERC command: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.error(f"ERC command failed: {result.stderr}")
return {
"success": False,
"message": "ERC command failed",
"errorDetails": result.stderr,
}
with open(json_output, "r", encoding="utf-8") as f:
erc_data = json.load(f)
violations = []
severity_counts = {"error": 0, "warning": 0, "info": 0}
for v in erc_data.get("violations", []):
vseverity = v.get("severity", "error")
items = v.get("items", [])
loc = {}
if items and "pos" in items[0]:
loc = {"x": items[0]["pos"].get("x", 0), "y": items[0]["pos"].get("y", 0)}
violations.append({
"type": v.get("type", "unknown"),
"severity": vseverity,
"message": v.get("description", ""),
"location": loc,
})
if vseverity in severity_counts:
severity_counts[vseverity] += 1
return {
"success": True,
"message": f"ERC complete: {len(violations)} violation(s)",
"summary": {
"total": len(violations),
"by_severity": severity_counts,
},
"violations": violations,
}
finally:
if os.path.exists(json_output):
os.unlink(json_output)
except subprocess.TimeoutExpired:
return {"success": False, "message": "ERC timed out after 120 seconds"}
except Exception as e:
logger.error(f"Error running ERC: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_generate_netlist(self, params):
"""Generate netlist from schematic"""
logger.info("Generating netlist from schematic")
@@ -1312,6 +1526,191 @@ class KiCADInterface:
logger.error(f"Error generating netlist: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_sync_schematic_to_board(self, params):
"""Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
Reads net connections from the schematic and assigns them to the matching pads in the PCB."""
logger.info("Syncing schematic to board")
try:
from pathlib import Path
schematic_path = params.get("schematicPath")
board_path = params.get("boardPath")
# Determine board to work with
board = None
if board_path:
board = pcbnew.LoadBoard(board_path)
elif self.board:
board = self.board
board_path = board.GetFileName() if not board_path else board_path
else:
return {"success": False, "message": "No board loaded. Use open_project first or provide boardPath."}
if not board_path:
board_path = board.GetFileName()
# Determine schematic path if not provided
if not schematic_path:
sch = Path(board_path).with_suffix(".kicad_sch")
if sch.exists():
schematic_path = str(sch)
else:
project_dir = Path(board_path).parent
sch_files = list(project_dir.glob("*.kicad_sch"))
if sch_files:
schematic_path = str(sch_files[0])
if not schematic_path or not Path(schematic_path).exists():
return {"success": False, "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}"}
# Generate netlist from schematic
schematic = SchematicManager.load_schematic(schematic_path)
if not schematic:
return {"success": False, "message": "Failed to load schematic"}
netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
# Build (reference, pad_number) -> net_name map
pad_net_map = {} # {(ref, pin_str): net_name}
net_names = set()
for net_entry in netlist.get("nets", []):
net_name = net_entry["name"]
net_names.add(net_name)
for conn in net_entry.get("connections", []):
ref = conn.get("component", "")
pin = str(conn.get("pin", ""))
if ref and pin and pin != "unknown":
pad_net_map[(ref, pin)] = net_name
# Add all nets to board
netinfo = board.GetNetInfo()
nets_by_name = netinfo.NetsByName()
added_nets = []
for net_name in net_names:
if not nets_by_name.has_key(net_name):
net_item = pcbnew.NETINFO_ITEM(board, net_name)
board.Add(net_item)
added_nets.append(net_name)
# Refresh nets map after additions
netinfo = board.GetNetInfo()
nets_by_name = netinfo.NetsByName()
# Assign nets to pads
assigned_pads = 0
unmatched = []
for fp in board.GetFootprints():
ref = fp.GetReference()
for pad in fp.Pads():
pad_num = pad.GetNumber()
key = (ref, str(pad_num))
if key in pad_net_map:
net_name = pad_net_map[key]
if nets_by_name.has_key(net_name):
pad.SetNet(nets_by_name[net_name])
assigned_pads += 1
else:
unmatched.append(f"{ref}/{pad_num}")
board.Save(board_path)
# If board was loaded fresh, update internal reference
if params.get("boardPath"):
self.board = board
self._update_command_handlers()
logger.info(f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned")
return {
"success": True,
"message": f"PCB nets synced from schematic: {len(added_nets)} nets added, {assigned_pads} pads assigned",
"nets_added": added_nets,
"nets_total": len(net_names),
"pads_assigned": assigned_pads,
"unmatched_pads_sample": unmatched[:10],
}
except Exception as e:
logger.error(f"Error in sync_schematic_to_board: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_import_svg_logo(self, params):
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB")
try:
from commands.svg_import import import_svg_to_pcb
pcb_path = params.get("pcbPath")
svg_path = params.get("svgPath")
x = float(params.get("x", 0))
y = float(params.get("y", 0))
width = float(params.get("width", 10))
layer = params.get("layer", "F.SilkS")
stroke_width = float(params.get("strokeWidth", 0))
filled = bool(params.get("filled", True))
if not pcb_path or not svg_path:
return {"success": False, "message": "Missing required parameters: pcbPath, svgPath"}
result = import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled)
# import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file,
# bypassing the pcbnew in-memory board object. Any subsequent board.Save()
# call would overwrite the file with the stale in-memory state, erasing the
# logo. Reload the board from disk so pcbnew's memory matches the file.
if result.get("success") and self.board:
try:
self.board = pcbnew.LoadBoard(pcb_path)
# Propagate updated board reference to all command handlers
self._update_command_handlers()
logger.info("Reloaded board into pcbnew after SVG logo import")
except Exception as reload_err:
logger.warning(f"Board reload after SVG import failed (non-fatal): {reload_err}")
return result
except Exception as e:
logger.error(f"Error importing SVG logo: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_snapshot_project(self, params):
"""Copy the entire project folder to a snapshot directory for checkpoint/resume."""
import shutil
from datetime import datetime
try:
step = params.get("step", "")
label = params.get("label", "")
# Determine project directory from loaded board or explicit path
project_dir = None
if self.board:
board_file = self.board.GetFileName()
if board_file:
project_dir = str(Path(board_file).parent)
if not project_dir:
project_dir = params.get("projectPath")
if not project_dir or not os.path.isdir(project_dir):
return {"success": False, "message": "Could not determine project directory for snapshot"}
base_name = Path(project_dir).name
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p]
snapshot_name = base_name + "_snapshot_" + "_".join(suffix_parts)
snapshot_dir = str(Path(project_dir).parent / snapshot_name)
shutil.copytree(project_dir, snapshot_dir)
logger.info(f"Project snapshot saved: {snapshot_dir}")
return {
"success": True,
"message": f"Snapshot saved: {snapshot_name}",
"snapshotPath": snapshot_dir,
"sourceDir": project_dir,
}
except Exception as e:
logger.error(f"snapshot_project error: {e}")
return {"success": False, "message": str(e)}
def _handle_check_kicad_ui(self, params):
"""Check if KiCAD UI is running"""
logger.info("Checking if KiCAD UI is running")
@@ -1350,8 +1749,16 @@ class KiCADInterface:
return {"success": False, "message": str(e)}
def _handle_refill_zones(self, params):
"""Refill all copper pour zones on the board"""
logger.info("Refilling zones")
"""Refill all copper pour zones on the board.
pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005)
that crashes the entire Python process when called from SWIG outside KiCAD UI.
To avoid killing the main process we run the fill in an isolated subprocess.
If the subprocess crashes or times out, we return a non-fatal warning so the
caller can continue — KiCAD Pcbnew will refill zones automatically when the
board is opened (press B).
"""
logger.info("Refilling zones (subprocess isolation)")
try:
if not self.board:
return {
@@ -1360,18 +1767,55 @@ class KiCADInterface:
"errorDetails": "Load or create a board first",
}
# Use pcbnew's zone filler for SWIG backend
filler = pcbnew.ZONE_FILLER(self.board)
zones = self.board.Zones()
filler.Fill(zones)
# First save the board so the subprocess can load it fresh
board_path = self.board.GetFileName()
if not board_path:
return {"success": False, "message": "Board has no file path — save first"}
self.board.Save(board_path)
zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
# Run pcbnew zone fill in an isolated subprocess to prevent crashes
import subprocess, sys, textwrap
script = textwrap.dedent(f"""
import pcbnew, sys
board = pcbnew.LoadBoard({repr(board_path)})
filler = pcbnew.ZONE_FILLER(board)
filler.Fill(board.Zones())
board.Save({repr(board_path)})
print("ok")
""")
try:
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=60
)
if result.returncode == 0 and "ok" in result.stdout:
# Reload board after subprocess modified it
self.board = pcbnew.LoadBoard(board_path)
self._update_command_handlers()
logger.info("Zone fill subprocess succeeded")
return {
"success": True,
"message": f"Zones refilled successfully ({zone_count} zones)",
"zoneCount": zone_count,
}
else:
logger.warning(f"Zone fill subprocess failed: rc={result.returncode} stderr={result.stderr[:200]}")
return {
"success": False,
"message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.",
"zoneCount": zone_count,
"details": result.stderr[:300] if result.stderr else result.stdout[:300],
}
except subprocess.TimeoutExpired:
logger.warning("Zone fill subprocess timed out after 60s")
return {
"success": False,
"message": "Zone fill timed out — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.",
"zoneCount": zone_count,
}
return {
"success": True,
"message": "Zones refilled successfully",
"zoneCount": (
zones.size() if hasattr(zones, "size") else len(list(zones))
),
}
except Exception as e:
logger.error(f"Error refilling zones: {str(e)}")
return {"success": False, "message": str(e)}
@@ -1791,7 +2235,19 @@ class KiCADInterface:
return {"success": False, "message": str(e)}
def _ipc_add_board_outline(self, params):
"""IPC handler for add_board_outline - adds board edge with real-time UI update"""
"""IPC handler for add_board_outline - adds board edge with real-time UI update.
Rounded rectangles are delegated to the SWIG path because the IPC BoardSegment
type cannot represent arcs; the SWIG path writes directly to the .kicad_pcb file
and correctly generates PCB_SHAPE arcs for rounded corners.
"""
shape = params.get("shape", "rectangle")
if shape in ("rounded_rectangle", "rectangle"):
# IPC path only supports straight segments from a points list,
# but Claude sends rectangle/rounded_rectangle as shape+width+height.
# Fall back to the SWIG path which correctly handles both shapes.
logger.info(f"_ipc_add_board_outline: delegating {shape} to SWIG path")
return self.board_commands.add_board_outline(params)
try:
from kipy.board_types import BoardSegment
from kipy.geometry import Vector2
@@ -1800,8 +2256,10 @@ class KiCADInterface:
board = self.ipc_board_api._get_board()
points = params.get("points", [])
width = params.get("width", 0.1)
# Unwrap nested params (Claude sends {"shape":..., "params":{...}})
inner = params.get("params", params)
points = inner.get("points", params.get("points", []))
width = inner.get("width", params.get("width", 0.1))
if len(points) < 2:
return {

View File

@@ -70,6 +70,28 @@ PROJECT_TOOLS = [
}
}
},
{
"name": "snapshot_project",
"title": "Snapshot Project (Checkpoint)",
"description": "Copies the entire project folder to a new timestamped snapshot directory so you can resume from this checkpoint later without redoing earlier steps. Call this after every successfully completed design step (e.g. after Step 1 schematic, after Step 2 PCB layout) before asking for user confirmation to proceed.",
"inputSchema": {
"type": "object",
"properties": {
"step": {
"type": "string",
"description": "Step number or name to include in snapshot folder name, e.g. '1' or '2'"
},
"label": {
"type": "string",
"description": "Optional short label, e.g. 'schematic_ok' or 'layout_ok'"
},
"projectPath": {
"type": "string",
"description": "Project directory path. Auto-detected from loaded board if omitted."
}
}
}
},
{
"name": "get_project_info",
"title": "Get Project Information",
@@ -110,7 +132,7 @@ BOARD_TOOLS = [
{
"name": "add_board_outline",
"title": "Add Board Outline",
"description": "Adds a board outline shape (rectangle, rounded rectangle, circle, or polygon) on the Edge.Cuts layer.",
"description": "Adds a board outline shape (rectangle, rounded_rectangle, circle, or polygon) on the Edge.Cuts layer. By default the board top-left corner is placed at (0, 0) so all coordinates are positive. Use x/y to set a different top-left corner position.",
"inputSchema": {
"type": "object",
"properties": {
@@ -129,19 +151,26 @@ BOARD_TOOLS = [
"description": "Height in mm (for rectangle/rounded_rectangle)",
"minimum": 1
},
"x": {
"type": "number",
"description": "X coordinate of the top-left corner in mm (default: 0). Board extends from x to x+width."
},
"y": {
"type": "number",
"description": "Y coordinate of the top-left corner in mm (default: 0). Board extends from y to y+height."
},
"radius": {
"type": "number",
"description": "Radius in mm (for circle) or corner radius (for rounded_rectangle)",
"description": "Corner radius in mm for rounded_rectangle, or radius for circle",
"minimum": 0
},
"points": {
"type": "array",
"description": "Array of [x, y] coordinates in mm (for polygon)",
"description": "Array of {x, y} point objects in mm (for polygon shape only)",
"items": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2
"type": "object",
"properties": {"x": {"type": "number"}, "y": {"type": "number"}},
"required": ["x", "y"]
},
"minItems": 3
}
@@ -264,6 +293,53 @@ BOARD_TOOLS = [
"required": ["x", "y", "diameter"]
}
},
{
"name": "import_svg_logo",
"title": "Import SVG Logo to PCB",
"description": "Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS). Curves are linearised automatically. Supports path, rect, circle, ellipse, polygon and group transforms.",
"inputSchema": {
"type": "object",
"properties": {
"pcbPath": {
"type": "string",
"description": "Path to the .kicad_pcb file"
},
"svgPath": {
"type": "string",
"description": "Path to the SVG logo file"
},
"x": {
"type": "number",
"description": "X position of the logo top-left corner in mm"
},
"y": {
"type": "number",
"description": "Y position of the logo top-left corner in mm"
},
"width": {
"type": "number",
"description": "Target width of the logo in mm (height scaled to preserve aspect ratio)",
"minimum": 0.1
},
"layer": {
"type": "string",
"description": "PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)",
"default": "F.SilkS"
},
"strokeWidth": {
"type": "number",
"description": "Outline stroke width in mm (0 = no outline, default 0)",
"default": 0
},
"filled": {
"type": "boolean",
"description": "Fill polygons with solid layer colour (default true)",
"default": True
}
},
"required": ["pcbPath", "svgPath", "x", "y", "width"]
}
},
{
"name": "add_board_text",
"title": "Add Text to Board",
@@ -1388,7 +1464,7 @@ SCHEMATIC_TOOLS = [
{
"name": "add_schematic_net_label",
"title": "Add Net Label",
"description": "Adds a net label to assign a name to a wire/net on the schematic.",
"description": "Adds a net label at exact coordinates on a schematic wire or pin endpoint. WARNING: x/y must match an existing wire endpoint or pin endpoint exactly — placing the label even 0.01mm away from a pin will result in an unconnected pin ERC error. To connect a component pin to a net by reference and pin number (recommended), use connect_to_net instead.",
"inputSchema": {
"type": "object",
"properties": {
@@ -1463,6 +1539,89 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath", "netName"]
}
},
{
"name": "get_schematic_pin_locations",
"title": "Get Schematic Pin Locations",
"description": "Returns the exact absolute coordinates of all pins on a schematic component. Use this BEFORE placing net labels with add_schematic_net_label to get the correct x/y position for each pin endpoint.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the schematic file"
},
"reference": {
"type": "string",
"description": "Component reference designator (e.g., U1, R1, J2)"
}
},
"required": ["schematicPath", "reference"]
}
},
{
"name": "connect_passthrough",
"title": "Connect Passthrough (Pin-to-Pin)",
"description": "Connects all pins of a source connector to the matching pins of a target connector using shared net labels. Ideal for passthrough adapters where J1 pin N connects directly to J2 pin N. Each pair gets a net label '{netPrefix}_{pinNumber}'. Use this instead of calling connect_to_net 15 times for FFC/ribbon cable passthroughs. NOTE: KiCAD Connector_Generic symbols always have pin 1 at the TOP of the symbol and pin N at the BOTTOM. When assigning named nets (e.g. GND, CAM_SCL) to specific pin numbers, always use the physical pin number as shown in the connector datasheet — pin 1 = top of symbol.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the schematic file"
},
"sourceRef": {
"type": "string",
"description": "Reference of the source connector (e.g., J1)"
},
"targetRef": {
"type": "string",
"description": "Reference of the target connector (e.g., J2)"
},
"netPrefix": {
"type": "string",
"description": "Prefix for generated net names, e.g. 'CSI' produces CSI_1, CSI_2, ... (default: PIN)"
},
"pinOffset": {
"type": "integer",
"description": "Add this value to the pin number when building net names (default: 0)"
}
},
"required": ["schematicPath", "sourceRef", "targetRef"]
}
},
{
"name": "run_erc",
"title": "Run Electrical Rules Check (ERC)",
"description": "Runs the KiCAD Electrical Rules Check (ERC) on a schematic via kicad-cli and returns all violations with type, severity, and location. Use this to verify the schematic is electrically correct before generating a netlist or exporting.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file"
}
},
"required": ["schematicPath"]
}
},
{
"name": "sync_schematic_to_board",
"title": "Sync Schematic to PCB (F8)",
"description": "Reads net connections from the schematic and assigns them to matching component pads in the PCB board file. Equivalent to KiCAD Pcbnew F8 'Update PCB from Schematic'. Must be called after placing components and before routing traces, so that pad-to-net assignments are correct.",
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to .kicad_sch file. If omitted, auto-detected from current board path."
},
"boardPath": {
"type": "string",
"description": "Path to .kicad_pcb file. If omitted, uses currently loaded board."
}
}
}
},
{
"name": "generate_netlist",
"title": "Generate Netlist",

View File

@@ -715,260 +715,18 @@
)
)
(symbol (lib_id "Device:R") (at -100 -100 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000001)
(property "Reference" "_TEMPLATE_R" (at -100 -102.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R" (at -100 -100 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -100 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -100 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 10000000-0000-0000-0000-000000000001))
(pin "2" (uuid 10000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Device:C") (at -100 -110 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000002)
(property "Reference" "_TEMPLATE_C" (at -100 -107.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "C" (at -100 -112.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -110 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -110 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 20000000-0000-0000-0000-000000000001))
(pin "2" (uuid 20000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Device:L") (at -100 -120 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000003)
(property "Reference" "_TEMPLATE_L" (at -100 -117.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "L" (at -100 -122.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -120 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -120 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 30000000-0000-0000-0000-000000000001))
(pin "2" (uuid 30000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Device:Crystal") (at -100 -130 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000004)
(property "Reference" "_TEMPLATE_Y" (at -100 -127.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Crystal" (at -100 -132.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -130 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -130 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 40000000-0000-0000-0000-000000000001))
(pin "2" (uuid 40000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Device:D") (at -100 -140 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000005)
(property "Reference" "_TEMPLATE_D" (at -100 -137.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "D" (at -100 -142.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -140 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -140 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 50000000-0000-0000-0000-000000000001))
(pin "2" (uuid 50000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Device:LED") (at -100 -150 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000006)
(property "Reference" "_TEMPLATE_LED" (at -100 -147.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at -100 -152.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -150 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -150 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 60000000-0000-0000-0000-000000000001))
(pin "2" (uuid 60000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Device:Q_NPN_BCE") (at -100 -160 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000007)
(property "Reference" "_TEMPLATE_Q_NPN" (at -100 -157.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Q_NPN" (at -100 -162.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -160 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -160 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 70000000-0000-0000-0000-000000000001))
(pin "2" (uuid 70000000-0000-0000-0000-000000000002))
(pin "3" (uuid 70000000-0000-0000-0000-000000000003))
)
(symbol (lib_id "Device:Q_NMOS_GSD") (at -100 -170 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000008)
(property "Reference" "_TEMPLATE_Q_NMOS" (at -100 -167.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Q_NMOS" (at -100 -172.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -170 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -170 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 80000000-0000-0000-0000-000000000001))
(pin "2" (uuid 80000000-0000-0000-0000-000000000002))
(pin "3" (uuid 80000000-0000-0000-0000-000000000003))
)
(symbol (lib_id "Amplifier_Operational:LM358") (at -100 -180 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000009)
(property "Reference" "_TEMPLATE_U_OPAMP" (at -100 -177.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "OpAmp" (at -100 -182.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -180 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "" (at -100 -180 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 90000000-0000-0000-0000-000000000001))
(pin "2" (uuid 90000000-0000-0000-0000-000000000002))
(pin "3" (uuid 90000000-0000-0000-0000-000000000003))
(pin "4" (uuid 90000000-0000-0000-0000-000000000004))
(pin "8" (uuid 90000000-0000-0000-0000-000000000005))
)
(symbol (lib_id "Connector_Generic:Conn_01x02") (at -100 -190 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-00000000000A)
(property "Reference" "_TEMPLATE_J2" (at -100 -187.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Conn_2" (at -100 -192.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -190 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -190 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid A0000000-0000-0000-0000-000000000001))
(pin "2" (uuid A0000000-0000-0000-0000-000000000002))
)
(symbol (lib_id "Connector_Generic:Conn_01x04") (at -100 -200 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-00000000000B)
(property "Reference" "_TEMPLATE_J4" (at -100 -197.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Conn_4" (at -100 -202.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -200 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -200 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid B0000000-0000-0000-0000-000000000001))
(pin "2" (uuid B0000000-0000-0000-0000-000000000002))
(pin "3" (uuid B0000000-0000-0000-0000-000000000003))
(pin "4" (uuid B0000000-0000-0000-0000-000000000004))
)
(symbol (lib_id "Regulator_Linear:AMS1117-3.3") (at -100 -210 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-00000000000C)
(property "Reference" "_TEMPLATE_U_REG" (at -100 -207.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Regulator" (at -100 -212.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -210 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "" (at -100 -210 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid C0000000-0000-0000-0000-000000000001))
(pin "2" (uuid C0000000-0000-0000-0000-000000000002))
(pin "3" (uuid C0000000-0000-0000-0000-000000000003))
)
(symbol (lib_id "Switch:SW_Push") (at -100 -220 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-00000000000D)
(property "Reference" "_TEMPLATE_SW" (at -100 -217.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "Switch" (at -100 -222.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -220 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -220 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid D0000000-0000-0000-0000-000000000001))
(pin "2" (uuid D0000000-0000-0000-0000-000000000002))
)
(sheet_instances
(path "/" (page "1"))

View File

@@ -84,7 +84,10 @@ class Logger {
* @param message Message to log
*/
private log(level: LogLevel, message: string): void {
const timestamp = new Date().toISOString();
const now = new Date();
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
const timestamp = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ` +
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())},${pad(now.getMilliseconds(), 3)}`;
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
// Log to console.error (stderr) only - stdout is reserved for MCP protocol

View File

@@ -145,11 +145,12 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu
server.tool(
"add_board_outline",
{
shape: z.enum(["rectangle", "circle", "polygon"]).describe("Shape of the outline"),
shape: z.enum(["rectangle", "circle", "polygon", "rounded_rectangle"]).describe("Shape of the outline"),
params: z.object({
// For rectangle
// For rectangle / rounded_rectangle
width: z.number().optional().describe("Width of rectangle"),
height: z.number().optional().describe("Height of rectangle"),
cornerRadius: z.number().optional().describe("Corner radius for rounded_rectangle (mm)"),
// For circle
radius: z.number().optional().describe("Radius of circle"),
// For polygon
@@ -159,21 +160,19 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu
y: z.number().describe("Y coordinate")
})
).optional().describe("Points of polygon"),
// Common parameters
x: z.number().describe("X coordinate of center/origin"),
y: z.number().describe("Y coordinate of center/origin"),
// Position: top-left corner for rectangles/rounded_rectangle, center for circle
x: z.number().describe("X coordinate of top-left corner for rectangles (default: 0)"),
y: z.number().describe("Y coordinate of top-left corner for rectangles (default: 0)"),
unit: z.enum(["mm", "inch"]).describe("Unit of measurement")
}).describe("Parameters for the outline shape")
},
async ({ shape, params }) => {
logger.debug(`Adding ${shape} board outline`);
// Flatten params and rename x/y to centerX/centerY for Python compatibility
const { x, y, ...otherParams } = params;
// Pass x/y as-is to Python; outline.py treats them as top-left corner
// and computes the center internally (center = x + width/2, y + height/2).
const result = await callKicadScript("add_board_outline", {
shape,
centerX: x,
centerY: y,
...otherParams
...params
});
return {
@@ -346,4 +345,41 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu
);
logger.info('Board management tools registered');
// Import SVG logo onto PCB layer (silkscreen)
server.tool(
"import_svg_logo",
"Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS / front silkscreen). Curves are linearised automatically. Ideal for placing a company or project logo on the board.",
{
pcbPath: z.string().describe("Path to the .kicad_pcb file"),
svgPath: z.string().describe("Path to the SVG logo file"),
x: z.number().describe("X position of the logo top-left corner in mm"),
y: z.number().describe("Y position of the logo top-left corner in mm"),
width: z.number().describe("Target width of the logo in mm (height is scaled to preserve aspect ratio)"),
layer: z.string().optional().describe("PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)"),
strokeWidth: z.number().optional().describe("Outline stroke width in mm (0 = no outline, default 0)"),
filled: z.boolean().optional().describe("Fill polygons with solid colour (default true)"),
},
async (args: { pcbPath: string; svgPath: string; x: number; y: number; width: number; layer?: string; strokeWidth?: number; filled?: boolean }) => {
const result = await callKicadScript("import_svg_logo", args);
if (result.success) {
return {
content: [{
type: "text",
text: [
result.message,
`Polygons: ${result.polygon_count}`,
`Size: ${result.logo_width_mm?.toFixed(2)} × ${result.logo_height_mm?.toFixed(2)} mm`,
`Layer: ${result.layer}`,
].join("\n"),
}],
};
} else {
return {
content: [{ type: "text", text: `SVG import failed: ${result.message || "Unknown error"}` }],
};
}
},
);
}

View File

@@ -76,4 +76,23 @@ export function registerProjectTools(server: McpServer, callKicadScript: Functio
};
}
);
// Snapshot project tool — saves a named checkpoint as PDF/image
server.tool(
"snapshot_project",
"Save a named checkpoint snapshot of the current project state (renders board to PDF and records step label). Call after completing each major step — e.g. after Step 1 (schematic_ok) and Step 2 (layout_ok). Required by the demo workflow before waiting for user confirmation.",
{
step: z.string().describe("Step number or identifier, e.g. '1' or '2'"),
label: z.string().describe("Short label for this checkpoint, e.g. 'schematic_ok' or 'layout_ok'"),
},
async (args: { step: string; label: string }) => {
const result = await callKicadScript("snapshot_project", args);
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
);
}

View File

@@ -92,7 +92,8 @@ export const toolCategories: ToolCategory[] = [
"add_schematic_net_label",
"connect_to_net",
"get_net_connections",
"generate_netlist"
"generate_netlist",
"sync_schematic_to_board"
]
},
{
@@ -124,6 +125,7 @@ export const directToolNames = [
"create_project",
"open_project",
"save_project",
"snapshot_project",
"get_project_info",
// Core PCB operations
@@ -137,6 +139,15 @@ export const directToolNames = [
// Board setup
"add_board_outline",
// Schematic essentials (always visible so AI uses them correctly)
"add_schematic_component",
"connect_passthrough",
"connect_to_net",
"add_schematic_net_label",
// Schematic <-> PCB sync (F8 equivalent)
"sync_schematic_to_board",
// UI management
"check_kicad_ui"
];
@@ -217,16 +228,24 @@ export function searchTools(query: string): SearchResult[] {
const q = query.toLowerCase();
const matches: SearchResult[] = [];
// This is a placeholder - we'll populate descriptions from actual tool definitions
// For now, we'll search by name and category
// Search direct tools first
for (const toolName of directToolNames) {
if (toolName.toLowerCase().includes(q)) {
matches.push({
category: "direct",
tool: toolName,
description: `${toolName} (direct tool — call directly, no execute_tool needed)`
});
}
}
// Search routed tools by name and category
for (const category of toolCategories) {
// Check if category name or description matches
const categoryMatch =
category.name.toLowerCase().includes(q) ||
category.description.toLowerCase().includes(q);
for (const toolName of category.tools) {
// Check if tool name matches or category matches
if (toolName.toLowerCase().includes(q) || categoryMatch) {
matches.push({
category: category.name,

View File

@@ -33,7 +33,7 @@ export function registerRoutingTools(
// Route trace tool
server.tool(
"route_trace",
"Route a trace between two points",
"Route a trace segment between two XY points on a fixed layer. WARNING: Does NOT handle layer changes — if start and end are on different copper layers, use route_pad_to_pad instead, which automatically inserts a via.",
{
start: z
.object({
@@ -331,7 +331,7 @@ export function registerRoutingTools(
// Route pad to pad tool
server.tool(
"route_pad_to_pad",
"Route a trace directly from one component pad to another without needing separate get_pad_position calls. Automatically looks up pad coordinates and uses the pad's net. Saves token usage compared to the 3-step get_pad_position + get_pad_position + route_trace sequence.",
"PREFERRED tool for pad-to-pad routing. Looks up pad positions automatically, detects the net from the pad, and — critically — if the two pads are on different copper layers (e.g. J1 on F.Cu and J2 on B.Cu) automatically inserts a via at the midpoint so the connection is complete. Always use this instead of route_trace when routing between named component pads.",
{
fromRef: z.string().describe("Reference of the source component (e.g. 'U2')"),
fromPad: z.union([z.string(), z.number()]).describe("Pad number on the source component (e.g. '6' or 6)"),

View File

@@ -227,7 +227,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
// Add pin-to-pin connection
server.tool(
"add_schematic_connection",
"Connect two component pins with a wire",
"Connect two component pins with a wire. Use this for individual connections between components with different pin roles (e.g. U1.SDA → J3.2). WARNING: Do NOT use this in a loop to wire N passthrough pins — use connect_passthrough instead (single call, cleaner layout, far fewer tokens).",
{
schematicPath: z.string().describe("Path to the schematic file"),
sourceRef: z.string().describe("Source component reference (e.g., R1)"),
@@ -385,6 +385,101 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
},
);
// Get pin locations for a schematic component
server.tool(
"get_schematic_pin_locations",
"Returns the exact x/y coordinates of every pin on a schematic component. Use this before add_schematic_net_label to place labels correctly on pin endpoints.",
{
schematicPath: z.string().describe("Path to the schematic file"),
reference: z.string().describe("Component reference designator (e.g. U1, R1, J2)"),
},
async (args: { schematicPath: string; reference: string }) => {
const result = await callKicadScript("get_schematic_pin_locations", args);
if (result.success && result.pins) {
const lines = Object.entries(result.pins as Record<string, any>).map(
([pinNum, data]: [string, any]) =>
` Pin ${pinNum} (${data.name || pinNum}): x=${data.x}, y=${data.y}, angle=${data.angle ?? 0}°`
);
return {
content: [{
type: "text",
text: `Pin locations for ${args.reference}:\n${lines.join("\n")}`,
}],
};
} else {
return {
content: [{
type: "text",
text: `Failed to get pin locations: ${result.message || "Unknown error"}`,
}],
};
}
},
);
// Connect all pins of source connector to matching pins of target connector (passthrough)
server.tool(
"connect_passthrough",
"Connects all pins of a source connector (e.g. J1) to matching pins of a target connector (e.g. J2) via shared net labels — pin N gets net '{netPrefix}_{N}'. Use this for FFC/ribbon cable passthrough adapters instead of calling connect_to_net for every pin.",
{
schematicPath: z.string().describe("Path to the schematic file"),
sourceRef: z.string().describe("Source connector reference (e.g. J1)"),
targetRef: z.string().describe("Target connector reference (e.g. J2)"),
netPrefix: z.string().optional().describe("Net name prefix, e.g. 'CSI' → CSI_1, CSI_2 (default: PIN)"),
pinOffset: z.number().optional().describe("Add to pin number when building net name (default: 0)"),
},
async (args: { schematicPath: string; sourceRef: string; targetRef: string; netPrefix?: string; pinOffset?: number }) => {
const result = await callKicadScript("connect_passthrough", args);
if (result.success !== false || (result.connected && result.connected.length > 0)) {
const lines: string[] = [];
if (result.connected?.length) lines.push(`Connected (${result.connected.length}): ${result.connected.slice(0, 5).join(", ")}${result.connected.length > 5 ? " ..." : ""}`);
if (result.failed?.length) lines.push(`Failed (${result.failed.length}): ${result.failed.join(", ")}`);
return {
content: [{ type: "text", text: result.message + "\n" + lines.join("\n") }],
};
} else {
return {
content: [{ type: "text", text: `Passthrough failed: ${result.message || "Unknown error"}` }],
};
}
},
);
// Run Electrical Rules Check (ERC)
server.tool(
"run_erc",
"Runs the KiCAD Electrical Rules Check (ERC) on a schematic and returns all violations. Use after wiring to verify the schematic before generating a netlist.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("run_erc", args);
if (result.success) {
const violations: any[] = result.violations || [];
const lines: string[] = [`ERC result: ${violations.length} violation(s)`];
if (result.summary?.by_severity) {
const s = result.summary.by_severity;
lines.push(` Errors: ${s.error ?? 0} Warnings: ${s.warning ?? 0} Info: ${s.info ?? 0}`);
}
if (violations.length > 0) {
lines.push("");
violations.slice(0, 30).forEach((v: any, i: number) => {
const loc = v.location && (v.location.x !== undefined) ? ` @ (${v.location.x}, ${v.location.y})` : "";
lines.push(`${i + 1}. [${v.severity}] ${v.message}${loc}`);
});
if (violations.length > 30) {
lines.push(`... and ${violations.length - 30} more`);
}
}
return { content: [{ type: "text", text: lines.join("\n") }] };
} else {
return {
content: [{ type: "text", text: `ERC failed: ${result.message || "Unknown error"}${result.errorDetails ? "\n" + result.errorDetails : ""}` }],
};
}
},
);
// Generate netlist
server.tool(
"generate_netlist",
@@ -432,4 +527,20 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
}
},
);
// Sync schematic to PCB board (equivalent to KiCAD F8 / "Update PCB from Schematic")
server.tool(
"sync_schematic_to_board",
"Import the schematic netlist into the PCB board — equivalent to pressing F8 in KiCAD (Tools → Update PCB from Schematic). MUST be called after the schematic is complete and before placing or routing components on the PCB. Without this step, the board has no footprints and no net assignments — place_component and route_pad_to_pad will produce an empty, unroutable board.",
{
schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"),
boardPath: z.string().describe("Absolute path to the .kicad_pcb board file"),
},
async (args: { schematicPath: string; boardPath: string }) => {
const result = await callKicadScript("sync_schematic_to_board", args);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
},
);
}