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 <noreply@anthropic.com>
This commit is contained in:
Ravi
2026-06-02 19:41:45 -07:00
committed by mixelpixx
parent df82ff5c56
commit a9d7af5edf
6 changed files with 445 additions and 0 deletions

View File

@@ -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

View File

@@ -325,6 +325,7 @@ try:
from commands.project import ProjectCommands from commands.project import ProjectCommands
from commands.routing import RoutingCommands from commands.routing import RoutingCommands
from commands.schematic import SchematicManager from commands.schematic import SchematicManager
from commands.schematic_hierarchy import SchematicHierarchyCommands
from commands.symbol_creator import SymbolCreator from commands.symbol_creator import SymbolCreator
from commands.symbol_pins import SymbolPinCommands from commands.symbol_pins import SymbolPinCommands
@@ -447,6 +448,8 @@ class KiCADInterface:
# Symbol pin discovery commands (read-only pin lookup from symbol libraries) # Symbol pin discovery commands (read-only pin lookup from symbol libraries)
self.symbol_pin_commands = SymbolPinCommands() self.symbol_pin_commands = SymbolPinCommands()
# Schematic hierarchy commands (insert sheets, scaffold sub-sheets)
self.hierarchy_commands = SchematicHierarchyCommands(self)
# Initialize JLCPCB API integration # Initialize JLCPCB API integration
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth) self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
@@ -534,6 +537,9 @@ class KiCADInterface:
# Symbol pin discovery commands (read pins straight from symbol libraries) # Symbol pin discovery commands (read pins straight from symbol libraries)
"list_symbol_pins": self.symbol_pin_commands.list_symbol_pins, "list_symbol_pins": self.symbol_pin_commands.list_symbol_pins,
"batch_list_symbol_pins": self.symbol_pin_commands.batch_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) # JLCPCB API commands (complete parts catalog via API)
"download_jlcpcb_database": self._handle_download_jlcpcb_database, "download_jlcpcb_database": self._handle_download_jlcpcb_database,
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts, "search_jlcpcb_parts": self._handle_search_jlcpcb_parts,

View File

@@ -20,6 +20,7 @@ import { registerExportTools } from "./tools/export.js";
import { registerSchematicTools } from "./tools/schematic.js"; import { registerSchematicTools } from "./tools/schematic.js";
import { registerLibraryTools } from "./tools/library.js"; import { registerLibraryTools } from "./tools/library.js";
import { registerSymbolLibraryTools } from "./tools/library-symbol.js"; import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js";
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js"; import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
import { registerDatasheetTools } from "./tools/datasheet.js"; import { registerDatasheetTools } from "./tools/datasheet.js";
import { registerFootprintTools } from "./tools/footprint.js"; import { registerFootprintTools } from "./tools/footprint.js";
@@ -296,6 +297,7 @@ export class KiCADMcpServer {
registerSchematicTools(this.server, this.callKicadScript.bind(this)); registerSchematicTools(this.server, this.callKicadScript.bind(this));
registerLibraryTools(this.server, this.callKicadScript.bind(this)); registerLibraryTools(this.server, this.callKicadScript.bind(this));
registerSymbolLibraryTools(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)); registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerDatasheetTools(this.server, this.callKicadScript.bind(this)); registerDatasheetTools(this.server, this.callKicadScript.bind(this));
registerFootprintTools(this.server, this.callKicadScript.bind(this)); registerFootprintTools(this.server, this.callKicadScript.bind(this));

View File

@@ -123,6 +123,11 @@ export const toolCategories: ToolCategory[] = [
description: "Read a symbol's pins straight from the library (no schematic needed)", description: "Read a symbol's pins straight from the library (no schematic needed)",
tools: ["list_symbol_pins", "batch_list_symbol_pins"], 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", name: "routing",
description: "Advanced routing operations: vias, copper pours", description: "Advanced routing operations: vias, copper pours",

View File

@@ -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"}` },
],
};
},
);
}

View File

@@ -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