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:
272
python/commands/schematic_hierarchy.py
Normal file
272
python/commands/schematic_hierarchy.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user