From 68fd4509b9ebbf8fe0d5fd80df569d63114c2de1 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 09:59:54 +0100 Subject: [PATCH 01/28] chore: update changelog and schematic template --- CHANGELOG.md | 15 ++ .../template_with_symbols_expanded.kicad_sch | 242 ------------------ 2 files changed, 15 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ddfc60..17ca4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/python/templates/template_with_symbols_expanded.kicad_sch b/python/templates/template_with_symbols_expanded.kicad_sch index 157e346..d2a7403 100644 --- a/python/templates/template_with_symbols_expanded.kicad_sch +++ b/python/templates/template_with_symbols_expanded.kicad_sch @@ -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")) From 1d390f4fed6436b56ee28f045b0a72eb1a86ee4d Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 11:29:52 +0100 Subject: [PATCH 02/28] fix: schematic pin connection reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add_schematic_net_label: warn in description that coords must be exact pin endpoints; recommend connect_to_net instead - connect_to_net: stub wire direction now follows pin angle (was hardcoded +X) - pin_locator.py: add get_pin_angle() and _get_lib_id() helpers - new tool: get_schematic_pin_locations(schematicPath, reference) → returns exact x/y of every pin endpoint, so Claude can place labels correctly --- python/commands/connection_schematic.py | 11 ++++- python/commands/pin_locator.py | 57 +++++++++++++++++++++++++ python/kicad_interface.py | 42 ++++++++++++++++++ python/schemas/tool_schemas.py | 21 ++++++++- src/tools/schematic.ts | 32 ++++++++++++++ 5 files changed, 161 insertions(+), 2 deletions(-) diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 286b67f..d18e298 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -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) diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index bf4367d..94a5b35 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -179,6 +179,63 @@ 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: + 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]]: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 891a6d1..c6b4355 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -375,6 +375,7 @@ 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, + "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_net_connections": self._handle_get_net_connections, "generate_netlist": self._handle_generate_netlist, "list_schematic_libraries": self._handle_list_schematic_libraries, @@ -1271,6 +1272,47 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } + 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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index fb02be9..223b8fb 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1388,7 +1388,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 +1463,25 @@ 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": "generate_netlist", "title": "Generate Netlist", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 4db17fc..9407b93 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -385,6 +385,38 @@ 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).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"}`, + }], + }; + } + }, + ); + // Generate netlist server.tool( "generate_netlist", From fec537950fb8cf8b8dbe6b3e97425bfd680bbcf1 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 11:38:54 +0100 Subject: [PATCH 03/28] fix: PinLocator resolves pin names (e.g. VCC1, SDA) in addition to pin numbers --- python/commands/pin_locator.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 94a5b35..857c76e 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -226,7 +226,14 @@ class PinLocator: pins = self.get_symbol_pins(schematic_path, lib_id) if pin_number not in pins: - return None + 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) @@ -294,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] From 4c12096886cec0b0d3b09a9c60be9183315f9497 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 11:49:31 +0100 Subject: [PATCH 04/28] fix: redirect Python logging from stderr to stdout (prevents false [ERROR] labels in MCP log) --- python/kicad_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index c6b4355..1f58207 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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), logging.StreamHandler(sys.stdout)], ) logger = logging.getLogger("kicad_interface") From 11d7e49e74db3a636ae59e5864e75d026112ed12 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 12:25:25 +0100 Subject: [PATCH 05/28] feat: add connect_passthrough tool for FFC/ribbon cable pin-to-pin wiring --- python/commands/connection_schematic.py | 71 +++++++++++++++++++++++++ python/kicad_interface.py | 34 ++++++++++++ python/schemas/tool_schemas.py | 33 +++++++++++- src/tools/schematic.ts | 28 ++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index d18e298..45542d1 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -291,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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 1f58207..0947d8f 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -375,6 +375,7 @@ 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, "generate_netlist": self._handle_generate_netlist, @@ -1272,6 +1273,39 @@ 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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 223b8fb..660e31b 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1483,7 +1483,38 @@ SCHEMATIC_TOOLS = [ } }, { - "name": "generate_netlist", + "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.", + "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": "generate_netlist",, "title": "Generate Netlist", "description": "Generates a netlist from the schematic showing all components and their net connections.", "inputSchema": { diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 9407b93..65a6822 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -417,6 +417,34 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // 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"}` }], + }; + } + }, + ); + // Generate netlist server.tool( "generate_netlist", From 23946a211a2117af5fd374ef9bd8ba3cbeffa703 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 12:32:35 +0100 Subject: [PATCH 06/28] feat: add run_erc tool for schematic electrical rules checking --- python/kicad_interface.py | 83 ++++++++++++++++++++++++++++++++++ python/schemas/tool_schemas.py | 15 ++++++ src/tools/schematic.ts | 35 ++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 0947d8f..6fc333e 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -378,6 +378,7 @@ class KiCADInterface: "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, "list_schematic_libraries": self._handle_list_schematic_libraries, "export_schematic_pdf": self._handle_export_schematic_pdf, @@ -1367,6 +1368,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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 660e31b..5b90a83 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1513,6 +1513,21 @@ SCHEMATIC_TOOLS = [ "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": "generate_netlist",, "title": "Generate Netlist", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 65a6822..d81a5fd 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -445,6 +445,41 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // 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", From 410d02ad8bfc30cf2f548ed0e1dc79d9eee33002 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 12:41:11 +0100 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20add=20import=5Fsvg=5Flogo=20tool?= =?UTF-8?q?=20=E2=80=94=20converts=20SVG=20to=20PCB=20silkscreen=20polygon?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/commands/svg_import.py | 596 +++++++++++++++++++++++++++++++++ python/kicad_interface.py | 27 ++ python/schemas/tool_schemas.py | 47 +++ src/tools/board.ts | 37 ++ 4 files changed, 707 insertions(+) create mode 100644 python/commands/svg_import.py diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py new file mode 100644 index 0000000..fea6d57 --- /dev/null +++ b/python/commands/svg_import.py @@ -0,0 +1,596 @@ +""" +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: + M L H V Z C S Q T A commands (curves are linearised) + → 4-point polygon + → N-gon approximation + / → direct point list + 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]: + return [tok for tok, _ in _TOKEN_RE.findall(d) if tok] + \ + [num for _, num in _TOKEN_RE.findall(d) if num] + + +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)} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 6fc333e..cf9496f 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -382,6 +382,7 @@ class KiCADInterface: "generate_netlist": self._handle_generate_netlist, "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, @@ -1471,6 +1472,32 @@ class KiCADInterface: logger.error(f"Error generating netlist: {str(e)}") 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"} + + return import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled) + + 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_check_kicad_ui(self, params): """Check if KiCAD UI is running""" logger.info("Checking if KiCAD UI is running") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 5b90a83..33ba780 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -264,6 +264,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", diff --git a/src/tools/board.ts b/src/tools/board.ts index b4799a0..5ab41d3 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -346,4 +346,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"}` }], + }; + } + }, + ); } + From e7a8234ec4e981d458c1ce8494505a17b92c9620 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 12:45:54 +0100 Subject: [PATCH 08/28] fix: list_library_footprints accepts library_name as alias for library param --- python/commands/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/commands/library.py b/python/commands/library.py index 765552f..abf30d0 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -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"} From 93ae23dacc75063a2ae1d5423f0cc6222fcf5013 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 13:17:25 +0100 Subject: [PATCH 09/28] fix: prevent header corruption when sexpdata compacts schematic to single line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_component_instance used line-based insertion which placed new symbols BEFORE (kicad_sch ...) header when the file was written as a single line by sexpdata.dumps(). Switch to rfind()-based string insertion which is format-independent. Also remove StreamHandler(sys.stdout) from logging — Python logs now go only to file (~/.kicad-mcp/logs/kicad_interface.log) to avoid polluting MCP stderr with INFO/DEBUG entries shown as [error]. --- python/commands/dynamic_symbol_loader.py | 26 +++++++----------------- python/kicad_interface.py | 2 +- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index cc22ebd..a2736d1 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -450,29 +450,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})" diff --git a/python/kicad_interface.py b/python/kicad_interface.py index cf9496f..7ae71c2 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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.stdout)], + handlers=[logging.FileHandler(log_file)], ) logger = logging.getLogger("kicad_interface") From dcecde8d2bb72dbd1b73a9f7d83a1739b01cd895 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 17:22:09 +0100 Subject: [PATCH 10/28] fix: remove duplicate comma in tool_schemas.py (SyntaxError) --- python/schemas/tool_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 33ba780..f827e2d 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1576,7 +1576,7 @@ SCHEMATIC_TOOLS = [ } }, { - "name": "generate_netlist",, + "name": "generate_netlist", "title": "Generate Netlist", "description": "Generates a netlist from the schematic showing all components and their net connections.", "inputSchema": { From bed65a6da25106da9209f274af0e8a65398248b6 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 17:32:21 +0100 Subject: [PATCH 11/28] fix: true -> True in tool_schemas.py (Python NameError) --- python/schemas/tool_schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index f827e2d..59dafa9 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -305,7 +305,7 @@ BOARD_TOOLS = [ "filled": { "type": "boolean", "description": "Fill polygons with solid layer colour (default true)", - "default": true + "default": True } }, "required": ["pcbPath", "svgPath", "x", "y", "width"] From 06886267817be6099ee50e7d053c0710b9696c6f Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 18:10:20 +0100 Subject: [PATCH 12/28] fix: inject_symbol_into_lib_symbols handles sexpdata single-line compaction --- python/commands/dynamic_symbol_loader.py | 46 +++++++++--------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index a2736d1..96ede68 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -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 = ( From 373ab35a10c0fdade471858016edb7f9902a4831 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 18:34:27 +0100 Subject: [PATCH 13/28] fix: svg_import tokenizer preserves command/number order (fixes V-command crash) --- python/commands/svg_import.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py index fea6d57..f0b7386 100644 --- a/python/commands/svg_import.py +++ b/python/commands/svg_import.py @@ -40,8 +40,13 @@ _TOKEN_RE = re.compile( def _tokenize_path(d: str) -> List[str]: - return [tok for tok, _ in _TOKEN_RE.findall(d) if tok] + \ - [num for _, num in _TOKEN_RE.findall(d) if num] + 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]: From e74653d59d2235d1906cec027558c7ff84d44750 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 19:28:30 +0100 Subject: [PATCH 14/28] fix: add connect_passthrough + schematic tools to directToolNames so AI always sees them --- src/tools/registry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 1f9294b..181b4ce 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -137,6 +137,12 @@ 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", + // UI management "check_kicad_ui" ]; From ec39905702f50e8a46421c4121fdf83c3929019b Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 20:05:30 +0100 Subject: [PATCH 15/28] feat: add sync_schematic_to_board + fix refill_zones subprocess isolation --- python/kicad_interface.py | 180 ++++++++++++++++++++++++++++++--- python/schemas/tool_schemas.py | 18 ++++ src/tools/registry.ts | 6 +- 3 files changed, 190 insertions(+), 14 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 7ae71c2..4fba299 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -380,6 +380,7 @@ class KiCADInterface: "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, @@ -1472,6 +1473,114 @@ 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") @@ -1536,8 +1645,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 { @@ -1546,18 +1663,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)} diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 59dafa9..7a752b0 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1575,6 +1575,24 @@ SCHEMATIC_TOOLS = [ "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", diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 181b4ce..f01e089 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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" ] }, { @@ -143,6 +144,9 @@ export const directToolNames = [ "connect_to_net", "add_schematic_net_label", + // Schematic <-> PCB sync (F8 equivalent) + "sync_schematic_to_board", + // UI management "check_kicad_ui" ]; From 8e92e144b3988f83f74dc13afc487b31a18b6b60 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 20:33:25 +0100 Subject: [PATCH 16/28] fix: board outline 3 bugs (cornerRadius fallback, top-left origin default, IPC rounded_rect arc delegation) --- python/commands/board/outline.py | 26 +++++++++++++++++++++++--- python/kicad_interface.py | 12 +++++++++++- python/schemas/tool_schemas.py | 21 ++++++++++++++------- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index d003213..9f34549 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -30,13 +30,33 @@ class BoardOutlineCommands: 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) + # Accept both "cornerRadius" and "radius" (schema uses "radius" for rounded_rectangle) + corner_radius = params.get("cornerRadius", params.get("radius", 0) if shape == "rounded_rectangle" else 0) points = params.get("points", []) unit = params.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 = params.get("x") + y = params.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 = params.get("centerX") + raw_cy = params.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 { "success": False, diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 4fba299..9b9ac94 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2131,7 +2131,17 @@ print("ok") 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 == "rounded_rectangle": + # IPC path only supports straight segments — fall back to SWIG for arcs + logger.info("_ipc_add_board_outline: delegating rounded_rectangle to SWIG path for arc support") + return self.board_commands.add_board_outline(params) + try: from kipy.board_types import BoardSegment from kipy.geometry import Vector2 diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 7a752b0..d692da6 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -110,7 +110,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 +129,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 } From ae66001886e7e38f3335cebea1f9514f4dac1167 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 20:38:16 +0100 Subject: [PATCH 17/28] fix: reload board into pcbnew after SVG logo import (prevents logo loss on next Save) --- python/kicad_interface.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 9b9ac94..6026396 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -1599,7 +1599,25 @@ class KiCADInterface: if not pcb_path or not svg_path: return {"success": False, "message": "Missing required parameters: pcbPath, svgPath"} - return import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled) + 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 to sub-command objects that hold a board reference + for attr in ("board_commands", "routing_commands", "component_commands"): + obj = getattr(self, attr, None) + if obj is not None: + obj.board = self.board + 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)}") From b43c63edcaef6126ceee26b5c6ca7284ec207187 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 20:42:42 +0100 Subject: [PATCH 18/28] feat: add snapshot_project tool for step checkpoints (resume from Step2 without full redo) --- python/kicad_interface.py | 37 ++++++++++++++++++++++++++++++++++ python/schemas/tool_schemas.py | 22 ++++++++++++++++++++ src/tools/registry.ts | 1 + 3 files changed, 60 insertions(+) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 6026396..0bc1b66 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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, @@ -1625,6 +1626,42 @@ class KiCADInterface: 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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index d692da6..16d6e37 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -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", diff --git a/src/tools/registry.ts b/src/tools/registry.ts index f01e089..6eec4fb 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -125,6 +125,7 @@ export const directToolNames = [ "create_project", "open_project", "save_project", + "snapshot_project", "get_project_info", // Core PCB operations From f3078b7a02562bbab8cef059d1c9c6e9c2e8c54e Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 6 Mar 2026 20:51:52 +0100 Subject: [PATCH 19/28] fix: B.Cu footprint Flip() in SWIG path + J2-on-B.Cu prompt note + pin-1-at-top schema hint --- python/commands/component.py | 13 +++++++++---- python/schemas/tool_schemas.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/python/commands/component.py b/python/commands/component.py index 9489060..bda0b40 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -125,10 +125,15 @@ 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 B.Cu the footprint must be flipped (mirrored), + # not just layer-assigned. Flip() handles layer + mirror in one step. + if layer == "B.Cu": + if not module.IsFlipped(): + module.Flip(module.GetPosition(), False) + else: + layer_id = self.board.GetLayerID(layer) + if layer_id >= 0: + module.SetLayer(layer_id) # Add to board self.board.Add(module) diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 16d6e37..dffe3d7 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1561,7 +1561,7 @@ SCHEMATIC_TOOLS = [ { "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.", + "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": { From f4d516f5c33e8af0ec25ba880a986d1064ab68b5 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 00:59:32 +0100 Subject: [PATCH 20/28] fix: Flip() after board.Add() to avoid 30s hang in KiCAD 9 FootprintLoad+Flip() on standalone footprint (not yet in board) causes a 30-second block in KiCAD 9 pcbnew. Solution: add footprint to board first, then call Flip() with board context available. Fixes B.Cu placement timeout that killed the Python process. --- python/commands/component.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/python/commands/component.py b/python/commands/component.py index bda0b40..c1bb98c 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -125,19 +125,20 @@ class ComponentCommands: angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) module.SetOrientation(angle) - # Set layer — for B.Cu the footprint must be flipped (mirrored), - # not just layer-assigned. Flip() handles layer + mirror in one step. - if layer == "B.Cu": - if not module.IsFlipped(): - module.Flip(module.GetPosition(), False) - else: + # 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}", From 44c5e7f388a1d03e771919d24083b48b31c509f9 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 01:43:43 +0100 Subject: [PATCH 21/28] fix: unwrap nested params in add_board_outline (SWIG + IPC paths) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude sends {shape:'rectangle', params:{x,y,width,height,...}} but handlers were reading params.get('width') on the outer dict → None → wrong board size. outline.py: extract inner = params.get('params', params) at method start, read all dimensions from inner dict. kicad_interface.py (_ipc_add_board_outline): delegate 'rectangle' to SWIG path (same as 'rounded_rectangle') so Claude's shape+dims call is handled correctly. For polygon IPC path: also unwrap inner params for points/width. --- python/commands/board/outline.py | 25 +++++++++++++++---------- python/kicad_interface.py | 14 +++++++++----- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index 9f34549..71f706c 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -27,28 +27,33 @@ 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") - radius = params.get("radius") + width = inner.get("width") + height = inner.get("height") + radius = inner.get("radius") # Accept both "cornerRadius" and "radius" (schema uses "radius" for rounded_rectangle) - corner_radius = params.get("cornerRadius", params.get("radius", 0) if shape == "rounded_rectangle" else 0) - points = params.get("points", []) - unit = params.get("unit", "mm") + corner_radius = inner.get("cornerRadius", inner.get("radius", 0) if shape == "rounded_rectangle" else 0) + 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 = params.get("x") - y = params.get("y") + 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 = params.get("centerX") - raw_cy = params.get("centerY") + 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 diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 0bc1b66..86eec53 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -2192,9 +2192,11 @@ print("ok") and correctly generates PCB_SHAPE arcs for rounded corners. """ shape = params.get("shape", "rectangle") - if shape == "rounded_rectangle": - # IPC path only supports straight segments — fall back to SWIG for arcs - logger.info("_ipc_add_board_outline: delegating rounded_rectangle to SWIG path for arc support") + 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: @@ -2205,8 +2207,10 @@ print("ok") 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 { From b6e1b8b7b152ee770d211d26bd112d1fdf4708a1 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 01:50:03 +0100 Subject: [PATCH 22/28] fix: propagate board reload to project_commands after SVG logo import save_project uses project_commands.board, which was NOT in the propagation list after pcbnew.LoadBoard() in _handle_import_svg_logo. As a result, save_project() wrote the old in-memory board (without logo gr_poly entries) back to disk, erasing the logo every time. Fix: call _update_command_handlers() after reload, which updates all command handler board references including project_commands. --- python/kicad_interface.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 86eec53..3917ae5 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -1609,11 +1609,8 @@ class KiCADInterface: if result.get("success") and self.board: try: self.board = pcbnew.LoadBoard(pcb_path) - # Propagate to sub-command objects that hold a board reference - for attr in ("board_commands", "routing_commands", "component_commands"): - obj = getattr(self, attr, None) - if obj is not None: - obj.board = self.board + # 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}") From 0f1bfa3eff52dc19684fdcb686ec494dd40a708a Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 09:56:08 +0100 Subject: [PATCH 23/28] fix: search_tools now includes direct tools (snapshot_project etc.) - searchTools() previously only searched routed tool categories, so direct tools like snapshot_project, save_project, create_project returned 0 results when Claude called search_tools('snapshot') - Direct tools are now searched first, with a hint that they must be called directly (not via execute_tool) Also includes routing.py zone-inset fix for rounded board corners (already staged from previous session) --- python/commands/routing.py | 22 ++++++++++++++++++---- src/tools/registry.ts | 16 ++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/python/commands/routing.py b/python/commands/routing.py index b84f4c1..563314d 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -1095,11 +1095,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 { diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 6eec4fb..ddb307e 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -228,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, From 8e4e3176c2091dc2fa9245449aa25b9938384777 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 11:27:41 +0100 Subject: [PATCH 24/28] fix: register sync_schematic_to_board + snapshot_project as MCP tools; auto-save board after SWIG mutations; boardPath reload in place_component; add_schematic_connection warns about connect_passthrough; via.GetWidth(F_Cu) for KiCAD 9.0 --- python/commands/routing.py | 4 +-- python/kicad_interface.py | 54 +++++++++++++++++++++++++++++++++++++- src/tools/project.ts | 19 ++++++++++++++ src/tools/schematic.ts | 18 ++++++++++++- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/python/commands/routing.py b/python/commands/routing.py index 563314d..befe72f 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -367,7 +367,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 +950,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()) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 3917ae5..3435d07 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -496,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: @@ -516,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") @@ -588,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 diff --git a/src/tools/project.ts b/src/tools/project.ts index ebd9fda..4a1b739 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -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) + }] + }; + } + ); } diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index d81a5fd..65ebc44 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -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)"), @@ -527,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) }], + }; + }, + ); } From 91fe6a379c7c35471446488a406e5172310e0e1f Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 12:45:52 +0100 Subject: [PATCH 25/28] =?UTF-8?q?feat:=20dev=20mode=20=E2=80=94=20auto-cop?= =?UTF-8?q?y=20MCP=20session=20log=20to=20project=20dir=20on=20export=5Fge?= =?UTF-8?q?rber=20(KICAD=5FMCP=5FDEV=3D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/windows-config.example.json | 3 +- python/commands/export.py | 57 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/config/windows-config.example.json b/config/windows-config.example.json index 78dcd09..7fdb060 100644 --- a/config/windows-config.example.json +++ b/config/windows-config.example.json @@ -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" } diff --git a/python/commands/export.py b/python/commands/export.py index 5c74a66..2307506 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -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: + /mcp_log_.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)") From 2e36f96c555582e89eb13bb110e2272b53a43c48 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 14:52:45 +0100 Subject: [PATCH 26/28] fix: board outline position and rounded corners (3 bugs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Rounded corners missing (src/tools/board.ts schema) Symptom: Claude sends shape='rectangle' + radius=2.5, but the Zod schema only listed 'rectangle', 'circle', 'polygon' as valid shapes. The JS handler never passed cornerRadius through the IPC path, and Python only generated arcs for shape='rounded_rectangle'. Result: 4 straight lines, no arcs. Fix: Added 'rounded_rectangle' and 'cornerRadius' to the Zod schema in src/tools/board.ts so Claude can send the correct shape directly. Also added a Python-side auto-upgrade: if shape='rectangle' and cornerRadius>0, silently promote to 'rounded_rectangle' so legacy callers still work (python/commands/board/outline.py). Bug 2 — Board outline placed at (-w/2, -h/2) instead of (0, 0) Symptom: A 30x30 mm board outline was placed centred at the origin (start -15 -15) ... (end 15 15) instead of the expected top-left at (0,0) → (30,30). Root cause: src/tools/board.ts extracted x/y from params and renamed them to centerX/centerY before forwarding to Python: const { x, y, ...otherParams } = params; callKicadScript('add_board_outline', { centerX: x, centerY: y, ...otherParams }) Python received centerX=0, centerY=0 and used that as the board centre, placing the outline at (-15,-15)→(+15,+15). Fix: Pass x/y directly as top-left corner coordinates. Python already contains the correct logic: center = x + width/2, y + height/2. Removed the incorrect rename in board.ts: callKicadScript('add_board_outline', { shape, ...params }) Bug 3 — print() on stdout corrupted JSON-RPC protocol → Timeout Symptom: Claude Desktop reported 'Timeout' for add_board_outline even though Python completed the command in <5 ms. KiCAD was falsely launched. Root cause: Debug print() statements written to stdout (the same channel used for JSON-RPC responses) injected non-JSON lines into the protocol. Node.js could not parse the response → timeout → Claude Desktop invented a 'KiCAD UI required' workaround. Fix: Removed all print() calls from outline.py. Logger.info/debug writes to the log file (~/.kicad-mcp/logs/kicad_interface.log) and is safe. Files changed: src/tools/board.ts — Zod schema + removed centerX/centerY rename python/commands/board/outline.py — auto-upgrade rectangle+radius, remove print() --- python/commands/board/outline.py | 7 +++++-- src/tools/board.ts | 19 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index 71f706c..52bf289 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -36,8 +36,11 @@ class BoardOutlineCommands: width = inner.get("width") height = inner.get("height") radius = inner.get("radius") - # Accept both "cornerRadius" and "radius" (schema uses "radius" for rounded_rectangle) - corner_radius = inner.get("cornerRadius", inner.get("radius", 0) if shape == "rounded_rectangle" else 0) + # 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") diff --git a/src/tools/board.ts b/src/tools/board.ts index 5ab41d3..1fc4ef8 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -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 { From 9d344c95a5fedb9bd4aa4aeaeb865a76ce314040 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 14:57:41 +0100 Subject: [PATCH 27/28] fix: use local system time in MCP server log (was UTC/ISO) --- src/logger.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/logger.ts b/src/logger.ts index 6a22d8b..f97e7c1 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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 From 3e329cbf6b4bb2b7a26879cb912b56e4e2bc0308 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 7 Mar 2026 15:46:54 +0100 Subject: [PATCH 28/28] fix: route_pad_to_pad auto-inserts via when pads are on different copper layers - Detects F.Cu<->B.Cu layer mismatch in route_pad_to_pad - Splits route into: trace on start layer + via at midpoint + trace on end layer - route_trace description warns: use route_pad_to_pad for cross-layer routing - route_pad_to_pad description highlights automatic via insertion --- python/commands/routing.py | 80 +++++++++++++++++++++++++------------- src/tools/routing.ts | 4 +- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/python/commands/routing.py b/python/commands/routing.py index befe72f..d630943 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -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 diff --git a/src/tools/routing.ts b/src/tools/routing.ts index f086608..309bd2d 100644 --- a/src/tools/routing.ts +++ b/src/tools/routing.ts @@ -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)"),