""" Schematic editing tools for modifying existing elements in KiCad schematics. Complements the creation-oriented tools in schematic.py with modification, removal, and annotation capabilities. Also includes s-expression-direct tools (e.g. ``remove_wires_by_criteria``) that bypass the schematic engine for operations that work directly on the s-expression tree. """ import logging from typing import Any from mckicad.server import mcp from mckicad.utils.sch_helpers import expand as _expand from mckicad.utils.sch_helpers import load_schematic, validate_schematic_path as _validate_schematic_path from mckicad.utils.sexp_parser import ( parse_wire_segments, remove_sexp_blocks_by_uuid, ) logger = logging.getLogger(__name__) def _get_schematic_engine() -> str: return "sch_document" # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @mcp.tool() def modify_component( schematic_path: str, reference: str, x: float | None = None, y: float | None = None, rotation: float | None = None, value: str | None = None, footprint: str | None = None, properties: dict[str, str] | None = None, in_bom: bool | None = None, on_board: bool | None = None, ) -> dict[str, Any]: """Edit an existing component in a KiCad schematic. Supports moving, rotating, changing value/footprint, updating arbitrary properties, and toggling BOM/board inclusion -- all in a single call. Only the parameters you provide will be changed; everything else is left untouched. Args: schematic_path: Path to an existing .kicad_sch file. reference: Reference designator of the component to modify (e.g. ``R1``, ``U3``). x: New X position. Must be provided together with ``y`` to move. y: New Y position. Must be provided together with ``x`` to move. rotation: New rotation angle in degrees (e.g. 0, 90, 180, 270). value: New value string (e.g. ``4.7k``, ``100nF``). footprint: New footprint identifier (e.g. ``Resistor_SMD:R_0603_1608Metric``). properties: Dictionary of property names to values to set or update. in_bom: Set to True/False to include or exclude the component from BOM output. on_board: Set to True/False to include or exclude the component on the PCB. Returns: Dictionary with ``success``, ``reference``, ``modified`` list, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) if not reference: return {"success": False, "error": "reference must be a non-empty string"} # Validate that move coordinates come in pairs if (x is None) != (y is None): return { "success": False, "error": "Both x and y must be provided together to move a component", } try: sch = load_schematic(schematic_path) comp = sch.components.get(reference) if comp is None: return { "success": False, "error": f"Component '{reference}' not found in schematic", "schematic_path": schematic_path, } modified: list[str] = [] if x is not None and y is not None: comp.move(x, y) modified.append(f"position=({x}, {y})") if rotation is not None: comp.rotate(rotation) modified.append(f"rotation={rotation}") if value is not None: comp.set_value(value) modified.append(f"value={value}") if footprint is not None: comp.set_footprint(footprint) modified.append(f"footprint={footprint}") if properties: for key, val in properties.items(): comp.set_property(key, val) modified.append(f"property[{key}]={val}") if in_bom is not None: comp.set_in_bom(in_bom) modified.append(f"in_bom={in_bom}") if on_board is not None: comp.set_on_board(on_board) modified.append(f"on_board={on_board}") if not modified: return { "success": True, "reference": reference, "modified": [], "note": "No changes requested -- component left unchanged", "schematic_path": schematic_path, "engine": _get_schematic_engine(), } sch.save(schematic_path) logger.info("Modified %s in %s: %s", reference, schematic_path, ", ".join(modified)) return { "success": True, "reference": reference, "modified": modified, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to modify component %s in %s: %s", reference, schematic_path, e) return { "success": False, "error": str(e), "reference": reference, "schematic_path": schematic_path, } @mcp.tool() def remove_component(schematic_path: str, reference: str) -> dict[str, Any]: """Remove a component from a KiCad schematic by its reference designator. The component and all its associated properties are deleted from the schematic file. Wires connected to the component's pins are *not* automatically removed -- use ``remove_wire`` to clean those up if needed. Args: schematic_path: Path to an existing .kicad_sch file. reference: Reference designator of the component to remove (e.g. ``R1``, ``U3``). Returns: Dictionary with ``success``, removed ``reference``, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) if not reference: return {"success": False, "error": "reference must be a non-empty string"} try: sch = load_schematic(schematic_path) removed = sch.components.remove(reference) if removed is False: return { "success": False, "error": f"Component '{reference}' not found in schematic", "reference": reference, "schematic_path": schematic_path, } sch.save(schematic_path) logger.info("Removed component %s from %s", reference, schematic_path) return { "success": True, "reference": reference, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to remove component %s from %s: %s", reference, schematic_path, e) return { "success": False, "error": str(e), "reference": reference, "schematic_path": schematic_path, } @mcp.tool() def remove_wire(schematic_path: str, wire_id: str) -> dict[str, Any]: """Remove a wire segment from a KiCad schematic by its UUID. Wire IDs are returned by ``add_wire`` and ``connect_pins``, or can be found via ``get_schematic_info``. Removing a wire breaks the electrical connection it represents. Args: schematic_path: Path to an existing .kicad_sch file. wire_id: UUID of the wire segment to remove. Returns: Dictionary with ``success``, removed ``wire_id``, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) if not wire_id: return {"success": False, "error": "wire_id must be a non-empty string"} try: sch = load_schematic(schematic_path) sch.remove_wire(wire_id) sch.save(schematic_path) logger.info("Removed wire %s from %s", wire_id, schematic_path) return { "success": True, "wire_id": wire_id, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to remove wire %s from %s: %s", wire_id, schematic_path, e) return { "success": False, "error": str(e), "wire_id": wire_id, "schematic_path": schematic_path, } @mcp.tool() def remove_label(schematic_path: str, label_id: str) -> dict[str, Any]: """Remove a net label (local or global) from a KiCad schematic by its UUID. Label IDs are returned by ``add_label`` or can be found via ``get_schematic_info``. Removing a label may disconnect nets that relied on it for naming or inter-sheet connectivity. Args: schematic_path: Path to an existing .kicad_sch file. label_id: UUID of the label to remove. Returns: Dictionary with ``success``, removed ``label_id``, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) if not label_id: return {"success": False, "error": "label_id must be a non-empty string"} try: sch = load_schematic(schematic_path) sch.remove_label(label_id) sch.save(schematic_path) logger.info("Removed label %s from %s", label_id, schematic_path) return { "success": True, "label_id": label_id, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to remove label %s from %s: %s", label_id, schematic_path, e) return { "success": False, "error": str(e), "label_id": label_id, "schematic_path": schematic_path, } @mcp.tool() def set_title_block( schematic_path: str, title: str | None = None, author: str | None = None, date: str | None = None, revision: str | None = None, company: str | None = None, ) -> dict[str, Any]: """Set or update the title block fields on a KiCad schematic. The title block appears in the lower-right corner of printed schematics and is embedded in exported PDFs. Only the fields you provide will be changed; omitted fields keep their current values. Args: schematic_path: Path to an existing .kicad_sch file. title: Schematic title (e.g. ``Motor Driver Rev B``). author: Designer or team name. date: Design date string (e.g. ``2026-03-03``). revision: Revision identifier (e.g. ``1.2``, ``C``). company: Company or organisation name. Returns: Dictionary with ``success``, ``fields_set`` list, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) # Build kwargs for kicad-sch-api's set_title_block(). # API signature: set_title_block(title, date, rev, company, comments) # There is no 'author' param — KiCad convention puts author in Comment1. kwargs: dict[str, Any] = {} fields_set: list[str] = [] if title is not None: kwargs["title"] = title fields_set.append("title") if date is not None: kwargs["date"] = date fields_set.append("date") if revision is not None: kwargs["rev"] = revision fields_set.append("revision") if company is not None: kwargs["company"] = company fields_set.append("company") if author is not None: kwargs["comments"] = {1: author} fields_set.append("author") if not fields_set: return { "success": True, "fields_set": [], "note": "No fields provided -- title block left unchanged", "schematic_path": schematic_path, "engine": _get_schematic_engine(), } try: sch = load_schematic(schematic_path) sch.set_title_block(**kwargs) sch.save(schematic_path) logger.info("Set title block fields %s in %s", fields_set, schematic_path) return { "success": True, "fields_set": fields_set, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to set title block in %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def add_text_annotation( schematic_path: str, text: str, x: float, y: float, ) -> dict[str, Any]: """Add a free-text annotation to a KiCad schematic. Text annotations are non-electrical notes placed directly on the schematic sheet -- useful for design notes, revision comments, or pin-function callouts that don't affect the netlist. Args: schematic_path: Path to an existing .kicad_sch file. text: The annotation text to display. x: Horizontal position in schematic units. y: Vertical position in schematic units. Returns: Dictionary with ``success``, ``text_id``, ``position``, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) if not text: return {"success": False, "error": "text must be a non-empty string"} try: sch = load_schematic(schematic_path) text_id = sch.add_text(text, x, y) sch.save(schematic_path) logger.info("Added text annotation at (%.1f, %.1f) in %s", x, y, schematic_path) return { "success": True, "text_id": text_id, "text": text, "position": {"x": x, "y": y}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to add text annotation in %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def add_no_connect( schematic_path: str, x: float, y: float, ) -> dict[str, Any]: """Place a no-connect flag (X marker) on a schematic pin. No-connect flags tell the ERC (electrical rules checker) that an unconnected pin is intentional, suppressing false-positive warnings. Place one on every unused pin to keep ERC results clean. Args: schematic_path: Path to an existing .kicad_sch file. x: X position of the pin to mark as no-connect. y: Y position of the pin to mark as no-connect. Returns: Dictionary with ``success``, ``position``, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) try: sch = load_schematic(schematic_path) sch.add_no_connect(x, y) sch.save(schematic_path) logger.info("Added no-connect at (%.1f, %.1f) in %s", x, y, schematic_path) return { "success": True, "position": {"x": x, "y": y}, "schematic_path": schematic_path, "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to add no-connect in %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def backup_schematic(schematic_path: str) -> dict[str, Any]: """Create a timestamped backup of a KiCad schematic file. Writes a copy of the schematic to a backup file alongside the original, named with a timestamp so that multiple backups can coexist. Call this before making destructive edits (bulk component removal, major rewiring) to ensure you can recover the previous state. Args: schematic_path: Path to the .kicad_sch file to back up. Returns: Dictionary with ``success``, ``backup_path``, and ``engine``. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) try: sch = load_schematic(schematic_path) backup_path = sch.backup() logger.info("Backed up %s to %s", schematic_path, backup_path) return { "success": True, "original_path": schematic_path, "backup_path": str(backup_path), "engine": _get_schematic_engine(), } except Exception as e: logger.error("Failed to back up %s: %s", schematic_path, e) return {"success": False, "error": str(e), "schematic_path": schematic_path} @mcp.tool() def remove_wires_by_criteria( schematic_path: str, y: float | None = None, x: float | None = None, min_x: float | None = None, max_x: float | None = None, min_y: float | None = None, max_y: float | None = None, tolerance: float = 0.01, dry_run: bool = False, ) -> dict[str, Any]: """Remove wire segments matching coordinate criteria from a KiCad schematic. Uses direct s-expression manipulation to surgically remove wires by their position — essential for cleaning up bulk wiring errors like overlapping horizontal/vertical segments that create shorts. All specified criteria must match for a wire to be selected. At least one criterion is required (won't delete all wires). Args: schematic_path: Path to an existing .kicad_sch file. y: Match horizontal wires where both endpoints have Y within ``tolerance`` of this value. x: Match vertical wires where both endpoints have X within ``tolerance`` of this value. min_x: Both endpoints' X values must be >= this value (minus tolerance). max_x: Both endpoints' X values must be <= this value (plus tolerance). min_y: Both endpoints' Y values must be >= this value (minus tolerance). max_y: Both endpoints' Y values must be <= this value (plus tolerance). tolerance: Coordinate comparison tolerance (default 0.01). dry_run: If True, return matching wires without removing them. Returns: Dictionary with ``matched_count``, ``removed_count`` (0 if dry_run), and ``matched_wires`` preview list. """ verr = _validate_schematic_path(schematic_path) if verr: return verr schematic_path = _expand(schematic_path) # Require at least one filtering criterion has_criteria = any(v is not None for v in (y, x, min_x, max_x, min_y, max_y)) if not has_criteria: return { "success": False, "error": "At least one coordinate criterion (y, x, min_x, max_x, min_y, max_y) is required", } try: all_wires = parse_wire_segments(schematic_path) if not all_wires: return { "success": True, "matched_count": 0, "removed_count": 0, "note": "No wire segments found in schematic", "schematic_path": schematic_path, } # Filter wires by criteria matched: list[dict[str, Any]] = [] for wire in all_wires: sx, sy = wire["start"]["x"], wire["start"]["y"] ex, ey = wire["end"]["x"], wire["end"]["y"] if y is not None and (abs(sy - y) > tolerance or abs(ey - y) > tolerance): continue if x is not None and (abs(sx - x) > tolerance or abs(ex - x) > tolerance): continue if min_x is not None and min(sx, ex) < min_x - tolerance: continue if max_x is not None and max(sx, ex) > max_x + tolerance: continue if min_y is not None and min(sy, ey) < min_y - tolerance: continue if max_y is not None and max(sy, ey) > max_y + tolerance: continue matched.append(wire) if dry_run or not matched: return { "success": True, "dry_run": dry_run, "matched_count": len(matched), "removed_count": 0, "matched_wires": matched[:50], # cap preview "schematic_path": schematic_path, } # Remove matched wires via s-expression manipulation uuids_to_remove = {w["uuid"] for w in matched if w.get("uuid")} if not uuids_to_remove: return { "success": False, "error": "Matched wires have no UUIDs — cannot remove (file may be malformed)", "matched_count": len(matched), "schematic_path": schematic_path, } removed_count = remove_sexp_blocks_by_uuid(schematic_path, uuids_to_remove) logger.info( "remove_wires_by_criteria: removed %d/%d matched wires from %s", removed_count, len(matched), schematic_path, ) return { "success": True, "dry_run": False, "matched_count": len(matched), "removed_count": removed_count, "matched_wires": matched[:50], "schematic_path": schematic_path, } except Exception as e: logger.error("remove_wires_by_criteria failed: %s", e, exc_info=True) return {"success": False, "error": str(e), "schematic_path": schematic_path}