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.
3105 lines
125 KiB
Python
3105 lines
125 KiB
Python
"""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)}
|