From 602d76af236628abc06a3c70e5b247c591efb755 Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Fri, 12 Jun 2026 12:29:46 -0400 Subject: [PATCH] refactor: extract schematic handlers into SchematicHandlersMixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kicad_interface.py had grown to 8,596 lines. Most domains already delegate to classes in commands/, but the schematic domain (43 _handle_* methods + the schematic-only _svg_to_png helper, ~3,100 lines) was still inline. Move it into commands/schematic_handlers.py as SchematicHandlersMixin and have KiCADInterface inherit from it. Pure mechanical relocation — no logic change: - Methods stay bound to the same instance via the mixin, so every self.board, self._auto_save_board(), cross-handler call, and the command_routes table resolve exactly as before. - Verified all 159 functions/methods (116 kept + 43 moved + _svg_to_png) are AST-identical between the original and the split. - tests/test_sync_schematic_to_board_footprints.py patch targets updated to follow the moved module (commands.schematic_handlers.pcbnew). - Full suite at the exact current main baseline (8 failed / 1044 passed; the 8 are the pre-existing Java-not-installed freerouting/autoroute set). Regenerated against main after the June merge wave (#226-229, #231-234, #238, #252, #253); deliberately excludes the 19 _handle_export_* kicad-cli handlers from #253 and the datasheet delegators. --- python/commands/schematic_handlers.py | 3104 +++++++++++++++++ python/kicad_interface.py | 3072 +--------------- ...test_sync_schematic_to_board_footprints.py | 10 +- 3 files changed, 3111 insertions(+), 3075 deletions(-) create mode 100644 python/commands/schematic_handlers.py diff --git a/python/commands/schematic_handlers.py b/python/commands/schematic_handlers.py new file mode 100644 index 0000000..d5d11fa --- /dev/null +++ b/python/commands/schematic_handlers.py @@ -0,0 +1,3104 @@ +"""Schematic-domain command handlers for KiCADInterface. + +Extracted verbatim from kicad_interface.py as a mixin to keep that dispatcher +file manageable. These methods are mixed into KiCADInterface, so ``self`` is the +full interface instance: every ``self.board``, ``self._auto_save_board()``, +cross-handler call, and the command_routes table resolve exactly as before. +No behavior change — pure relocation. +""" + +import base64 +import glob +import json +import logging +import os +import re +import shutil +import subprocess +import tempfile +import traceback +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import pcbnew +import sexpdata + +from commands.library_schematic import LibraryManager as SchematicLibraryManager +from commands.schematic import SchematicManager +from commands.wire_manager import WireManager + +logger = logging.getLogger("kicad_interface") + + +def _svg_to_png(svg_path: str, width: int, height: int) -> Optional[bytes]: + """Convert SVG to PNG. No cffi dependency. + + Priority: + 1. pymupdf (fitz) — bundled MuPDF renderer, pure Python, no system deps + 2. Inkscape CLI — accurate KiCAD SVG rendering + 3. ImageMagick convert — broad availability fallback + Returns PNG bytes or None if all converters fail. + """ + import subprocess + import tempfile + + try: + import fitz + + doc = fitz.open(svg_path) + page = doc[0] + mat = fitz.Matrix(width / page.rect.width, height / page.rect.height) + return page.get_pixmap(matrix=mat).tobytes("png") + except Exception: + pass + + out_path = os.path.join(tempfile.mkdtemp(), "out.png") + + try: + r = subprocess.run( + [ + "inkscape", + svg_path, + "--export-type=png", + f"--export-width={width}", + f"--export-height={height}", + f"--export-filename={out_path}", + ], + capture_output=True, + timeout=60, + ) + if r.returncode == 0 and os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + try: + r = subprocess.run( + ["convert", "-density", "150", svg_path, "-resize", f"{width}x{height}", out_path], + capture_output=True, + timeout=60, + ) + if r.returncode == 0 and os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return None + + +class SchematicHandlersMixin: + """Schematic-domain handlers mixed into KiCADInterface.""" + + def _handle_create_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Create a new schematic""" + logger.info("Creating schematic") + try: + # Support multiple parameter naming conventions for compatibility: + # - TypeScript tools use: name, path + # - Python schema uses: filename, title + # - Legacy uses: projectName, path, metadata + project_name = params.get("projectName") or params.get("name") or params.get("title") + + # Handle filename parameter - it may contain full path + filename = params.get("filename") + if filename: + # If filename provided, extract name and path from it + if filename.endswith(".kicad_sch"): + filename = filename[:-10] # Remove .kicad_sch extension + path = os.path.dirname(filename) or "." + project_name = project_name or os.path.basename(filename) + else: + path = params.get("path", ".") + metadata = params.get("metadata", {}) + + if not project_name: + return { + "success": False, + "message": "Schematic name is required. Provide 'name', 'projectName', or 'filename' parameter.", + } + + sch_path = path if path and path != "." else None + schematic = SchematicManager.create_schematic( + project_name, path=sch_path, metadata=metadata + ) + base_name = ( + project_name if project_name.endswith(".kicad_sch") else f"{project_name}.kicad_sch" + ) + normalized_path = path or "." + file_path = os.path.join(normalized_path, base_name) + success = SchematicManager.save_schematic(schematic, file_path) + + return {"success": success, "file_path": file_path} + except Exception as e: + logger.error(f"Error creating schematic: {str(e)}") + return {"success": False, "message": str(e)} + + def _handle_load_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Load an existing schematic""" + logger.info("Loading schematic") + try: + filename = params.get("filename") + + if not filename: + return {"success": False, "message": "Filename is required"} + + schematic = SchematicManager.load_schematic(filename) + success = schematic is not None + + if success: + metadata = SchematicManager.get_schematic_metadata(schematic) + return {"success": success, "metadata": metadata} + else: + return {"success": False, "message": "Failed to load schematic"} + except Exception as e: + logger.error(f"Error loading schematic: {str(e)}") + return {"success": False, "message": str(e)} + + def _handle_add_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a component to a schematic using text-based injection (no sexpdata)""" + logger.info("Adding component to schematic") + try: + from pathlib import Path + + from commands.dynamic_symbol_loader import DynamicSymbolLoader + + schematic_path = params.get("schematicPath") + component = params.get("component", {}) + + if not schematic_path: + return {"success": False, "message": "Schematic path is required"} + if not component: + return {"success": False, "message": "Component definition is required"} + + comp_type = component.get("type", "R") + library = component.get("library", "Device") + reference = component.get("reference", "X?") + value = component.get("value", comp_type) + footprint = component.get("footprint", "") + x = component.get("x", 0) + y = component.get("y", 0) + unit = component.get("unit", 1) + + # Derive project path from schematic path for project-local library resolution. + # Walk up from the schematic file to find the directory that owns the project + # (contains sym-lib-table or a .kicad_pro file). Schematics stored in a + # sub-folder (e.g. sheets/) would otherwise resolve to the wrong directory and + # miss any project-local sym-lib-table entries. + schematic_file = Path(schematic_path) + derived_project_path = schematic_file.parent + for ancestor in schematic_file.parents: + if (ancestor / "sym-lib-table").exists() or list(ancestor.glob("*.kicad_pro")): + derived_project_path = ancestor + break + + loader = DynamicSymbolLoader(project_path=derived_project_path) + loader.add_component( + schematic_file, + library, + comp_type, + reference=reference, + value=value, + footprint=footprint, + x=x, + y=y, + unit=unit, + project_path=derived_project_path, + ) + + return { + "success": True, + "component_reference": reference, + "symbol_source": f"{library}:{comp_type}", + } + except Exception as e: + logger.error(f"Error adding component to schematic: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_delete_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" + logger.info("Deleting schematic component") + try: + import re + from pathlib import Path + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + with open(sch_file, "r", encoding="utf-8") as f: + content = f.read() + + # String-aware paren matcher (see _find_matching_paren): a naive + # counter over-runs on unescaped parens inside quoted strings (e.g. + # MCU pin names like "PA13(JTMS"), which would extend lib_symbols to + # EOF and make every placed-symbol lookup fail. + find_matching_paren = self._find_matching_paren + + # Skip lib_symbols section + lib_sym_pos = content.find("(lib_symbols") + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + + # Find ALL placed symbol blocks matching the reference (handles duplicates). + # Use content-string search so multi-line KiCAD format is handled correctly: + # KiCAD writes (symbol\n\t\t(lib_id "...") across two lines, which a + # line-by-line regex would never match. + blocks_to_delete = [] # list of (char_start, char_end) into content + search_start = 0 + # Match the opening of any placed-symbol block. KiCAD may emit the + # children of (symbol ...) in any order — most commonly + # `(symbol (lib_id "..."))`, but symbols whose library entry has been + # rescued / customised carry an additional `(lib_name "...")` first: + # `(symbol (lib_name "...") (lib_id "...") ...)`. Matching just + # `(symbol\s+(` covers both, and the lib_symbols range check below + # still excludes library-definition symbols (which use the + # `(symbol "name" ...)` form with a quoted string, not a paren). + pattern = re.compile(r"\(symbol\s+\(") + while True: + m = pattern.search(content, search_start) + if not m: + break + pos = m.start() + # Skip blocks inside lib_symbols + if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: + search_start = lib_sym_end + 1 + continue + end = find_matching_paren(content, pos) + if end < 0: + search_start = pos + 1 + continue + block_text = content[pos : end + 1] + if re.search( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', + block_text, + ): + blocks_to_delete.append((pos, end)) + search_start = end + 1 + + if not blocks_to_delete: + return { + "success": False, + "message": f"Component '{reference}' not found in schematic (note: this tool removes schematic symbols, use delete_component for PCB footprints)", + } + + # Delete from back to front to preserve character offsets + for b_start, b_end in sorted(blocks_to_delete, reverse=True): + # Include any leading newline/whitespace before the block + trim_start = b_start + while trim_start > 0 and content[trim_start - 1] in (" ", "\t"): + trim_start -= 1 + if trim_start > 0 and content[trim_start - 1] == "\n": + trim_start -= 1 + content = content[:trim_start] + content[b_end + 1 :] + + with open(sch_file, "w", encoding="utf-8") as f: + f.write(content) + + deleted_count = len(blocks_to_delete) + logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") + return { + "success": True, + "reference": reference, + "deleted_count": deleted_count, + "schematic": str(sch_file), + } + + except Exception as e: + logger.error(f"Error deleting schematic component: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + @staticmethod + def _escape_sexpr_string(value: str) -> str: + """Escape a string for safe insertion into an S-expression double-quoted token.""" + return value.replace("\\", "\\\\").replace('"', '\\"') + + @staticmethod + def _find_matching_paren(s: str, start: int) -> int: + """Return the index of the closing paren matching the opening paren at `start`. + + String-aware: parens inside double-quoted tokens are ignored. KiCAD does + NOT backslash-escape bare parens inside quoted strings — e.g. MCU pin + names like "PA13(JTMS" or descriptions like "Vin(fwd) 40V" appear raw in + .kicad_sch / .kicad_sym files. A naive depth counter treats such an + in-string "(" as real structure, so it never rebalances and runs to EOF. + When that happens to the (lib_symbols ...) block, every placed symbol — + which follows lib_symbols — looks like it lives *inside* it and gets + skipped, so reference lookups silently fail for the whole schematic. + + Returns -1 if no match is found. + """ + depth = 0 + i = start + in_string = False + while i < len(s): + ch = s[i] + if in_string: + if ch == "\\": + i += 2 # skip escaped char (e.g. \" or \\) + continue + if ch == '"': + in_string = False + elif ch == '"': + in_string = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Update properties of a placed symbol in a schematic. + + Supports updating the standard fields (footprint / value / reference rename), + repositioning field labels, and managing **arbitrary custom properties** + (MPN, Manufacturer, Distributor part numbers, Voltage, Dielectric, Tolerance, + LCSC, etc.) used by BOM/CPL exporters and JLCPCB / Digi-Key sourcing. + + Uses text-based in-place editing — preserves position, UUID, and all + unrelated fields. + """ + logger.info("Editing schematic component") + try: + import re + from pathlib import Path + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + new_footprint = params.get("footprint") + new_value = params.get("value") + new_reference = params.get("newReference") + # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} + field_positions = params.get("fieldPositions") + # dict: {"MPN": "RC0603FR-0710KL"} OR {"MPN": {"value": "...", "hide": true}} + properties = params.get("properties") + # list[str]: ["OldField"] — protected built-ins are rejected + remove_properties = params.get("removeProperties") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + if not any( + [ + new_footprint is not None, + new_value is not None, + new_reference is not None, + field_positions is not None, + properties is not None, + remove_properties is not None, + ] + ): + return { + "success": False, + "message": ( + "At least one of footprint, value, newReference, fieldPositions, " + "properties, or removeProperties must be provided" + ), + } + + # Reject removal attempts targeting protected built-in fields up-front + if remove_properties: + blocked = [n for n in remove_properties if n in self._PROTECTED_PROPERTY_FIELDS] + if blocked: + return { + "success": False, + "message": ( + f"Cannot remove built-in field(s) {blocked}: use the dedicated " + "value/footprint/newReference parameters or set the value to ''" + ), + } + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + with open(sch_file, "r", encoding="utf-8") as f: + content = f.read() + + # Skip lib_symbols section + lib_sym_pos = content.find("(lib_symbols") + lib_sym_end = ( + self._find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + ) + + # Find placed symbol blocks that match the reference. KiCAD may + # serialise the children of (symbol ...) in different orders — + # `(symbol (lib_id "..."))` is the common case but rescued or + # locally-customised symbols carry an extra `(lib_name "...")` + # before the lib_id: `(symbol (lib_name "...") (lib_id "..."))`. + # Match any opening paren after `(symbol`; the lib_symbols range + # check below excludes library-definition symbols, which use the + # `(symbol "name" ...)` form (quoted string, not paren). + block_start = block_end = None + search_start = 0 + pattern = re.compile(r"\(symbol\s+\(") + while True: + m = pattern.search(content, search_start) + if not m: + break + pos = m.start() + # Skip if inside lib_symbols section + if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: + search_start = lib_sym_end + 1 + continue + end = self._find_matching_paren(content, pos) + if end < 0: + search_start = pos + 1 + continue + block_text = content[pos : end + 1] + if re.search( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', + block_text, + ): + block_start, block_end = pos, end + break + search_start = end + 1 + + if block_start is None or block_end is None: + return { + "success": False, + "message": f"Component '{reference}' not found in schematic", + } + + # Apply property replacements within the found block + block_text = content[block_start : block_end + 1] + + # Determine the parent symbol position so that newly-added properties + # default to a sensible location (anchored near the component). + # KiCAD always emits the symbol's own (at x y angle) before any + # (property ...) child blocks, so the FIRST (at ...) inside the + # symbol block is the symbol origin regardless of whether + # (lib_name ...) precedes (lib_id ...). + comp_at = re.search( + r"\(at\s+([\d\.\-]+)\s+([\d\.\-]+)", + block_text, + ) + comp_origin: Tuple[float, float] = ( + (float(comp_at.group(1)), float(comp_at.group(2))) if comp_at else (0.0, 0.0) + ) + + if new_footprint is not None: + escaped_fp = self._escape_sexpr_string(str(new_footprint)) + block_text = re.sub( + r'(\(property\s+"Footprint"\s+)"[^"]*"', + rf'\1"{escaped_fp}"', + block_text, + ) + if new_value is not None: + escaped_v = self._escape_sexpr_string(str(new_value)) + block_text = re.sub( + r'(\(property\s+"Value"\s+)"[^"]*"', + rf'\1"{escaped_v}"', + block_text, + ) + if new_reference is not None: + escaped_r = self._escape_sexpr_string(str(new_reference)) + block_text = re.sub( + r'(\(property\s+"Reference"\s+)"[^"]*"', + rf'\1"{escaped_r}"', + block_text, + ) + # Also update the (reference "...") leaves inside the symbol's + # (instances) → (project) → (path) subtree. KiCad reads those + # entries — not the (property "Reference" ...) field — when + # generating netlists and syncing the PCB via "Update PCB from + # Schematic", so leaving them stale produces a silent + # reference mismatch where eeschema shows the new ref but ERC + # / netlist export / PCB sync all use the old one. See #126. + instances_pos = block_text.find("(instances") + if instances_pos >= 0: + instances_end = self._find_matching_paren(block_text, instances_pos) + if instances_end >= 0: + instances_block = block_text[instances_pos : instances_end + 1] + updated_instances = re.sub( + r'(\(reference\s+)"' + re.escape(reference) + r'"', + rf'\1"{escaped_r}"', + instances_block, + ) + block_text = ( + block_text[:instances_pos] + + updated_instances + + block_text[instances_end + 1 :] + ) + if field_positions is not None: + for field_name, pos in field_positions.items(): + x = pos.get("x", 0) + y = pos.get("y", 0) + angle = pos.get("angle", 0) + block_text = re.sub( + r'(\(property\s+"' + + re.escape(field_name) + + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf"\1(at {x} {y} {angle})", + block_text, + ) + justify = pos.get("justify") + if justify is not None: + block_text = self._set_justify_on_property( + block_text, field_name, str(justify) + ) + + properties_added: Dict[str, Any] = {} + properties_updated: Dict[str, Any] = {} + if properties: + if not isinstance(properties, dict): + return { + "success": False, + "message": "properties must be a dict mapping property name -> value or spec", + } + for name, spec in properties.items(): + if not isinstance(name, str) or not name: + return { + "success": False, + "message": f"Invalid property name: {name!r}", + } + # Normalise scalar values to a spec dict with just {"value": ...} + if not isinstance(spec, dict): + spec = {"value": spec} + try: + block_text, action = self._set_property_in_block( + block_text, name, spec, comp_origin + ) + except ValueError as ve: + return {"success": False, "message": str(ve)} + target = properties_added if action == "added" else properties_updated + target[name] = spec.get("value") + + properties_removed: list = [] + if remove_properties: + if not isinstance(remove_properties, list): + return { + "success": False, + "message": "removeProperties must be a list of property names", + } + for name in remove_properties: + block_text, removed = self._remove_property_from_block(block_text, name) + if removed: + properties_removed.append(name) + + content = content[:block_start] + block_text + content[block_end + 1 :] + + with open(sch_file, "w", encoding="utf-8") as f: + f.write(content) + + changes: Dict[str, Any] = { + k: v + for k, v in { + "footprint": new_footprint, + "value": new_value, + "reference": new_reference, + }.items() + if v is not None + } + if field_positions is not None: + changes["fieldPositions"] = field_positions + if properties_added: + changes["propertiesAdded"] = properties_added + if properties_updated: + changes["propertiesUpdated"] = properties_updated + if properties_removed: + changes["propertiesRemoved"] = properties_removed + + logger.info(f"Edited schematic component {reference}: {changes}") + return {"success": True, "reference": reference, "updated": changes} + + except Exception as e: + logger.error(f"Error editing schematic component: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_set_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add or update a single property on a placed schematic symbol. + + Convenience wrapper around `edit_schematic_component` for the very common + case of setting one BOM/sourcing field at a time. The property is created + if it does not already exist, otherwise its value (and optionally its + position / visibility) is updated in place. + """ + logger.info("Setting schematic component property") + name = params.get("name") + if not isinstance(name, str) or not name: + return {"success": False, "message": "name is required"} + if "value" not in params: + return {"success": False, "message": "value is required"} + + spec: Dict[str, Any] = {"value": params["value"]} + for key in ("x", "y", "angle", "hide", "fontSize", "justify"): + if params.get(key) is not None: + spec[key] = params[key] + + return self._handle_edit_schematic_component( + { + "schematicPath": params.get("schematicPath"), + "reference": params.get("reference"), + "properties": {name: spec}, + } + ) + + def _handle_remove_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Remove a single custom property from a placed schematic symbol. + + Built-in fields (Reference, Value, Footprint, Datasheet) cannot be + removed — use `edit_schematic_component` to clear them instead. + """ + logger.info("Removing schematic component property") + name = params.get("name") + if not isinstance(name, str) or not name: + return {"success": False, "message": "name is required"} + return self._handle_edit_schematic_component( + { + "schematicPath": params.get("schematicPath"), + "reference": params.get("reference"), + "removeProperties": [name], + } + ) + + def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Return full component info: position and all field values with their (at x y angle) positions.""" + logger.info("Getting schematic component info") + try: + import re + from pathlib import Path + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + with open(sch_file, "r", encoding="utf-8") as f: + content = f.read() + + # String-aware paren matcher (see _find_matching_paren): a naive + # counter over-runs on unescaped parens inside quoted strings (e.g. + # MCU pin names like "PA13(JTMS"), which would extend lib_symbols to + # EOF and make every placed-symbol lookup fail. + find_matching_paren = self._find_matching_paren + + # Skip lib_symbols section + lib_sym_pos = content.find("(lib_symbols") + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + + # Find the placed symbol block for this reference. KiCAD may emit + # the children of (symbol ...) in different orders — most commonly + # `(symbol (lib_id "..."))`, but symbols whose library entry has + # been rescued / customised carry an extra `(lib_name "...")` first + # (`(symbol (lib_name "...") (lib_id "..."))`). Match `(symbol\s+(` + # — any opening paren — to handle both. The lib_symbols range check + # below excludes library-definition symbols, which use the + # `(symbol "name" ...)` form (quoted string, not paren). + block_start = block_end = None + search_start = 0 + pattern = re.compile(r"\(symbol\s+\(") + while True: + m = pattern.search(content, search_start) + if not m: + break + pos = m.start() + if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: + search_start = lib_sym_end + 1 + continue + end = find_matching_paren(content, pos) + if end < 0: + search_start = pos + 1 + continue + block_text = content[pos : end + 1] + if re.search( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', + block_text, + ): + block_start, block_end = pos, end + break + search_start = end + 1 + + if block_start is None or block_end is None: + return { + "success": False, + "message": f"Component '{reference}' not found in schematic", + } + + block_text = content[block_start : block_end + 1] + + # Extract component position: the first (at x y angle) inside the + # symbol block. KiCAD always writes the symbol's own (at) before + # any (property ...) child blocks, so the first match is the + # symbol origin regardless of the (lib_name)/(lib_id) ordering. + comp_at = re.search( + r"\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)", + block_text, + ) + if comp_at: + comp_pos = { + "x": float(comp_at.group(1)), + "y": float(comp_at.group(2)), + "angle": float(comp_at.group(3)), + } + else: + comp_pos = None + + # Extract all properties with their at positions + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + fields = {} + for m in prop_pattern.finditer(block_text): + name, value, x, y, angle = ( + m.group(1), + m.group(2), + m.group(3), + m.group(4), + m.group(5), + ) + fields[name] = { + "value": value, + "x": float(x), + "y": float(y), + "angle": float(angle), + } + + return { + "success": True, + "reference": reference, + "position": comp_pos, + "fields": fields, + } + + except Exception as e: + logger.error(f"Error getting schematic component: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_add_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a wire to a schematic using WireManager, with optional pin snapping""" + logger.info("Adding wire to schematic") + try: + from pathlib import Path + + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + points = params.get("waypoints") + properties = params.get("properties", {}) + snap_to_pins = params.get("snapToPins", True) + snap_tolerance = params.get("snapTolerance", 1.0) + + if not schematic_path: + return {"success": False, "message": "Schematic path is required"} + if not points or len(points) < 2: + return { + "success": False, + "message": "At least 2 waypoints are required", + } + + # Make a mutable copy of points + points = [list(p) for p in points] + + # Pin snapping: adjust first and last endpoints to nearest pin + snapped_info = [] + if snap_to_pins: + from commands.pin_locator import PinLocator + + locator = PinLocator() + sch_path = Path(schematic_path) + + # Load schematic to iterate all symbols + from skip import Schematic as SkipSchematic + + sch = SkipSchematic(str(sch_path)) + + # Collect all pin locations: list of (ref, pin_num, [x, y]) + all_pins = [] + for symbol in sch.symbol: + if not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + pin_locs = locator.get_all_symbol_pins(sch_path, ref) + for pin_num, coords in pin_locs.items(): + all_pins.append((ref, pin_num, coords)) + + def find_nearest_pin(point: Any, tolerance: Any) -> Any: + """Find the nearest pin within tolerance of a point.""" + best = None + best_dist = tolerance + for ref, pin_num, coords in all_pins: + dx = point[0] - coords[0] + dy = point[1] - coords[1] + dist = (dx * dx + dy * dy) ** 0.5 + if dist <= best_dist: + best_dist = dist + best = (ref, pin_num, coords) + return best + + # Snap first endpoint + match = find_nearest_pin(points[0], snap_tolerance) + if match: + ref, pin_num, coords = match + logger.info( + f"Snapped start point {points[0]} -> {coords} (pin {ref}/{pin_num})" + ) + snapped_info.append( + f"start snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" + ) + points[0] = list(coords) + + # Snap last endpoint + match = find_nearest_pin(points[-1], snap_tolerance) + if match: + ref, pin_num, coords = match + logger.info(f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})") + snapped_info.append( + f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" + ) + points[-1] = list(coords) + + # Extract wire properties + stroke_width = properties.get("stroke_width", 0) + stroke_type = properties.get("stroke_type", "default") + + # Use WireManager for S-expression manipulation + if len(points) == 2: + success = WireManager.add_wire( + Path(schematic_path), + points[0], + points[1], + stroke_width=stroke_width, + stroke_type=stroke_type, + ) + else: + success = WireManager.add_polyline_wire( + Path(schematic_path), + points, + stroke_width=stroke_width, + stroke_type=stroke_type, + ) + + if success: + message = "Wire added successfully" + if snapped_info: + message += "; " + "; ".join(snapped_info) + return {"success": True, "message": message} + else: + return {"success": False, "message": "Failed to add wire"} + except Exception as e: + logger.error(f"Error adding wire to schematic: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } + + def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List available symbol libraries""" + logger.info("Listing schematic libraries") + try: + search_paths = params.get("searchPaths") + + libraries = SchematicLibraryManager.list_available_libraries(search_paths) + return {"success": True, "libraries": libraries} + except Exception as e: + logger.error(f"Error listing schematic libraries: {str(e)}") + return {"success": False, "message": str(e)} + + def _handle_check_wire_collisions(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Detect wires passing through component bodies without connecting to pins""" + logger.info("Checking wire collisions") + try: + from commands.schematic_analysis import check_wire_collisions + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + result = check_wire_collisions(schematic_path) + return {"success": True, **result} + except ImportError: + return { + "success": False, + "message": "schematic_analysis module not available", + } + except Exception as e: + logger.error(f"Error checking wire collisions: {e}") + return {"success": False, "message": str(e)} + + def _handle_export_schematic_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export schematic to PDF""" + logger.info("Exporting schematic to PDF") + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path: + return {"success": False, "message": "Schematic path is required"} + if not output_path: + return {"success": False, "message": "Output path is required"} + + if not os.path.exists(schematic_path): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + import subprocess + + cmd = [ + "kicad-cli", + "sch", + "export", + "pdf", + "--output", + output_path, + schematic_path, + ] + + if params.get("blackAndWhite"): + cmd.insert(-1, "--black-and-white") + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode == 0: + return {"success": True, "file": {"path": output_path}} + else: + return { + "success": False, + "message": f"kicad-cli failed: {result.stderr}", + } + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except Exception as e: + logger.error(f"Error exporting schematic to PDF: {str(e)}") + return {"success": False, "message": str(e)} + + def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a net label to schematic using WireManager. + + When componentRef and pinNumber are supplied the label is placed at the + exact pin endpoint retrieved via PinLocator, ignoring the provided + position. The response includes the actual coordinates used and + whether the label landed on a pin endpoint. + """ + logger.info("Adding net label to schematic") + try: + from pathlib import Path + + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + net_name = params.get("netName") + position = params.get("position") + label_type = params.get("labelType", "label") + orientation = params.get("orientation", 0) + component_ref = params.get("componentRef") + pin_number = params.get("pinNumber") + + if not all([schematic_path, net_name]): + return { + "success": False, + "message": "Missing required parameters: schematicPath, netName", + } + + snapped_to_pin: Optional[Dict[str, Any]] = None + + if component_ref and pin_number: + # Snap position to exact pin endpoint using PinLocator + from commands.pin_locator import PinLocator + + locator = PinLocator() + pin_loc = locator.get_pin_location( + Path(schematic_path), component_ref, str(pin_number) + ) + if pin_loc is None: + return { + "success": False, + "message": ( + f"Could not locate pin {pin_number} on {component_ref}. " + "Check the reference and pin number." + ), + } + position = pin_loc + snapped_to_pin = {"component": component_ref, "pin": str(pin_number)} + logger.info( + f"Snapped label '{net_name}' to pin {component_ref}/{pin_number} at {position}" + ) + elif position is None: + return { + "success": False, + "message": ( + "Missing position. Either provide position [x, y] or " + "componentRef + pinNumber to snap to a pin endpoint." + ), + } + + # Collect existing net names BEFORE adding the new label so we can + # detect case-mismatch collisions against pre-existing nets only. + existing_net_names: List[str] = [] + try: + pre_schematic = SchematicManager.load_schematic(schematic_path) + if pre_schematic is not None: + if hasattr(pre_schematic, "label"): + for lbl in pre_schematic.label: + if hasattr(lbl, "value"): + existing_net_names.append(lbl.value) + if hasattr(pre_schematic, "global_label"): + for lbl in pre_schematic.global_label: + if hasattr(lbl, "value"): + existing_net_names.append(lbl.value) + except Exception: + # Non-fatal: if we can't read existing nets, skip the warning + existing_net_names = [] + + # Use WireManager for S-expression manipulation + success = WireManager.add_label( + Path(schematic_path), + net_name, + position, + label_type=label_type, + orientation=orientation, + ) + + if not success: + return {"success": False, "message": "Failed to add net label"} + + # Compute case-mismatch warnings against pre-existing net names. + # A collision is: existing name != new name, but lowercases match. + new_name_lower = net_name.lower() + case_warnings: List[str] = [ + f"Net '{existing}' already exists — label '{net_name}' may be a case mismatch." + for existing in existing_net_names + if existing.lower() == new_name_lower and existing != net_name + ] + + response: Dict[str, Any] = { + "success": True, + "message": f"Added net label '{net_name}' at {position}", + "actual_position": position, + } + if snapped_to_pin: + response["snapped_to_pin"] = snapped_to_pin + response["message"] = ( + f"Added net label '{net_name}' at exact pin endpoint " + f"{component_ref}/{pin_number} ({position[0]}, {position[1]})" + ) + if case_warnings: + response["case_warnings"] = case_warnings + return response + + except Exception as e: + logger.error(f"Error adding net label: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } + + def _handle_get_schematic_pin_locations(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Return exact pin endpoint coordinates for a schematic component""" + logger.info("Getting schematic pin locations") + try: + from pathlib import Path + + from commands.pin_locator import PinLocator + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not all([schematic_path, reference]): + return { + "success": False, + "message": "Missing required parameters: schematicPath, reference", + } + + locator = PinLocator() + all_pins = locator.get_all_symbol_pins(Path(schematic_path), reference) + + if not all_pins: + return { + "success": False, + "message": f"No pins found for {reference} — check reference and schematic path", + } + + # Enrich with pin names and angles from the symbol definition + pins_def = ( + locator.get_symbol_pins( + Path(schematic_path), + locator._get_lib_id(Path(schematic_path), reference), + ) + if hasattr(locator, "_get_lib_id") + else {} + ) + + result = {} + for pin_num, coords in all_pins.items(): + entry = {"x": coords[0], "y": coords[1]} + if pin_num in pins_def: + entry["name"] = pins_def[pin_num].get("name", pin_num) + entry["angle"] = ( + locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0 + ) + result[pin_num] = entry + + return {"success": True, "reference": reference, "pins": result} + + except Exception as e: + logger.error(f"Error getting pin locations: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_get_schematic_view(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a rasterised image of the schematic (SVG export → optional PNG conversion)""" + logger.info("Getting schematic view") + import base64 + import subprocess + import tempfile + + try: + schematic_path = params.get("schematicPath") + if not schematic_path or not os.path.exists(schematic_path): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + fmt = params.get("format", "png") + width = params.get("width", 1200) + height = params.get("height", 900) + + # Step 1: Export schematic to SVG via kicad-cli + with tempfile.TemporaryDirectory() as tmpdir: + svg_path = os.path.join(tmpdir, "schematic.svg") + cmd = [ + "kicad-cli", + "sch", + "export", + "svg", + "--output", + tmpdir, + "--no-background-color", + schematic_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli SVG export failed: {result.stderr}", + } + + # kicad-cli may name the file after the schematic, find it + import glob + + svg_files = glob.glob(os.path.join(tmpdir, "*.svg")) + if not svg_files: + return { + "success": False, + "message": "No SVG file produced by kicad-cli", + } + svg_path = svg_files[0] + + if fmt == "svg": + with open(svg_path, "r", encoding="utf-8") as f: + svg_data = f.read() + return {"success": True, "imageData": svg_data, "format": "svg"} + + # Step 2: Convert SVG to PNG (cffi-free) + png_data = _svg_to_png(svg_path, width, height) + if png_data is None: + with open(svg_path, "r", encoding="utf-8") as f: + svg_data = f.read() + return { + "success": True, + "imageData": svg_data, + "format": "svg", + "message": "No PNG converter available — returning SVG. Install pymupdf, inkscape, or imagemagick.", + } + + return { + "success": True, + "imageData": base64.b64encode(png_data).decode("utf-8"), + "format": "png", + "width": width, + "height": height, + } + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except Exception as e: + logger.error(f"Error getting schematic view: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List all components in a schematic""" + logger.info("Listing schematic components") + try: + from pathlib import Path + + from commands.pin_locator import PinLocator + + 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}", + } + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + # Optional filters + filter_params = params.get("filter", {}) + lib_id_filter = filter_params.get("libId", "") + ref_prefix_filter = filter_params.get("referencePrefix", "") + + locator = PinLocator() + components = [] + + for symbol in schematic.symbol: + if not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + # Skip template symbols + if ref.startswith("_TEMPLATE"): + continue + + lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else "" + + # Apply filters + if lib_id_filter and lib_id_filter not in lib_id: + continue + if ref_prefix_filter and not ref.startswith(ref_prefix_filter): + continue + + value = symbol.property.Value.value if hasattr(symbol.property, "Value") else "" + footprint = ( + symbol.property.Footprint.value if hasattr(symbol.property, "Footprint") else "" + ) + position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] + uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else "" + + comp = { + "reference": ref, + "libId": lib_id, + "value": value, + "footprint": footprint, + "position": {"x": float(position[0]), "y": float(position[1])}, + "rotation": float(position[2]) if len(position) > 2 else 0, + "uuid": str(uuid_val), + } + + # Get pins if available + try: + all_pins = locator.get_all_symbol_pins(sch_file, ref) + if all_pins: + pins_def = locator.get_symbol_pins(sch_file, lib_id) or {} + pin_list = [] + for pin_num, coords in all_pins.items(): + pin_info = { + "number": pin_num, + "position": {"x": coords[0], "y": coords[1]}, + } + if pin_num in pins_def: + pin_info["name"] = pins_def[pin_num].get("name", pin_num) + pin_list.append(pin_info) + comp["pins"] = pin_list + except Exception: + pass # Pin lookup is best-effort + + components.append(comp) + + return {"success": True, "components": components, "count": len(components)} + + except Exception as e: + logger.error(f"Error listing schematic components: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_list_schematic_nets(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List all nets in a schematic with their connections""" + logger.info("Listing schematic nets") + try: + from commands.wire_connectivity import ( + _build_adjacency, + _discover_sub_sheets, + _load_sexp, + _parse_labels_sexp, + _parse_virtual_connections, + _parse_wires, + count_pins_on_net, + get_connections_for_net, + ) + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + # Collect net names from the top-level sheet using sexpdata. + # Falls back to kicad-skip's label collections when the file + # cannot be read (e.g. mocked schematics in unit tests). + net_names: set = set() + sexp_loaded = False + try: + sexp = _load_sexp(schematic_path) + sexp_loaded = True + _, label_to_points = _parse_labels_sexp(sexp) + net_names.update(label_to_points.keys()) + except Exception as e: + logger.debug( + f"Could not parse labels from {schematic_path} via sexp ({e}); " + "falling back to kicad-skip label collections" + ) + for attr in ("label", "global_label"): + if not hasattr(schematic, attr): + continue + for label in getattr(schematic, attr): + if hasattr(label, "value"): + net_names.add(label.value) + + # Collect net names from all sub-sheets (only when the parent + # sheet was readable; fake/mock paths skip recursion entirely). + if sexp_loaded: + sub_sheets = _discover_sub_sheets(schematic_path) + for sub_path in sub_sheets: + try: + sub_sexp = _load_sexp(sub_path) + _, sub_label_to_points = _parse_labels_sexp(sub_sexp) + net_names.update(sub_label_to_points.keys()) + except Exception as e: + logger.warning(f"Error reading sub-sheet {sub_path}: {e}") + + # Pre-build shared wire graph structures for efficiency + all_wires = _parse_wires(schematic) + if all_wires: + adjacency, iu_to_wires = _build_adjacency(all_wires) + else: + adjacency, iu_to_wires = [], {} + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + + nets = [] + for net_name in sorted(net_names): + connections = get_connections_for_net(schematic, schematic_path, net_name) + pin_count = count_pins_on_net( + schematic, + schematic_path, + net_name, + all_wires, + iu_to_wires, + adjacency, + point_to_label, + label_to_points, + ) + nets.append( + { + "name": net_name, + "connections": connections, + "connected_pin_count": pin_count, + } + ) + + return {"success": True, "nets": nets, "count": len(nets)} + + except Exception as e: + logger.error(f"Error listing schematic nets: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List all wires in a schematic""" + logger.info("Listing schematic wires") + try: + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + wires = [] + if hasattr(schematic, "wire"): + for wire in schematic.wire: + if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): + points = [] + for point in wire.pts.xy: + if hasattr(point, "value"): + points.append( + { + "x": float(point.value[0]), + "y": float(point.value[1]), + } + ) + + if len(points) >= 2: + wires.append( + { + "start": points[0], + "end": points[-1], + } + ) + + return {"success": True, "wires": wires, "count": len(wires)} + + except Exception as e: + logger.error(f"Error listing schematic wires: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_list_schematic_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List all net labels and power flags in a schematic""" + logger.info("Listing schematic labels") + try: + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + net_name = params.get("netName") + label_type = params.get("labelType") + + _valid_label_types = {"net", "global", "power"} + if label_type is not None and label_type not in _valid_label_types: + return {"success": False, "message": "labelType must be one of: net, global, power"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + labels = [] + + # Regular labels + if hasattr(schematic, "label"): + for label in schematic.label: + if hasattr(label, "value"): + pos = ( + label.at.value + if hasattr(label, "at") and hasattr(label.at, "value") + else [0, 0] + ) + labels.append( + { + "name": label.value, + "type": "net", + "position": {"x": float(pos[0]), "y": float(pos[1])}, + } + ) + + # Global labels + if hasattr(schematic, "global_label"): + for label in schematic.global_label: + if hasattr(label, "value"): + pos = ( + label.at.value + if hasattr(label, "at") and hasattr(label.at, "value") + else [0, 0] + ) + labels.append( + { + "name": label.value, + "type": "global", + "position": {"x": float(pos[0]), "y": float(pos[1])}, + } + ) + + # Power symbols (components with power flag) + if hasattr(schematic, "symbol"): + for symbol in schematic.symbol: + if not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + if not ref.startswith("#PWR"): + continue + value = ( + symbol.property.Value.value if hasattr(symbol.property, "Value") else ref + ) + pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] + labels.append( + { + "name": value, + "type": "power", + "position": {"x": float(pos[0]), "y": float(pos[1])}, + } + ) + + # Apply filters + if net_name is not None: + labels = [lbl for lbl in labels if lbl["name"] == net_name] + if label_type is not None: + labels = [lbl for lbl in labels if lbl["type"] == label_type] + + return {"success": True, "labels": labels, "count": len(labels)} + + except Exception as e: + logger.error(f"Error listing schematic labels: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_move_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Move a schematic component to a new position, dragging connected wires.""" + logger.info("Moving schematic component") + try: + from commands.wire_dragger import WireDragger + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + position = params.get("position", {}) + new_x = position.get("x") + new_y = position.get("y") + preserve_wires = params.get("preserveWires", True) + + if not schematic_path or not reference: + return { + "success": False, + "message": "schematicPath and reference are required", + } + if new_x is None or new_y is None: + return { + "success": False, + "message": "position with x and y is required", + } + + with open(schematic_path, "r", encoding="utf-8") as f: + sch_data = sexpdata.loads(f.read()) + + # Find symbol and record old position + found = WireDragger.find_symbol(sch_data, reference) + if found is None: + return {"success": False, "message": f"Component {reference} not found"} + _, old_x, old_y = found[0], found[1], found[2] + old_position = {"x": old_x, "y": old_y} + + drag_summary = {} + if preserve_wires: + # Compute pin world positions before and after the move + pin_positions = WireDragger.compute_pin_positions( + sch_data, reference, float(new_x), float(new_y) + ) + # Build old→new coordinate map (deduplicate coincident pins) + old_to_new = {} + for _pin, (old_xy, new_xy) in pin_positions.items(): + if old_xy in old_to_new: + logger.warning( + f"move_schematic_component: pin {_pin!r} of {reference!r} " + f"shares old position {old_xy} with another pin; " + f"keeping first entry, skipping duplicate" + ) + continue + old_to_new[old_xy] = new_xy + + drag_summary = WireDragger.drag_wires(sch_data, old_to_new) + + # Synthesize wires for touching-pin connections after dragging, + # so drag_wires doesn't accidentally move and collapse the new wire. + wires_synthesized = WireDragger.synthesize_touching_pin_wires( + sch_data, reference, pin_positions + ) + drag_summary["wires_synthesized"] = wires_synthesized + + # Update symbol position + WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y)) + + WireManager.sync_junctions(sch_data) + + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(sexpdata.dumps(sch_data)) + + return { + "success": True, + "oldPosition": old_position, + "newPosition": {"x": new_x, "y": new_y}, + "wiresMoved": drag_summary.get("endpoints_moved", 0), + "wiresRemoved": drag_summary.get("wires_removed", 0), + "wiresSynthesized": drag_summary.get("wires_synthesized", 0), + } + + except Exception as e: + logger.error(f"Error moving schematic component: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Rotate and/or mirror a schematic component, dragging connected wires.""" + logger.info("Rotating schematic component") + try: + import sexpdata as _sexpdata + from commands.wire_dragger import WireDragger + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + angle = params.get("angle", 0) + mirror = params.get("mirror") # "x", "y", or None + + if not schematic_path or not reference: + return { + "success": False, + "message": "schematicPath and reference are required", + } + + with open(schematic_path, "r", encoding="utf-8") as f: + sch_data = _sexpdata.loads(f.read()) + + found = WireDragger.find_symbol(sch_data, reference) + if found is None: + return {"success": False, "message": f"Component {reference} not found"} + + # Determine new mirror state: explicit param overrides; None preserves existing + _, _, _, _, _, old_mirror_x, old_mirror_y = found + if mirror is None: + new_mirror_x = old_mirror_x + new_mirror_y = old_mirror_y + effective_mirror = "x" if old_mirror_x else ("y" if old_mirror_y else None) + else: + new_mirror_x = mirror == "x" + new_mirror_y = mirror == "y" + effective_mirror = mirror + + # Compute pin world positions before and after the transform + pin_positions = WireDragger.compute_pin_positions_for_rotation( + sch_data, reference, float(angle), new_mirror_x, new_mirror_y + ) + + # Build old→new map (skip pins that don't move) + old_to_new = {} + for _pin, (old_xy, new_xy) in pin_positions.items(): + if old_xy == new_xy: + continue + if old_xy in old_to_new: + logger.warning( + f"rotate: pin {_pin!r} of {reference!r} shares old position " + f"{old_xy} with another pin; skipping duplicate" + ) + continue + old_to_new[old_xy] = new_xy + + # Drag connected wires to follow pins + drag_summary = WireDragger.drag_wires(sch_data, old_to_new) + + # Update the symbol's rotation and mirror token in sexpdata + WireDragger.update_symbol_rotation_mirror( + sch_data, reference, float(angle), effective_mirror + ) + + WireManager.sync_junctions(sch_data) + + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(_sexpdata.dumps(sch_data)) + + return { + "success": True, + "reference": reference, + "angle": angle, + "mirror": effective_mirror, + "wiresMoved": drag_summary.get("endpoints_moved", 0), + "wiresRemoved": drag_summary.get("wires_removed", 0), + } + + except Exception as e: + logger.error(f"Error rotating schematic component: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Annotate unannotated components in schematic (R? -> R1, R2, ...)""" + logger.info("Annotating schematic") + try: + import re + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + # Collect existing references by prefix + existing_refs = {} # prefix -> set of numbers + unannotated = [] # (symbol, prefix) + + for symbol in schematic.symbol: + if not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + + # Split reference into prefix and number + match = re.match(r"^([A-Za-z_]+)(\d+)$", ref) + if match: + prefix = match.group(1) + num = int(match.group(2)) + if prefix not in existing_refs: + existing_refs[prefix] = set() + existing_refs[prefix].add(num) + elif ref.endswith("?"): + prefix = ref[:-1] + unannotated.append((symbol, prefix)) + + if not unannotated: + return { + "success": True, + "annotated": [], + "message": "All components already annotated", + } + + annotated = [] + for symbol, prefix in unannotated: + if prefix not in existing_refs: + existing_refs[prefix] = set() + + # Find next available number + next_num = 1 + while next_num in existing_refs[prefix]: + next_num += 1 + + old_ref = symbol.property.Reference.value + new_ref = f"{prefix}{next_num}" + symbol.setAllReferences(new_ref) + existing_refs[prefix].add(next_num) + + uuid_val = str(symbol.uuid.value) if hasattr(symbol, "uuid") else "" + annotated.append( + { + "uuid": uuid_val, + "oldReference": old_ref, + "newReference": new_ref, + } + ) + + SchematicManager.save_schematic(schematic, schematic_path) + return {"success": True, "annotated": annotated} + + except Exception as e: + logger.error(f"Error annotating schematic: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_delete_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Delete a wire from the schematic matching start/end points""" + logger.info("Deleting schematic wire") + try: + schematic_path = params.get("schematicPath") + start = params.get("start", {}) + end = params.get("end", {}) + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + from pathlib import Path + + from commands.wire_manager import WireManager + + start_point = [start.get("x", 0), start.get("y", 0)] + end_point = [end.get("x", 0), end.get("y", 0)] + + deleted = WireManager.delete_wire(Path(schematic_path), start_point, end_point) + if deleted: + return {"success": True} + else: + return {"success": False, "message": "No matching wire found"} + + except Exception as e: + logger.error(f"Error deleting schematic wire: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_delete_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Delete a net label from the schematic""" + logger.info("Deleting schematic net label") + try: + schematic_path = params.get("schematicPath") + net_name = params.get("netName") + position = params.get("position") + + if not schematic_path or not net_name: + return { + "success": False, + "message": "schematicPath and netName are required", + } + + from pathlib import Path + + from commands.wire_manager import WireManager + + pos_list = None + if position: + pos_list = [position.get("x", 0), position.get("y", 0)] + + deleted = WireManager.delete_label(Path(schematic_path), net_name, pos_list) + if deleted: + return {"success": True} + else: + return {"success": False, "message": f"Label '{net_name}' not found"} + + except Exception as e: + logger.error(f"Error deleting schematic net label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_move_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Move a net label to a new position in the schematic.""" + logger.info("Moving schematic net label") + try: + import sexpdata as _sexpdata + from sexpdata import Symbol + + schematic_path = params.get("schematicPath") + net_name = params.get("netName") + new_position = params.get("newPosition", {}) + new_x = new_position.get("x") + new_y = new_position.get("y") + current_position = params.get("currentPosition") + label_type = params.get("labelType") + + if not schematic_path or not net_name: + return {"success": False, "message": "schematicPath and netName are required"} + if new_x is None or new_y is None: + return {"success": False, "message": "newPosition with x and y is required"} + + _valid_types = {"label", "global_label", "hierarchical_label"} + if label_type is not None and label_type not in _valid_types: + return { + "success": False, + "message": f"labelType must be one of: {', '.join(sorted(_valid_types))}", + } + + _SYM_AT = Symbol("at") + target_syms = ( + {Symbol(label_type)} + if label_type is not None + else {Symbol(t) for t in _valid_types} + ) + + TOLERANCE = 0.5 + + with open(schematic_path, "r", encoding="utf-8") as f: + sch_data = _sexpdata.loads(f.read()) + + for item in sch_data: + if not (isinstance(item, list) and len(item) >= 2 and item[0] in target_syms): + continue + if item[1] != net_name: + continue + + at_idx = next( + ( + j + for j, p in enumerate(item) + if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT + ), + None, + ) + if at_idx is None: + continue + + at_entry = item[at_idx] + old_x, old_y = float(at_entry[1]), float(at_entry[2]) + + if current_position is not None: + cx = current_position.get("x", 0) + cy = current_position.get("y", 0) + if not (abs(old_x - cx) < TOLERANCE and abs(old_y - cy) < TOLERANCE): + continue + + rotation = at_entry[3] if len(at_entry) > 3 else 0 + item[at_idx] = [_SYM_AT, float(new_x), float(new_y), rotation] + + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(_sexpdata.dumps(sch_data)) + + return { + "success": True, + "oldPosition": {"x": old_x, "y": old_y}, + "newPosition": {"x": float(new_x), "y": float(new_y)}, + } + + return {"success": False, "message": f"Label '{net_name}' not found"} + + except Exception as e: + logger.error(f"Error moving schematic net label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export schematic to SVG using kicad-cli""" + logger.info("Exporting schematic SVG") + import glob + import shutil + import subprocess + + try: + schematic_path = params.get("schematicPath") + output_path = params.get("outputPath") + + if not schematic_path or not output_path: + return { + "success": False, + "message": "schematicPath and outputPath are required", + } + + if not os.path.exists(schematic_path): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + # kicad-cli's --output flag for SVG export expects a directory, not a file path. + # The output file is auto-named based on the schematic name. + output_dir = os.path.dirname(output_path) + if not output_dir: + output_dir = "." + + os.makedirs(output_dir, exist_ok=True) + + cmd = [ + "kicad-cli", + "sch", + "export", + "svg", + schematic_path, + "-o", + output_dir, + ] + + if params.get("blackAndWhite"): + cmd.append("--black-and-white") + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + return { + "success": False, + "message": f"kicad-cli failed: {result.stderr}", + } + + # kicad-cli names the file after the schematic, so find the generated SVG + svg_files = glob.glob(os.path.join(output_dir, "*.svg")) + if not svg_files: + return { + "success": False, + "message": "No SVG file produced by kicad-cli", + } + + generated_svg = svg_files[0] + + # Move/rename to the user-specified output path if it differs + if os.path.abspath(generated_svg) != os.path.abspath(output_path): + shutil.move(generated_svg, output_path) + + return {"success": True, "file": {"path": output_path}} + + except FileNotFoundError: + return {"success": False, "message": "kicad-cli not found in PATH"} + except Exception as e: + logger.error(f"Error exporting schematic SVG: {e}") + return {"success": False, "message": str(e)} + + def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find net name and all component pins reachable from a point or component pin.""" + logger.info("Getting wire connections") + try: + from pathlib import Path + + from commands.pin_locator import PinLocator + from commands.wire_connectivity import get_wire_connections + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Missing required parameter: schematicPath"} + + reference = params.get("reference") + pin = params.get("pin") + x = params.get("x") + y = params.get("y") + + has_ref_pin = reference is not None and pin is not None + has_coords = x is not None and y is not None + + if has_ref_pin and has_coords: + return { + "success": False, + "message": "Supply either {reference, pin} or {x, y}, not both", + } + + if not has_ref_pin and not has_coords: + if reference is not None or pin is not None: + return { + "success": False, + "message": "Both reference and pin are required together", + } + return { + "success": False, + "message": "Must supply either {reference, pin} or {x, y}", + } + + if has_ref_pin: + location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin)) + if location is None: + return { + "success": False, + "message": f"Pin {pin} not found on {reference}", + } + x, y = location[0], location[1] + else: + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + if not hasattr(schematic, "wire"): + return {"success": False, "message": "Schematic has no wires"} + + result = get_wire_connections(schematic, schematic_path, x, y) + if result is None: + return { + "success": False, + "message": f"No wire found at ({x},{y}) — point may not be connected", + } + + return {"success": True, **result} + + except Exception as e: + logger.error(f"Error getting wire connections: {str(e)}") + import traceback + + 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") + try: + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + text = params.get("text") + position = params.get("position") + shape = params.get("shape", "bidirectional") + orientation = params.get("orientation", 0) + + 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 shape not in ("input", "output", "bidirectional"): + return { + "success": False, + "message": "shape must be input, output, or bidirectional", + } + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + success = WireManager.add_hierarchical_label( + sch_file, text, position, shape=shape, orientation=orientation + ) + + if success: + return { + "success": True, + "message": ( + f"Added hierarchical_label '{text}' " f"at {position} shape={shape}" + ), + } + return {"success": False, "message": "Failed to add hierarchical label"} + + except Exception as e: + logger.error(f"Error adding hierarchical label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_add_sheet_pin(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a sheet pin to a sheet block on the parent schematic.""" + logger.info("Adding sheet pin to schematic") + try: + from commands.wire_manager import WireManager + + schematic_path = params.get("schematicPath") + sheet_name = params.get("sheetName") + pin_name = params.get("pinName") + pin_type = params.get("pinType", "bidirectional") + position = params.get("position") + orientation = params.get("orientation", 0) + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not sheet_name: + return {"success": False, "message": "sheetName is required"} + if not pin_name: + return {"success": False, "message": "pinName is required"} + if not position or len(position) != 2: + return {"success": False, "message": "position [x, y] is required"} + if pin_type not in ("input", "output", "bidirectional"): + return { + "success": False, + "message": "pinType must be input, output, or bidirectional", + } + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + with open(sch_file, "r", encoding="utf-8") as f: + content = f.read() + + modified, success = WireManager.add_sheet_pin( + content, + sheet_name, + pin_name, + pin_type, + position, + orientation=orientation, + ) + + if not success: + return { + "success": False, + "message": f"Sheet '{sheet_name}' not found in {schematic_path}", + } + + with open(sch_file, "w", encoding="utf-8") as f: + f.write(modified) + + return { + "success": True, + "message": ( + f"Added sheet pin '{pin_name}' ({pin_type}) " f"to sheet '{sheet_name}'" + ), + } + + except Exception as e: + logger.error(f"Error adding sheet pin: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Run Electrical Rules Check on a schematic via kicad-cli""" + logger.info("Running ERC on schematic") + import os + import subprocess + import tempfile + + try: + schematic_path = params.get("schematicPath") + if not schematic_path or not os.path.exists(schematic_path): + return { + "success": False, + "message": "Schematic file not found", + "errorDetails": f"Path does not exist: {schematic_path}", + } + + kicad_cli = self.design_rule_commands._find_kicad_cli() + if not kicad_cli: + return { + "success": False, + "message": "kicad-cli not found", + "errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.", + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + json_output = tmp.name + + try: + cmd = [ + kicad_cli, + "sch", + "erc", + "--format", + "json", + "--output", + json_output, + schematic_path, + ] + logger.info(f"Running ERC command: {' '.join(cmd)}") + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + # kicad-cli returns non-zero when ERC violations are found — + # this is normal, not an error. Only fail when no JSON was + # produced (genuine CLI failure). + if not os.path.exists(json_output) or os.path.getsize(json_output) == 0: + logger.error(f"ERC command produced no output: {result.stderr}") + return { + "success": False, + "message": "ERC command failed - no output produced", + "errorDetails": result.stderr, + } + + with open(json_output, "r", encoding="utf-8") as f: + erc_data = json.load(f) + + violations = [] + severity_counts = {"error": 0, "warning": 0, "info": 0} + + # KiCad 9 nests violations under sheets[].violations + # instead of (or in addition to) the top-level violations + # array used by KiCad 8. + all_violations = erc_data.get("violations", []) + for sheet in erc_data.get("sheets", []): + all_violations.extend(sheet.get("violations", [])) + + for v in all_violations: + vseverity = v.get("severity", "error") + items = v.get("items", []) + loc = {} + if items and "pos" in items[0]: + loc = { + "x": items[0]["pos"].get("x", 0), + "y": items[0]["pos"].get("y", 0), + } + violations.append( + { + "type": v.get("type", "unknown"), + "severity": vseverity, + "message": v.get("description", ""), + "location": loc, + } + ) + if vseverity in severity_counts: + severity_counts[vseverity] += 1 + + return { + "success": True, + "message": f"ERC complete: {len(violations)} violation(s)", + "summary": { + "total": len(violations), + "by_severity": severity_counts, + }, + "violations": violations, + } + + finally: + if os.path.exists(json_output): + os.unlink(json_output) + + except subprocess.TimeoutExpired: + return {"success": False, "message": "ERC timed out after 120 seconds"} + except Exception as e: + logger.error(f"Error running ERC: {str(e)}") + return {"success": False, "message": str(e)} + + def _build_hierarchical_pad_net_map(self, project_sch_path: str): + """Walk all .kicad_sch files in the project and build a {(ref, pin_num): net_name} map. + + Handles hierarchical schematics by scanning every sub-sheet file. Net names + from global_label / hierarchical_label / local label / power symbols are all + collected. Wire connectivity is traced via BFS so labels not placed directly + on a pin endpoint still reach through wire segments. + + Returns: (pad_net_map, net_names_set) + """ + from collections import defaultdict + from pathlib import Path + + from commands.pin_locator import PinLocator + from skip import Schematic + + TOLERANCE = 0.5 # mm; schematic grid is 1.27 mm so 0.5 is safe + + def snap(x, y): + """Round to 2 dp to use exact dict lookup instead of O(n²) scan.""" + return (round(float(x), 2), round(float(y), 2)) + + def nearby_net(pt, point_net, tol=TOLERANCE): + """Return net name for the nearest occupied grid point, or None.""" + x, y = pt + # Try exact snap first (fast path) + key = snap(x, y) + if key in point_net: + return point_net[key] + # Slow fallback for off-grid placements + for (lx, ly), name in point_net.items(): + if abs(x - lx) < tol and abs(y - ly) < tol: + return name + return None + + project_dir = Path(project_sch_path).parent + pad_net_map: dict = {} + all_net_names: set = set() + pin_locator = PinLocator() + + sch_files = sorted(project_dir.rglob("*.kicad_sch")) + logger.info(f"_build_hierarchical_pad_net_map: scanning {len(sch_files)} schematic files") + + for sch_path in sch_files: + try: + sch = Schematic(str(sch_path)) + except Exception as e: + logger.warning(f"Could not load {sch_path}: {e}") + continue + + # ── 1. Collect explicit label positions → net name ────────────── + point_net: dict = {} # snap(x,y) -> net_name + + for attr in ("label", "global_label", "hierarchical_label"): + for lbl in getattr(sch, attr, None) or []: + try: + pos = lbl.at.value + name = lbl.value + if name: + k = snap(pos[0], pos[1]) + point_net[k] = name + all_net_names.add(name) + except Exception: + pass + + # Power symbols (#PWR / #FLG): value property IS the net name; use pin 1 pos + for sym in getattr(sch, "symbol", None) or []: + try: + ref = sym.property.Reference.value + if not (ref.startswith("#PWR") or ref.startswith("#FLG")): + continue + net_name = sym.property.Value.value + if not net_name: + continue + all_pins = pin_locator.get_all_symbol_pins(sch_path, ref) + for _pin_num, (px, py) in all_pins.items(): + k = snap(px, py) + point_net[k] = net_name + all_net_names.add(net_name) + except Exception: + pass + + # ── 2. Build wire adjacency and BFS-propagate net names ────────── + wire_segments = [] + for wire in getattr(sch, "wire", None) or []: + try: + pts = [] + for pt in wire.pts.xy: + pts.append(snap(pt.value[0], pt.value[1])) + if len(pts) >= 2: + wire_segments.append(pts) + except Exception: + pass + + # Adjacency: connect endpoints of different segments that share a grid point + point_adj: dict = defaultdict(set) + for seg in wire_segments: + # Connect consecutive points within the segment + for i in range(len(seg) - 1): + point_adj[seg[i]].add(seg[i + 1]) + point_adj[seg[i + 1]].add(seg[i]) + + # All unique wire points + all_wire_pts = set() + for seg in wire_segments: + all_wire_pts.update(seg) + + # BFS: propagate known net names through wire connections + queue = [pt for pt in all_wire_pts if pt in point_net] + visited = set(queue) + while queue: + pt = queue.pop() + net = point_net[pt] + for neighbor in point_adj[pt]: + if neighbor not in point_net: + point_net[neighbor] = net + all_net_names.add(net) + if neighbor not in visited: + visited.add(neighbor) + queue.append(neighbor) + + # ── 3. Match component pin positions to net names ──────────────── + for sym in getattr(sch, "symbol", None) or []: + try: + ref = sym.property.Reference.value + if ref.startswith("#"): + continue + except Exception: + continue + + pin_positions = pin_locator.get_all_symbol_pins(sch_path, ref) + for pin_num, (px, py) in pin_positions.items(): + net = nearby_net((px, py), point_net) + if net: + pad_net_map[(ref, pin_num)] = net + + logger.info( + f"_build_hierarchical_pad_net_map: {len(pad_net_map)} pin→net assignments, " + f"{len(all_net_names)} unique nets" + ) + return pad_net_map, all_net_names + + def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic'). + Reads net connections from the schematic and assigns them to the matching pads in the PCB. + """ + logger.info("Syncing schematic to board") + try: + from pathlib import Path + + schematic_path = params.get("schematicPath") + board_path = params.get("boardPath") + + # Determine board to work with + board = None + if board_path: + board = self._safe_load_board(board_path) + if board is None: + return { + "success": False, + "message": f"Could not load board from {board_path}", + "errorDetails": ( + "pcbnew.LoadBoard failed or returned a dehydrated " + "SWIG proxy that could not be recovered" + ), + } + elif self.board: + board = self.board + board_path = board.GetFileName() if not board_path else board_path + else: + return { + "success": False, + "message": "No board loaded. Use open_project first or provide boardPath.", + } + + if not board_path: + board_path = board.GetFileName() + + # Determine schematic path if not provided + if not schematic_path: + sch = Path(board_path).with_suffix(".kicad_sch") + if sch.exists(): + schematic_path = str(sch) + else: + project_dir = Path(board_path).parent + sch_files = list(project_dir.glob("*.kicad_sch")) + if sch_files: + schematic_path = str(sch_files[0]) + + if not schematic_path or not Path(schematic_path).exists(): + return { + "success": False, + "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}", + } + + # Build hierarchical pad→net map (walks all sub-sheets) + pad_net_map, net_names = self._build_hierarchical_pad_net_map(schematic_path) + + # Add missing footprints from the schematic to the board *before* + # we add nets and assign pads — F8 in KiCad does this implicitly + # ("Update PCB from Schematic"), but our previous implementation + # only mutated nets, leaving newly-added schematic symbols with no + # PCB footprint at all. + added_footprints, skipped_footprints = self._add_missing_footprints_from_schematic( + board, schematic_path + ) + + # Add all nets to board + netinfo = board.GetNetInfo() + nets_by_name = netinfo.NetsByName() + added_nets = [] + for net_name in net_names: + if not nets_by_name.has_key(net_name): + net_item = pcbnew.NETINFO_ITEM(board, net_name) + board.Add(net_item) + added_nets.append(net_name) + + # Refresh nets map after additions + netinfo = board.GetNetInfo() + nets_by_name = netinfo.NetsByName() + + # Assign nets to pads (now also covers any footprints we just added) + assigned_pads = 0 + unmatched = [] + for fp in board.GetFootprints(): + ref = fp.GetReference() + for pad in fp.Pads(): + pad_num = pad.GetNumber() + key = (ref, str(pad_num)) + if key in pad_net_map: + net_name = pad_net_map[key] + if nets_by_name.has_key(net_name): + pad.SetNet(nets_by_name[net_name]) + assigned_pads += 1 + else: + unmatched.append(f"{ref}/{pad_num}") + + board.Save(board_path) + + # If board was loaded fresh, update internal reference + if params.get("boardPath"): + self.board = board + self._update_command_handlers() + + logger.info( + f"sync_schematic_to_board: {len(added_nets)} nets added, " + f"{len(added_footprints)} footprints added, {assigned_pads} pads assigned" + ) + return { + "success": True, + "message": ( + f"PCB updated from schematic: {len(added_footprints)} footprints added, " + f"{len(added_nets)} nets added, {assigned_pads} pads assigned" + ), + "nets_added": added_nets, + "nets_total": len(net_names), + "pads_assigned": assigned_pads, + "unmatched_pads_sample": unmatched[:10], + "footprints_added": added_footprints, + "footprints_skipped": skipped_footprints, + } + + except Exception as e: + logger.error(f"Error in sync_schematic_to_board: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _extract_components_from_schematic(self, schematic_path: str) -> List[Dict[str, str]]: + """Run kicad-cli netlist export and return the flat list of components. + + Each entry: {"reference": str, "value": str, "footprint": str} + Empty list on any failure (kicad-cli missing, parse error, etc.) — the + caller treats that as "no missing footprints to add". + """ + import subprocess + import tempfile + import xml.etree.ElementTree as ET + + kicad_cli = self._find_kicad_cli_static() + if not kicad_cli: + logger.warning("kicad-cli not found — sync will not add new footprints") + return [] + + with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp: + tmp_path = tmp.name + try: + cmd = [ + kicad_cli, + "sch", + "export", + "netlist", + "--format", + "kicadxml", + "--output", + tmp_path, + schematic_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + logger.warning( + f"kicad-cli netlist export failed (exit {result.returncode}): " + f"{result.stderr.strip()}" + ) + return [] + + tree = ET.parse(tmp_path) + root = tree.getroot() + components = [] + for comp in root.findall("./components/comp"): + components.append( + { + "reference": comp.get("ref", ""), + "value": comp.findtext("value", ""), + "footprint": comp.findtext("footprint", ""), + } + ) + return components + except Exception as e: + logger.warning(f"Failed to extract components from schematic: {e}") + return [] + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + def _add_missing_footprints_from_schematic( + self, board: Any, schematic_path: str + ) -> Tuple[List[Dict[str, str]], List[Dict[str, str]]]: + """Add footprints to ``board`` for any schematic component not yet present. + + New footprints are placed at the board origin so the user can move them + into position. Power/flag references (``#PWR``, ``#FLG``) are skipped — + they have no PCB representation. + + Returns ``(added, skipped)``: each entry is + ``{"reference": str, "footprint": str, "reason": str?}``. + """ + from pathlib import Path + + from commands.library import LibraryManager + + added: List[Dict[str, str]] = [] + skipped: List[Dict[str, str]] = [] + + components = self._extract_components_from_schematic(schematic_path) + if not components: + return added, skipped + + existing_refs = {fp.GetReference() for fp in board.GetFootprints()} + project_dir = Path(schematic_path).parent + library_manager = LibraryManager(project_path=project_dir) + + for comp in components: + ref = comp["reference"] + fp_str = comp["footprint"] + if not ref or ref.startswith("#"): + # Power flags / global indicators — no PCB footprint expected. + continue + if ref in existing_refs: + continue + if not fp_str or ":" not in fp_str: + skipped.append( + { + "reference": ref, + "footprint": fp_str, + "reason": "no Library:Name footprint set on schematic symbol", + } + ) + continue + + lib_name, fp_name = fp_str.split(":", 1) + library_path = library_manager.libraries.get(lib_name) + if not library_path: + skipped.append( + { + "reference": ref, + "footprint": fp_str, + "reason": f"library '{lib_name}' not in fp-lib-table", + } + ) + continue + + try: + module = pcbnew.FootprintLoad(library_path, fp_name) + except Exception as e: + skipped.append( + {"reference": ref, "footprint": fp_str, "reason": f"FootprintLoad failed: {e}"} + ) + continue + + if not module: + skipped.append( + { + "reference": ref, + "footprint": fp_str, + "reason": f"footprint '{fp_name}' not in '{lib_name}'", + } + ) + continue + + module.SetReference(ref) + if comp["value"]: + module.SetValue(comp["value"]) + module.SetFPID(pcbnew.LIB_ID(lib_name, fp_name)) + # Place at board origin; user / autoplacer can position from there. + module.SetPosition(pcbnew.VECTOR2I(0, 0)) + + board.Add(module) + existing_refs.add(ref) + added.append({"reference": ref, "footprint": fp_str}) + + if added: + logger.info(f"_add_missing_footprints_from_schematic: added {len(added)} footprints") + return added, skipped + + def _handle_get_schematic_view_region(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export a cropped region of the schematic as an image""" + logger.info("Exporting schematic view region") + import base64 + import os + import subprocess + import tempfile + + try: + schematic_path = params.get("schematicPath") + if not schematic_path or not os.path.exists(schematic_path): + return {"success": False, "message": "Schematic file not found"} + + x1 = float(params.get("x1", 0)) + y1 = float(params.get("y1", 0)) + x2 = float(params.get("x2", 297)) + y2 = float(params.get("y2", 210)) + x1, x2 = min(x1, x2), max(x1, x2) + y1, y2 = min(y1, y2), max(y1, y2) + out_format = params.get("format", "png") + width = int(params.get("width", 800)) + height = int(params.get("height", 600)) + + kicad_cli = self.design_rule_commands._find_kicad_cli() + if not kicad_cli: + return {"success": False, "message": "kicad-cli not found"} + + tmp_dir = tempfile.mkdtemp() + svg_output = None + + try: + cmd = [ + kicad_cli, + "sch", + "export", + "svg", + "--output", + tmp_dir, + schematic_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + return { + "success": False, + "message": f"SVG export failed: {result.stderr}", + } + + # kicad-cli names the file after the schematic + svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")] + if not svg_files: + return { + "success": False, + "message": "kicad-cli produced no SVG output", + } + svg_output = os.path.join(tmp_dir, svg_files[0]) + + import xml.etree.ElementTree as ET + + tree = ET.parse(svg_output) + root = tree.getroot() + + # KiCad schematic SVGs use mm as viewBox units directly + vb = root.get("viewBox", "") + if vb: + parts = vb.split() + if len(parts) == 4: + orig_vb_x = float(parts[0]) + orig_vb_y = float(parts[1]) + + new_x = orig_vb_x + x1 + new_y = orig_vb_y + y1 + new_w = x2 - x1 + new_h = y2 - y1 + + root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}") + root.set("width", str(width)) + root.set("height", str(height)) + + # Write modified SVG + cropped_svg_path = os.path.join(tmp_dir, "cropped.svg") + tree.write(cropped_svg_path, xml_declaration=True, encoding="utf-8") + + if out_format == "svg": + with open(cropped_svg_path, "r", encoding="utf-8") as f: + svg_data = f.read() + return {"success": True, "imageData": svg_data, "format": "svg"} + else: + png_data = _svg_to_png(cropped_svg_path, width, height) + if png_data is None: + return { + "success": False, + "message": "No PNG converter available. Install pymupdf, inkscape, or imagemagick.", + } + return { + "success": True, + "imageData": base64.b64encode(png_data).decode("utf-8"), + "format": "png", + } + finally: + import shutil + + shutil.rmtree(tmp_dir, ignore_errors=True) + + except Exception as e: + logger.error(f"Error in get_schematic_view_region: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_find_wires_crossing_symbols(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find wires that cross over component symbol bodies""" + logger.info("Finding wires crossing symbols in schematic") + try: + from pathlib import Path + + from commands.schematic_analysis import find_wires_crossing_symbols + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + result = find_wires_crossing_symbols(Path(schematic_path)) + return { + "success": True, + "collisions": result, + "count": len(result), + "message": f"Found {len(result)} wire(s) crossing symbols", + } + except Exception as e: + logger.error(f"Error checking wire collisions: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_find_orphaned_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find wire segments with at least one dangling (unconnected) endpoint""" + logger.info("Finding orphaned wires in schematic") + try: + from pathlib import Path + + from commands.schematic_analysis import find_orphaned_wires + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + result = find_orphaned_wires(Path(schematic_path)) + return { + "success": True, + **result, + "message": f"Found {result['count']} orphaned wire(s)", + } + except Exception as e: + logger.error(f"Error finding orphaned wires: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_list_floating_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List net labels that are not connected to any component pin""" + logger.info("Listing floating net labels in schematic") + try: + from commands.wire_connectivity import list_floating_labels + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + labels = list_floating_labels(schematic, schematic_path) + return { + "success": True, + "floating_labels": labels, + "count": len(labels), + "message": f"Found {len(labels)} floating label(s)", + } + except Exception as e: + logger.error(f"Error listing floating labels: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Snap schematic element coordinates to the nearest grid point""" + logger.info("Snapping schematic elements to grid") + try: + from pathlib import Path + + from commands.schematic_snap import snap_to_grid + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + grid_size = float(params.get("gridSize", 1.27)) + elements = params.get("elements") # None → defaults inside snap_to_grid + + result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements) + total = result["snapped"] + result["already_on_grid"] + return { + "success": True, + **result, + "message": ( + f"Snapped {result['snapped']} element(s) to {grid_size} mm grid " + f"({result['already_on_grid']} of {total} were already on grid)" + ), + } + except Exception as e: + logger.error(f"Error snapping to grid: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index a0ec531..9bc4662 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -37,6 +37,7 @@ if sys.platform == "win32": import sexpdata from annotations import AnnotationLoader from commands.wire_manager import WireManager +from commands.schematic_handlers import SchematicHandlersMixin from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read # Import tool schemas, resource definitions, and IPC API annotations @@ -343,65 +344,7 @@ except ImportError as e: sys.exit(1) -def _svg_to_png(svg_path: str, width: int, height: int) -> Optional[bytes]: - """Convert SVG to PNG. No cffi dependency. - - Priority: - 1. pymupdf (fitz) — bundled MuPDF renderer, pure Python, no system deps - 2. Inkscape CLI — accurate KiCAD SVG rendering - 3. ImageMagick convert — broad availability fallback - Returns PNG bytes or None if all converters fail. - """ - import subprocess - import tempfile - - try: - import fitz - - doc = fitz.open(svg_path) - page = doc[0] - mat = fitz.Matrix(width / page.rect.width, height / page.rect.height) - return page.get_pixmap(matrix=mat).tobytes("png") - except Exception: - pass - - out_path = os.path.join(tempfile.mkdtemp(), "out.png") - - try: - r = subprocess.run( - [ - "inkscape", - svg_path, - "--export-type=png", - f"--export-width={width}", - f"--export-height={height}", - f"--export-filename={out_path}", - ], - capture_output=True, - timeout=60, - ) - if r.returncode == 0 and os.path.exists(out_path): - with open(out_path, "rb") as f: - return f.read() - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - - try: - r = subprocess.run( - ["convert", "-density", "150", svg_path, "-resize", f"{width}x{height}", out_path], - capture_output=True, - timeout=60, - ) - if r.returncode == 0 and os.path.exists(out_path): - with open(out_path, "rb") as f: - return f.read() - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - - return None - - -class KiCADInterface: +class KiCADInterface(SchematicHandlersMixin): """Main interface class to handle KiCAD operations""" def __init__(self) -> None: @@ -1286,70 +1229,6 @@ class KiCADInterface: return board # Schematic command handlers - def _handle_create_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Create a new schematic""" - logger.info("Creating schematic") - try: - # Support multiple parameter naming conventions for compatibility: - # - TypeScript tools use: name, path - # - Python schema uses: filename, title - # - Legacy uses: projectName, path, metadata - project_name = params.get("projectName") or params.get("name") or params.get("title") - - # Handle filename parameter - it may contain full path - filename = params.get("filename") - if filename: - # If filename provided, extract name and path from it - if filename.endswith(".kicad_sch"): - filename = filename[:-10] # Remove .kicad_sch extension - path = os.path.dirname(filename) or "." - project_name = project_name or os.path.basename(filename) - else: - path = params.get("path", ".") - metadata = params.get("metadata", {}) - - if not project_name: - return { - "success": False, - "message": "Schematic name is required. Provide 'name', 'projectName', or 'filename' parameter.", - } - - sch_path = path if path and path != "." else None - schematic = SchematicManager.create_schematic( - project_name, path=sch_path, metadata=metadata - ) - base_name = ( - project_name if project_name.endswith(".kicad_sch") else f"{project_name}.kicad_sch" - ) - normalized_path = path or "." - file_path = os.path.join(normalized_path, base_name) - success = SchematicManager.save_schematic(schematic, file_path) - - return {"success": success, "file_path": file_path} - except Exception as e: - logger.error(f"Error creating schematic: {str(e)}") - return {"success": False, "message": str(e)} - - def _handle_load_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Load an existing schematic""" - logger.info("Loading schematic") - try: - filename = params.get("filename") - - if not filename: - return {"success": False, "message": "Filename is required"} - - schematic = SchematicManager.load_schematic(filename) - success = schematic is not None - - if success: - metadata = SchematicManager.get_schematic_metadata(schematic) - return {"success": success, "metadata": metadata} - else: - return {"success": False, "message": "Failed to load schematic"} - except Exception as e: - logger.error(f"Error loading schematic: {str(e)}") - return {"success": False, "message": str(e)} def _project_path_from_filename(self, filename: Optional[str]) -> Optional[Path]: """Resolve a project directory from a filename param. @@ -1441,222 +1320,11 @@ class KiCADInterface: return self.component_commands.place_component(params) - def _handle_add_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a component to a schematic using text-based injection (no sexpdata)""" - logger.info("Adding component to schematic") - try: - from pathlib import Path - - from commands.dynamic_symbol_loader import DynamicSymbolLoader - - schematic_path = params.get("schematicPath") - component = params.get("component", {}) - - if not schematic_path: - return {"success": False, "message": "Schematic path is required"} - if not component: - return {"success": False, "message": "Component definition is required"} - - comp_type = component.get("type", "R") - library = component.get("library", "Device") - reference = component.get("reference", "X?") - value = component.get("value", comp_type) - footprint = component.get("footprint", "") - x = component.get("x", 0) - y = component.get("y", 0) - unit = component.get("unit", 1) - - # Derive project path from schematic path for project-local library resolution. - # Walk up from the schematic file to find the directory that owns the project - # (contains sym-lib-table or a .kicad_pro file). Schematics stored in a - # sub-folder (e.g. sheets/) would otherwise resolve to the wrong directory and - # miss any project-local sym-lib-table entries. - schematic_file = Path(schematic_path) - derived_project_path = schematic_file.parent - for ancestor in schematic_file.parents: - if (ancestor / "sym-lib-table").exists() or list(ancestor.glob("*.kicad_pro")): - derived_project_path = ancestor - break - - loader = DynamicSymbolLoader(project_path=derived_project_path) - loader.add_component( - schematic_file, - library, - comp_type, - reference=reference, - value=value, - footprint=footprint, - x=x, - y=y, - unit=unit, - project_path=derived_project_path, - ) - - return { - "success": True, - "component_reference": reference, - "symbol_source": f"{library}:{comp_type}", - } - except Exception as e: - logger.error(f"Error adding component to schematic: {str(e)}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_delete_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" - logger.info("Deleting schematic component") - try: - import re - from pathlib import Path - - schematic_path = params.get("schematicPath") - reference = params.get("reference") - - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - if not reference: - return {"success": False, "message": "reference is required"} - - sch_file = Path(schematic_path) - if not sch_file.exists(): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - with open(sch_file, "r", encoding="utf-8") as f: - content = f.read() - - # String-aware paren matcher (see _find_matching_paren): a naive - # counter over-runs on unescaped parens inside quoted strings (e.g. - # MCU pin names like "PA13(JTMS"), which would extend lib_symbols to - # EOF and make every placed-symbol lookup fail. - find_matching_paren = self._find_matching_paren - - # Skip lib_symbols section - lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 - - # Find ALL placed symbol blocks matching the reference (handles duplicates). - # Use content-string search so multi-line KiCAD format is handled correctly: - # KiCAD writes (symbol\n\t\t(lib_id "...") across two lines, which a - # line-by-line regex would never match. - blocks_to_delete = [] # list of (char_start, char_end) into content - search_start = 0 - # Match the opening of any placed-symbol block. KiCAD may emit the - # children of (symbol ...) in any order — most commonly - # `(symbol (lib_id "..."))`, but symbols whose library entry has been - # rescued / customised carry an additional `(lib_name "...")` first: - # `(symbol (lib_name "...") (lib_id "...") ...)`. Matching just - # `(symbol\s+(` covers both, and the lib_symbols range check below - # still excludes library-definition symbols (which use the - # `(symbol "name" ...)` form with a quoted string, not a paren). - pattern = re.compile(r"\(symbol\s+\(") - while True: - m = pattern.search(content, search_start) - if not m: - break - pos = m.start() - # Skip blocks inside lib_symbols - if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: - search_start = lib_sym_end + 1 - continue - end = find_matching_paren(content, pos) - if end < 0: - search_start = pos + 1 - continue - block_text = content[pos : end + 1] - if re.search( - r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', - block_text, - ): - blocks_to_delete.append((pos, end)) - search_start = end + 1 - - if not blocks_to_delete: - return { - "success": False, - "message": f"Component '{reference}' not found in schematic (note: this tool removes schematic symbols, use delete_component for PCB footprints)", - } - - # Delete from back to front to preserve character offsets - for b_start, b_end in sorted(blocks_to_delete, reverse=True): - # Include any leading newline/whitespace before the block - trim_start = b_start - while trim_start > 0 and content[trim_start - 1] in (" ", "\t"): - trim_start -= 1 - if trim_start > 0 and content[trim_start - 1] == "\n": - trim_start -= 1 - content = content[:trim_start] + content[b_end + 1 :] - - with open(sch_file, "w", encoding="utf-8") as f: - f.write(content) - - deleted_count = len(blocks_to_delete) - logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") - return { - "success": True, - "reference": reference, - "deleted_count": deleted_count, - "schematic": str(sch_file), - } - - except Exception as e: - logger.error(f"Error deleting schematic component: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - # Built-in property names that have dedicated parameters and cannot be removed # via the generic removeProperties path. They are also written by KiCad on every # save, so deleting them produces an invalid schematic. _PROTECTED_PROPERTY_FIELDS = frozenset({"Reference", "Value", "Footprint", "Datasheet"}) - @staticmethod - def _escape_sexpr_string(value: str) -> str: - """Escape a string for safe insertion into an S-expression double-quoted token.""" - return value.replace("\\", "\\\\").replace('"', '\\"') - - @staticmethod - def _find_matching_paren(s: str, start: int) -> int: - """Return the index of the closing paren matching the opening paren at `start`. - - String-aware: parens inside double-quoted tokens are ignored. KiCAD does - NOT backslash-escape bare parens inside quoted strings — e.g. MCU pin - names like "PA13(JTMS" or descriptions like "Vin(fwd) 40V" appear raw in - .kicad_sch / .kicad_sym files. A naive depth counter treats such an - in-string "(" as real structure, so it never rebalances and runs to EOF. - When that happens to the (lib_symbols ...) block, every placed symbol — - which follows lib_symbols — looks like it lives *inside* it and gets - skipped, so reference lookups silently fail for the whole schematic. - - Returns -1 if no match is found. - """ - depth = 0 - i = start - in_string = False - while i < len(s): - ch = s[i] - if in_string: - if ch == "\\": - i += 2 # skip escaped char (e.g. \" or \\) - continue - if ch == '"': - in_string = False - elif ch == '"': - in_string = True - elif ch == "(": - depth += 1 - elif ch == ")": - depth -= 1 - if depth == 0: - return i - i += 1 - return -1 - def _set_property_in_block( self, block: str, @@ -1887,580 +1555,6 @@ class KiCADInterface: trim_start -= 1 return block[:trim_start] + block[end + 1 :], True - def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Update properties of a placed symbol in a schematic. - - Supports updating the standard fields (footprint / value / reference rename), - repositioning field labels, and managing **arbitrary custom properties** - (MPN, Manufacturer, Distributor part numbers, Voltage, Dielectric, Tolerance, - LCSC, etc.) used by BOM/CPL exporters and JLCPCB / Digi-Key sourcing. - - Uses text-based in-place editing — preserves position, UUID, and all - unrelated fields. - """ - logger.info("Editing schematic component") - try: - import re - from pathlib import Path - - schematic_path = params.get("schematicPath") - reference = params.get("reference") - new_footprint = params.get("footprint") - new_value = params.get("value") - new_reference = params.get("newReference") - # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} - field_positions = params.get("fieldPositions") - # dict: {"MPN": "RC0603FR-0710KL"} OR {"MPN": {"value": "...", "hide": true}} - properties = params.get("properties") - # list[str]: ["OldField"] — protected built-ins are rejected - remove_properties = params.get("removeProperties") - - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - if not reference: - return {"success": False, "message": "reference is required"} - if not any( - [ - new_footprint is not None, - new_value is not None, - new_reference is not None, - field_positions is not None, - properties is not None, - remove_properties is not None, - ] - ): - return { - "success": False, - "message": ( - "At least one of footprint, value, newReference, fieldPositions, " - "properties, or removeProperties must be provided" - ), - } - - # Reject removal attempts targeting protected built-in fields up-front - if remove_properties: - blocked = [n for n in remove_properties if n in self._PROTECTED_PROPERTY_FIELDS] - if blocked: - return { - "success": False, - "message": ( - f"Cannot remove built-in field(s) {blocked}: use the dedicated " - "value/footprint/newReference parameters or set the value to ''" - ), - } - - sch_file = Path(schematic_path) - if not sch_file.exists(): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - with open(sch_file, "r", encoding="utf-8") as f: - content = f.read() - - # Skip lib_symbols section - lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = ( - self._find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 - ) - - # Find placed symbol blocks that match the reference. KiCAD may - # serialise the children of (symbol ...) in different orders — - # `(symbol (lib_id "..."))` is the common case but rescued or - # locally-customised symbols carry an extra `(lib_name "...")` - # before the lib_id: `(symbol (lib_name "...") (lib_id "..."))`. - # Match any opening paren after `(symbol`; the lib_symbols range - # check below excludes library-definition symbols, which use the - # `(symbol "name" ...)` form (quoted string, not paren). - block_start = block_end = None - search_start = 0 - pattern = re.compile(r"\(symbol\s+\(") - while True: - m = pattern.search(content, search_start) - if not m: - break - pos = m.start() - # Skip if inside lib_symbols section - if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: - search_start = lib_sym_end + 1 - continue - end = self._find_matching_paren(content, pos) - if end < 0: - search_start = pos + 1 - continue - block_text = content[pos : end + 1] - if re.search( - r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', - block_text, - ): - block_start, block_end = pos, end - break - search_start = end + 1 - - if block_start is None or block_end is None: - return { - "success": False, - "message": f"Component '{reference}' not found in schematic", - } - - # Apply property replacements within the found block - block_text = content[block_start : block_end + 1] - - # Determine the parent symbol position so that newly-added properties - # default to a sensible location (anchored near the component). - # KiCAD always emits the symbol's own (at x y angle) before any - # (property ...) child blocks, so the FIRST (at ...) inside the - # symbol block is the symbol origin regardless of whether - # (lib_name ...) precedes (lib_id ...). - comp_at = re.search( - r"\(at\s+([\d\.\-]+)\s+([\d\.\-]+)", - block_text, - ) - comp_origin: Tuple[float, float] = ( - (float(comp_at.group(1)), float(comp_at.group(2))) if comp_at else (0.0, 0.0) - ) - - if new_footprint is not None: - escaped_fp = self._escape_sexpr_string(str(new_footprint)) - block_text = re.sub( - r'(\(property\s+"Footprint"\s+)"[^"]*"', - rf'\1"{escaped_fp}"', - block_text, - ) - if new_value is not None: - escaped_v = self._escape_sexpr_string(str(new_value)) - block_text = re.sub( - r'(\(property\s+"Value"\s+)"[^"]*"', - rf'\1"{escaped_v}"', - block_text, - ) - if new_reference is not None: - escaped_r = self._escape_sexpr_string(str(new_reference)) - block_text = re.sub( - r'(\(property\s+"Reference"\s+)"[^"]*"', - rf'\1"{escaped_r}"', - block_text, - ) - # Also update the (reference "...") leaves inside the symbol's - # (instances) → (project) → (path) subtree. KiCad reads those - # entries — not the (property "Reference" ...) field — when - # generating netlists and syncing the PCB via "Update PCB from - # Schematic", so leaving them stale produces a silent - # reference mismatch where eeschema shows the new ref but ERC - # / netlist export / PCB sync all use the old one. See #126. - instances_pos = block_text.find("(instances") - if instances_pos >= 0: - instances_end = self._find_matching_paren(block_text, instances_pos) - if instances_end >= 0: - instances_block = block_text[instances_pos : instances_end + 1] - updated_instances = re.sub( - r'(\(reference\s+)"' + re.escape(reference) + r'"', - rf'\1"{escaped_r}"', - instances_block, - ) - block_text = ( - block_text[:instances_pos] - + updated_instances - + block_text[instances_end + 1 :] - ) - if field_positions is not None: - for field_name, pos in field_positions.items(): - x = pos.get("x", 0) - y = pos.get("y", 0) - angle = pos.get("angle", 0) - block_text = re.sub( - r'(\(property\s+"' - + re.escape(field_name) - + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', - rf"\1(at {x} {y} {angle})", - block_text, - ) - justify = pos.get("justify") - if justify is not None: - block_text = self._set_justify_on_property( - block_text, field_name, str(justify) - ) - - properties_added: Dict[str, Any] = {} - properties_updated: Dict[str, Any] = {} - if properties: - if not isinstance(properties, dict): - return { - "success": False, - "message": "properties must be a dict mapping property name -> value or spec", - } - for name, spec in properties.items(): - if not isinstance(name, str) or not name: - return { - "success": False, - "message": f"Invalid property name: {name!r}", - } - # Normalise scalar values to a spec dict with just {"value": ...} - if not isinstance(spec, dict): - spec = {"value": spec} - try: - block_text, action = self._set_property_in_block( - block_text, name, spec, comp_origin - ) - except ValueError as ve: - return {"success": False, "message": str(ve)} - target = properties_added if action == "added" else properties_updated - target[name] = spec.get("value") - - properties_removed: list = [] - if remove_properties: - if not isinstance(remove_properties, list): - return { - "success": False, - "message": "removeProperties must be a list of property names", - } - for name in remove_properties: - block_text, removed = self._remove_property_from_block(block_text, name) - if removed: - properties_removed.append(name) - - content = content[:block_start] + block_text + content[block_end + 1 :] - - with open(sch_file, "w", encoding="utf-8") as f: - f.write(content) - - changes: Dict[str, Any] = { - k: v - for k, v in { - "footprint": new_footprint, - "value": new_value, - "reference": new_reference, - }.items() - if v is not None - } - if field_positions is not None: - changes["fieldPositions"] = field_positions - if properties_added: - changes["propertiesAdded"] = properties_added - if properties_updated: - changes["propertiesUpdated"] = properties_updated - if properties_removed: - changes["propertiesRemoved"] = properties_removed - - logger.info(f"Edited schematic component {reference}: {changes}") - return {"success": True, "reference": reference, "updated": changes} - - except Exception as e: - logger.error(f"Error editing schematic component: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_set_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add or update a single property on a placed schematic symbol. - - Convenience wrapper around `edit_schematic_component` for the very common - case of setting one BOM/sourcing field at a time. The property is created - if it does not already exist, otherwise its value (and optionally its - position / visibility) is updated in place. - """ - logger.info("Setting schematic component property") - name = params.get("name") - if not isinstance(name, str) or not name: - return {"success": False, "message": "name is required"} - if "value" not in params: - return {"success": False, "message": "value is required"} - - spec: Dict[str, Any] = {"value": params["value"]} - for key in ("x", "y", "angle", "hide", "fontSize", "justify"): - if params.get(key) is not None: - spec[key] = params[key] - - return self._handle_edit_schematic_component( - { - "schematicPath": params.get("schematicPath"), - "reference": params.get("reference"), - "properties": {name: spec}, - } - ) - - def _handle_remove_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Remove a single custom property from a placed schematic symbol. - - Built-in fields (Reference, Value, Footprint, Datasheet) cannot be - removed — use `edit_schematic_component` to clear them instead. - """ - logger.info("Removing schematic component property") - name = params.get("name") - if not isinstance(name, str) or not name: - return {"success": False, "message": "name is required"} - return self._handle_edit_schematic_component( - { - "schematicPath": params.get("schematicPath"), - "reference": params.get("reference"), - "removeProperties": [name], - } - ) - - def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Return full component info: position and all field values with their (at x y angle) positions.""" - logger.info("Getting schematic component info") - try: - import re - from pathlib import Path - - schematic_path = params.get("schematicPath") - reference = params.get("reference") - - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - if not reference: - return {"success": False, "message": "reference is required"} - - sch_file = Path(schematic_path) - if not sch_file.exists(): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - with open(sch_file, "r", encoding="utf-8") as f: - content = f.read() - - # String-aware paren matcher (see _find_matching_paren): a naive - # counter over-runs on unescaped parens inside quoted strings (e.g. - # MCU pin names like "PA13(JTMS"), which would extend lib_symbols to - # EOF and make every placed-symbol lookup fail. - find_matching_paren = self._find_matching_paren - - # Skip lib_symbols section - lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 - - # Find the placed symbol block for this reference. KiCAD may emit - # the children of (symbol ...) in different orders — most commonly - # `(symbol (lib_id "..."))`, but symbols whose library entry has - # been rescued / customised carry an extra `(lib_name "...")` first - # (`(symbol (lib_name "...") (lib_id "..."))`). Match `(symbol\s+(` - # — any opening paren — to handle both. The lib_symbols range check - # below excludes library-definition symbols, which use the - # `(symbol "name" ...)` form (quoted string, not paren). - block_start = block_end = None - search_start = 0 - pattern = re.compile(r"\(symbol\s+\(") - while True: - m = pattern.search(content, search_start) - if not m: - break - pos = m.start() - if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: - search_start = lib_sym_end + 1 - continue - end = find_matching_paren(content, pos) - if end < 0: - search_start = pos + 1 - continue - block_text = content[pos : end + 1] - if re.search( - r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', - block_text, - ): - block_start, block_end = pos, end - break - search_start = end + 1 - - if block_start is None or block_end is None: - return { - "success": False, - "message": f"Component '{reference}' not found in schematic", - } - - block_text = content[block_start : block_end + 1] - - # Extract component position: the first (at x y angle) inside the - # symbol block. KiCAD always writes the symbol's own (at) before - # any (property ...) child blocks, so the first match is the - # symbol origin regardless of the (lib_name)/(lib_id) ordering. - comp_at = re.search( - r"\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)", - block_text, - ) - if comp_at: - comp_pos = { - "x": float(comp_at.group(1)), - "y": float(comp_at.group(2)), - "angle": float(comp_at.group(3)), - } - else: - comp_pos = None - - # Extract all properties with their at positions - prop_pattern = re.compile( - r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' - ) - fields = {} - for m in prop_pattern.finditer(block_text): - name, value, x, y, angle = ( - m.group(1), - m.group(2), - m.group(3), - m.group(4), - m.group(5), - ) - fields[name] = { - "value": value, - "x": float(x), - "y": float(y), - "angle": float(angle), - } - - return { - "success": True, - "reference": reference, - "position": comp_pos, - "fields": fields, - } - - except Exception as e: - logger.error(f"Error getting schematic component: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_add_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a wire to a schematic using WireManager, with optional pin snapping""" - logger.info("Adding wire to schematic") - try: - from pathlib import Path - - from commands.wire_manager import WireManager - - schematic_path = params.get("schematicPath") - points = params.get("waypoints") - properties = params.get("properties", {}) - snap_to_pins = params.get("snapToPins", True) - snap_tolerance = params.get("snapTolerance", 1.0) - - if not schematic_path: - return {"success": False, "message": "Schematic path is required"} - if not points or len(points) < 2: - return { - "success": False, - "message": "At least 2 waypoints are required", - } - - # Make a mutable copy of points - points = [list(p) for p in points] - - # Pin snapping: adjust first and last endpoints to nearest pin - snapped_info = [] - if snap_to_pins: - from commands.pin_locator import PinLocator - - locator = PinLocator() - sch_path = Path(schematic_path) - - # Load schematic to iterate all symbols - from skip import Schematic as SkipSchematic - - sch = SkipSchematic(str(sch_path)) - - # Collect all pin locations: list of (ref, pin_num, [x, y]) - all_pins = [] - for symbol in sch.symbol: - if not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - pin_locs = locator.get_all_symbol_pins(sch_path, ref) - for pin_num, coords in pin_locs.items(): - all_pins.append((ref, pin_num, coords)) - - def find_nearest_pin(point: Any, tolerance: Any) -> Any: - """Find the nearest pin within tolerance of a point.""" - best = None - best_dist = tolerance - for ref, pin_num, coords in all_pins: - dx = point[0] - coords[0] - dy = point[1] - coords[1] - dist = (dx * dx + dy * dy) ** 0.5 - if dist <= best_dist: - best_dist = dist - best = (ref, pin_num, coords) - return best - - # Snap first endpoint - match = find_nearest_pin(points[0], snap_tolerance) - if match: - ref, pin_num, coords = match - logger.info( - f"Snapped start point {points[0]} -> {coords} (pin {ref}/{pin_num})" - ) - snapped_info.append( - f"start snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" - ) - points[0] = list(coords) - - # Snap last endpoint - match = find_nearest_pin(points[-1], snap_tolerance) - if match: - ref, pin_num, coords = match - logger.info(f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})") - snapped_info.append( - f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" - ) - points[-1] = list(coords) - - # Extract wire properties - stroke_width = properties.get("stroke_width", 0) - stroke_type = properties.get("stroke_type", "default") - - # Use WireManager for S-expression manipulation - if len(points) == 2: - success = WireManager.add_wire( - Path(schematic_path), - points[0], - points[1], - stroke_width=stroke_width, - stroke_type=stroke_type, - ) - else: - success = WireManager.add_polyline_wire( - Path(schematic_path), - points, - stroke_width=stroke_width, - stroke_type=stroke_type, - ) - - if success: - message = "Wire added successfully" - if snapped_info: - message += "; " + "; ".join(snapped_info) - return {"success": True, "message": message} - else: - return {"success": False, "message": "Failed to add wire"} - except Exception as e: - logger.error(f"Error adding wire to schematic: {str(e)}") - import traceback - - logger.error(traceback.format_exc()) - return { - "success": False, - "message": str(e), - "errorDetails": traceback.format_exc(), - } - - def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: - """List available symbol libraries""" - logger.info("Listing schematic libraries") - try: - search_paths = params.get("searchPaths") - - libraries = SchematicLibraryManager.list_available_libraries(search_paths) - return {"success": True, "libraries": libraries} - except Exception as e: - logger.error(f"Error listing schematic libraries: {str(e)}") - return {"success": False, "message": str(e)} - def _handle_find_unconnected_pins(self, params: Dict[str, Any]) -> Dict[str, Any]: """List component pins with no wire/label/power symbol touching them""" logger.info("Finding unconnected pins") @@ -2481,26 +1575,6 @@ class KiCADInterface: logger.error(f"Error finding unconnected pins: {e}") return {"success": False, "message": str(e)} - def _handle_check_wire_collisions(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Detect wires passing through component bodies without connecting to pins""" - logger.info("Checking wire collisions") - try: - from commands.schematic_analysis import check_wire_collisions - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - result = check_wire_collisions(schematic_path) - return {"success": True, **result} - except ImportError: - return { - "success": False, - "message": "schematic_analysis module not available", - } - except Exception as e: - logger.error(f"Error checking wire collisions: {e}") - return {"success": False, "message": str(e)} - # ------------------------------------------------------------------ # # Footprint handlers # # ------------------------------------------------------------------ # @@ -2641,180 +1715,6 @@ class KiCADInterface: logger.error(f"register_symbol_library error: {e}") return {"success": False, "error": str(e)} - def _handle_export_schematic_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export schematic to PDF""" - logger.info("Exporting schematic to PDF") - try: - schematic_path = params.get("schematicPath") - output_path = params.get("outputPath") - - if not schematic_path: - return {"success": False, "message": "Schematic path is required"} - if not output_path: - return {"success": False, "message": "Output path is required"} - - if not os.path.exists(schematic_path): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - import subprocess - - cmd = [ - "kicad-cli", - "sch", - "export", - "pdf", - "--output", - output_path, - schematic_path, - ] - - if params.get("blackAndWhite"): - cmd.insert(-1, "--black-and-white") - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - - if result.returncode == 0: - return {"success": True, "file": {"path": output_path}} - else: - return { - "success": False, - "message": f"kicad-cli failed: {result.stderr}", - } - - except FileNotFoundError: - return {"success": False, "message": "kicad-cli not found in PATH"} - except Exception as e: - logger.error(f"Error exporting schematic to PDF: {str(e)}") - return {"success": False, "message": str(e)} - - def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a net label to schematic using WireManager. - - When componentRef and pinNumber are supplied the label is placed at the - exact pin endpoint retrieved via PinLocator, ignoring the provided - position. The response includes the actual coordinates used and - whether the label landed on a pin endpoint. - """ - logger.info("Adding net label to schematic") - try: - from pathlib import Path - - from commands.wire_manager import WireManager - - schematic_path = params.get("schematicPath") - net_name = params.get("netName") - position = params.get("position") - label_type = params.get("labelType", "label") - orientation = params.get("orientation", 0) - component_ref = params.get("componentRef") - pin_number = params.get("pinNumber") - - if not all([schematic_path, net_name]): - return { - "success": False, - "message": "Missing required parameters: schematicPath, netName", - } - - snapped_to_pin: Optional[Dict[str, Any]] = None - - if component_ref and pin_number: - # Snap position to exact pin endpoint using PinLocator - from commands.pin_locator import PinLocator - - locator = PinLocator() - pin_loc = locator.get_pin_location( - Path(schematic_path), component_ref, str(pin_number) - ) - if pin_loc is None: - return { - "success": False, - "message": ( - f"Could not locate pin {pin_number} on {component_ref}. " - "Check the reference and pin number." - ), - } - position = pin_loc - snapped_to_pin = {"component": component_ref, "pin": str(pin_number)} - logger.info( - f"Snapped label '{net_name}' to pin {component_ref}/{pin_number} at {position}" - ) - elif position is None: - return { - "success": False, - "message": ( - "Missing position. Either provide position [x, y] or " - "componentRef + pinNumber to snap to a pin endpoint." - ), - } - - # Collect existing net names BEFORE adding the new label so we can - # detect case-mismatch collisions against pre-existing nets only. - existing_net_names: List[str] = [] - try: - pre_schematic = SchematicManager.load_schematic(schematic_path) - if pre_schematic is not None: - if hasattr(pre_schematic, "label"): - for lbl in pre_schematic.label: - if hasattr(lbl, "value"): - existing_net_names.append(lbl.value) - if hasattr(pre_schematic, "global_label"): - for lbl in pre_schematic.global_label: - if hasattr(lbl, "value"): - existing_net_names.append(lbl.value) - except Exception: - # Non-fatal: if we can't read existing nets, skip the warning - existing_net_names = [] - - # Use WireManager for S-expression manipulation - success = WireManager.add_label( - Path(schematic_path), - net_name, - position, - label_type=label_type, - orientation=orientation, - ) - - if not success: - return {"success": False, "message": "Failed to add net label"} - - # Compute case-mismatch warnings against pre-existing net names. - # A collision is: existing name != new name, but lowercases match. - new_name_lower = net_name.lower() - case_warnings: List[str] = [ - f"Net '{existing}' already exists — label '{net_name}' may be a case mismatch." - for existing in existing_net_names - if existing.lower() == new_name_lower and existing != net_name - ] - - response: Dict[str, Any] = { - "success": True, - "message": f"Added net label '{net_name}' at {position}", - "actual_position": position, - } - if snapped_to_pin: - response["snapped_to_pin"] = snapped_to_pin - response["message"] = ( - f"Added net label '{net_name}' at exact pin endpoint " - f"{component_ref}/{pin_number} ({position[0]}, {position[1]})" - ) - if case_warnings: - response["case_warnings"] = case_warnings - return response - - except Exception as e: - logger.error(f"Error adding net label: {str(e)}") - import traceback - - logger.error(traceback.format_exc()) - return { - "success": False, - "message": str(e), - "errorDetails": traceback.format_exc(), - } - def _handle_add_no_connect(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a no-connect flag (X marker) to an unconnected pin in the schematic.""" logger.info("Adding no-connect flag to schematic") @@ -3034,941 +1934,6 @@ class KiCADInterface: logger.warning(f"Footprint '{component_ref}' not found on board") return False - def _handle_get_schematic_pin_locations(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Return exact pin endpoint coordinates for a schematic component""" - logger.info("Getting schematic pin locations") - try: - from pathlib import Path - - from commands.pin_locator import PinLocator - - schematic_path = params.get("schematicPath") - reference = params.get("reference") - - if not all([schematic_path, reference]): - return { - "success": False, - "message": "Missing required parameters: schematicPath, reference", - } - - locator = PinLocator() - all_pins = locator.get_all_symbol_pins(Path(schematic_path), reference) - - if not all_pins: - return { - "success": False, - "message": f"No pins found for {reference} — check reference and schematic path", - } - - # Enrich with pin names and angles from the symbol definition - pins_def = ( - locator.get_symbol_pins( - Path(schematic_path), - locator._get_lib_id(Path(schematic_path), reference), - ) - if hasattr(locator, "_get_lib_id") - else {} - ) - - result = {} - for pin_num, coords in all_pins.items(): - entry = {"x": coords[0], "y": coords[1]} - if pin_num in pins_def: - entry["name"] = pins_def[pin_num].get("name", pin_num) - entry["angle"] = ( - locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0 - ) - result[pin_num] = entry - - return {"success": True, "reference": reference, "pins": result} - - except Exception as e: - logger.error(f"Error getting pin locations: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_get_schematic_view(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a rasterised image of the schematic (SVG export → optional PNG conversion)""" - logger.info("Getting schematic view") - import base64 - import subprocess - import tempfile - - try: - schematic_path = params.get("schematicPath") - if not schematic_path or not os.path.exists(schematic_path): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - fmt = params.get("format", "png") - width = params.get("width", 1200) - height = params.get("height", 900) - - # Step 1: Export schematic to SVG via kicad-cli - with tempfile.TemporaryDirectory() as tmpdir: - svg_path = os.path.join(tmpdir, "schematic.svg") - cmd = [ - "kicad-cli", - "sch", - "export", - "svg", - "--output", - tmpdir, - "--no-background-color", - schematic_path, - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - - if result.returncode != 0: - return { - "success": False, - "message": f"kicad-cli SVG export failed: {result.stderr}", - } - - # kicad-cli may name the file after the schematic, find it - import glob - - svg_files = glob.glob(os.path.join(tmpdir, "*.svg")) - if not svg_files: - return { - "success": False, - "message": "No SVG file produced by kicad-cli", - } - svg_path = svg_files[0] - - if fmt == "svg": - with open(svg_path, "r", encoding="utf-8") as f: - svg_data = f.read() - return {"success": True, "imageData": svg_data, "format": "svg"} - - # Step 2: Convert SVG to PNG (cffi-free) - png_data = _svg_to_png(svg_path, width, height) - if png_data is None: - with open(svg_path, "r", encoding="utf-8") as f: - svg_data = f.read() - return { - "success": True, - "imageData": svg_data, - "format": "svg", - "message": "No PNG converter available — returning SVG. Install pymupdf, inkscape, or imagemagick.", - } - - return { - "success": True, - "imageData": base64.b64encode(png_data).decode("utf-8"), - "format": "png", - "width": width, - "height": height, - } - - except FileNotFoundError: - return {"success": False, "message": "kicad-cli not found in PATH"} - except Exception as e: - logger.error(f"Error getting schematic view: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]: - """List all components in a schematic""" - logger.info("Listing schematic components") - try: - from pathlib import Path - - from commands.pin_locator import PinLocator - - 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}", - } - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - # Optional filters - filter_params = params.get("filter", {}) - lib_id_filter = filter_params.get("libId", "") - ref_prefix_filter = filter_params.get("referencePrefix", "") - - locator = PinLocator() - components = [] - - for symbol in schematic.symbol: - if not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - # Skip template symbols - if ref.startswith("_TEMPLATE"): - continue - - lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else "" - - # Apply filters - if lib_id_filter and lib_id_filter not in lib_id: - continue - if ref_prefix_filter and not ref.startswith(ref_prefix_filter): - continue - - value = symbol.property.Value.value if hasattr(symbol.property, "Value") else "" - footprint = ( - symbol.property.Footprint.value if hasattr(symbol.property, "Footprint") else "" - ) - position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] - uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else "" - - comp = { - "reference": ref, - "libId": lib_id, - "value": value, - "footprint": footprint, - "position": {"x": float(position[0]), "y": float(position[1])}, - "rotation": float(position[2]) if len(position) > 2 else 0, - "uuid": str(uuid_val), - } - - # Get pins if available - try: - all_pins = locator.get_all_symbol_pins(sch_file, ref) - if all_pins: - pins_def = locator.get_symbol_pins(sch_file, lib_id) or {} - pin_list = [] - for pin_num, coords in all_pins.items(): - pin_info = { - "number": pin_num, - "position": {"x": coords[0], "y": coords[1]}, - } - if pin_num in pins_def: - pin_info["name"] = pins_def[pin_num].get("name", pin_num) - pin_list.append(pin_info) - comp["pins"] = pin_list - except Exception: - pass # Pin lookup is best-effort - - components.append(comp) - - return {"success": True, "components": components, "count": len(components)} - - except Exception as e: - logger.error(f"Error listing schematic components: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_list_schematic_nets(self, params: Dict[str, Any]) -> Dict[str, Any]: - """List all nets in a schematic with their connections""" - logger.info("Listing schematic nets") - try: - from commands.wire_connectivity import ( - _build_adjacency, - _discover_sub_sheets, - _load_sexp, - _parse_labels_sexp, - _parse_virtual_connections, - _parse_wires, - count_pins_on_net, - get_connections_for_net, - ) - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - # Collect net names from the top-level sheet using sexpdata. - # Falls back to kicad-skip's label collections when the file - # cannot be read (e.g. mocked schematics in unit tests). - net_names: set = set() - sexp_loaded = False - try: - sexp = _load_sexp(schematic_path) - sexp_loaded = True - _, label_to_points = _parse_labels_sexp(sexp) - net_names.update(label_to_points.keys()) - except Exception as e: - logger.debug( - f"Could not parse labels from {schematic_path} via sexp ({e}); " - "falling back to kicad-skip label collections" - ) - for attr in ("label", "global_label"): - if not hasattr(schematic, attr): - continue - for label in getattr(schematic, attr): - if hasattr(label, "value"): - net_names.add(label.value) - - # Collect net names from all sub-sheets (only when the parent - # sheet was readable; fake/mock paths skip recursion entirely). - if sexp_loaded: - sub_sheets = _discover_sub_sheets(schematic_path) - for sub_path in sub_sheets: - try: - sub_sexp = _load_sexp(sub_path) - _, sub_label_to_points = _parse_labels_sexp(sub_sexp) - net_names.update(sub_label_to_points.keys()) - except Exception as e: - logger.warning(f"Error reading sub-sheet {sub_path}: {e}") - - # Pre-build shared wire graph structures for efficiency - all_wires = _parse_wires(schematic) - if all_wires: - adjacency, iu_to_wires = _build_adjacency(all_wires) - else: - adjacency, iu_to_wires = [], {} - point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) - - nets = [] - for net_name in sorted(net_names): - connections = get_connections_for_net(schematic, schematic_path, net_name) - pin_count = count_pins_on_net( - schematic, - schematic_path, - net_name, - all_wires, - iu_to_wires, - adjacency, - point_to_label, - label_to_points, - ) - nets.append( - { - "name": net_name, - "connections": connections, - "connected_pin_count": pin_count, - } - ) - - return {"success": True, "nets": nets, "count": len(nets)} - - except Exception as e: - logger.error(f"Error listing schematic nets: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: - """List all wires in a schematic""" - logger.info("Listing schematic wires") - try: - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - wires = [] - if hasattr(schematic, "wire"): - for wire in schematic.wire: - if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): - points = [] - for point in wire.pts.xy: - if hasattr(point, "value"): - points.append( - { - "x": float(point.value[0]), - "y": float(point.value[1]), - } - ) - - if len(points) >= 2: - wires.append( - { - "start": points[0], - "end": points[-1], - } - ) - - return {"success": True, "wires": wires, "count": len(wires)} - - except Exception as e: - logger.error(f"Error listing schematic wires: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_list_schematic_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: - """List all net labels and power flags in a schematic""" - logger.info("Listing schematic labels") - try: - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - net_name = params.get("netName") - label_type = params.get("labelType") - - _valid_label_types = {"net", "global", "power"} - if label_type is not None and label_type not in _valid_label_types: - return {"success": False, "message": "labelType must be one of: net, global, power"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - labels = [] - - # Regular labels - if hasattr(schematic, "label"): - for label in schematic.label: - if hasattr(label, "value"): - pos = ( - label.at.value - if hasattr(label, "at") and hasattr(label.at, "value") - else [0, 0] - ) - labels.append( - { - "name": label.value, - "type": "net", - "position": {"x": float(pos[0]), "y": float(pos[1])}, - } - ) - - # Global labels - if hasattr(schematic, "global_label"): - for label in schematic.global_label: - if hasattr(label, "value"): - pos = ( - label.at.value - if hasattr(label, "at") and hasattr(label.at, "value") - else [0, 0] - ) - labels.append( - { - "name": label.value, - "type": "global", - "position": {"x": float(pos[0]), "y": float(pos[1])}, - } - ) - - # Power symbols (components with power flag) - if hasattr(schematic, "symbol"): - for symbol in schematic.symbol: - if not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - if not ref.startswith("#PWR"): - continue - value = ( - symbol.property.Value.value if hasattr(symbol.property, "Value") else ref - ) - pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] - labels.append( - { - "name": value, - "type": "power", - "position": {"x": float(pos[0]), "y": float(pos[1])}, - } - ) - - # Apply filters - if net_name is not None: - labels = [lbl for lbl in labels if lbl["name"] == net_name] - if label_type is not None: - labels = [lbl for lbl in labels if lbl["type"] == label_type] - - return {"success": True, "labels": labels, "count": len(labels)} - - except Exception as e: - logger.error(f"Error listing schematic labels: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_move_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Move a schematic component to a new position, dragging connected wires.""" - logger.info("Moving schematic component") - try: - from commands.wire_dragger import WireDragger - - schematic_path = params.get("schematicPath") - reference = params.get("reference") - position = params.get("position", {}) - new_x = position.get("x") - new_y = position.get("y") - preserve_wires = params.get("preserveWires", True) - - if not schematic_path or not reference: - return { - "success": False, - "message": "schematicPath and reference are required", - } - if new_x is None or new_y is None: - return { - "success": False, - "message": "position with x and y is required", - } - - with open(schematic_path, "r", encoding="utf-8") as f: - sch_data = sexpdata.loads(f.read()) - - # Find symbol and record old position - found = WireDragger.find_symbol(sch_data, reference) - if found is None: - return {"success": False, "message": f"Component {reference} not found"} - _, old_x, old_y = found[0], found[1], found[2] - old_position = {"x": old_x, "y": old_y} - - drag_summary = {} - if preserve_wires: - # Compute pin world positions before and after the move - pin_positions = WireDragger.compute_pin_positions( - sch_data, reference, float(new_x), float(new_y) - ) - # Build old→new coordinate map (deduplicate coincident pins) - old_to_new = {} - for _pin, (old_xy, new_xy) in pin_positions.items(): - if old_xy in old_to_new: - logger.warning( - f"move_schematic_component: pin {_pin!r} of {reference!r} " - f"shares old position {old_xy} with another pin; " - f"keeping first entry, skipping duplicate" - ) - continue - old_to_new[old_xy] = new_xy - - drag_summary = WireDragger.drag_wires(sch_data, old_to_new) - - # Synthesize wires for touching-pin connections after dragging, - # so drag_wires doesn't accidentally move and collapse the new wire. - wires_synthesized = WireDragger.synthesize_touching_pin_wires( - sch_data, reference, pin_positions - ) - drag_summary["wires_synthesized"] = wires_synthesized - - # Update symbol position - WireDragger.update_symbol_position(sch_data, reference, float(new_x), float(new_y)) - - WireManager.sync_junctions(sch_data) - - with open(schematic_path, "w", encoding="utf-8") as f: - f.write(sexpdata.dumps(sch_data)) - - return { - "success": True, - "oldPosition": old_position, - "newPosition": {"x": new_x, "y": new_y}, - "wiresMoved": drag_summary.get("endpoints_moved", 0), - "wiresRemoved": drag_summary.get("wires_removed", 0), - "wiresSynthesized": drag_summary.get("wires_synthesized", 0), - } - - except Exception as e: - logger.error(f"Error moving schematic component: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Rotate and/or mirror a schematic component, dragging connected wires.""" - logger.info("Rotating schematic component") - try: - import sexpdata as _sexpdata - from commands.wire_dragger import WireDragger - - schematic_path = params.get("schematicPath") - reference = params.get("reference") - angle = params.get("angle", 0) - mirror = params.get("mirror") # "x", "y", or None - - if not schematic_path or not reference: - return { - "success": False, - "message": "schematicPath and reference are required", - } - - with open(schematic_path, "r", encoding="utf-8") as f: - sch_data = _sexpdata.loads(f.read()) - - found = WireDragger.find_symbol(sch_data, reference) - if found is None: - return {"success": False, "message": f"Component {reference} not found"} - - # Determine new mirror state: explicit param overrides; None preserves existing - _, _, _, _, _, old_mirror_x, old_mirror_y = found - if mirror is None: - new_mirror_x = old_mirror_x - new_mirror_y = old_mirror_y - effective_mirror = "x" if old_mirror_x else ("y" if old_mirror_y else None) - else: - new_mirror_x = mirror == "x" - new_mirror_y = mirror == "y" - effective_mirror = mirror - - # Compute pin world positions before and after the transform - pin_positions = WireDragger.compute_pin_positions_for_rotation( - sch_data, reference, float(angle), new_mirror_x, new_mirror_y - ) - - # Build old→new map (skip pins that don't move) - old_to_new = {} - for _pin, (old_xy, new_xy) in pin_positions.items(): - if old_xy == new_xy: - continue - if old_xy in old_to_new: - logger.warning( - f"rotate: pin {_pin!r} of {reference!r} shares old position " - f"{old_xy} with another pin; skipping duplicate" - ) - continue - old_to_new[old_xy] = new_xy - - # Drag connected wires to follow pins - drag_summary = WireDragger.drag_wires(sch_data, old_to_new) - - # Update the symbol's rotation and mirror token in sexpdata - WireDragger.update_symbol_rotation_mirror( - sch_data, reference, float(angle), effective_mirror - ) - - WireManager.sync_junctions(sch_data) - - with open(schematic_path, "w", encoding="utf-8") as f: - f.write(_sexpdata.dumps(sch_data)) - - return { - "success": True, - "reference": reference, - "angle": angle, - "mirror": effective_mirror, - "wiresMoved": drag_summary.get("endpoints_moved", 0), - "wiresRemoved": drag_summary.get("wires_removed", 0), - } - - except Exception as e: - logger.error(f"Error rotating schematic component: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Annotate unannotated components in schematic (R? -> R1, R2, ...)""" - logger.info("Annotating schematic") - try: - import re - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - # Collect existing references by prefix - existing_refs = {} # prefix -> set of numbers - unannotated = [] # (symbol, prefix) - - for symbol in schematic.symbol: - if not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - - # Split reference into prefix and number - match = re.match(r"^([A-Za-z_]+)(\d+)$", ref) - if match: - prefix = match.group(1) - num = int(match.group(2)) - if prefix not in existing_refs: - existing_refs[prefix] = set() - existing_refs[prefix].add(num) - elif ref.endswith("?"): - prefix = ref[:-1] - unannotated.append((symbol, prefix)) - - if not unannotated: - return { - "success": True, - "annotated": [], - "message": "All components already annotated", - } - - annotated = [] - for symbol, prefix in unannotated: - if prefix not in existing_refs: - existing_refs[prefix] = set() - - # Find next available number - next_num = 1 - while next_num in existing_refs[prefix]: - next_num += 1 - - old_ref = symbol.property.Reference.value - new_ref = f"{prefix}{next_num}" - symbol.setAllReferences(new_ref) - existing_refs[prefix].add(next_num) - - uuid_val = str(symbol.uuid.value) if hasattr(symbol, "uuid") else "" - annotated.append( - { - "uuid": uuid_val, - "oldReference": old_ref, - "newReference": new_ref, - } - ) - - SchematicManager.save_schematic(schematic, schematic_path) - return {"success": True, "annotated": annotated} - - except Exception as e: - logger.error(f"Error annotating schematic: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_delete_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Delete a wire from the schematic matching start/end points""" - logger.info("Deleting schematic wire") - try: - schematic_path = params.get("schematicPath") - start = params.get("start", {}) - end = params.get("end", {}) - - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - from pathlib import Path - - from commands.wire_manager import WireManager - - start_point = [start.get("x", 0), start.get("y", 0)] - end_point = [end.get("x", 0), end.get("y", 0)] - - deleted = WireManager.delete_wire(Path(schematic_path), start_point, end_point) - if deleted: - return {"success": True} - else: - return {"success": False, "message": "No matching wire found"} - - except Exception as e: - logger.error(f"Error deleting schematic wire: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_delete_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Delete a net label from the schematic""" - logger.info("Deleting schematic net label") - try: - schematic_path = params.get("schematicPath") - net_name = params.get("netName") - position = params.get("position") - - if not schematic_path or not net_name: - return { - "success": False, - "message": "schematicPath and netName are required", - } - - from pathlib import Path - - from commands.wire_manager import WireManager - - pos_list = None - if position: - pos_list = [position.get("x", 0), position.get("y", 0)] - - deleted = WireManager.delete_label(Path(schematic_path), net_name, pos_list) - if deleted: - return {"success": True} - else: - return {"success": False, "message": f"Label '{net_name}' not found"} - - except Exception as e: - logger.error(f"Error deleting schematic net label: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_move_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Move a net label to a new position in the schematic.""" - logger.info("Moving schematic net label") - try: - import sexpdata as _sexpdata - from sexpdata import Symbol - - schematic_path = params.get("schematicPath") - net_name = params.get("netName") - new_position = params.get("newPosition", {}) - new_x = new_position.get("x") - new_y = new_position.get("y") - current_position = params.get("currentPosition") - label_type = params.get("labelType") - - if not schematic_path or not net_name: - return {"success": False, "message": "schematicPath and netName are required"} - if new_x is None or new_y is None: - return {"success": False, "message": "newPosition with x and y is required"} - - _valid_types = {"label", "global_label", "hierarchical_label"} - if label_type is not None and label_type not in _valid_types: - return { - "success": False, - "message": f"labelType must be one of: {', '.join(sorted(_valid_types))}", - } - - _SYM_AT = Symbol("at") - target_syms = ( - {Symbol(label_type)} - if label_type is not None - else {Symbol(t) for t in _valid_types} - ) - - TOLERANCE = 0.5 - - with open(schematic_path, "r", encoding="utf-8") as f: - sch_data = _sexpdata.loads(f.read()) - - for item in sch_data: - if not (isinstance(item, list) and len(item) >= 2 and item[0] in target_syms): - continue - if item[1] != net_name: - continue - - at_idx = next( - ( - j - for j, p in enumerate(item) - if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT - ), - None, - ) - if at_idx is None: - continue - - at_entry = item[at_idx] - old_x, old_y = float(at_entry[1]), float(at_entry[2]) - - if current_position is not None: - cx = current_position.get("x", 0) - cy = current_position.get("y", 0) - if not (abs(old_x - cx) < TOLERANCE and abs(old_y - cy) < TOLERANCE): - continue - - rotation = at_entry[3] if len(at_entry) > 3 else 0 - item[at_idx] = [_SYM_AT, float(new_x), float(new_y), rotation] - - with open(schematic_path, "w", encoding="utf-8") as f: - f.write(_sexpdata.dumps(sch_data)) - - return { - "success": True, - "oldPosition": {"x": old_x, "y": old_y}, - "newPosition": {"x": float(new_x), "y": float(new_y)}, - } - - return {"success": False, "message": f"Label '{net_name}' not found"} - - except Exception as e: - logger.error(f"Error moving schematic net label: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export schematic to SVG using kicad-cli""" - logger.info("Exporting schematic SVG") - import glob - import shutil - import subprocess - - try: - schematic_path = params.get("schematicPath") - output_path = params.get("outputPath") - - if not schematic_path or not output_path: - return { - "success": False, - "message": "schematicPath and outputPath are required", - } - - if not os.path.exists(schematic_path): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - # kicad-cli's --output flag for SVG export expects a directory, not a file path. - # The output file is auto-named based on the schematic name. - output_dir = os.path.dirname(output_path) - if not output_dir: - output_dir = "." - - os.makedirs(output_dir, exist_ok=True) - - cmd = [ - "kicad-cli", - "sch", - "export", - "svg", - schematic_path, - "-o", - output_dir, - ] - - if params.get("blackAndWhite"): - cmd.append("--black-and-white") - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - - if result.returncode != 0: - return { - "success": False, - "message": f"kicad-cli failed: {result.stderr}", - } - - # kicad-cli names the file after the schematic, so find the generated SVG - svg_files = glob.glob(os.path.join(output_dir, "*.svg")) - if not svg_files: - return { - "success": False, - "message": "No SVG file produced by kicad-cli", - } - - generated_svg = svg_files[0] - - # Move/rename to the user-specified output path if it differs - if os.path.abspath(generated_svg) != os.path.abspath(output_path): - shutil.move(generated_svg, output_path) - - return {"success": True, "file": {"path": output_path}} - - except FileNotFoundError: - return {"success": False, "message": "kicad-cli not found in PATH"} - except Exception as e: - logger.error(f"Error exporting schematic SVG: {e}") - return {"success": False, "message": str(e)} - def _handle_get_net_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get all connections for a named net""" logger.info("Getting net connections") @@ -3991,81 +1956,6 @@ class KiCADInterface: logger.error(f"Error getting net connections: {str(e)}") return {"success": False, "message": str(e)} - def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Find net name and all component pins reachable from a point or component pin.""" - logger.info("Getting wire connections") - try: - from pathlib import Path - - from commands.pin_locator import PinLocator - from commands.wire_connectivity import get_wire_connections - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "Missing required parameter: schematicPath"} - - reference = params.get("reference") - pin = params.get("pin") - x = params.get("x") - y = params.get("y") - - has_ref_pin = reference is not None and pin is not None - has_coords = x is not None and y is not None - - if has_ref_pin and has_coords: - return { - "success": False, - "message": "Supply either {reference, pin} or {x, y}, not both", - } - - if not has_ref_pin and not has_coords: - if reference is not None or pin is not None: - return { - "success": False, - "message": "Both reference and pin are required together", - } - return { - "success": False, - "message": "Must supply either {reference, pin} or {x, y}", - } - - if has_ref_pin: - location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin)) - if location is None: - return { - "success": False, - "message": f"Pin {pin} not found on {reference}", - } - x, y = location[0], location[1] - else: - try: - x, y = float(x), float(y) - except (TypeError, ValueError): - return {"success": False, "message": "Parameters x and y must be numeric"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - if not hasattr(schematic, "wire"): - return {"success": False, "message": "Schematic has no wires"} - - result = get_wire_connections(schematic, schematic_path, x, y) - if result is None: - return { - "success": False, - "message": f"No wire found at ({x},{y}) — point may not be connected", - } - - return {"success": True, **result} - - except Exception as e: - logger.error(f"Error getting wire connections: {str(e)}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - def _handle_get_net_at_point(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return the net name at a given (x, y) coordinate, or null if none found.""" logger.info("Getting net at point") @@ -4100,324 +1990,6 @@ 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") - try: - from commands.wire_manager import WireManager - - schematic_path = params.get("schematicPath") - text = params.get("text") - position = params.get("position") - shape = params.get("shape", "bidirectional") - orientation = params.get("orientation", 0) - - 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 shape not in ("input", "output", "bidirectional"): - return { - "success": False, - "message": "shape must be input, output, or bidirectional", - } - - sch_file = Path(schematic_path) - if not sch_file.exists(): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - success = WireManager.add_hierarchical_label( - sch_file, text, position, shape=shape, orientation=orientation - ) - - if success: - return { - "success": True, - "message": ( - f"Added hierarchical_label '{text}' " f"at {position} shape={shape}" - ), - } - return {"success": False, "message": "Failed to add hierarchical label"} - - except Exception as e: - logger.error(f"Error adding hierarchical label: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_add_sheet_pin(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a sheet pin to a sheet block on the parent schematic.""" - logger.info("Adding sheet pin to schematic") - try: - from commands.wire_manager import WireManager - - schematic_path = params.get("schematicPath") - sheet_name = params.get("sheetName") - pin_name = params.get("pinName") - pin_type = params.get("pinType", "bidirectional") - position = params.get("position") - orientation = params.get("orientation", 0) - - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - if not sheet_name: - return {"success": False, "message": "sheetName is required"} - if not pin_name: - return {"success": False, "message": "pinName is required"} - if not position or len(position) != 2: - return {"success": False, "message": "position [x, y] is required"} - if pin_type not in ("input", "output", "bidirectional"): - return { - "success": False, - "message": "pinType must be input, output, or bidirectional", - } - - sch_file = Path(schematic_path) - if not sch_file.exists(): - return { - "success": False, - "message": f"Schematic not found: {schematic_path}", - } - - with open(sch_file, "r", encoding="utf-8") as f: - content = f.read() - - modified, success = WireManager.add_sheet_pin( - content, - sheet_name, - pin_name, - pin_type, - position, - orientation=orientation, - ) - - if not success: - return { - "success": False, - "message": f"Sheet '{sheet_name}' not found in {schematic_path}", - } - - with open(sch_file, "w", encoding="utf-8") as f: - f.write(modified) - - return { - "success": True, - "message": ( - f"Added sheet pin '{pin_name}' ({pin_type}) " f"to sheet '{sheet_name}'" - ), - } - - except Exception as e: - logger.error(f"Error adding sheet pin: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Run Electrical Rules Check on a schematic via kicad-cli""" - logger.info("Running ERC on schematic") - import os - import subprocess - import tempfile - - try: - schematic_path = params.get("schematicPath") - if not schematic_path or not os.path.exists(schematic_path): - return { - "success": False, - "message": "Schematic file not found", - "errorDetails": f"Path does not exist: {schematic_path}", - } - - kicad_cli = self.design_rule_commands._find_kicad_cli() - if not kicad_cli: - return { - "success": False, - "message": "kicad-cli not found", - "errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.", - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: - json_output = tmp.name - - try: - cmd = [ - kicad_cli, - "sch", - "erc", - "--format", - "json", - "--output", - json_output, - schematic_path, - ] - logger.info(f"Running ERC command: {' '.join(cmd)}") - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - - # kicad-cli returns non-zero when ERC violations are found — - # this is normal, not an error. Only fail when no JSON was - # produced (genuine CLI failure). - if not os.path.exists(json_output) or os.path.getsize(json_output) == 0: - logger.error(f"ERC command produced no output: {result.stderr}") - return { - "success": False, - "message": "ERC command failed - no output produced", - "errorDetails": result.stderr, - } - - with open(json_output, "r", encoding="utf-8") as f: - erc_data = json.load(f) - - violations = [] - severity_counts = {"error": 0, "warning": 0, "info": 0} - - # KiCad 9 nests violations under sheets[].violations - # instead of (or in addition to) the top-level violations - # array used by KiCad 8. - all_violations = erc_data.get("violations", []) - for sheet in erc_data.get("sheets", []): - all_violations.extend(sheet.get("violations", [])) - - for v in all_violations: - vseverity = v.get("severity", "error") - items = v.get("items", []) - loc = {} - if items and "pos" in items[0]: - loc = { - "x": items[0]["pos"].get("x", 0), - "y": items[0]["pos"].get("y", 0), - } - violations.append( - { - "type": v.get("type", "unknown"), - "severity": vseverity, - "message": v.get("description", ""), - "location": loc, - } - ) - if vseverity in severity_counts: - severity_counts[vseverity] += 1 - - return { - "success": True, - "message": f"ERC complete: {len(violations)} violation(s)", - "summary": { - "total": len(violations), - "by_severity": severity_counts, - }, - "violations": violations, - } - - finally: - if os.path.exists(json_output): - os.unlink(json_output) - - except subprocess.TimeoutExpired: - return {"success": False, "message": "ERC timed out after 120 seconds"} - except Exception as e: - logger.error(f"Error running ERC: {str(e)}") - return {"success": False, "message": str(e)} - # ------------------------------------------------------------------ # kicad-cli helper shared by netlist handlers # ------------------------------------------------------------------ @@ -6194,539 +3766,10 @@ class KiCADInterface: logger.error(f"Error generating netlist: {e}") return {"success": False, "message": str(e)} - def _build_hierarchical_pad_net_map(self, project_sch_path: str): - """Walk all .kicad_sch files in the project and build a {(ref, pin_num): net_name} map. - - Handles hierarchical schematics by scanning every sub-sheet file. Net names - from global_label / hierarchical_label / local label / power symbols are all - collected. Wire connectivity is traced via BFS so labels not placed directly - on a pin endpoint still reach through wire segments. - - Returns: (pad_net_map, net_names_set) - """ - from collections import defaultdict - from pathlib import Path - - from commands.pin_locator import PinLocator - from skip import Schematic - - TOLERANCE = 0.5 # mm; schematic grid is 1.27 mm so 0.5 is safe - - def snap(x, y): - """Round to 2 dp to use exact dict lookup instead of O(n²) scan.""" - return (round(float(x), 2), round(float(y), 2)) - - def nearby_net(pt, point_net, tol=TOLERANCE): - """Return net name for the nearest occupied grid point, or None.""" - x, y = pt - # Try exact snap first (fast path) - key = snap(x, y) - if key in point_net: - return point_net[key] - # Slow fallback for off-grid placements - for (lx, ly), name in point_net.items(): - if abs(x - lx) < tol and abs(y - ly) < tol: - return name - return None - - project_dir = Path(project_sch_path).parent - pad_net_map: dict = {} - all_net_names: set = set() - pin_locator = PinLocator() - - sch_files = sorted(project_dir.rglob("*.kicad_sch")) - logger.info(f"_build_hierarchical_pad_net_map: scanning {len(sch_files)} schematic files") - - for sch_path in sch_files: - try: - sch = Schematic(str(sch_path)) - except Exception as e: - logger.warning(f"Could not load {sch_path}: {e}") - continue - - # ── 1. Collect explicit label positions → net name ────────────── - point_net: dict = {} # snap(x,y) -> net_name - - for attr in ("label", "global_label", "hierarchical_label"): - for lbl in getattr(sch, attr, None) or []: - try: - pos = lbl.at.value - name = lbl.value - if name: - k = snap(pos[0], pos[1]) - point_net[k] = name - all_net_names.add(name) - except Exception: - pass - - # Power symbols (#PWR / #FLG): value property IS the net name; use pin 1 pos - for sym in getattr(sch, "symbol", None) or []: - try: - ref = sym.property.Reference.value - if not (ref.startswith("#PWR") or ref.startswith("#FLG")): - continue - net_name = sym.property.Value.value - if not net_name: - continue - all_pins = pin_locator.get_all_symbol_pins(sch_path, ref) - for _pin_num, (px, py) in all_pins.items(): - k = snap(px, py) - point_net[k] = net_name - all_net_names.add(net_name) - except Exception: - pass - - # ── 2. Build wire adjacency and BFS-propagate net names ────────── - wire_segments = [] - for wire in getattr(sch, "wire", None) or []: - try: - pts = [] - for pt in wire.pts.xy: - pts.append(snap(pt.value[0], pt.value[1])) - if len(pts) >= 2: - wire_segments.append(pts) - except Exception: - pass - - # Adjacency: connect endpoints of different segments that share a grid point - point_adj: dict = defaultdict(set) - for seg in wire_segments: - # Connect consecutive points within the segment - for i in range(len(seg) - 1): - point_adj[seg[i]].add(seg[i + 1]) - point_adj[seg[i + 1]].add(seg[i]) - - # All unique wire points - all_wire_pts = set() - for seg in wire_segments: - all_wire_pts.update(seg) - - # BFS: propagate known net names through wire connections - queue = [pt for pt in all_wire_pts if pt in point_net] - visited = set(queue) - while queue: - pt = queue.pop() - net = point_net[pt] - for neighbor in point_adj[pt]: - if neighbor not in point_net: - point_net[neighbor] = net - all_net_names.add(net) - if neighbor not in visited: - visited.add(neighbor) - queue.append(neighbor) - - # ── 3. Match component pin positions to net names ──────────────── - for sym in getattr(sch, "symbol", None) or []: - try: - ref = sym.property.Reference.value - if ref.startswith("#"): - continue - except Exception: - continue - - pin_positions = pin_locator.get_all_symbol_pins(sch_path, ref) - for pin_num, (px, py) in pin_positions.items(): - net = nearby_net((px, py), point_net) - if net: - pad_net_map[(ref, pin_num)] = net - - logger.info( - f"_build_hierarchical_pad_net_map: {len(pad_net_map)} pin→net assignments, " - f"{len(all_net_names)} unique nets" - ) - return pad_net_map, all_net_names - - def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic'). - Reads net connections from the schematic and assigns them to the matching pads in the PCB. - """ - logger.info("Syncing schematic to board") - try: - from pathlib import Path - - schematic_path = params.get("schematicPath") - board_path = params.get("boardPath") - - # Determine board to work with - board = None - if board_path: - board = self._safe_load_board(board_path) - if board is None: - return { - "success": False, - "message": f"Could not load board from {board_path}", - "errorDetails": ( - "pcbnew.LoadBoard failed or returned a dehydrated " - "SWIG proxy that could not be recovered" - ), - } - elif self.board: - board = self.board - board_path = board.GetFileName() if not board_path else board_path - else: - return { - "success": False, - "message": "No board loaded. Use open_project first or provide boardPath.", - } - - if not board_path: - board_path = board.GetFileName() - - # Determine schematic path if not provided - if not schematic_path: - sch = Path(board_path).with_suffix(".kicad_sch") - if sch.exists(): - schematic_path = str(sch) - else: - project_dir = Path(board_path).parent - sch_files = list(project_dir.glob("*.kicad_sch")) - if sch_files: - schematic_path = str(sch_files[0]) - - if not schematic_path or not Path(schematic_path).exists(): - return { - "success": False, - "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}", - } - - # Build hierarchical pad→net map (walks all sub-sheets) - pad_net_map, net_names = self._build_hierarchical_pad_net_map(schematic_path) - - # Add missing footprints from the schematic to the board *before* - # we add nets and assign pads — F8 in KiCad does this implicitly - # ("Update PCB from Schematic"), but our previous implementation - # only mutated nets, leaving newly-added schematic symbols with no - # PCB footprint at all. - added_footprints, skipped_footprints = self._add_missing_footprints_from_schematic( - board, schematic_path - ) - - # Add all nets to board - netinfo = board.GetNetInfo() - nets_by_name = netinfo.NetsByName() - added_nets = [] - for net_name in net_names: - if not nets_by_name.has_key(net_name): - net_item = pcbnew.NETINFO_ITEM(board, net_name) - board.Add(net_item) - added_nets.append(net_name) - - # Refresh nets map after additions - netinfo = board.GetNetInfo() - nets_by_name = netinfo.NetsByName() - - # Assign nets to pads (now also covers any footprints we just added) - assigned_pads = 0 - unmatched = [] - for fp in board.GetFootprints(): - ref = fp.GetReference() - for pad in fp.Pads(): - pad_num = pad.GetNumber() - key = (ref, str(pad_num)) - if key in pad_net_map: - net_name = pad_net_map[key] - if nets_by_name.has_key(net_name): - pad.SetNet(nets_by_name[net_name]) - assigned_pads += 1 - else: - unmatched.append(f"{ref}/{pad_num}") - - board.Save(board_path) - - # If board was loaded fresh, update internal reference - if params.get("boardPath"): - self.board = board - self._update_command_handlers() - - logger.info( - f"sync_schematic_to_board: {len(added_nets)} nets added, " - f"{len(added_footprints)} footprints added, {assigned_pads} pads assigned" - ) - return { - "success": True, - "message": ( - f"PCB updated from schematic: {len(added_footprints)} footprints added, " - f"{len(added_nets)} nets added, {assigned_pads} pads assigned" - ), - "nets_added": added_nets, - "nets_total": len(net_names), - "pads_assigned": assigned_pads, - "unmatched_pads_sample": unmatched[:10], - "footprints_added": added_footprints, - "footprints_skipped": skipped_footprints, - } - - except Exception as e: - logger.error(f"Error in sync_schematic_to_board: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _extract_components_from_schematic(self, schematic_path: str) -> List[Dict[str, str]]: - """Run kicad-cli netlist export and return the flat list of components. - - Each entry: {"reference": str, "value": str, "footprint": str} - Empty list on any failure (kicad-cli missing, parse error, etc.) — the - caller treats that as "no missing footprints to add". - """ - import subprocess - import tempfile - import xml.etree.ElementTree as ET - - kicad_cli = self._find_kicad_cli_static() - if not kicad_cli: - logger.warning("kicad-cli not found — sync will not add new footprints") - return [] - - with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp: - tmp_path = tmp.name - try: - cmd = [ - kicad_cli, - "sch", - "export", - "netlist", - "--format", - "kicadxml", - "--output", - tmp_path, - schematic_path, - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - if result.returncode != 0: - logger.warning( - f"kicad-cli netlist export failed (exit {result.returncode}): " - f"{result.stderr.strip()}" - ) - return [] - - tree = ET.parse(tmp_path) - root = tree.getroot() - components = [] - for comp in root.findall("./components/comp"): - components.append( - { - "reference": comp.get("ref", ""), - "value": comp.findtext("value", ""), - "footprint": comp.findtext("footprint", ""), - } - ) - return components - except Exception as e: - logger.warning(f"Failed to extract components from schematic: {e}") - return [] - finally: - try: - os.unlink(tmp_path) - except OSError: - pass - - def _add_missing_footprints_from_schematic( - self, board: Any, schematic_path: str - ) -> Tuple[List[Dict[str, str]], List[Dict[str, str]]]: - """Add footprints to ``board`` for any schematic component not yet present. - - New footprints are placed at the board origin so the user can move them - into position. Power/flag references (``#PWR``, ``#FLG``) are skipped — - they have no PCB representation. - - Returns ``(added, skipped)``: each entry is - ``{"reference": str, "footprint": str, "reason": str?}``. - """ - from pathlib import Path - - from commands.library import LibraryManager - - added: List[Dict[str, str]] = [] - skipped: List[Dict[str, str]] = [] - - components = self._extract_components_from_schematic(schematic_path) - if not components: - return added, skipped - - existing_refs = {fp.GetReference() for fp in board.GetFootprints()} - project_dir = Path(schematic_path).parent - library_manager = LibraryManager(project_path=project_dir) - - for comp in components: - ref = comp["reference"] - fp_str = comp["footprint"] - if not ref or ref.startswith("#"): - # Power flags / global indicators — no PCB footprint expected. - continue - if ref in existing_refs: - continue - if not fp_str or ":" not in fp_str: - skipped.append( - { - "reference": ref, - "footprint": fp_str, - "reason": "no Library:Name footprint set on schematic symbol", - } - ) - continue - - lib_name, fp_name = fp_str.split(":", 1) - library_path = library_manager.libraries.get(lib_name) - if not library_path: - skipped.append( - { - "reference": ref, - "footprint": fp_str, - "reason": f"library '{lib_name}' not in fp-lib-table", - } - ) - continue - - try: - module = pcbnew.FootprintLoad(library_path, fp_name) - except Exception as e: - skipped.append( - {"reference": ref, "footprint": fp_str, "reason": f"FootprintLoad failed: {e}"} - ) - continue - - if not module: - skipped.append( - { - "reference": ref, - "footprint": fp_str, - "reason": f"footprint '{fp_name}' not in '{lib_name}'", - } - ) - continue - - module.SetReference(ref) - if comp["value"]: - module.SetValue(comp["value"]) - module.SetFPID(pcbnew.LIB_ID(lib_name, fp_name)) - # Place at board origin; user / autoplacer can position from there. - module.SetPosition(pcbnew.VECTOR2I(0, 0)) - - board.Add(module) - existing_refs.add(ref) - added.append({"reference": ref, "footprint": fp_str}) - - if added: - logger.info(f"_add_missing_footprints_from_schematic: added {len(added)} footprints") - return added, skipped - # =================================================================== # Schematic analysis tools (read-only) # =================================================================== - def _handle_get_schematic_view_region(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export a cropped region of the schematic as an image""" - logger.info("Exporting schematic view region") - import base64 - import os - import subprocess - import tempfile - - try: - schematic_path = params.get("schematicPath") - if not schematic_path or not os.path.exists(schematic_path): - return {"success": False, "message": "Schematic file not found"} - - x1 = float(params.get("x1", 0)) - y1 = float(params.get("y1", 0)) - x2 = float(params.get("x2", 297)) - y2 = float(params.get("y2", 210)) - x1, x2 = min(x1, x2), max(x1, x2) - y1, y2 = min(y1, y2), max(y1, y2) - out_format = params.get("format", "png") - width = int(params.get("width", 800)) - height = int(params.get("height", 600)) - - kicad_cli = self.design_rule_commands._find_kicad_cli() - if not kicad_cli: - return {"success": False, "message": "kicad-cli not found"} - - tmp_dir = tempfile.mkdtemp() - svg_output = None - - try: - cmd = [ - kicad_cli, - "sch", - "export", - "svg", - "--output", - tmp_dir, - schematic_path, - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) - - if result.returncode != 0: - return { - "success": False, - "message": f"SVG export failed: {result.stderr}", - } - - # kicad-cli names the file after the schematic - svg_files = [f for f in os.listdir(tmp_dir) if f.endswith(".svg")] - if not svg_files: - return { - "success": False, - "message": "kicad-cli produced no SVG output", - } - svg_output = os.path.join(tmp_dir, svg_files[0]) - - import xml.etree.ElementTree as ET - - tree = ET.parse(svg_output) - root = tree.getroot() - - # KiCad schematic SVGs use mm as viewBox units directly - vb = root.get("viewBox", "") - if vb: - parts = vb.split() - if len(parts) == 4: - orig_vb_x = float(parts[0]) - orig_vb_y = float(parts[1]) - - new_x = orig_vb_x + x1 - new_y = orig_vb_y + y1 - new_w = x2 - x1 - new_h = y2 - y1 - - root.set("viewBox", f"{new_x} {new_y} {new_w} {new_h}") - root.set("width", str(width)) - root.set("height", str(height)) - - # Write modified SVG - cropped_svg_path = os.path.join(tmp_dir, "cropped.svg") - tree.write(cropped_svg_path, xml_declaration=True, encoding="utf-8") - - if out_format == "svg": - with open(cropped_svg_path, "r", encoding="utf-8") as f: - svg_data = f.read() - return {"success": True, "imageData": svg_data, "format": "svg"} - else: - png_data = _svg_to_png(cropped_svg_path, width, height) - if png_data is None: - return { - "success": False, - "message": "No PNG converter available. Install pymupdf, inkscape, or imagemagick.", - } - return { - "success": True, - "imageData": base64.b64encode(png_data).decode("utf-8"), - "format": "png", - } - finally: - import shutil - - shutil.rmtree(tmp_dir, ignore_errors=True) - - except Exception as e: - logger.error(f"Error in get_schematic_view_region: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - def _handle_find_overlapping_elements(self, params: Dict[str, Any]) -> Dict[str, Any]: """Detect spatially overlapping symbols, wires, and labels""" logger.info("Finding overlapping elements in schematic") @@ -6783,117 +3826,6 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_wires_crossing_symbols(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Find wires that cross over component symbol bodies""" - logger.info("Finding wires crossing symbols in schematic") - try: - from pathlib import Path - - from commands.schematic_analysis import find_wires_crossing_symbols - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - result = find_wires_crossing_symbols(Path(schematic_path)) - return { - "success": True, - "collisions": result, - "count": len(result), - "message": f"Found {len(result)} wire(s) crossing symbols", - } - except Exception as e: - logger.error(f"Error checking wire collisions: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_find_orphaned_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Find wire segments with at least one dangling (unconnected) endpoint""" - logger.info("Finding orphaned wires in schematic") - try: - from pathlib import Path - - from commands.schematic_analysis import find_orphaned_wires - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - result = find_orphaned_wires(Path(schematic_path)) - return { - "success": True, - **result, - "message": f"Found {result['count']} orphaned wire(s)", - } - except Exception as e: - logger.error(f"Error finding orphaned wires: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_list_floating_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: - """List net labels that are not connected to any component pin""" - logger.info("Listing floating net labels in schematic") - try: - from commands.wire_connectivity import list_floating_labels - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - labels = list_floating_labels(schematic, schematic_path) - return { - "success": True, - "floating_labels": labels, - "count": len(labels), - "message": f"Found {len(labels)} floating label(s)", - } - except Exception as e: - logger.error(f"Error listing floating labels: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Snap schematic element coordinates to the nearest grid point""" - logger.info("Snapping schematic elements to grid") - try: - from pathlib import Path - - from commands.schematic_snap import snap_to_grid - - schematic_path = params.get("schematicPath") - if not schematic_path: - return {"success": False, "message": "schematicPath is required"} - - grid_size = float(params.get("gridSize", 1.27)) - elements = params.get("elements") # None → defaults inside snap_to_grid - - result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements) - total = result["snapped"] + result["already_on_grid"] - return { - "success": True, - **result, - "message": ( - f"Snapped {result['snapped']} element(s) to {grid_size} mm grid " - f"({result['already_on_grid']} of {total} were already on grid)" - ), - } - except Exception as e: - logger.error(f"Error snapping to grid: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]: """Import an SVG file as PCB graphic polygons on the silkscreen""" logger.info("Importing SVG logo into PCB") diff --git a/tests/test_sync_schematic_to_board_footprints.py b/tests/test_sync_schematic_to_board_footprints.py index 1dc1c04..ee48ff0 100644 --- a/tests/test_sync_schematic_to_board_footprints.py +++ b/tests/test_sync_schematic_to_board_footprints.py @@ -74,7 +74,7 @@ class TestAddMissingFootprintsFromSchematic: } ], ), - patch("kicad_interface.pcbnew") as mock_pcbnew, + patch("commands.schematic_handlers.pcbnew") as mock_pcbnew, patch("commands.library.LibraryManager") as mock_lm_cls, ): mock_pcbnew.FootprintLoad.return_value = loaded_module @@ -113,7 +113,7 @@ class TestAddMissingFootprintsFromSchematic: } ], ), - patch("kicad_interface.pcbnew"), + patch("commands.schematic_handlers.pcbnew"), patch("commands.library.LibraryManager") as mock_lm_cls, ): lm = MagicMock() @@ -144,7 +144,7 @@ class TestAddMissingFootprintsFromSchematic: {"reference": "#FLG0001", "value": "PWR_FLAG", "footprint": ""}, ], ), - patch("kicad_interface.pcbnew"), + patch("commands.schematic_handlers.pcbnew"), patch("commands.library.LibraryManager") as mock_lm_cls, ): mock_lm_cls.return_value = MagicMock(libraries={}) @@ -172,7 +172,7 @@ class TestAddMissingFootprintsFromSchematic: "_extract_components_from_schematic", return_value=[{"reference": "R1", "value": "10k", "footprint": ""}], ), - patch("kicad_interface.pcbnew"), + patch("commands.schematic_handlers.pcbnew"), patch("commands.library.LibraryManager") as mock_lm_cls, ): mock_lm_cls.return_value = MagicMock(libraries={}) @@ -205,7 +205,7 @@ class TestAddMissingFootprintsFromSchematic: } ], ), - patch("kicad_interface.pcbnew"), + patch("commands.schematic_handlers.pcbnew"), patch("commands.library.LibraryManager") as mock_lm_cls, ): mock_lm_cls.return_value = MagicMock(libraries={}) # MyVendor not present