feat: add add_schematic_text and list_schematic_texts tools
Adds two new MCP tools for working with free-form text annotations (SCH_TEXT elements) in KiCad schematics: - add_schematic_text: place a text note with optional angle, font size, bold/italic, and justification - list_schematic_texts: list all text annotations with optional case-insensitive substring filter Includes WireManager.add_text / list_texts using _text_insert + sexpdata, handler dispatch in KiCADInterface, TypeScript tool definitions, registry entry, reference doc updates, and 30 unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -754,6 +754,128 @@ class WireManager:
|
||||
|
||||
return [start, corner, end]
|
||||
|
||||
@staticmethod
|
||||
def list_texts(schematic_path: Path) -> Optional[List[Any]]:
|
||||
"""Return all free-form text annotations (SCH_TEXT) in a schematic.
|
||||
|
||||
Each entry is a dict with keys: text, position (x/y), angle,
|
||||
font_size, bold, italic, justify, uuid.
|
||||
Returns None on parse error.
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_data = sexpdata.loads(f.read())
|
||||
|
||||
_SYM_TEXT = Symbol("text")
|
||||
_SYM_EFFECTS = Symbol("effects")
|
||||
_SYM_FONT = Symbol("font")
|
||||
_SYM_SIZE = Symbol("size")
|
||||
_SYM_JUSTIFY = Symbol("justify")
|
||||
_SYM_BOLD = Symbol("bold")
|
||||
_SYM_ITALIC = Symbol("italic")
|
||||
|
||||
results = []
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and len(item) >= 2 and item[0] == _SYM_TEXT):
|
||||
continue
|
||||
# item[1] is the text string
|
||||
text_val = item[1] if len(item) > 1 else ""
|
||||
|
||||
pos_x = pos_y = angle = 0.0
|
||||
font_size = 1.27
|
||||
bold = italic = False
|
||||
justify = "left"
|
||||
uid = ""
|
||||
|
||||
for part in item[2:]:
|
||||
if not isinstance(part, list) or not part:
|
||||
continue
|
||||
tag = part[0]
|
||||
if tag == _SYM_AT and len(part) >= 3:
|
||||
pos_x = float(part[1])
|
||||
pos_y = float(part[2])
|
||||
angle = float(part[3]) if len(part) >= 4 else 0.0
|
||||
elif tag == _SYM_UUID and len(part) >= 2:
|
||||
uid = str(part[1])
|
||||
elif tag == _SYM_EFFECTS:
|
||||
for eff in part[1:]:
|
||||
if not isinstance(eff, list) or not eff:
|
||||
continue
|
||||
if eff[0] == _SYM_FONT:
|
||||
for fp in eff[1:]:
|
||||
if not isinstance(fp, list) or not fp:
|
||||
continue
|
||||
if fp[0] == _SYM_SIZE and len(fp) >= 2:
|
||||
font_size = float(fp[1])
|
||||
elif fp[0] == _SYM_BOLD and len(fp) >= 2:
|
||||
bold = str(fp[1]).lower() == "yes"
|
||||
elif fp[0] == _SYM_ITALIC and len(fp) >= 2:
|
||||
italic = str(fp[1]).lower() == "yes"
|
||||
elif eff[0] == _SYM_JUSTIFY and len(eff) >= 2:
|
||||
justify = str(eff[1])
|
||||
|
||||
results.append(
|
||||
{
|
||||
"text": text_val,
|
||||
"position": {"x": pos_x, "y": pos_y},
|
||||
"angle": angle,
|
||||
"font_size": font_size,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"justify": justify,
|
||||
"uuid": uid,
|
||||
}
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing texts: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def add_text(
|
||||
schematic_path: Path,
|
||||
text: str,
|
||||
position: List[float],
|
||||
angle: float = 0,
|
||||
font_size: float = 1.27,
|
||||
bold: bool = False,
|
||||
italic: bool = False,
|
||||
justify: str = "left",
|
||||
) -> bool:
|
||||
"""Add a free-form text annotation (SCH_TEXT) to a KiCad schematic."""
|
||||
try:
|
||||
text_escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
uid = str(uuid.uuid4())
|
||||
font_attrs = f"\n\t\t\t\t(size {font_size} {font_size})"
|
||||
if bold:
|
||||
font_attrs += "\n\t\t\t\t(bold yes)"
|
||||
if italic:
|
||||
font_attrs += "\n\t\t\t\t(italic yes)"
|
||||
text_sexp = (
|
||||
f'\t(text "{text_escaped}"\n'
|
||||
f"\t\t(exclude_from_sim no)\n"
|
||||
f"\t\t(at {position[0]} {position[1]} {angle})\n"
|
||||
f"\t\t(effects\n"
|
||||
f"\t\t\t(font{font_attrs}\n"
|
||||
f"\t\t\t)\n"
|
||||
f"\t\t\t(justify {justify} bottom)\n"
|
||||
f"\t\t)\n"
|
||||
f'\t\t(uuid "{uid}")\n'
|
||||
f"\t)\n"
|
||||
)
|
||||
_text_insert(schematic_path, text_sexp)
|
||||
logger.info(f"Added text '{text}' at {position}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding text: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_hierarchical_label(
|
||||
schematic_path: Path,
|
||||
|
||||
@@ -410,6 +410,8 @@ class KiCADInterface:
|
||||
"list_floating_labels": self._handle_list_floating_labels,
|
||||
"snap_to_grid": self._handle_snap_to_grid,
|
||||
"add_schematic_hierarchical_label": self._handle_add_schematic_hierarchical_label,
|
||||
"add_schematic_text": self._handle_add_schematic_text,
|
||||
"list_schematic_texts": self._handle_list_schematic_texts,
|
||||
"add_sheet_pin": self._handle_add_sheet_pin,
|
||||
"import_svg_logo": self._handle_import_svg_logo,
|
||||
# UI/Process management commands
|
||||
@@ -2731,6 +2733,98 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_list_schematic_texts(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all free-form text annotations (SCH_TEXT) in a schematic."""
|
||||
logger.info("Listing schematic text annotations")
|
||||
try:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
schematic_path = params.get("schematicPath")
|
||||
if not schematic_path:
|
||||
return {"success": False, "message": "schematicPath is required"}
|
||||
|
||||
sch_file = Path(schematic_path)
|
||||
if not sch_file.exists():
|
||||
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||
|
||||
texts = WireManager.list_texts(sch_file)
|
||||
if texts is None:
|
||||
return {"success": False, "message": "Failed to parse schematic"}
|
||||
|
||||
# Optional text filter
|
||||
filter_text = params.get("text")
|
||||
if filter_text is not None:
|
||||
texts = [t for t in texts if filter_text.lower() in t["text"].lower()]
|
||||
|
||||
return {"success": True, "texts": texts, "count": len(texts)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing schematic texts: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_add_schematic_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a free-form text annotation (SCH_TEXT) to a schematic."""
|
||||
logger.info("Adding text annotation to schematic")
|
||||
try:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
schematic_path = params.get("schematicPath")
|
||||
text = params.get("text")
|
||||
position = params.get("position")
|
||||
angle = params.get("angle", 0)
|
||||
font_size = params.get("fontSize", 1.27)
|
||||
bold = params.get("bold", False)
|
||||
italic = params.get("italic", False)
|
||||
justify = params.get("justify", "left")
|
||||
|
||||
if not schematic_path:
|
||||
return {"success": False, "message": "schematicPath is required"}
|
||||
if not text:
|
||||
return {"success": False, "message": "text is required"}
|
||||
if not position or len(position) != 2:
|
||||
return {"success": False, "message": "position [x, y] is required"}
|
||||
if justify not in ("left", "center", "right"):
|
||||
return {"success": False, "message": "justify must be left, center, or right"}
|
||||
if font_size <= 0:
|
||||
return {"success": False, "message": "fontSize must be positive"}
|
||||
|
||||
sch_file = Path(schematic_path)
|
||||
if not sch_file.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Schematic not found: {schematic_path}",
|
||||
}
|
||||
|
||||
success = WireManager.add_text(
|
||||
sch_file,
|
||||
text,
|
||||
position,
|
||||
angle=angle,
|
||||
font_size=font_size,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
justify=justify,
|
||||
)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added text '{text}' at ({position[0]}, {position[1]})",
|
||||
"position": {"x": position[0], "y": position[1]},
|
||||
"angle": angle,
|
||||
}
|
||||
return {"success": False, "message": "Failed to add text annotation"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding schematic text: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_add_schematic_hierarchical_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a hierarchical label to a sub-sheet schematic."""
|
||||
logger.info("Adding hierarchical label to schematic")
|
||||
|
||||
Reference in New Issue
Block a user