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>
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""
|
|
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
|