From a9d7af5edf45299b7605bd79558964373ef394d3 Mon Sep 17 00:00:00 2001 From: Ravi Date: Tue, 2 Jun 2026 19:41:45 -0700 Subject: [PATCH] feat(schematic): hierarchical sheet insertion and subsheet scaffolding Adds add_hierarchical_sheet (insert a sheet symbol referencing a child .kicad_sch, with sheet_instances + fixed component instance paths) and create_hierarchical_subsheet (create the child file + wire it into the parent in one call). Upstream has add_sheet_pin / add_schematic_hierarchical_label but no way to create a sheet or stand up a child sheet, so hierarchical designs can't be built through the MCP server today. - python/commands/schematic_hierarchy.py: SchematicHierarchyCommands - src/tools/schematic-hierarchy.ts + registry 'schematic_hierarchy' category - python/kicad_interface.py: import + instantiate + dispatch routes - tests/test_schematic_hierarchy.py: 5 unit/integration tests on real .kicad_sch Co-Authored-By: Claude Opus 4.8 --- python/commands/schematic_hierarchy.py | 272 +++++++++++++++++++++++++ python/kicad_interface.py | 6 + src/server.ts | 2 + src/tools/registry.ts | 5 + src/tools/schematic-hierarchy.ts | 64 ++++++ tests/test_schematic_hierarchy.py | 96 +++++++++ 6 files changed, 445 insertions(+) create mode 100644 python/commands/schematic_hierarchy.py create mode 100644 src/tools/schematic-hierarchy.ts create mode 100644 tests/test_schematic_hierarchy.py diff --git a/python/commands/schematic_hierarchy.py b/python/commands/schematic_hierarchy.py new file mode 100644 index 0000000..3530177 --- /dev/null +++ b/python/commands/schematic_hierarchy.py @@ -0,0 +1,272 @@ +""" +Schematic hierarchical-sheet commands. + +Tools: + - add_hierarchical_sheet: insert a hierarchical-sheet reference into a parent schematic + - create_hierarchical_subsheet: create a sub-sheet file and link it in one call + +The command class holds a back-reference to KiCADInterface so it can reuse the existing +create_schematic handler, and exposes fix_subsheet_instances for the batch module to call. +""" + +import logging +import re +import uuid +from pathlib import Path +from typing import Any, Dict, List + +import sexpdata +from sexpdata import Symbol + +logger = logging.getLogger("kicad_interface") + + +class SchematicHierarchyCommands: + """Handlers for hierarchical sheet insertion and subsheet scaffolding.""" + + def __init__(self, iface): + self.iface = iface + + def add_hierarchical_sheet(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Insert a hierarchical sheet reference block into a parent schematic.""" + logger.info("Adding hierarchical sheet") + try: + schematic_path = params.get("schematicPath") + subsheet_path = params.get("subsheetPath") + sheet_name = params.get("sheetName", "Sheet") + position = params.get("position", {}) + size = params.get("size", {}) + + if not schematic_path or not subsheet_path: + return {"success": False, "message": "schematicPath and subsheetPath are required"} + + x = float(position.get("x", 50)) + y = float(position.get("y", 50)) + w = float(size.get("width", 80)) + h = float(size.get("height", 50)) + + parent_file = Path(schematic_path) + try: + rel_str = str( + Path(subsheet_path).resolve().relative_to(parent_file.parent.resolve()) + ).replace("\\", "/") + except ValueError: + rel_str = str(subsheet_path).replace("\\", "/") + + sheet_block_uuid = str(uuid.uuid4()) + name_x, name_y = round(x + 2.54, 4), round(y - 1.27, 4) + file_x, file_y = round(x + 2.54, 4), round(y + h + 1.27, 4) + + sheet_block = ( + f" (sheet (at {x} {y}) (size {w} {h}) (fields_autoplaced yes)\n" + f" (stroke (width 0.0006) (type default))\n" + f" (fill (color 0 0 0 0.0000))\n" + f' (uuid "{sheet_block_uuid}")\n' + f' (property "Sheet name" "{sheet_name}" (at {name_x} {name_y} 0)\n' + f" (effects (font (size 1.27 1.27)) (justify left bottom))\n" + f" )\n" + f' (property "Sheet file" "{rel_str}" (at {file_x} {file_y} 0)\n' + f" (effects (font (size 1.27 1.27)) (justify left bottom))\n" + f" )\n" + f" )\n" + ) + + content = parent_file.read_text(encoding="utf-8") + parent_uuid_match = re.search(r"\(uuid\s+([0-9a-fA-F-]+)\)", content) + parent_uuid = parent_uuid_match.group(1) if parent_uuid_match else "" + existing_pages = re.findall(r'\(page\s+"(\d+)"\)', content) + next_page = max((int(p) for p in existing_pages), default=0) + 1 + + instance_path = ( + f"/{parent_uuid}/{sheet_block_uuid}" if parent_uuid else f"/{sheet_block_uuid}" + ) + path_entry = f' (path "{instance_path}" (page "{next_page}"))\n' + + insert_at = content.rfind("(sheet_instances") + if insert_at == -1: + return {"success": False, "message": "Could not find (sheet_instances in schematic"} + content = content[:insert_at] + sheet_block + " " + content[insert_at:] + + si_start = content.rfind("(sheet_instances") + depth = 0 + si_close = len(content) - 1 + for i in range(si_start, len(content)): + if content[i] == "(": + depth += 1 + elif content[i] == ")": + depth -= 1 + if depth == 0: + si_close = i + break + content = content[:si_close] + path_entry + " " + content[si_close:] + + parent_file.write_text(content, encoding="utf-8") + + # Ensure each sub-sheet component has the hierarchical instance entry. + self.fix_subsheet_instances(str(parent_file), content) + + return { + "success": True, + "sheet_uuid": sheet_block_uuid, + "sheet_name": sheet_name, + "subsheet_path": rel_str, + "page": next_page, + } + + except Exception as e: + logger.error(f"Error adding hierarchical sheet: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def create_hierarchical_subsheet(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Create a new sub-sheet file and link it into a parent schematic in one call.""" + logger.info("Creating hierarchical subsheet") + try: + parent_path = params.get("parentSchematicPath") + subsheet_path = params.get("subsheetPath") + sheet_name = params.get("sheetName", "Sheet") + position = params.get("position", {}) + size = params.get("size", {}) + metadata = params.get("metadata", {}) + + if not parent_path or not subsheet_path: + return { + "success": False, + "message": "parentSchematicPath and subsheetPath are required", + } + + create_result = self.iface._handle_create_schematic( + {"filename": subsheet_path, "metadata": metadata} + ) + if not create_result.get("success"): + return { + "success": False, + "message": f"Failed to create sub-sheet: {create_result.get('message')}", + } + + link_result = self.add_hierarchical_sheet( + { + "schematicPath": parent_path, + "subsheetPath": subsheet_path, + "sheetName": sheet_name, + "position": position, + "size": size, + } + ) + if not link_result.get("success"): + return { + "success": False, + "message": f"Created sub-sheet but failed to link: {link_result.get('message')}", + "subsheet_created": create_result.get("file_path", subsheet_path), + } + + return { + "success": True, + "subsheet_path": create_result.get("file_path", subsheet_path), + "subsheet_uuid": create_result.get("schematic_uuid"), + "sheet_block_uuid": link_result.get("sheet_uuid"), + "sheet_name": sheet_name, + "page": link_result.get("page"), + "message": ( + f"Created sub-sheet '{sheet_name}' at {subsheet_path} " + f"and linked it into {parent_path} (page {link_result.get('page')})" + ), + } + except Exception as e: + logger.error(f"Error in create_hierarchical_subsheet: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def fix_subsheet_instances(self, parent_path: str, parent_content: str) -> List[str]: + """Ensure every component in each referenced sub-sheet has an instances entry for the + sheet-block UUID, so ERC resolves references correctly. Returns modified sub-sheet paths. + """ + modified_sheets: List[str] = [] + try: + parent_file = Path(parent_path) + parent_data = sexpdata.loads(parent_content) + + for item in parent_data: + if not (isinstance(item, list) and len(item) > 0 and item[0] == Symbol("sheet")): + continue + + sheet_block_uuid = None + sheet_file_rel = None + for sub in item: + if isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("uuid"): + sheet_block_uuid = str(sub[1]) + elif ( + isinstance(sub, list) + and len(sub) >= 3 + and sub[0] == Symbol("property") + and sub[1] == "Sheet file" + ): + sheet_file_rel = str(sub[2]) + if not sheet_block_uuid or not sheet_file_rel: + continue + + sub_sheet_path = parent_file.parent / sheet_file_rel + if not sub_sheet_path.exists(): + logger.warning(f"Sub-sheet not found: {sub_sheet_path}") + continue + + parent_uuid_match = re.search(r"\(uuid\s+([0-9a-fA-F-]+)\)", parent_content) + parent_uuid = parent_uuid_match.group(1) if parent_uuid_match else "" + target_path = ( + f"/{parent_uuid}/{sheet_block_uuid}" if parent_uuid else f"/{sheet_block_uuid}" + ) + + sub_content = sub_sheet_path.read_text(encoding="utf-8") + + def _balanced_end(s: str, start: int) -> int: + depth = 0 + for j in range(start, len(s)): + if s[j] == "(": + depth += 1 + elif s[j] == ")": + depth -= 1 + if depth == 0: + return j + return len(s) - 1 + + result_parts: List[str] = [] + pos = 0 + changed = False + while True: + idx = sub_content.find("(instances", pos) + if idx == -1: + result_parts.append(sub_content[pos:]) + break + result_parts.append(sub_content[pos:idx]) + end = _balanced_end(sub_content, idx) + block = sub_content[idx : end + 1] + + if target_path not in block: + existing = re.search(r'\(reference\s+"([^"]+)"\)\s*\(unit\s+(\d+)\)', block) + if existing: + new_entry = ( + f'(path "{target_path}" (reference "{existing.group(1)}") ' + f"(unit {existing.group(2)}))" + ) + proj_start = block.find("(project ") + if proj_start != -1: + proj_end = _balanced_end(block, proj_start) + block = block[:proj_end] + " " + new_entry + block[proj_end:] + changed = True + result_parts.append(block) + pos = end + 1 + + if changed: + sub_sheet_path.write_text("".join(result_parts), encoding="utf-8") + modified_sheets.append(str(sub_sheet_path)) + logger.info( + f"Fixed instances in {sub_sheet_path} for sheet-block {sheet_block_uuid}" + ) + + except Exception as e: + logger.error(f"Error fixing sub-sheet instances: {e}") + return modified_sheets diff --git a/python/kicad_interface.py b/python/kicad_interface.py index c0e7848..a37b28a 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -325,6 +325,7 @@ try: from commands.project import ProjectCommands from commands.routing import RoutingCommands from commands.schematic import SchematicManager + from commands.schematic_hierarchy import SchematicHierarchyCommands from commands.symbol_creator import SymbolCreator from commands.symbol_pins import SymbolPinCommands @@ -447,6 +448,8 @@ class KiCADInterface: # Symbol pin discovery commands (read-only pin lookup from symbol libraries) self.symbol_pin_commands = SymbolPinCommands() + # Schematic hierarchy commands (insert sheets, scaffold sub-sheets) + self.hierarchy_commands = SchematicHierarchyCommands(self) # Initialize JLCPCB API integration self.jlcpcb_client = JLCPCBClient() # Official API (requires auth) @@ -534,6 +537,9 @@ class KiCADInterface: # Symbol pin discovery commands (read pins straight from symbol libraries) "list_symbol_pins": self.symbol_pin_commands.list_symbol_pins, "batch_list_symbol_pins": self.symbol_pin_commands.batch_list_symbol_pins, + # Schematic hierarchy commands (sheet insertion + subsheet scaffolding) + "add_hierarchical_sheet": self.hierarchy_commands.add_hierarchical_sheet, + "create_hierarchical_subsheet": self.hierarchy_commands.create_hierarchical_subsheet, # JLCPCB API commands (complete parts catalog via API) "download_jlcpcb_database": self._handle_download_jlcpcb_database, "search_jlcpcb_parts": self._handle_search_jlcpcb_parts, diff --git a/src/server.ts b/src/server.ts index fc9a564..deb4704 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,7 @@ import { registerExportTools } from "./tools/export.js"; import { registerSchematicTools } from "./tools/schematic.js"; import { registerLibraryTools } from "./tools/library.js"; import { registerSymbolLibraryTools } from "./tools/library-symbol.js"; +import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js"; import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js"; import { registerDatasheetTools } from "./tools/datasheet.js"; import { registerFootprintTools } from "./tools/footprint.js"; @@ -296,6 +297,7 @@ export class KiCADMcpServer { registerSchematicTools(this.server, this.callKicadScript.bind(this)); registerLibraryTools(this.server, this.callKicadScript.bind(this)); registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this)); + registerSchematicHierarchyTools(this.server, this.callKicadScript.bind(this)); registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this)); registerDatasheetTools(this.server, this.callKicadScript.bind(this)); registerFootprintTools(this.server, this.callKicadScript.bind(this)); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index bd64921..ac7a8ae 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -123,6 +123,11 @@ export const toolCategories: ToolCategory[] = [ description: "Read a symbol's pins straight from the library (no schematic needed)", tools: ["list_symbol_pins", "batch_list_symbol_pins"], }, + { + name: "schematic_hierarchy", + description: "Hierarchical schematic sheets: insert a sheet, scaffold a sub-sheet", + tools: ["add_hierarchical_sheet", "create_hierarchical_subsheet"], + }, { name: "routing", description: "Advanced routing operations: vias, copper pours", diff --git a/src/tools/schematic-hierarchy.ts b/src/tools/schematic-hierarchy.ts new file mode 100644 index 0000000..92dafd9 --- /dev/null +++ b/src/tools/schematic-hierarchy.ts @@ -0,0 +1,64 @@ +/** + * Schematic hierarchy tools: insert a hierarchical sheet, scaffold a sub-sheet. + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerSchematicHierarchyTools(server: McpServer, callKicadScript: Function) { + // Link an existing sub-sheet into a parent + server.tool( + "add_hierarchical_sheet", + "Insert a hierarchical-sheet reference block into a parent schematic, pointing at an existing sub-sheet file. Adds the sheet box, name/file fields, a sheet_instances path entry on the next page number, and fixes sub-sheet component instance paths so ERC resolves references.", + { + schematicPath: z.string().describe("Path to the parent .kicad_sch"), + subsheetPath: z.string().describe("Path to the existing sub-sheet .kicad_sch to reference"), + sheetName: z.string().optional().default("Sheet").describe("Display name for the sheet"), + position: z + .object({ x: z.number(), y: z.number() }) + .optional() + .describe("Top-left of the sheet box in mm (default 50,50)"), + size: z + .object({ width: z.number(), height: z.number() }) + .optional() + .describe("Sheet box size in mm (default 80x50)"), + }, + async (args: any) => { + const r = await callKicadScript("add_hierarchical_sheet", args); + if (!r.success) + return { content: [{ type: "text", text: `Failed: ${r.message || "Unknown error"}` }] }; + return { + content: [ + { + type: "text", + text: `Added sheet '${r.sheet_name}' -> ${r.subsheet_path} (page ${r.page})`, + }, + ], + }; + }, + ); + + // Create a sub-sheet file AND link it in one call + server.tool( + "create_hierarchical_subsheet", + "Create a new sub-sheet .kicad_sch file and link it into a parent schematic in a single call (create_schematic + add_hierarchical_sheet). The fastest way to grow a hierarchical design.", + { + parentSchematicPath: z.string().describe("Path to the parent .kicad_sch"), + subsheetPath: z.string().describe("Path for the new sub-sheet .kicad_sch to create"), + sheetName: z.string().optional().default("Sheet").describe("Display name for the sheet"), + position: z.object({ x: z.number(), y: z.number() }).optional(), + size: z.object({ width: z.number(), height: z.number() }).optional(), + metadata: z + .record(z.string(), z.any()) + .optional() + .describe("Optional metadata for the new sub-sheet (title, etc.)"), + }, + async (args: any) => { + const r = await callKicadScript("create_hierarchical_subsheet", args); + return { + content: [ + { type: "text", text: r.success ? r.message : `Failed: ${r.message || "Unknown error"}` }, + ], + }; + }, + ); +} diff --git a/tests/test_schematic_hierarchy.py b/tests/test_schematic_hierarchy.py new file mode 100644 index 0000000..7e4948c --- /dev/null +++ b/tests/test_schematic_hierarchy.py @@ -0,0 +1,96 @@ +""" +Unit/integration tests for the hierarchy commands (commands/schematic_hierarchy.py). + +The hierarchical-sheet text manipulation is exercised against real files in tmp, +and create_hierarchical_subsheet's orchestration is checked with a stub interface. +No KiCad needed. +""" + +import sys +import types +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.schematic_hierarchy import SchematicHierarchyCommands # noqa: E402 + + +def _cmds(iface=None): + return SchematicHierarchyCommands(iface or types.SimpleNamespace()) + + +class TestAddHierarchicalSheet: + def test_requires_params(self): + assert _cmds().add_hierarchical_sheet({"schematicPath": "/x"})["success"] is False + + def test_inserts_sheet_block(self, tmp_path): + parent = tmp_path / "top.kicad_sch" + parent.write_text( + '(kicad_sch (uuid abcd-1234)\n (sheet_instances (path "/" (page "1")))\n)' + ) + r = _cmds().add_hierarchical_sheet( + { + "schematicPath": str(parent), + "subsheetPath": str(tmp_path / "sub.kicad_sch"), + "sheetName": "Power", + } + ) + assert r["success"] is True + assert r["page"] == 2 # next page after existing "1" + content = parent.read_text() + assert "(sheet " in content + assert '"Sheet name" "Power"' in content + assert '"Sheet file" "sub.kicad_sch"' in content + # a sheet_instances path entry for the new sheet block was added + assert f'/{ "abcd-1234" }/{ r["sheet_uuid"] }' in content + + +class TestCreateHierarchicalSubsheet: + def test_orchestrates(self): + iface = types.SimpleNamespace( + _handle_create_schematic=lambda p: { + "success": True, + "file_path": p["filename"], + "schematic_uuid": "sub-uuid", + } + ) + c = SchematicHierarchyCommands(iface) + c.add_hierarchical_sheet = lambda p: {"success": True, "sheet_uuid": "blk", "page": 2} + r = c.create_hierarchical_subsheet( + { + "parentSchematicPath": "/top.kicad_sch", + "subsheetPath": "/sub.kicad_sch", + "sheetName": "IO", + } + ) + assert r["success"] is True + assert r["sheet_block_uuid"] == "blk" and r["page"] == 2 + + def test_requires_params(self): + assert ( + _cmds().create_hierarchical_subsheet({"parentSchematicPath": "/x"})["success"] is False + ) + + +class TestFixSubsheetInstances: + def test_adds_path_entry(self, tmp_path): + sub = tmp_path / "sub.kicad_sch" + sub.write_text( + '(kicad_sch (symbol (lib_id "Device:R")' + ' (instances (project "proj" (path "/old" (reference "R1") (unit 1))))))' + ) + parent = tmp_path / "top.kicad_sch" + parent_content = ( + "(kicad_sch (uuid abcd-1234)" + ' (sheet (at 50 50) (uuid "sheet-blk-1")' + ' (property "Sheet name" "Sub" (at 1 1 0))' + ' (property "Sheet file" "sub.kicad_sch" (at 1 2 0)))' + ' (sheet_instances (path "/" (page "1"))))' + ) + parent.write_text(parent_content) + + modified = _cmds().fix_subsheet_instances(str(parent), parent_content) + assert str(sub) in modified + sub_after = sub.read_text() + assert "/abcd-1234/sheet-blk-1" in sub_after + assert '(reference "R1")' in sub_after