merge: upstream/main (57 commits) — preserve PR #102 net label fix

Merged upstream/main into our fork. Conflict in connection_schematic.py
resolved by taking upstream's file and re-applying our fix:
- all_match_points = connected_wire_points | label positions
- Allows nets where labels are placed directly at pin endpoints (no wire)

Upstream changes include: security fixes (8 vulns), new schematic tools
(get_net_at_point, find_orphaned_wires, snap_to_grid, get_wire_connections),
generate_netlist rewrite via kicad-cli, wire preservation on component move,
schematic analysis tools, KiCad 10 support.
This commit is contained in:
ffindog
2026-04-15 22:27:41 +10:00
159 changed files with 39178 additions and 23947 deletions

View File

@@ -2,18 +2,18 @@
KiCAD command implementations package
"""
from .project import ProjectCommands
from .board import BoardCommands
from .component import ComponentCommands
from .routing import RoutingCommands
from .design_rules import DesignRuleCommands
from .export import ExportCommands
from .project import ProjectCommands
from .routing import RoutingCommands
__all__ = [
'ProjectCommands',
'BoardCommands',
'ComponentCommands',
'RoutingCommands',
'DesignRuleCommands',
'ExportCommands'
"ProjectCommands",
"BoardCommands",
"ComponentCommands",
"RoutingCommands",
"DesignRuleCommands",
"ExportCommands",
]

View File

@@ -8,4 +8,4 @@ It imports and re-exports the BoardCommands class from the board package.
from commands.board import BoardCommands
# Re-export the BoardCommands class for backward compatibility
__all__ = ['BoardCommands']
__all__ = ["BoardCommands"]

View File

@@ -2,17 +2,20 @@
Board-related command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
# Import specialized modules
from .size import BoardSizeCommands
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
from .view import BoardViewCommands
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""
@@ -20,63 +23,63 @@ class BoardCommands:
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
# Initialize specialized command classes
self.size_commands = BoardSizeCommands(board)
self.layer_commands = BoardLayerCommands(board)
self.outline_commands = BoardOutlineCommands(board)
self.view_commands = BoardViewCommands(board)
# Delegate board size commands
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board"""
self.size_commands.board = self.board
return self.size_commands.set_board_size(params)
# Delegate layer commands
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a new layer to the PCB"""
self.layer_commands.board = self.board
return self.layer_commands.add_layer(params)
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
self.layer_commands.board = self.board
return self.layer_commands.set_active_layer(params)
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a list of all layers in the PCB"""
self.layer_commands.board = self.board
return self.layer_commands.get_layer_list(params)
# Delegate board outline commands
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a board outline to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_board_outline(params)
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a mounting hole to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_mounting_hole(params)
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text annotation to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_text(params)
# Delegate view commands
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current board"""
self.view_commands.board = self.board
return self.view_commands.get_board_info(params)
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a 2D image of the PCB"""
self.view_commands.board = self.board
return self.view_commands.get_board_2d_view(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)

View File

@@ -2,11 +2,13 @@
Board layer command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardLayerCommands:
"""Handles board layer operations"""
@@ -22,7 +24,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
name = params.get("name")
@@ -34,7 +36,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "name, type, and position are required"
"errorDetails": "name, type, and position are required",
}
# Get layer stack
@@ -47,7 +49,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Missing layer number",
"errorDetails": "number is required for inner layers"
"errorDetails": "number is required for inner layers",
}
layer_id = pcbnew.In1_Cu + (number - 1)
elif position == "top":
@@ -59,34 +61,25 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Invalid layer position",
"errorDetails": "position must be 'top', 'bottom', or 'inner'"
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
}
# Set layer properties
layer_stack.SetLayerName(layer_id, name)
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
# Enable the layer
self.board.SetLayerEnabled(layer_id, True)
return {
"success": True,
"message": f"Added layer: {name}",
"layer": {
"name": name,
"type": layer_type,
"position": position,
"number": number
}
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
}
except Exception as e:
logger.error(f"Error adding layer: {str(e)}")
return {
"success": False,
"message": "Failed to add layer",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
@@ -95,7 +88,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
@@ -103,7 +96,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No layer specified",
"errorDetails": "layer parameter is required"
"errorDetails": "layer parameter is required",
}
# Find layer ID by name
@@ -112,7 +105,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Layer not found",
"errorDetails": f"Layer '{layer}' does not exist"
"errorDetails": f"Layer '{layer}' does not exist",
}
# Set active layer
@@ -121,10 +114,7 @@ class BoardLayerCommands:
return {
"success": True,
"message": f"Set active layer to: {layer}",
"layer": {
"name": layer,
"id": layer_id
}
"layer": {"name": layer, "id": layer_id},
}
except Exception as e:
@@ -132,7 +122,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Failed to set active layer",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -142,40 +132,35 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append({
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
})
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id,
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}
)
return {
"success": True,
"layers": layers
}
return {"success": True, "layers": layers}
except Exception as e:
logger.error(f"Error getting layer list: {str(e)}")
return {
"success": False,
"message": "Failed to get layer list",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
def _get_layer_type(self, type_name: str) -> int:
"""Convert layer type name to KiCAD layer type constant"""
type_map = {
"copper": pcbnew.LT_SIGNAL,
"technical": pcbnew.LT_SIGNAL,
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
"signal": pcbnew.LT_SIGNAL
"signal": pcbnew.LT_SIGNAL,
}
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
@@ -185,7 +170,7 @@ class BoardLayerCommands:
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")

View File

@@ -2,10 +2,11 @@
Board outline command implementations for KiCAD interface
"""
import pcbnew
import logging
import math
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -224,9 +225,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
diameter_nm = int(diameter * scale)
@@ -252,9 +251,7 @@ class BoardOutlineCommands:
pad = pcbnew.PAD(module)
pad.SetNumber(1)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(
pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH
)
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
@@ -311,9 +308,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
size_nm = int(size * scale)
@@ -372,9 +367,7 @@ class BoardOutlineCommands:
"errorDetails": str(e),
}
def _add_edge_line(
self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int
) -> None:
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
"""Add a line to the edge cuts layer"""
line = pcbnew.PCB_SHAPE(self.board)
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
@@ -396,18 +389,12 @@ class BoardOutlineCommands:
"""Add a rounded rectangle to the edge cuts layer"""
if radius_nm <= 0:
# If no radius, create regular rectangle
top_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
)
top_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
)
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)

View File

@@ -2,11 +2,13 @@
Board size command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardSizeCommands:
"""Handles board size operations"""
@@ -22,7 +24,7 @@ class BoardSizeCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
width = params.get("width")
@@ -33,41 +35,36 @@ class BoardSizeCommands:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required"
"errorDetails": "Both width and height are required",
}
# Create board outline using BoardOutlineCommands
# This properly creates edge cuts on Edge.Cuts layer
from commands.board.outline import BoardOutlineCommands
outline_commands = BoardOutlineCommands(self.board)
# Create rectangular outline centered at origin
result = outline_commands.add_board_outline({
"shape": "rectangle",
"centerX": width / 2, # Center X
"centerY": height / 2, # Center Y
"width": width,
"height": height,
"unit": unit
})
result = outline_commands.add_board_outline(
{
"shape": "rectangle",
"centerX": width / 2, # Center X
"centerY": height / 2, # Center Y
"width": width,
"height": height,
"unit": unit,
}
)
if result.get("success"):
return {
"success": True,
"message": f"Created board outline: {width}x{height} {unit}",
"size": {
"width": width,
"height": height,
"unit": unit
}
"size": {"width": width, "height": height, "unit": unit},
}
else:
return result
except Exception as e:
logger.error(f"Error setting board size: {str(e)}")
return {
"success": False,
"message": "Failed to set board size",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}

View File

@@ -2,15 +2,17 @@
Board view command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
from PIL import Image
import io
import base64
import io
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from PIL import Image
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardViewCommands:
"""Handles board viewing operations"""
@@ -26,7 +28,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get board dimensions
@@ -42,26 +44,24 @@ class BoardViewCommands:
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append({
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id
})
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {
"width": width_mm,
"height": height_mm,
"unit": "mm"
},
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"title": self.board.GetTitleBlock().GetTitle()
"title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}
},
}
except Exception as e:
@@ -69,7 +69,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -79,7 +79,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -90,7 +90,7 @@ class BoardViewCommands:
# Create plot controller
plotter = pcbnew.PLOT_CONTROLLER(self.board)
# Set up plot options
plot_opts = plotter.GetPlotOptions()
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
@@ -100,7 +100,7 @@ class BoardViewCommands:
plot_opts.SetPlotFrameRef(False)
plot_opts.SetPlotValue(True)
plot_opts.SetPlotReference(True)
# Plot to SVG first (for vector output)
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
@@ -126,36 +126,33 @@ class BoardViewCommands:
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, 'r') as f:
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_svg)
return {
"success": True,
"imageData": svg_data,
"format": "svg"
}
return {"success": True, "imageData": svg_data, "format": "svg"}
else:
# Use PIL to convert SVG to PNG/JPG
from cairosvg import svg2png
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg)
if format == "jpg":
# Convert PNG to JPG
img = Image.open(io.BytesIO(png_data))
jpg_buffer = io.BytesIO()
img.convert('RGB').save(jpg_buffer, format='JPEG')
img.convert("RGB").save(jpg_buffer, format="JPEG")
jpg_data = jpg_buffer.getvalue()
return {
"success": True,
"imageData": base64.b64encode(jpg_data).decode('utf-8'),
"format": "jpg"
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
"format": "jpg",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode('utf-8'),
"format": "png"
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
@@ -163,70 +160,67 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {
"x": center_x,
"y": center_y
},
"unit": unit
}
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e)
}
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e),
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
from skip import Schematic
import logging
import os
import uuid
import logging
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic
logger = logging.getLogger(__name__)
@@ -13,9 +14,7 @@ try:
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning(
"Dynamic symbol loader not available - falling back to template-only mode"
)
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
@@ -26,7 +25,7 @@ class ComponentManager:
_dynamic_loader = None
@classmethod
def get_dynamic_loader(cls):
def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader()
@@ -87,7 +86,7 @@ class ComponentManager:
"""
# Helper function to check if template exists in schematic
def template_exists(schematic, template_ref):
def template_exists(schematic: Any, template_ref: str) -> bool:
"""Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol:
if (
@@ -135,32 +134,22 @@ class ComponentManager:
# Check if schematic path is available
if schematic_path is None:
logger.warning(
"Dynamic loading requires schematic file path but none was provided"
)
logger.warning("Dynamic loading requires schematic file path but none was provided")
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
# Determine library name
if library is None:
# Default library for common component types
library = (
"Device" # Most passives and basic components are in Device library
)
library = "Device" # Most passives and basic components are in Device library
try:
logger.info(
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
# Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically(
schematic_path, library, comp_type
)
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
logger.info(
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
@@ -176,7 +165,7 @@ class ComponentManager:
@staticmethod
def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
):
) -> Any:
"""
Add a component to the schematic by cloning from template
@@ -198,9 +187,7 @@ class ComponentManager:
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get(
"library", None
) # Optional library specification
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(
@@ -209,9 +196,7 @@ class ComponentManager:
# If dynamic loading occurred, reload schematic to see new template
if needs_reload and schematic_path:
logger.info(
f"Reloading schematic after dynamic loading: {schematic_path}"
)
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
@@ -280,7 +265,7 @@ class ComponentManager:
raise
@staticmethod
def remove_component(schematic: Schematic, component_ref: str):
def remove_component(schematic: Schematic, component_ref: str) -> bool:
"""Remove a component from the schematic by reference designator"""
try:
# kicad-skip doesn't have a direct remove_symbol method by reference.
@@ -293,19 +278,17 @@ class ComponentManager:
if symbol_to_remove:
schematic.symbol._elements.remove(symbol_to_remove)
print(f"Removed component {component_ref} from schematic.")
logger.info(f"Removed component {component_ref} from schematic.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error removing component {component_ref}: {e}")
logger.error(f"Error removing component {component_ref}: {e}")
return False
@staticmethod
def update_component(
schematic: Schematic, component_ref: str, new_properties: dict
):
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -319,29 +302,28 @@ class ComponentManager:
if key in symbol_to_update.property:
symbol_to_update.property[key].value = value
else:
# Add as a new property if it doesn't exist
symbol_to_update.property.append(key, value)
print(f"Updated properties for component {component_ref}.")
logger.info(f"Updated properties for component {component_ref}.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error updating component {component_ref}: {e}")
logger.error(f"Error updating component {component_ref}: {e}")
return False
@staticmethod
def get_component(schematic: Schematic, component_ref: str):
def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator"""
for symbol in schematic.symbol:
if symbol.reference == component_ref:
print(f"Found component with reference {component_ref}.")
logger.debug(f"Found component with reference {component_ref}.")
return symbol
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return None
@staticmethod
def search_components(schematic: Schematic, query: str):
def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic
matching_components = []
@@ -356,21 +338,21 @@ class ComponentManager:
)
):
matching_components.append(symbol)
print(f"Found {len(matching_components)} components matching query '{query}'.")
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@staticmethod
def get_all_components(schematic: Schematic):
def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic"""
print(f"Retrieving all {len(schematic.symbol)} components.")
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import (
from schematic import ( # Assuming schematic.py is in the same directory
SchematicManager,
) # Assuming schematic.py is in the same directory
)
# Create a new schematic
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
@@ -401,19 +383,13 @@ if __name__ == "__main__":
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
matching_comps = ComponentManager.search_components(
test_sch, "100"
) # Search by position
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components
@@ -423,9 +399,7 @@ if __name__ == "__main__":
# Remove a component
ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print(
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")

View File

@@ -1,15 +1,16 @@
from skip import Schematic
import os
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
# Import new wire and pin managers
try:
from commands.wire_manager import WireManager
from commands.pin_locator import PinLocator
from commands.wire_manager import WireManager
WIRE_MANAGER_AVAILABLE = True
except ImportError:
@@ -24,180 +25,14 @@ class ConnectionManager:
_pin_locator = None
@classmethod
def get_pin_locator(cls):
def get_pin_locator(cls) -> Any:
"""Get or create pin locator instance"""
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
cls._pin_locator = PinLocator()
return cls._pin_locator
@staticmethod
def add_wire(
schematic_path: Path,
start_point: list,
end_point: list,
properties: dict = None,
):
"""
Add a wire between two points using WireManager
Args:
schematic_path: Path to .kicad_sch file
start_point: [x, y] coordinates for wire start
end_point: [x, y] coordinates for wire end
properties: Optional wire properties (stroke_width, stroke_type)
Returns:
True if successful, False otherwise
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager not available")
return False
stroke_width = properties.get("stroke_width", 0) if properties else 0
stroke_type = (
properties.get("stroke_type", "default") if properties else "default"
)
success = WireManager.add_wire(
schematic_path,
start_point,
end_point,
stroke_width=stroke_width,
stroke_type=stroke_type,
)
return success
except Exception as e:
logger.error(f"Error adding wire: {e}")
return False
@staticmethod
def get_pin_location(symbol, pin_name: str):
"""
Get the absolute location of a pin on a symbol
Args:
symbol: Symbol object
pin_name: Name or number of the pin (e.g., "1", "GND", "VCC")
Returns:
[x, y] coordinates or None if pin not found
"""
try:
if not hasattr(symbol, "pin"):
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
return None
# Find the pin by name
target_pin = None
for pin in symbol.pin:
if pin.name == pin_name:
target_pin = pin
break
if not target_pin:
logger.warning(
f"Pin '{pin_name}' not found on {symbol.property.Reference.value}"
)
return None
# Get pin location relative to symbol
pin_loc = target_pin.location
# Get symbol location
symbol_at = symbol.at.value
# Calculate absolute position
# pin_loc is relative to symbol origin, need to add symbol position
abs_x = symbol_at[0] + pin_loc[0]
abs_y = symbol_at[1] + pin_loc[1]
return [abs_x, abs_y]
except Exception as e:
logger.error(f"Error getting pin location: {e}")
return None
@staticmethod
def add_connection(
schematic_path: Path,
source_ref: str,
source_pin: str,
target_ref: str,
target_pin: str,
routing: str = "direct",
):
"""
Add a wire connection between two component pins
Args:
schematic_path: Path to .kicad_sch file
source_ref: Reference designator of source component (e.g., "R1", "R1_")
source_pin: Pin name/number on source component
target_ref: Reference designator of target component (e.g., "C1", "C1_")
target_pin: Pin name/number on target component
routing: Routing style ('direct', 'orthogonal_h', 'orthogonal_v')
Returns:
True if connection was successful, False otherwise
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
# Get pin locations
source_loc = locator.get_pin_location(
schematic_path, source_ref, source_pin
)
target_loc = locator.get_pin_location(
schematic_path, target_ref, target_pin
)
if not source_loc or not target_loc:
logger.error("Could not determine pin locations")
return False
# Create wire based on routing style
if routing == "direct":
# Simple direct wire
success = WireManager.add_wire(schematic_path, source_loc, target_loc)
elif routing == "orthogonal_h":
# Orthogonal routing (horizontal first)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=True
)
success = WireManager.add_polyline_wire(schematic_path, path)
elif routing == "orthogonal_v":
# Orthogonal routing (vertical first)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=False
)
success = WireManager.add_polyline_wire(schematic_path, path)
else:
logger.error(f"Unknown routing style: {routing}")
return False
if success:
logger.info(
f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})"
)
return True
else:
return False
except Exception as e:
logger.error(f"Error adding connection: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def add_net_label(schematic: Schematic, net_name: str, position: list):
def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any:
"""
Add a net label to the schematic
@@ -214,9 +49,7 @@ class ConnectionManager:
logger.error("Schematic does not have label collection")
return None
label = schematic.label.append(
text=net_name, at={"x": position[0], "y": position[1]}
)
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
logger.info(f"Added net label '{net_name}' at {position}")
return label
except Exception as e:
@@ -226,9 +59,9 @@ class ConnectionManager:
@staticmethod
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
) -> Dict[str, Any]:
"""
Connect a component pin to a named net using a wire stub and label
Connect a component pin to a named net using a wire stub and label.
Args:
schematic_path: Path to .kicad_sch file
@@ -237,59 +70,78 @@ class ConnectionManager:
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
Returns:
True if successful, False otherwise
Dict with keys:
success bool
pin_location [x, y] exact pin endpoint used (present on success)
label_location [x, y] where the net label was placed (present on success)
wire_stub [[x1,y1],[x2,y2]] the wire segment added (present on success)
message human-readable status
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
return {"success": False, "message": "WireManager/PinLocator not available"}
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
return {"success": False, "message": "Pin locator unavailable"}
# Get pin location using PinLocator
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
if not pin_loc:
logger.error(f"Could not locate pin {component_ref}/{pin_name}")
return False
msg = f"Could not locate pin {component_ref}/{pin_name}"
logger.error(msg)
return {"success": False, "message": msg}
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
# Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, '_last_pin_angle', 0)
try:
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
except Exception:
except Exception as e:
logger.warning(
f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}"
)
pin_angle_deg = 0
import math as _math
angle_rad = _math.radians(pin_angle_deg)
stub_end = [round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4)]
stub_end = [
round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4),
]
# Create wire stub using WireManager
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
if not wire_success:
logger.error(f"Failed to create wire stub for net connection")
return False
msg = "Failed to create wire stub for net connection"
logger.error(msg)
return {"success": False, "message": msg}
# Add label at the end of the stub using WireManager
label_success = WireManager.add_label(
schematic_path, net_name, stub_end, label_type="label"
)
if not label_success:
logger.error(f"Failed to add net label '{net_name}'")
return False
msg = f"Failed to add net label '{net_name}'"
logger.error(msg)
return {"success": False, "message": msg}
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
return True
return {
"success": True,
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
"pin_location": pin_loc,
"label_location": stub_end,
"wire_stub": [pin_loc, stub_end],
}
except Exception as e:
logger.error(f"Error connecting to net: {e}")
import traceback
logger.error(traceback.format_exc())
return False
return {"success": False, "message": str(e)}
@staticmethod
def connect_passthrough(
@@ -298,7 +150,7 @@ class ConnectionManager:
target_ref: str,
net_prefix: str = "PIN",
pin_offset: int = 0,
):
) -> Dict[str, List[str]]:
"""
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
@@ -335,20 +187,24 @@ class ConnectionManager:
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
try:
net_name = f"{net_prefix}_{int(pin_num) + pin_offset}" if pin_num.isdigit() else f"{net_prefix}_{pin_num}"
net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}"
if pin_num.isdigit()
else f"{net_prefix}_{pin_num}"
)
ok_src = ConnectionManager.connect_to_net(
res_src = ConnectionManager.connect_to_net(
schematic_path, source_ref, pin_num, net_name
)
if not ok_src:
if not res_src.get("success"):
failed.append(f"{source_ref}/{pin_num}")
continue
if pin_num in tgt_pins:
ok_tgt = ConnectionManager.connect_to_net(
res_tgt = ConnectionManager.connect_to_net(
schematic_path, target_ref, pin_num, net_name
)
if not ok_tgt:
if not res_tgt.get("success"):
failed.append(f"{target_ref}/{pin_num}")
continue
else:
@@ -365,7 +221,7 @@ class ConnectionManager:
@staticmethod
def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
):
) -> List[Dict]:
"""
Get all connections for a named net using wire graph analysis
@@ -383,7 +239,7 @@ class ConnectionManager:
connections = []
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
def points_coincide(p1, p2):
def points_coincide(p1: Any, p2: Any) -> bool:
"""Check if two points are the same (within tolerance)"""
if not p1 or not p2:
return False
@@ -407,52 +263,55 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'")
return connections
logger.debug(
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
# 2. Find all wires connected to these label positions
if not hasattr(schematic, "wire"):
logger.warning("Schematic has no wires")
return connections
connected_wire_points = set()
if hasattr(schematic, "wire"):
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
# Get all points in this wire (polyline)
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
# Get all points in this wire (polyline)
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append([float(point.value[0]), float(point.value[1])])
# Check if any wire point touches a label
wire_connected = False
for wire_pt in wire_points:
for label_pt in net_label_positions:
if points_coincide(wire_pt, label_pt):
wire_connected = True
break
if wire_connected:
# Check if any wire point touches a label
wire_connected = False
for wire_pt in wire_points:
for label_pt in net_label_positions:
if points_coincide(wire_pt, label_pt):
wire_connected = True
break
# If this wire is connected to the net, add all its points
if wire_connected:
for pt in wire_points:
connected_wire_points.add((pt[0], pt[1]))
break
# Build the full set of candidate match points:
# wire endpoints that touch this net PLUS label positions themselves.
# This handles labels placed directly at pin endpoints (no wire needed).
# If this wire is connected to the net, add all its points
if wire_connected:
for pt in wire_points:
connected_wire_points.add((pt[0], pt[1]))
# Build match points: union of wire endpoints AND label positions.
# This handles the valid KiCad style where a net label is placed
# directly at a pin endpoint with no wire segment in between.
all_match_points = connected_wire_points | {
(p[0], p[1]) for p in net_label_positions
}
if not all_match_points:
logger.debug(f"No connection points found for net '{net_name}'")
return connections
logger.debug(
f"Net '{net_name}': {len(connected_wire_points)} wire points, "
f"Found {len(connected_wire_points)} wire points, "
f"{len(net_label_positions)} direct label positions, "
f"{len(all_match_points)} total match points"
f"{len(all_match_points)} total match points for net '{net_name}'"
)
# 3. Find component pins at wire endpoints or direct label positions
# 3. Find component pins at wire endpoints
if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols")
return connections
@@ -487,19 +346,15 @@ class ConnectionManager:
# Check each pin
for pin_num, pin_data in pins.items():
# Get pin location
pin_loc = locator.get_pin_location(
schematic_path, ref, pin_num
)
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
if not pin_loc:
continue
# Check if pin coincides with any wire point or label position
for match_pt in all_match_points:
if points_coincide(pin_loc, list(match_pt)):
connections.append(
{"component": ref, "pin": pin_num}
)
break # Pin found, no need to check more points
# Check if pin coincides with any match point
for wire_pt_tup in all_match_points:
if points_coincide(pin_loc, list(wire_pt_tup)):
connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points
except Exception as e:
logger.warning(f"Error matching pins for {ref}: {e}")
@@ -515,10 +370,10 @@ class ConnectionManager:
symbol_x = float(symbol_pos[0])
symbol_y = float(symbol_pos[1])
# Check if symbol is near any wire point or label position (within 10mm)
for wire_pt in all_match_points:
# Check if symbol is near any match point (within 10mm)
for wire_pt_tup in all_match_points:
dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"})
@@ -535,7 +390,9 @@ class ConnectionManager:
return []
@staticmethod
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
def generate_netlist(
schematic: Schematic, schematic_path: Optional[Path] = None
) -> Dict[str, Any]:
"""
Generate a netlist from the schematic
@@ -572,9 +429,7 @@ class ConnectionManager:
component_info = {
"reference": symbol.property.Reference.value,
"value": (
symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
),
"footprint": (
symbol.property.Footprint.value
@@ -597,9 +452,7 @@ class ConnectionManager:
schematic, net_name, schematic_path
)
if connections:
netlist["nets"].append(
{"name": net_name, "connections": connections}
)
netlist["nets"].append({"name": net_name, "connections": connections})
logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
@@ -609,35 +462,3 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error generating netlist: {e}")
return {"nets": [], "components": []}
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import (
SchematicManager,
) # Assuming schematic.py is in the same directory
# Create a new schematic
test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")
# Add some wires
wire1 = ConnectionManager.add_wire(test_sch, [100, 100], [200, 100])
wire2 = ConnectionManager.add_wire(test_sch, [200, 100], [200, 200])
# Note: add_connection, remove_connection, get_net_connections are placeholders
# and require more complex implementation based on kicad-skip's structure.
# Example of how you might add a net label (requires finding a point on a wire)
# from skip import Label
# if wire1:
# net_label_pos = wire1.start # Or calculate a point on the wire
# net_label = test_sch.add_label(text="Net_01", at=net_label_pos)
# print(f"Added net label 'Net_01' at {net_label_pos}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "connection_test.kicad_sch")
# Clean up (if saved)
# if os.path.exists("connection_test.kicad_sch"):
# os.remove("connection_test.kicad_sch")
# print("Cleaned up connection_test.kicad_sch")

View File

@@ -9,10 +9,10 @@ URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf
No API key required.
"""
import re
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -49,7 +49,7 @@ class DatasheetManager:
return None
@staticmethod
def _find_lib_symbols_range(lines: List[str]):
def _find_lib_symbols_range(lines: List[str]) -> Tuple[Optional[int], Optional[int]]:
"""
Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found.
@@ -81,9 +81,7 @@ class DatasheetManager:
return lib_sym_start, lib_sym_end
@staticmethod
def _process_symbol_block(
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]:
"""
Extract LCSC and Datasheet info from a placed symbol block.
@@ -114,9 +112,7 @@ class DatasheetManager:
"datasheet_value": datasheet_current,
}
def enrich_schematic(
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict:
"""
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
@@ -223,9 +219,7 @@ class DatasheetManager:
no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1
logger.debug(
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}")
else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run:
@@ -256,9 +250,7 @@ class DatasheetManager:
if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
logger.info(
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written")
return {
"success": True,

View File

@@ -2,10 +2,11 @@
Design rules command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -58,13 +59,9 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["microViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["microViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values
if "minTrackWidth" in params:
@@ -77,19 +74,13 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["minMicroViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["minMicroViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params:
design_settings.m_MinThroughDrill = int(
params["minHoleDiameter"] * scale
)
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
# KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params:
@@ -181,11 +172,11 @@ class DesignRuleCommands:
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Design Rule Check using kicad-cli"""
import subprocess
import json
import tempfile
import platform
import shutil
import subprocess
import tempfile
try:
if not self.board:
@@ -216,9 +207,7 @@ class DesignRuleCommands:
}
# Create temporary JSON output file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
@@ -297,9 +286,7 @@ class DesignRuleCommands:
# Determine where to save the violations file
board_dir = os.path.dirname(board_file)
board_name = os.path.splitext(os.path.basename(board_file))[0]
violations_file = os.path.join(
board_dir, f"{board_name}_drc_violations.json"
)
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
# Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f:
@@ -453,9 +440,7 @@ class DesignRuleCommands:
# Filter by severity if specified
if severity != "all":
filtered_violations = [
v for v in all_violations if v.get("severity") == severity
]
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
else:
filtered_violations = all_violations

View File

@@ -7,10 +7,10 @@ on-the-fly using TEXT MANIPULATION (not sexpdata) to preserve file formatting.
This enables access to all ~10,000+ KiCad symbols dynamically.
"""
import logging
import os
import re
import uuid
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@@ -41,10 +41,17 @@ class DynamicSymbolLoader:
Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
Path("C:/Program Files/KiCad/8.0/share/kicad/symbols"),
Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"),
Path.home() / ".local" / "share" / "kicad" / "10.0" / "symbols",
Path.home() / ".local" / "share" / "kicad" / "9.0" / "symbols",
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
]
for env_var in ["KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]:
for env_var in [
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD_SYMBOL_DIR",
]:
if env_var in os.environ:
possible_paths.insert(0, Path(os.environ[env_var]))
@@ -81,7 +88,9 @@ class DynamicSymbolLoader:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
lib_pattern = (
r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
)
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
if nickname != library_name:
@@ -97,6 +106,11 @@ class DynamicSymbolLoader:
def _resolve_sym_uri(self, uri: str) -> Optional[str]:
"""Resolve environment variables in a sym-lib-table URI."""
env_map = {
"KICAD10_SYMBOL_DIR": [
"/usr/share/kicad/symbols",
"C:/Program Files/KiCad/10.0/share/kicad/symbols",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
],
"KICAD9_SYMBOL_DIR": [
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
"/usr/share/kicad/symbols",
@@ -201,9 +215,7 @@ class DynamicSymbolLoader:
return items
def _inline_extends_symbol(
self, lib_content: str, symbol_name: str, child_block: str
) -> str:
def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
"""
Fully inline a child symbol that uses (extends "ParentName") by merging
the parent's pins / graphics into the child definition.
@@ -248,22 +260,16 @@ class DynamicSymbolLoader:
for item in self._iter_top_level_items(parent_block):
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
sub_match = re.search(
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
)
sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
if prop_match:
pname = prop_match.group(1)
parent_prop_names.add(pname)
body_lines.append(
child_props[pname] if pname in child_props else item
)
body_lines.append(child_props[pname] if pname in child_props else item)
elif sub_match:
# Rename ParentName_0_1 → ChildName_0_1
body_lines.append(
item.replace(f'"{parent_name}_', f'"{symbol_name}_')
)
elif re.match(r'[\s\t]*\(extends ', item):
body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
elif re.match(r"[\s\t]*\(extends ", item):
pass # drop extends clause
else:
body_lines.append(item) # pin_names, in_bom, on_board …
@@ -273,16 +279,12 @@ class DynamicSymbolLoader:
if pname not in parent_prop_names:
body_lines.append(pblock)
first_line = parent_block.split("\n")[0].replace(
f'"{parent_name}"', f'"{symbol_name}"'
)
first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
last_line = parent_block.split("\n")[-1]
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
def extract_symbol_from_library(
self, library_name: str, symbol_name: str
) -> Optional[str]:
def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
"""
Extract a symbol definition from a KiCad .kicad_sym library file.
Returns the raw text block, ready to be injected into a schematic.
@@ -304,9 +306,7 @@ class DynamicSymbolLoader:
block = self._extract_symbol_block(lib_content, symbol_name)
if block is None:
logger.warning(
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
)
logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
return None
# If the symbol uses (extends "ParentName"), inline the parent content
@@ -315,9 +315,7 @@ class DynamicSymbolLoader:
# load a schematic whose lib_symbols section contains it.
if re.search(r'\(extends "([^"]+)"\)', block):
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
logger.info(
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
)
logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
block = self._inline_extends_symbol(lib_content, symbol_name, block)
# Prefix top-level symbol name with library
@@ -355,9 +353,7 @@ class DynamicSymbolLoader:
# Extract symbol from library
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
if not symbol_block:
raise ValueError(
f"Symbol '{symbol_name}' not found in library '{library_name}'"
)
raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
indented_lines = []
@@ -392,11 +388,7 @@ class DynamicSymbolLoader:
f.write(content)
# Handle both Path objects and strings
sch_name = (
schematic_path.name
if hasattr(schematic_path, "name")
else str(schematic_path)
)
sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
logger.info(f"Injected symbol {full_name} into {sch_name}")
return True
@@ -433,6 +425,14 @@ class DynamicSymbolLoader:
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(instances
(project "project"
(path "/"
(reference "{reference}")
(unit 1)
)
)
)
)"""
with open(schematic_path, "r", encoding="utf-8") as f:
@@ -450,9 +450,7 @@ class DynamicSymbolLoader:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info(
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
)
logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
return True
def load_symbol_dynamically(

View File

@@ -2,13 +2,14 @@
Export command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import base64
import logging
import os
import shutil
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -105,22 +106,16 @@ class ExportCommands:
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
# Get list of generated drill files
for file in os.listdir(output_dir):
if file.endswith((".drl", ".cnc")):
drill_files.append(file)
else:
logger.warning(
f"Drill file generation failed: {result.stderr}"
)
logger.warning(f"Drill file generation failed: {result.stderr}")
except Exception as drill_error:
logger.warning(
f"Could not generate drill files: {str(drill_error)}"
)
logger.warning(f"Could not generate drill files: {str(drill_error)}")
else:
logger.warning("kicad-cli not available for drill file generation")
@@ -236,9 +231,7 @@ class ExportCommands:
# Get the actual output filename that was created
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
actual_filename = f"{board_name}-{base_name}.pdf"
actual_output_path = os.path.join(
os.path.dirname(output_path), actual_filename
)
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
return {
"success": True,
@@ -329,9 +322,9 @@ class ExportCommands:
def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export 3D model files using kicad-cli (KiCAD 9.0 compatible)"""
import subprocess
import platform
import shutil
import subprocess
try:
if not self.board:
@@ -395,9 +388,7 @@ class ExportCommands:
if not include_components:
cmd.append("--no-components")
if include_copper:
cmd.extend(
["--include-tracks", "--include-pads", "--include-zones"]
)
cmd.extend(["--include-tracks", "--include-pads", "--include-zones"])
if include_silkscreen:
cmd.append("--include-silkscreen")
if include_solder_mask:
@@ -618,8 +609,8 @@ class ExportCommands:
Returns:
Path to kicad-cli executable, or None if not found
"""
import shutil
import platform
import shutil
# Try system PATH first
cli_path = shutil.which("kicad-cli")
@@ -696,6 +687,7 @@ class ExportCommands:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
from pathlib import Path
logs_dir = Path(project_dir) / "logs"
logs_dir.mkdir(exist_ok=True)
dest = str(logs_dir / f"mcp_log_{timestamp}.txt")

View File

@@ -8,15 +8,15 @@ KiCAD 9 .kicad_mod format reference:
https://dev-docs.kicad.org/en/file-formats/sexpr-footprint/
"""
import logging
import os
import re
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("kicad_interface")
KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files
KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files
KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files
@@ -106,7 +106,7 @@ class FootprintCreator:
# ---- header ----
lines.append(f'(footprint "{name}"')
lines.append(f' (version {KICAD9_FOOTPRINT_VERSION})')
lines.append(f" (version {KICAD9_FOOTPRINT_VERSION})")
lines.append(f' (generator "kicad-mcp")')
lines.append(f' (generator_version "9.0")')
lines.append(f' (layer "F.Cu")')
@@ -122,25 +122,21 @@ class FootprintCreator:
val_x = value_position.get("x", 0.0) if value_position else 0.0
val_y = value_position.get("y", 1.27) if value_position else 1.27
lines.append(
f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)'
)
lines.append(f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)')
lines.append(f' (layer "F.SilkS")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(
f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)'
)
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append(f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)')
lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append(f' (property "Datasheet" "" (at 0 0 0)')
lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append("")
# ---- courtyard ----
@@ -217,33 +213,35 @@ class FootprintCreator:
changes = []
if size:
new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})'
block, n = re.subn(r'\(size\s+[\d.]+\s+[\d.]+\)', new_size, block)
block, n = re.subn(r"\(size\s+[\d.]+\s+[\d.]+\)", new_size, block)
if n:
changes.append(f"size→{new_size}")
if at:
angle = at.get("angle", 0)
new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})'
block, n = re.subn(r'\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_at, block)
block, n = re.subn(r"\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_at, block)
if n:
changes.append(f"at→{new_at}")
if drill is not None:
if isinstance(drill, (int, float)):
new_drill = f'(drill {_fmt(drill)})'
new_drill = f"(drill {_fmt(drill)})"
else:
new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})'
block, n = re.subn(r'\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_drill, block)
block, n = re.subn(
r"\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_drill, block
)
if n:
changes.append(f"drill→{new_drill}")
else:
# Insert drill before closing paren of pad block
block = block.rstrip().rstrip(')') + f'\n {new_drill}\n )'
block = block.rstrip().rstrip(")") + f"\n {new_drill}\n )"
changes.append(f"drill (inserted)→{new_drill}")
if shape:
block, n = re.subn(
r'(pad\s+"[^"]*"\s+\w+\s+)\w+',
lambda m: m.group(1) + shape,
lambda m: str(m.group(1)) + shape,
block,
count=1
count=1,
)
if n:
changes.append(f"shape→{shape}")
@@ -280,7 +278,7 @@ class FootprintCreator:
if not updated:
return {
"success": False,
"error": f"Pad \"{pad_number}\" not found or no changes made in {footprint_path}",
"error": f'Pad "{pad_number}" not found or no changes made in {footprint_path}',
}
path.write_text("\n".join(result_lines), encoding="utf-8")
@@ -429,6 +427,7 @@ class FootprintCreator:
# Internal helpers #
# ------------------------------------------------------------------ #
def _esc(s: str) -> str:
"""Escape double-quotes inside S-Expression string values."""
return s.replace('"', '\\"')
@@ -436,6 +435,7 @@ def _esc(s: str) -> str:
def _new_uuid() -> str:
import uuid
return str(uuid.uuid4())
@@ -445,8 +445,8 @@ _DEFAULT_THT_LAYERS = ["*.Cu", "*.Mask"]
def _pad_lines(pad: Dict[str, Any]) -> List[str]:
number = str(pad.get("number", "1"))
ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole
shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect
ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole
shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect
at = pad.get("at", {"x": 0.0, "y": 0.0})
size = pad.get("size", {"w": 1.0, "h": 1.0})
drill = pad.get("drill", None)
@@ -462,7 +462,9 @@ def _pad_lines(pad: Dict[str, Any]) -> List[str]:
sh = _fmt(size.get("h", 1.0))
if layers is None:
layers = _DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS
layers = (
_DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS
)
layers_str = " ".join(f'"{l}"' for l in layers)
lines = [f' (pad "{number}" {ptype} {shape}']
@@ -494,13 +496,13 @@ def _rect_lines(rect: Dict[str, Any], layer: str, default_width: float = 0.05) -
y2 = _fmt(rect.get("y2", 1.0))
w = _fmt(rect.get("width", default_width))
return [
f' (fp_rect',
f' (start {x1} {y1})',
f' (end {x2} {y2})',
f' (stroke (width {w}) (type default))',
f' (fill none)',
f" (fp_rect",
f" (start {x1} {y1})",
f" (end {x2} {y2})",
f" (stroke (width {w}) (type default))",
f" (fill none)",
f' (layer "{layer}")',
f' (uuid "{_new_uuid()}")',
f' )',
f" )",
"",
]

View File

@@ -9,22 +9,20 @@ Supports two execution modes:
- Docker: docker run eclipse-temurin:21-jre (requires Docker)
"""
import os
import subprocess
import shutil
import time
import logging
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Any, Dict, List, Optional
logger = logging.getLogger("kicad_interface")
# Default Freerouting JAR location
DEFAULT_FREEROUTING_JAR = os.environ.get(
"FREEROUTING_JAR",
os.path.join(
os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"
),
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
)
DOCKER_IMAGE = "eclipse-temurin:21-jre"
@@ -97,38 +95,55 @@ def _build_freerouting_cmd(
"""Build the command to run Freerouting."""
if use_docker:
docker_exe = _find_docker()
if docker_exe is None:
raise RuntimeError("Docker/Podman executable not found")
board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path)
jar_name = os.path.basename(jar_path)
return [
docker_exe, "run", "--rm",
"-v", f"{jar_path}:/app/{jar_name}:ro",
"-v", f"{board_dir}:/work",
docker_exe,
"run",
"--rm",
"-v",
f"{jar_path}:/app/{jar_name}:ro",
"-v",
f"{board_dir}:/work",
DOCKER_IMAGE,
"java", "-jar", f"/app/{jar_name}",
"-de", f"/work/{dsn_name}",
"-do", f"/work/{ses_name}",
"-mp", str(passes),
"java",
"-jar",
f"/app/{jar_name}",
"-de",
f"/work/{dsn_name}",
"-do",
f"/work/{ses_name}",
"-mp",
str(passes),
]
else:
java_exe = _find_java()
if java_exe is None:
raise RuntimeError("Java executable not found")
return [
java_exe, "-jar", jar_path,
"-de", dsn_path, "-do", ses_path,
"-mp", str(passes),
java_exe,
"-jar",
jar_path,
"-de",
dsn_path,
"-do",
ses_path,
"-mp",
str(passes),
]
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board=None):
def __init__(self, board: Any = None) -> None:
self.board = board
def _resolve_execution_mode(
self, jar_path: str
) -> Dict[str, Any]:
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:
"""Determine how to run Freerouting: direct or docker.
Returns dict with 'mode', 'use_docker', or 'error'.
@@ -152,8 +167,7 @@ class FreeroutingCommands:
return {
"mode": "error",
"error": (
"Neither Java 21+ nor Docker found. "
"Install one of them to use Freerouting."
"Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting."
),
}
@@ -190,14 +204,10 @@ class FreeroutingCommands:
return {
"success": False,
"message": "No board file path available",
"errorDetails": (
"Provide boardPath or open a project first"
),
"errorDetails": ("Provide boardPath or open a project first"),
}
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
timeout = params.get("timeout", 300)
passes = params.get("maxPasses", 20)
@@ -238,9 +248,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
}
except Exception as e:
return {
@@ -260,14 +268,10 @@ class FreeroutingCommands:
logger.info(f"DSN exported: {dsn_size} bytes")
# Step 2: Run Freerouting
cmd = _build_freerouting_cmd(
jar_path, dsn_path, ses_path, passes, use_docker
)
cmd = _build_freerouting_cmd(jar_path, dsn_path, ses_path, passes, use_docker)
mode_label = "docker" if use_docker else "direct"
logger.info(
f"Running Freerouting ({mode_label}): {' '.join(cmd)}"
)
logger.info(f"Running Freerouting ({mode_label}): {' '.join(cmd)}")
start_time = time.time()
try:
@@ -283,10 +287,7 @@ class FreeroutingCommands:
if proc.returncode != 0:
return {
"success": False,
"message": (
f"Freerouting exited with code "
f"{proc.returncode}"
),
"message": (f"Freerouting exited with code " f"{proc.returncode}"),
"errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": elapsed,
"mode": mode_label,
@@ -294,12 +295,8 @@ class FreeroutingCommands:
except subprocess.TimeoutExpired:
return {
"success": False,
"message": (
f"Freerouting timed out after {timeout}s"
),
"errorDetails": (
"Increase timeout or reduce board complexity"
),
"message": (f"Freerouting timed out after {timeout}s"),
"errorDetails": ("Increase timeout or reduce board complexity"),
}
except Exception as e:
return {
@@ -313,10 +310,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Freerouting did not produce SES output",
"errorDetails": (
f"Expected at: {ses_path}. "
f"Stdout: {proc.stdout[:500]}"
),
"errorDetails": (f"Expected at: {ses_path}. " f"Stdout: {proc.stdout[:500]}"),
"elapsed_seconds": elapsed,
}
@@ -331,9 +325,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "SES import failed",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
"elapsed_seconds": elapsed,
}
except Exception as e:
@@ -348,9 +340,7 @@ class FreeroutingCommands:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(
f"Board save after autoroute failed: {e}"
)
logger.warning(f"Board save after autoroute failed: {e}")
# Collect stats
tracks = self.board.GetTracks()
@@ -373,9 +363,7 @@ class FreeroutingCommands:
"tracks": track_count,
"vias": via_count,
},
"freerouting_stdout": (
proc.stdout[:1000] if proc.stdout else ""
),
"freerouting_stdout": (proc.stdout[:1000] if proc.stdout else ""),
}
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -396,36 +384,26 @@ class FreeroutingCommands:
"errorDetails": "Load or create a board first",
}
board_path = (
params.get("boardPath") or self.board.GetFileName()
)
board_path = params.get("boardPath") or self.board.GetFileName()
output_path = params.get("outputPath")
if not output_path:
if board_path:
output_path = (
os.path.splitext(board_path)[0] + ".dsn"
)
output_path = os.path.splitext(board_path)[0] + ".dsn"
else:
return {
"success": False,
"message": "No output path",
"errorDetails": (
"Provide outputPath or have a board open"
),
"errorDetails": ("Provide outputPath or have a board open"),
}
try:
result = pcbnew.ExportSpecctraDSN(
self.board, output_path
)
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
}
except Exception as e:
return {
@@ -434,11 +412,7 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
file_size = (
os.path.getsize(output_path)
if os.path.isfile(output_path)
else 0
)
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
return {
"success": True,
"message": f"Exported DSN to {output_path}",
@@ -469,9 +443,7 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Missing sesPath parameter",
"errorDetails": (
"Provide the path to the .ses file"
),
"errorDetails": ("Provide the path to the .ses file"),
}
if not os.path.isfile(ses_path):
@@ -482,16 +454,12 @@ class FreeroutingCommands:
}
try:
result = pcbnew.ImportSpecctraSES(
self.board, ses_path
)
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
}
except Exception as e:
return {
@@ -500,24 +468,16 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
board_path = (
params.get("boardPath") or self.board.GetFileName()
)
board_path = params.get("boardPath") or self.board.GetFileName()
if board_path:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(
f"Board save after SES import failed: {e}"
)
logger.warning(f"Board save after SES import failed: {e}")
tracks = self.board.GetTracks()
track_count = sum(
1 for t in tracks if t.GetClass() != "PCB_VIA"
)
via_count = sum(
1 for t in tracks if t.GetClass() == "PCB_VIA"
)
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
return {
"success": True,
@@ -528,13 +488,9 @@ class FreeroutingCommands:
},
}
def check_freerouting(
self, params: Dict[str, Any]
) -> Dict[str, Any]:
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Check if Freerouting and Java/Docker are available."""
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
# Check local Java
java_exe = _find_java()
@@ -548,11 +504,7 @@ class FreeroutingCommands:
text=True,
timeout=10,
)
java_version = (
(proc.stderr or proc.stdout)
.strip()
.split("\n")[0]
)
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
java_21_ok = _java_version_ok(java_exe)
except Exception:
pass

View File

@@ -5,20 +5,21 @@ Handles authentication and downloading the JLCPCB parts library
for integration with KiCAD component selection.
"""
import os
import logging
import requests
import time
import hmac
import base64
import hashlib
import hmac
import json
import logging
import os
import secrets
import string
import base64
import json
from typing import Optional, Dict, List, Callable
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional
logger = logging.getLogger('kicad_interface')
import requests
logger = logging.getLogger("kicad_interface")
class JLCPCBClient:
@@ -31,7 +32,12 @@ class JLCPCBClient:
BASE_URL = "https://jlcpcb.com/external"
def __init__(self, app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None):
def __init__(
self,
app_id: Optional[str] = None,
access_key: Optional[str] = None,
secret_key: Optional[str] = None,
):
"""
Initialize JLCPCB API client
@@ -40,20 +46,24 @@ class JLCPCBClient:
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
if not self.app_id or not self.access_key or not self.secret_key:
logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
logger.warning(
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
@staticmethod
def _generate_nonce() -> str:
"""Generate a 32-character random nonce"""
chars = string.ascii_letters + string.digits
return ''.join(secrets.choice(chars) for _ in range(32))
return "".join(secrets.choice(chars) for _ in range(32))
def _build_signature_string(self, method: str, path: str, timestamp: int, nonce: str, body: str) -> str:
def _build_signature_string(
self, method: str, path: str, timestamp: int, nonce: str, body: str
) -> str:
"""
Build the signature string according to JLCPCB spec
@@ -87,11 +97,9 @@ class JLCPCBClient:
Base64-encoded signature
"""
signature_bytes = hmac.new(
self.secret_key.encode('utf-8'),
signature_string.encode('utf-8'),
hashlib.sha256
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
).digest()
return base64.b64encode(signature_bytes).decode('utf-8')
return base64.b64encode(signature_bytes).decode("utf-8")
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
"""
@@ -106,7 +114,9 @@ class JLCPCBClient:
Authorization header value
"""
if not self.app_id or not self.access_key or not self.secret_key:
raise Exception("JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
raise Exception(
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
nonce = self._generate_nonce()
timestamp = int(time.time())
@@ -116,7 +126,9 @@ class JLCPCBClient:
logger.debug(f"Signature string:\n{repr(signature_string)}")
logger.debug(f"Signature: {signature}")
logger.debug(f"Auth header: JOP appid=\"{self.app_id}\",accesskey=\"{self.access_key}\",nonce=\"{nonce}\",timestamp=\"{timestamp}\",signature=\"{signature}\"")
logger.debug(
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
)
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
@@ -138,22 +150,16 @@ class JLCPCBClient:
# Convert payload to JSON string for signing
# For POST requests, we always send JSON, even if empty dict
body_str = json.dumps(payload, separators=(',', ':'))
body_str = json.dumps(payload, separators=(",", ":"))
# Generate authorization header
auth_header = self._get_auth_header("POST", path, body_str)
headers = {
"Authorization": auth_header,
"Content-Type": "application/json"
}
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
try:
response = requests.post(
f"{self.BASE_URL}{path}",
headers=headers,
json=payload,
timeout=60
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
)
logger.debug(f"Response status: {response.status_code}")
@@ -163,18 +169,19 @@ class JLCPCBClient:
response.raise_for_status()
data = response.json()
if data.get('code') != 200:
raise Exception(f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}")
if data.get("code") != 200:
raise Exception(
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
)
return data['data']
return data["data"]
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch parts page: {e}")
raise Exception(f"JLCPCB API request failed: {e}")
def download_full_database(
self,
callback: Optional[Callable[[int, int, str], None]] = None
self, callback: Optional[Callable[[int, int, str], None]] = None
) -> List[Dict]:
"""
Download entire parts library from JLCPCB
@@ -197,10 +204,10 @@ class JLCPCBClient:
try:
data = self.fetch_parts_page(last_key)
parts = data.get('componentInfos', [])
parts = data.get("componentInfos", [])
all_parts.extend(parts)
last_key = data.get('lastKey')
last_key = data.get("lastKey")
if callback:
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
@@ -245,7 +252,9 @@ class JLCPCBClient:
return None
def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None) -> bool:
def test_jlcpcb_connection(
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
) -> bool:
"""
Test JLCPCB API connection
@@ -268,7 +277,7 @@ def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[st
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCPCB client
logging.basicConfig(level=logging.INFO)
@@ -279,7 +288,7 @@ if __name__ == '__main__':
client = JLCPCBClient()
print("\nFetching first page of parts...")
data = client.fetch_parts_page()
parts = data.get('componentInfos', [])
parts = data.get("componentInfos", [])
print(f"✓ Retrieved {len(parts)} parts in first page")
if parts:

View File

@@ -5,15 +5,15 @@ Manages local SQLite database of JLCPCB parts for fast searching
and component selection.
"""
import os
import sqlite3
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class JLCPCBPartsManager:
@@ -38,10 +38,10 @@ class JLCPCBPartsManager:
db_path = str(data_dir / "jlcpcb_parts.db")
self.db_path = db_path
self.conn = None
self.conn: Optional[sqlite3.Connection] = None
self._init_database()
def _init_database(self):
def _init_database(self) -> None:
"""Initialize SQLite database with schema"""
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row # Return rows as dicts
@@ -49,7 +49,7 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor()
# Create components table
cursor.execute('''
cursor.execute("""
CREATE TABLE IF NOT EXISTS components (
lcsc TEXT PRIMARY KEY,
category TEXT,
@@ -65,17 +65,19 @@ class JLCPCBPartsManager:
price_json TEXT,
last_updated INTEGER
)
''')
""")
# Create indexes for fast searching
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)')
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)")
# Full-text search index for descriptions
cursor.execute('''
cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
lcsc,
description,
@@ -83,12 +85,14 @@ class JLCPCBPartsManager:
manufacturer,
content=components
)
''')
""")
self.conn.commit()
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
def import_parts(self, parts: List[Dict], progress_callback=None):
def import_parts(
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
) -> None:
"""
Import parts into database from JLCPCB API response
@@ -103,32 +107,35 @@ class JLCPCBPartsManager:
for i, part in enumerate(parts):
try:
# Extract price breaks
price_json = json.dumps(part.get('prices', []))
price_json = json.dumps(part.get("prices", []))
# Determine library type
library_type = self._determine_library_type(part)
cursor.execute('''
cursor.execute(
"""
INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
part.get('componentCode'), # lcsc
part.get('firstSortName'), # category
part.get('secondSortName'), # subcategory
part.get('componentModelEn'), # mfr_part
part.get('componentSpecificationEn'), # package
part.get('soldPoint'), # solder_joints
part.get('componentBrandEn'), # manufacturer
library_type, # library_type
part.get('describe'), # description
part.get('dataManualUrl'), # datasheet
part.get('stockCount', 0), # stock
price_json, # price_json
int(datetime.now().timestamp()) # last_updated
))
""",
(
part.get("componentCode"), # lcsc
part.get("firstSortName"), # category
part.get("secondSortName"), # subcategory
part.get("componentModelEn"), # mfr_part
part.get("componentSpecificationEn"), # package
part.get("soldPoint"), # solder_joints
part.get("componentBrandEn"), # manufacturer
library_type, # library_type
part.get("describe"), # description
part.get("dataManualUrl"), # datasheet
part.get("stockCount", 0), # stock
price_json, # price_json
int(datetime.now().timestamp()), # last_updated
),
)
imported += 1
@@ -140,10 +147,10 @@ class JLCPCBPartsManager:
skipped += 1
# Update FTS index
cursor.execute('''
cursor.execute("""
INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer)
SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components
''')
""")
self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -151,18 +158,20 @@ class JLCPCBPartsManager:
def _determine_library_type(self, part: Dict) -> str:
"""Determine if part is Basic, Extended, or Preferred"""
# JLCPCB API should provide this, but if not, we infer from assembly type
assembly_type = part.get('assemblyType', '')
assembly_type = part.get("assemblyType", "")
if 'Basic' in assembly_type or part.get('libraryType') == 'base':
return 'Basic'
elif 'Extended' in assembly_type:
return 'Extended'
elif 'Prefer' in assembly_type:
return 'Preferred'
if "Basic" in assembly_type or part.get("libraryType") == "base":
return "Basic"
elif "Extended" in assembly_type:
return "Extended"
elif "Prefer" in assembly_type:
return "Preferred"
else:
return 'Extended' # Default to Extended
return "Extended" # Default to Extended
def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None):
def import_jlcsearch_parts(
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
) -> None:
"""
Import parts into database from JLCSearch API response
@@ -178,56 +187,59 @@ class JLCPCBPartsManager:
try:
# JLCSearch format is different from official API
# LCSC is an integer, we need to add 'C' prefix
lcsc = part.get('lcsc')
lcsc = part.get("lcsc")
if isinstance(lcsc, int):
lcsc = f"C{lcsc}"
# Build price JSON from jlcsearch single price
price = part.get('price') or part.get('price1')
price = part.get("price") or part.get("price1")
price_json = json.dumps([{"qty": 1, "price": price}] if price else [])
# Determine library type from is_basic flag
library_type = 'Basic' if part.get('is_basic') else 'Extended'
if part.get('is_preferred'):
library_type = 'Preferred'
library_type = "Basic" if part.get("is_basic") else "Extended"
if part.get("is_preferred"):
library_type = "Preferred"
# Extract description from various fields
description_parts = []
if 'resistance' in part:
if "resistance" in part:
description_parts.append(f"{part['resistance']}Ω")
if 'capacitance' in part:
if "capacitance" in part:
description_parts.append(f"{part['capacitance']}F")
if 'tolerance_fraction' in part:
tol = part['tolerance_fraction'] * 100
if "tolerance_fraction" in part:
tol = part["tolerance_fraction"] * 100
description_parts.append(f"±{tol}%")
if 'power_watts' in part:
if "power_watts" in part:
description_parts.append(f"{part['power_watts']}mW")
if 'voltage' in part:
if "voltage" in part:
description_parts.append(f"{part['voltage']}V")
description = part.get('description', ' '.join(description_parts))
description = part.get("description", " ".join(description_parts))
cursor.execute('''
cursor.execute(
"""
INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
lcsc, # lcsc with C prefix
part.get('category', ''), # category
part.get('subcategory', ''), # subcategory
part.get('mfr', ''), # mfr_part
part.get('package', ''), # package
0, # solder_joints (not in jlcsearch)
part.get('manufacturer', ''), # manufacturer
library_type, # library_type
description, # description
'', # datasheet (not in jlcsearch)
part.get('stock', 0), # stock
price_json, # price_json
int(datetime.now().timestamp()) # last_updated
))
""",
(
lcsc, # lcsc with C prefix
part.get("category", ""), # category
part.get("subcategory", ""), # subcategory
part.get("mfr", ""), # mfr_part
part.get("package", ""), # package
0, # solder_joints (not in jlcsearch)
part.get("manufacturer", ""), # manufacturer
library_type, # library_type
description, # description
"", # datasheet (not in jlcsearch)
part.get("stock", 0), # stock
price_json, # price_json
int(datetime.now().timestamp()), # last_updated
),
)
imported += 1
@@ -239,10 +251,10 @@ class JLCPCBPartsManager:
skipped += 1
# Update FTS index
cursor.execute('''
cursor.execute("""
INSERT INTO components_fts(components_fts)
VALUES('rebuild')
''')
""")
self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -255,7 +267,7 @@ class JLCPCBPartsManager:
library_type: Optional[str] = None,
manufacturer: Optional[str] = None,
in_stock: bool = True,
limit: int = 20
limit: int = 20,
) -> List[Dict]:
"""
Search for parts with filters
@@ -280,13 +292,18 @@ class JLCPCBPartsManager:
if query:
# Use FTS for text search
sql_parts.append('''
# Add prefix wildcard to each term for partial matching
# (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR")
fts_query = " ".join(
f"{term}*" if not term.endswith("*") else term for term in query.strip().split()
)
sql_parts.append("""
AND lcsc IN (
SELECT lcsc FROM components_fts
WHERE components_fts MATCH ?
)
''')
params.append(query)
""")
params.append(fts_query)
if category:
sql_parts.append("AND category LIKE ?")
@@ -337,11 +354,11 @@ class JLCPCBPartsManager:
if row:
part = dict(row)
# Parse price JSON
if part.get('price_json'):
if part.get("price_json"):
try:
part['price_breaks'] = json.loads(part['price_json'])
part["price_breaks"] = json.loads(part["price_json"])
except:
part['price_breaks'] = []
part["price_breaks"] = []
return part
return None
@@ -350,23 +367,25 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor()
cursor.execute("SELECT COUNT(*) as total FROM components")
total = cursor.fetchone()['total']
total = cursor.fetchone()["total"]
cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'")
basic = cursor.fetchone()['basic']
basic = cursor.fetchone()["basic"]
cursor.execute("SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'")
extended = cursor.fetchone()['extended']
cursor.execute(
"SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'"
)
extended = cursor.fetchone()["extended"]
cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0")
in_stock = cursor.fetchone()['in_stock']
in_stock = cursor.fetchone()["in_stock"]
return {
'total_parts': total,
'basic_parts': basic,
'extended_parts': extended,
'in_stock': in_stock,
'db_path': self.db_path
"total_parts": total,
"basic_parts": basic,
"extended_parts": extended,
"in_stock": in_stock,
"db_path": self.db_path,
}
def map_package_to_footprint(self, package: str) -> List[str]:
@@ -384,43 +403,22 @@ class JLCPCBPartsManager:
"0402": [
"Resistor_SMD:R_0402_1005Metric",
"Capacitor_SMD:C_0402_1005Metric",
"LED_SMD:LED_0402_1005Metric"
"LED_SMD:LED_0402_1005Metric",
],
"0603": [
"Resistor_SMD:R_0603_1608Metric",
"Capacitor_SMD:C_0603_1608Metric",
"LED_SMD:LED_0603_1608Metric"
"LED_SMD:LED_0603_1608Metric",
],
"0805": [
"Resistor_SMD:R_0805_2012Metric",
"Capacitor_SMD:C_0805_2012Metric"
],
"1206": [
"Resistor_SMD:R_1206_3216Metric",
"Capacitor_SMD:C_1206_3216Metric"
],
"SOT-23": [
"Package_TO_SOT_SMD:SOT-23",
"Package_TO_SOT_SMD:SOT-23-3"
],
"SOT-23-5": [
"Package_TO_SOT_SMD:SOT-23-5"
],
"SOT-23-6": [
"Package_TO_SOT_SMD:SOT-23-6"
],
"SOIC-8": [
"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"
],
"SOIC-16": [
"Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"
],
"QFN-20": [
"Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"
],
"QFN-32": [
"Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"
]
"0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"],
"1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"],
"SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"],
"SOT-23-5": ["Package_TO_SOT_SMD:SOT-23-5"],
"SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"],
"SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"],
"SOIC-16": ["Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"],
"QFN-20": ["Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"],
"QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"],
}
# Normalize package name
@@ -451,24 +449,21 @@ class JLCPCBPartsManager:
# Search for parts in same category with same package
alternatives = self.search_parts(
category=part['subcategory'],
package=part['package'],
in_stock=True,
limit=limit * 3
category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3
)
# Filter out the original part
alternatives = [p for p in alternatives if p['lcsc'] != lcsc_number]
alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number]
# Sort by: Basic first, then by price, then by stock
def sort_key(p):
is_basic = 1 if p.get('library_type') == 'Basic' else 0
def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]:
is_basic = 1 if p.get("library_type") == "Basic" else 0
try:
prices = json.loads(p.get('price_json', '[]'))
price = float(prices[0].get('price', 999)) if prices else 999
prices = json.loads(p.get("price_json", "[]"))
price = float(prices[0].get("price", 999)) if prices else 999
except:
price = 999
stock = p.get('stock', 0)
stock = p.get("stock", 0)
return (-is_basic, price, -stock)
@@ -476,13 +471,13 @@ class JLCPCBPartsManager:
return alternatives[:limit]
def close(self):
def close(self) -> None:
"""Close database connection"""
if self.conn:
self.conn.close()
if __name__ == '__main__':
if __name__ == "__main__":
# Test the parts manager
logging.basicConfig(level=logging.INFO)
@@ -497,8 +492,10 @@ if __name__ == '__main__':
print(f" In stock: {stats['in_stock']}")
print(f" Database: {stats['db_path']}")
if stats['total_parts'] > 0:
if stats["total_parts"] > 0:
print("\nSearching for '10k resistor'...")
results = manager.search_parts(query="10k resistor", limit=5)
for part in results:
print(f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})")
print(
f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})"
)

View File

@@ -6,11 +6,12 @@ jlcsearch service at https://jlcsearch.tscircuit.com/
"""
import logging
import requests
from typing import Optional, Dict, List, Callable
import time
from typing import Any, Callable, Dict, List, Optional, Union
logger = logging.getLogger('kicad_interface')
import requests
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
@@ -23,16 +24,12 @@ class JLCSearchClient:
BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self):
def __init__(self) -> None:
"""Initialize JLCSearch API client"""
pass
def search_components(
self,
category: str = "components",
limit: int = 100,
offset: int = 0,
**filters
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
) -> List[Dict]:
"""
Search components in JLCSearch database
@@ -48,11 +45,7 @@ class JLCSearchClient:
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {
"limit": limit,
"offset": offset,
**filters
}
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
@@ -71,7 +64,9 @@ class JLCSearchClient:
logger.error(f"Failed to search JLCSearch: {e}")
raise Exception(f"JLCSearch API request failed: {e}")
def search_resistors(self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_resistors(
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for resistors
@@ -92,7 +87,7 @@ class JLCSearchClient:
- stock: Available stock
- price1: Price per unit
"""
filters = {}
filters: Dict[str, Any] = {}
if resistance is not None:
filters["resistance"] = resistance
if package:
@@ -100,7 +95,9 @@ class JLCSearchClient:
return self.search_components("resistors", limit=limit, **filters)
def search_capacitors(self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_capacitors(
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for capacitors
@@ -112,7 +109,7 @@ class JLCSearchClient:
Returns:
List of capacitor dicts
"""
filters = {}
filters: Dict[str, Any] = {}
if capacitance is not None:
filters["capacitance"] = capacitance
if package:
@@ -141,9 +138,7 @@ class JLCSearchClient:
return None
def download_all_components(
self,
callback: Optional[Callable[[int, str], None]] = None,
batch_size: int = 100
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
@@ -165,11 +160,7 @@ class JLCSearchClient:
while True:
try:
batch = self.search_components(
"components",
limit=batch_size,
offset=offset
)
batch = self.search_components("components", limit=batch_size, offset=offset)
# Stop if no results returned (end of catalog)
if not batch or len(batch) == 0:
@@ -219,7 +210,7 @@ def test_jlcsearch_connection() -> bool:
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)

View File

@@ -5,12 +5,12 @@ Handles parsing fp-lib-table files, discovering footprints,
and providing search functionality for component placement.
"""
import glob
import logging
import os
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import glob
logger = logging.getLogger("kicad_interface")
@@ -35,7 +35,7 @@ class LibraryManager:
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from fp-lib-table files"""
# Load global libraries
global_table = self._get_global_fp_lib_table()
@@ -58,13 +58,16 @@ class LibraryManager:
"""Get path to global fp-lib-table file"""
# Try different possible locations
kicad_config_paths = [
Path.home() / ".config" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "fp-lib-table",
# Windows paths
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table",
# macOS paths
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table",
]
@@ -75,7 +78,7 @@ class LibraryManager:
return None
def _parse_fp_lib_table(self, table_path: Path):
def _parse_fp_lib_table(self, table_path: Path) -> None:
"""
Parse fp-lib-table file
@@ -90,11 +93,21 @@ class LibraryManager:
# Simple regex-based parser for lib entries
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?'
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
uri = match.group(2)
lib_type = match.group(2)
uri = match.group(3)
if lib_type.lower() == "table":
table_uri = uri
if os.path.isabs(table_uri) and os.path.isfile(table_uri):
logger.info(f" Following Table reference: {nickname} -> {table_uri}")
self._parse_fp_lib_table(Path(table_uri))
else:
logger.warning(f" Could not resolve Table URI: {table_uri}")
continue
# Resolve environment variables in URI
resolved_uri = self._resolve_uri(uri)
@@ -103,9 +116,7 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else:
logger.warning(
f" Could not resolve URI for library {nickname}: {uri}"
)
logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -126,10 +137,12 @@ class LibraryManager:
# Common KiCAD environment variables
env_vars = {
"KICAD10_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KISYSMOD": self._find_kicad_footprint_dir(),
"KICAD10_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(),
}
@@ -206,12 +219,7 @@ class LibraryManager:
/ "9.0"
/ "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home()
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
]
for config_path in kicad_common_paths:
@@ -337,9 +345,7 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists():
logger.info(
f"Found footprint {footprint_name} in library {library_nickname}"
)
logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -446,9 +452,7 @@ class LibraryCommands:
# Filter by library if specified
if library_filter:
results = [
r
for r in results
if r.get("library", "").lower() == library_filter.lower()
r for r in results if r.get("library", "").lower() == library_filter.lower()
]
results = results[:limit]
@@ -492,7 +496,7 @@ class LibraryCommands:
def get_footprint_info(self, params: Dict) -> Dict:
"""Get information about a specific footprint"""
try:
footprint_spec = params.get("footprint")
footprint_spec = params.get("footprint_name")
if not footprint_spec:
return {"success": False, "message": "Missing footprint parameter"}
@@ -508,19 +512,39 @@ class LibraryCommands:
library_nickname = nick
break
info = {
"library": library_nickname,
"footprint": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
# Minimal info — always returned even if the parser fails
info: Dict = {
"library": library_nickname,
"name": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
return {"success": True, "footprint_info": info}
else:
return {
"success": False,
"message": f"Footprint not found: {footprint_spec}",
}
# Attempt to enrich with parsed .kicad_mod data
try:
from pathlib import Path as _Path
from parsers.kicad_mod_parser import parse_kicad_mod
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
parsed = parse_kicad_mod(mod_file)
if parsed:
# Merge parser output into info; keep our resolved library context
info.update(parsed)
info["name"] = footprint_name # entry name wins over in-file name
info["library"] = library_nickname
info["full_name"] = f"{library_nickname}:{footprint_name}"
info["library_path"] = library_path
else:
logger.warning(
f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info"
)
except Exception as parse_err:
logger.warning(
f"get_footprint_info: parser error ({parse_err}), using minimal info"
)
return {"success": True, "info": info}
except Exception as e:
logger.error(f"Error getting footprint info: {e}")

View File

@@ -1,22 +1,31 @@
from skip import Schematic
import glob
import logging
# Symbol class might not be directly importable in the current version
import os
import glob
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
class LibraryManager:
"""Manage symbol libraries"""
@staticmethod
def list_available_libraries(search_paths=None):
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
"""List all available symbol libraries"""
if search_paths is None:
# Default library paths based on common KiCAD installations
# This would need to be configured for the specific environment
search_paths = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
os.path.expanduser("~/Documents/KiCad/*/symbols/*.kicad_sym") # User libraries pattern
os.path.expanduser(
"~/Documents/KiCad/*/symbols/*.kicad_sym"
), # User libraries pattern
]
libraries = []
@@ -26,70 +35,80 @@ class LibraryManager:
matching_libs = glob.glob(path_pattern, recursive=True)
libraries.extend(matching_libs)
except Exception as e:
print(f"Error searching for libraries at {path_pattern}: {e}")
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
# Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
print(f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}")
logger.info(
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
)
# Return both full paths and library names
return {"paths": libraries, "names": library_names}
@staticmethod
def list_library_symbols(library_path):
def list_library_symbols(library_path: str) -> List[Any]:
"""List all symbols in a library"""
try:
# kicad-skip doesn't provide a direct way to simply list symbols in a library
# without loading each one. We might need to implement this using KiCAD's Python API
# directly, or by using a different approach.
# For now, this is a placeholder implementation.
# A potential approach would be to load the library file using KiCAD's Python API
# or by parsing the library file format.
# KiCAD symbol libraries are .kicad_sym files which are S-expression format
print(f"Attempted to list symbols in library {library_path}. This requires advanced implementation.")
logger.warning(
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
)
return []
except Exception as e:
print(f"Error listing symbols in library {library_path}: {e}")
logger.error(f"Error listing symbols in library {library_path}: {e}")
return []
@staticmethod
def get_symbol_details(library_path, symbol_name):
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
"""Get detailed information about a symbol"""
try:
# Similar to list_library_symbols, this might require a more direct approach
# using KiCAD's Python API or by parsing the symbol library.
print(f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation.")
logger.warning(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {}
except Exception as e:
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
return {}
@staticmethod
def search_symbols(query, search_paths=None):
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
"""Search for symbols matching criteria"""
try:
# This would typically involve:
# 1. Getting a list of all libraries using list_available_libraries
# 2. For each library, getting a list of all symbols
# 3. Filtering symbols based on the query
# For now, this is a placeholder implementation
libraries = LibraryManager.list_available_libraries(search_paths)
results = []
print(f"Searched for symbols matching '{query}'. This requires advanced implementation.")
logger.warning(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results
except Exception as e:
print(f"Error searching for symbols matching '{query}': {e}")
logger.error(f"Error searching for symbols matching '{query}': {e}")
return []
@staticmethod
def get_default_symbol_for_component_type(component_type, search_paths=None):
def get_default_symbol_for_component_type(
component_type: str, search_paths: Optional[List[str]] = None
) -> Dict[str, str]:
"""Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types
# It's useful when the user doesn't specify a particular library/symbol
# Define common mappings from component type to library/symbol
common_mappings = {
"resistor": {"library": "Device", "symbol": "R"},
@@ -103,23 +122,24 @@ class LibraryManager:
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
# Add more common components as needed
}
# Normalize input to lowercase
component_type_lower = component_type.lower()
# Try direct match first
if component_type_lower in common_mappings:
return common_mappings[component_type_lower]
# Try partial matches
for key, value in common_mappings.items():
if component_type_lower in key or key in component_type_lower:
return value
# Default fallback
return {"library": "Device", "symbol": "R"}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()
@@ -127,15 +147,15 @@ if __name__ == '__main__':
first_lib = libraries["paths"][0]
lib_name = libraries["names"][0]
print(f"Testing with first library: {lib_name} ({first_lib})")
# List symbols in the first library
symbols = LibraryManager.list_library_symbols(first_lib)
# This will report that it requires advanced implementation
# Get default symbol for a component type
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
# Try a partial match
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")

View File

@@ -5,33 +5,34 @@ Handles parsing sym-lib-table files, discovering symbols,
and providing search functionality for component selection.
"""
import logging
import os
import re
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
@dataclass
class SymbolInfo:
"""Information about a symbol in a library"""
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
value: str = "" # Value property
description: str = "" # Description property
footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
value: str = "" # Value property
description: str = "" # Description property
footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
manufacturer: str = "" # Manufacturer property
mpn: str = "" # Part/MPN property
category: str = "" # Category property
datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended
mpn: str = "" # Part/MPN property
category: str = "" # Category property
datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended
class SymbolLibraryManager:
@@ -54,7 +55,7 @@ class SymbolLibraryManager:
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files"""
# Load global libraries
global_table = self._get_global_sym_lib_table()
@@ -77,13 +78,16 @@ class SymbolLibraryManager:
"""Get path to global sym-lib-table file"""
# Try different possible locations (same as fp-lib-table but for symbols)
kicad_config_paths = [
Path.home() / ".config" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "8.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "sym-lib-table",
# Windows paths
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "sym-lib-table",
# macOS paths
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "sym-lib-table",
]
@@ -94,7 +98,7 @@ class SymbolLibraryManager:
return None
def _parse_sym_lib_table(self, table_path: Path):
def _parse_sym_lib_table(self, table_path: Path) -> None:
"""
Parse sym-lib-table file
@@ -104,16 +108,26 @@ class SymbolLibraryManager:
)
"""
try:
with open(table_path, 'r', encoding='utf-8') as f:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
# Simple regex-based parser for lib entries
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?'
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
uri = match.group(2)
lib_type = match.group(2)
uri = match.group(3)
if lib_type.lower() == "table":
table_uri = uri
if os.path.isabs(table_uri) and os.path.isfile(table_uri):
logger.info(f" Following Table reference: {nickname} -> {table_uri}")
self._parse_sym_lib_table(Path(table_uri))
else:
logger.warning(f" Could not resolve Table URI: {table_uri}")
continue
# Resolve environment variables in URI
resolved_uri = self._resolve_uri(uri)
@@ -142,23 +156,25 @@ class SymbolLibraryManager:
# Common KiCAD environment variables
env_vars = {
'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD9_3RD_PARTY': self._find_3rd_party_dir(),
'KICAD8_3RD_PARTY': self._find_3rd_party_dir(),
'KISYSSYM': self._find_kicad_symbol_dir(),
"KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
"KISYSSYM": self._find_kicad_symbol_dir(),
}
# Project directory
if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path)
env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables
for var, value in env_vars.items():
if value:
resolved = resolved.replace(f'${{{var}}}', value)
resolved = resolved.replace(f'${var}', value)
resolved = resolved.replace(f"${{{var}}}", value)
resolved = resolved.replace(f"${var}", value)
# Expand ~ to home directory
resolved = os.path.expanduser(resolved)
@@ -184,10 +200,10 @@ class SymbolLibraryManager:
]
# Check environment variable
if 'KICAD9_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR'])
if 'KICAD8_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR'])
if "KICAD9_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
if "KICAD8_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
for path in possible_paths:
if os.path.isdir(path):
@@ -198,15 +214,18 @@ class SymbolLibraryManager:
def _find_3rd_party_dir(self) -> Optional[str]:
"""Find KiCAD 3rd party library directory (PCM installed libs)"""
possible_paths = [
str(Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty"),
str(Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty"),
str(Path.home() / "Documents" / "KiCad" / "8.0" / "3rdparty"),
]
# Check environment variable
if 'KICAD9_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY'])
if 'KICAD8_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY'])
if "KICAD10_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
if "KICAD9_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
if "KICAD8_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
for path in possible_paths:
if os.path.isdir(path):
@@ -228,7 +247,7 @@ class SymbolLibraryManager:
symbols = []
try:
with open(library_path, 'r', encoding='utf-8') as f:
with open(library_path, "r", encoding="utf-8") as f:
content = f.read()
# Find all top-level symbol definitions
@@ -243,7 +262,7 @@ class SymbolLibraryManager:
symbol_name = match.group(1)
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
if re.search(r'_\d+_\d+$', symbol_name):
if re.search(r"_\d+_\d+$", symbol_name):
continue
# Find the start position of this symbol
@@ -262,17 +281,17 @@ class SymbolLibraryManager:
name=symbol_name,
library=library_name,
full_ref=f"{library_name}:{symbol_name}",
value=properties.get('Value', ''),
description=properties.get('Description', ''),
footprint=properties.get('Footprint', ''),
lcsc_id=properties.get('LCSC', ''),
manufacturer=properties.get('Manufacturer', ''),
mpn=properties.get('Part', properties.get('MPN', '')),
category=properties.get('Category', ''),
datasheet=properties.get('Datasheet', ''),
stock=properties.get('Stock', ''),
price=properties.get('Price', ''),
lib_class=properties.get('Class', ''),
value=properties.get("Value", ""),
description=properties.get("Description", ""),
footprint=properties.get("Footprint", ""),
lcsc_id=properties.get("LCSC", ""),
manufacturer=properties.get("Manufacturer", ""),
mpn=properties.get("Part", properties.get("MPN", "")),
category=properties.get("Category", ""),
datasheet=properties.get("Datasheet", ""),
stock=properties.get("Stock", ""),
price=properties.get("Price", ""),
lib_class=properties.get("Class", ""),
)
symbols.append(symbol_info)
@@ -333,7 +352,9 @@ class SymbolLibraryManager:
return symbols
def search_symbols(self, query: str, limit: int = 20, library_filter: Optional[str] = None) -> List[SymbolInfo]:
def search_symbols(
self, query: str, limit: int = 20, library_filter: Optional[str] = None
) -> List[SymbolInfo]:
"""
Search for symbols matching a query
@@ -349,10 +370,12 @@ class SymbolLibraryManager:
query_lower = query.lower()
# Determine which libraries to search
libraries_to_search = self.libraries.keys()
libraries_to_search: list[str] = list(self.libraries.keys())
if library_filter:
filter_lower = library_filter.lower()
libraries_to_search = [lib for lib in libraries_to_search if filter_lower in lib.lower()]
libraries_to_search = [
lib for lib in libraries_to_search if filter_lower in lib.lower()
]
for library_nickname in libraries_to_search:
symbols = self.list_symbols(library_nickname)
@@ -477,17 +500,13 @@ class SymbolLibraryCommands:
"""List all available symbol libraries"""
try:
libraries = self.library_manager.list_libraries()
return {
"success": True,
"libraries": libraries,
"count": len(libraries)
}
return {"success": True, "libraries": libraries, "count": len(libraries)}
except Exception as e:
logger.error(f"Error listing symbol libraries: {e}")
return {
"success": False,
"message": "Failed to list symbol libraries",
"errorDetails": str(e)
"errorDetails": str(e),
}
def search_symbols(self, params: Dict) -> Dict:
@@ -495,10 +514,7 @@ class SymbolLibraryCommands:
try:
query = params.get("query", "")
if not query:
return {
"success": False,
"message": "Missing query parameter"
}
return {"success": False, "message": "Missing query parameter"}
limit = params.get("limit", 20)
library_filter = params.get("library")
@@ -509,25 +525,18 @@ class SymbolLibraryCommands:
"success": True,
"symbols": [asdict(s) for s in results],
"count": len(results),
"query": query
"query": query,
}
except Exception as e:
logger.error(f"Error searching symbols: {e}")
return {
"success": False,
"message": "Failed to search symbols",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
def list_library_symbols(self, params: Dict) -> Dict:
"""List all symbols in a specific library"""
try:
library = params.get("library")
if not library:
return {
"success": False,
"message": "Missing library parameter"
}
return {"success": False, "message": "Missing library parameter"}
# Check if library exists in sym-lib-table
if library not in self.library_manager.libraries:
@@ -536,10 +545,10 @@ class SymbolLibraryCommands:
"success": False,
"message": f"Library '{library}' not found in sym-lib-table",
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
"available_libraries_count": len(available_libs),
"suggestion": "Use 'list_symbol_libraries' to see all available libraries"
"suggestion": "Use 'list_symbol_libraries' to see all available libraries",
}
symbols = self.library_manager.list_symbols(library)
@@ -548,14 +557,14 @@ class SymbolLibraryCommands:
"success": True,
"library": library,
"symbols": [asdict(s) for s in symbols],
"count": len(symbols)
"count": len(symbols),
}
except Exception as e:
logger.error(f"Error listing library symbols: {e}")
return {
"success": False,
"message": "Failed to list library symbols",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_symbol_info(self, params: Dict) -> Dict:
@@ -563,34 +572,25 @@ class SymbolLibraryCommands:
try:
symbol_spec = params.get("symbol")
if not symbol_spec:
return {
"success": False,
"message": "Missing symbol parameter"
}
return {"success": False, "message": "Missing symbol parameter"}
result = self.library_manager.find_symbol(symbol_spec)
if result:
return {
"success": True,
"symbol_info": asdict(result)
}
return {"success": True, "symbol_info": asdict(result)}
else:
return {
"success": False,
"message": f"Symbol not found: {symbol_spec}"
}
return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
except Exception as e:
logger.error(f"Error getting symbol info: {e}")
return {
"success": False,
"message": "Failed to get symbol info",
"errorDetails": str(e)
"errorDetails": str(e),
}
if __name__ == '__main__':
if __name__ == "__main__":
# Test the symbol library manager
import json

View File

@@ -9,7 +9,8 @@ import logging
import math
import tempfile
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
from skip import Schematic
@@ -20,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
class PinLocator:
"""Locate pins on symbol instances in KiCad schematics"""
def __init__(self):
def __init__(self) -> None:
"""Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@@ -40,9 +41,9 @@ class PinLocator:
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}
}
"""
pins = {}
pins: Dict[str, Dict[str, Any]] = {}
def extract_pins_recursive(sexp):
def extract_pins_recursive(sexp: Any) -> None:
"""Recursively search for pin definitions"""
if not isinstance(sexp, list):
return
@@ -117,11 +118,7 @@ class PinLocator:
# Find lib_symbols section
lib_symbols = None
for item in sch_data:
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols = item
break
@@ -131,11 +128,7 @@ class PinLocator:
# Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if (
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
@@ -284,9 +277,7 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return None
@@ -309,7 +300,9 @@ class PinLocator:
None,
)
if matched_num:
logger.debug(f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}")
logger.debug(
f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}"
)
pin_number = matched_num
else:
logger.error(
@@ -324,26 +317,18 @@ class PinLocator:
pin_rel_x = pin_data["x"]
pin_rel_y = pin_data["y"]
logger.debug(
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
# Apply symbol rotation to pin position
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(
pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
# Calculate absolute position
abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y
logger.info(
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
return [abs_x, abs_y]
except Exception as e:
@@ -385,9 +370,7 @@ class PinLocator:
return {}
# Get lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return {}
@@ -400,9 +383,7 @@ class PinLocator:
# Calculate location for each pin
result = {}
for pin_num in pins.keys():
location = self.get_pin_location(
schematic_path, symbol_reference, pin_num
)
location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
if location:
result[pin_num] = location
@@ -416,14 +397,14 @@ class PinLocator:
if __name__ == "__main__":
# Test pin location discovery
import shutil
import sys
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
from commands.component_schematic import ComponentManager
from commands.schematic import SchematicManager
import shutil
sys.path.insert(0, str(Path(__file__).parent.parent))
print("=" * 80)
print("PIN LOCATOR TEST")
@@ -431,8 +412,8 @@ if __name__ == "__main__":
# Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch"
template_path = (
Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch"
)
shutil.copy(template_path, test_path)

View File

@@ -2,11 +2,12 @@
Project-related command implementations for KiCAD interface
"""
import os
import pcbnew # type: ignore
import logging
import os
import shutil
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew # type: ignore
logger = logging.getLogger("kicad_interface")
@@ -22,9 +23,7 @@ class ProjectCommands:
"""Create a new KiCAD project"""
try:
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
project_name = params.get("name") or params.get(
"projectName", "New_Project"
)
project_name = params.get("name") or params.get("projectName", "New_Project")
path = params.get("path", os.getcwd())
template = params.get("template")
@@ -101,9 +100,7 @@ class ProjectCommands:
schematic_uuid = str(uuid_module.uuid4())
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -207,9 +204,7 @@ class ProjectCommands:
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(
os.path.basename(self.board.GetFileName())
)[0],
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}

View File

@@ -2,11 +2,12 @@
Routing-related command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
import math
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -114,7 +115,7 @@ class RoutingCommands:
"errorDetails": f"'{ref}' does not exist on the board",
}
def find_pad(ref: str, pad_num: str):
def find_pad(ref: str, pad_num: str) -> Any:
fp = footprints[ref]
for pad in fp.Pads():
if pad.GetNumber() == pad_num:
@@ -149,9 +150,9 @@ class RoutingCommands:
# KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects
# the actual placed layer after Flip().
fp_start = footprints[from_ref]
fp_end = footprints[to_ref]
fp_end = footprints[to_ref]
start_layer = self.board.GetLayerName(fp_start.GetLayer())
end_layer = self.board.GetLayerName(fp_end.GetLayer())
end_layer = self.board.GetLayerName(fp_end.GetLayer())
copper_layers = {"F.Cu", "B.Cu"}
needs_via = (
start_layer in copper_layers
@@ -168,24 +169,34 @@ class RoutingCommands:
via_y = (start_pos.y + end_pos.y) / 2 / scale
# Trace on start layer: start_pad → via
r1 = self.route_trace({
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": via_x, "y": via_y, "unit": "mm"},
"layer": start_layer, "width": width, "net": net,
})
r1 = self.route_trace(
{
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": via_x, "y": via_y, "unit": "mm"},
"layer": start_layer,
"width": width,
"net": net,
}
)
# Via connecting both layers
self.add_via({
"position": {"x": via_x, "y": via_y, "unit": "mm"},
"net": net,
"from_layer": start_layer,
"to_layer": end_layer,
})
self.add_via(
{
"position": {"x": via_x, "y": via_y, "unit": "mm"},
"net": net,
"from_layer": start_layer,
"to_layer": end_layer,
}
)
# Trace on end layer: via → end_pad
r2 = self.route_trace({
"start": {"x": via_x, "y": via_y, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": end_layer, "width": width, "net": net,
})
r2 = self.route_trace(
{
"start": {"x": via_x, "y": via_y, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": end_layer,
"width": width,
"net": net,
}
)
success = r1.get("success") and r2.get("success")
result = {
"success": success,
@@ -195,21 +206,28 @@ class RoutingCommands:
}
else:
# Same layer — direct trace
result = self.route_trace({
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": layer if layer else start_layer,
"width": width, "net": net,
})
result = self.route_trace(
{
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": layer if layer else start_layer,
"width": width,
"net": net,
}
)
if result.get("success"):
result["fromPad"] = {
"ref": from_ref, "pad": from_pad,
"x": start_pos.x / scale, "y": start_pos.y / scale,
"ref": from_ref,
"pad": from_pad,
"x": start_pos.x / scale,
"y": start_pos.y / scale,
}
result["toPad"] = {
"ref": to_ref, "pad": to_pad,
"x": end_pos.x / scale, "y": end_pos.y / scale,
"ref": to_ref,
"pad": to_pad,
"x": end_pos.x / scale,
"y": end_pos.y / scale,
}
return result
@@ -352,21 +370,15 @@ class RoutingCommands:
via = pcbnew.PCB_VIA(self.board)
# Set position
scale = (
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Set size and drill (default to board's current via settings)
design_settings = self.board.GetDesignSettings()
via.SetWidth(
int(size * 1000000) if size else design_settings.GetCurrentViaSize()
)
via.SetDrill(
int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()
)
via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
# Set layers
from_id = self.board.GetLayerID(from_layer)
@@ -500,9 +512,7 @@ class RoutingCommands:
# Find track by position
if position:
scale = (
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
point = pcbnew.VECTOR2I(x_nm, y_nm)
@@ -940,9 +950,7 @@ class RoutingCommands:
else:
traces_to_copy.append(track)
filter_method = (
"net-based" if use_net_filter else "geometric (pads have no nets)"
)
filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
logger.info(
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
f"{len(vias_to_copy)} vias selected via {filter_method}"
@@ -958,9 +966,7 @@ class RoutingCommands:
# Create new track
new_track = pcbnew.PCB_TRACK(self.board)
new_track.SetStart(
pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)
)
new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y))
new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y))
new_track.SetLayer(track.GetLayer())
@@ -1320,15 +1326,11 @@ class RoutingCommands:
pos_start = pcbnew.VECTOR2I(
int(start_point.x + offset_x), int(start_point.y + offset_y)
)
pos_end = pcbnew.VECTOR2I(
int(end_point.x + offset_x), int(end_point.y + offset_y)
)
pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
neg_start = pcbnew.VECTOR2I(
int(start_point.x - offset_x), int(start_point.y - offset_y)
)
neg_end = pcbnew.VECTOR2I(
int(end_point.x - offset_x), int(end_point.y - offset_y)
)
neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
# Create positive trace
pos_track = pcbnew.PCB_TRACK(self.board)
@@ -1395,9 +1397,7 @@ class RoutingCommands:
return pad.GetPosition()
raise ValueError("Invalid point specification")
def _point_to_track_distance(
self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK
) -> float:
def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
"""Calculate distance from point to track segment"""
start = track.GetStart()
end = track.GetEnd()

View File

@@ -1,8 +1,10 @@
from skip import Schematic
import logging
import os
import shutil
import logging
import uuid
from typing import Any, Optional
from skip import Schematic
logger = logging.getLogger("kicad_interface")
@@ -11,7 +13,7 @@ class SchematicManager:
"""Core schematic operations using kicad-skip"""
@staticmethod
def create_schematic(name, metadata=None):
def create_schematic(name: str, metadata: Optional[Any] = None) -> Any:
"""Create a new empty schematic from template"""
try:
# Determine template path (use template_with_symbols for component cloning support)
@@ -31,31 +33,28 @@ class SchematicManager:
# Regenerate UUID to ensure uniqueness for each created schematic
import re
with open(output_path, 'r', encoding='utf-8') as f:
with open(output_path, "r", encoding="utf-8") as f:
content = f.read()
new_uuid = str(uuid.uuid4())
content = re.sub(
r'\(uuid [0-9a-fA-F-]+\)',
f'(uuid {new_uuid})',
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
content,
count=1 # Only replace first (schematic) UUID
count=1, # Only replace first (schematic) UUID
)
with open(output_path, 'w', encoding='utf-8', newline='\n') as f:
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {output_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_path}, creating minimal schematic"
)
logger.warning(f"Template not found at {template_path}, creating minimal schematic")
# Generate unique UUID for this schematic
schematic_uuid = str(uuid.uuid4())
# Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -72,7 +71,7 @@ class SchematicManager:
raise
@staticmethod
def load_schematic(file_path):
def load_schematic(file_path: str) -> Optional[Any]:
"""Load an existing schematic"""
if not os.path.exists(file_path):
logger.error(f"Schematic file not found at {file_path}")
@@ -86,7 +85,7 @@ class SchematicManager:
return None
@staticmethod
def save_schematic(schematic, file_path):
def save_schematic(schematic: Any, file_path: str) -> bool:
"""Save a schematic to file"""
try:
# kicad-skip uses write method, not save
@@ -98,7 +97,7 @@ class SchematicManager:
return False
@staticmethod
def get_schematic_metadata(schematic):
def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
"""Extract metadata from schematic"""
# kicad-skip doesn't expose a direct metadata object on Schematic.
# We can return basic info like version and generator.

View File

@@ -0,0 +1,976 @@
"""
Schematic Analysis Tools for KiCad Schematics
Read-only analysis tools for detecting spatial problems, querying regions,
and checking connectivity in KiCad schematic files.
"""
import logging
import math
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import sexpdata
from commands.pin_locator import PinLocator
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
from sexpdata import Symbol
from skip import Schematic
logger = logging.getLogger("kicad_interface")
# ---------------------------------------------------------------------------
# S-expression parsing helpers
# ---------------------------------------------------------------------------
def _load_sexp(schematic_path: Path) -> list:
"""Load schematic file and return parsed S-expression data."""
with open(schematic_path, "r", encoding="utf-8") as f:
return sexpdata.loads(f.read())
def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all wire segments from the schematic S-expression.
Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)}
"""
wires = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("wire"):
continue
pts = None
for sub in item:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
pts = sub
break
if not pts:
continue
coords = []
for sub in pts:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"):
coords.append((float(sub[1]), float(sub[2])))
if len(coords) >= 2:
wires.append({"start": coords[0], "end": coords[1]})
return wires
def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all labels (label and global_label) from the schematic S-expression.
Returns list of dicts: {name, type ('label'|'global_label'), x, y}
"""
labels = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
tag = item[0]
if tag not in (Symbol("label"), Symbol("global_label")):
continue
name = str(item[1]).strip('"')
label_type = str(tag)
x, y = 0.0, 0.0
for sub in item:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"):
x = float(sub[1])
y = float(sub[2])
break
labels.append({"name": name, "type": label_type, "x": x, "y": y})
return labels
def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all placed symbol instances from the schematic S-expression.
Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power}
"""
symbols = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("symbol"):
continue
lib_id = ""
x, y, rotation = 0.0, 0.0, 0.0
reference = ""
is_power = False
mirror_x = False
mirror_y = False
for sub in item:
if isinstance(sub, list) and len(sub) >= 2:
if sub[0] == Symbol("lib_id"):
lib_id = str(sub[1]).strip('"')
elif sub[0] == Symbol("at") and len(sub) >= 3:
x = float(sub[1])
y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif sub[0] == Symbol("mirror"):
m = str(sub[1])
if m == "x":
mirror_x = True
elif m == "y":
mirror_y = True
elif sub[0] == Symbol("property") and len(sub) >= 3:
prop_name = str(sub[1]).strip('"')
if prop_name == "Reference":
reference = str(sub[2]).strip('"')
is_power = reference.startswith("#PWR") or reference.startswith("#FLG")
symbols.append(
{
"reference": reference,
"lib_id": lib_id,
"x": x,
"y": y,
"rotation": rotation,
"mirror_x": mirror_x,
"mirror_y": mirror_y,
"is_power": is_power,
}
)
return symbols
def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
"""
Parse graphical body elements from a lib_symbol definition and return
local-coordinate bounding points.
Extracts points from rectangle, polyline, circle, arc, and bezier
elements found in sub-symbols (typically the ``_0_1`` layers that
contain body shapes).
Returns a list of ``(x, y)`` points in local symbol coordinates.
"""
points: List[Tuple[float, float]] = []
def _extract_graphics_recursive(sexp: list) -> None:
if not isinstance(sexp, list) or len(sexp) == 0:
return
tag = sexp[0]
if tag == Symbol("rectangle"):
# (rectangle (start x y) (end x y) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3:
if sub[0] in (Symbol("start"), Symbol("end")):
points.append((float(sub[1]), float(sub[2])))
elif tag == Symbol("polyline"):
# (polyline (pts (xy x y) (xy x y) ...) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
cx, cy = float(sub[1]), float(sub[2])
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
r = float(sub[1])
if r > 0:
points.extend(
[
(cx - r, cy - r),
(cx + r, cy + r),
]
)
elif tag == Symbol("arc"):
# (arc (start x y) (mid x y) (end x y) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3:
if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")):
points.append((float(sub[1]), float(sub[2])))
elif tag == Symbol("bezier"):
# (bezier (pts (xy x y) ...) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
else:
# Recurse into sub-symbols to find graphics in nested definitions
for sub in sexp[1:]:
if isinstance(sub, list):
_extract_graphics_recursive(sub)
# Search the top-level symbol definition and its sub-symbols
for item in symbol_def[1:]:
if isinstance(item, list):
_extract_graphics_recursive(item)
return points
def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
"""
Walk the lib_symbols section of already-parsed sexp_data and return
pin definitions and graphics points for every symbol definition.
Returns:
Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}.
"""
lib_symbols_section = None
for item in sexp_data:
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols_section = item
break
if not lib_symbols_section:
return {}
result: Dict[str, Dict] = {}
for item in lib_symbols_section[1:]:
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
result[symbol_name] = {
"pins": PinLocator.parse_symbol_definition(item),
"graphics_points": _parse_lib_symbol_graphics(item),
}
return result
# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------
def compute_symbol_bbox(
schematic_path: Path,
reference: str,
locator: PinLocator,
) -> Optional[Tuple[float, float, float, float]]:
"""
Compute bounding box of a symbol from its pin positions.
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found.
"""
pins = locator.get_all_symbol_pins(schematic_path, reference)
if not pins:
return None
xs = [p[0] for p in pins.values()]
ys = [p[1] for p in pins.values()]
return (min(xs), min(ys), max(xs), max(ys))
def _line_segment_intersects_aabb(
x1: float,
y1: float,
x2: float,
y2: float,
box_min_x: float,
box_min_y: float,
box_max_x: float,
box_max_y: float,
) -> bool:
"""
Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box.
Uses the Liang-Barsky clipping algorithm.
"""
dx = x2 - x1
dy = y2 - y1
p = [-dx, dx, -dy, dy]
q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1]
t_min = 0.0
t_max = 1.0
for i in range(4):
if abs(p[i]) < 1e-12:
# Parallel to this edge
if q[i] < 0:
return False
else:
t = q[i] / p[i]
if p[i] < 0:
t_min = max(t_min, t)
else:
t_max = min(t_max, t)
if t_min > t_max:
return False
return True
def _point_in_rect(
px: float,
py: float,
min_x: float,
min_y: float,
max_x: float,
max_y: float,
) -> bool:
"""Check if a point is within a rectangle."""
return min_x <= px <= max_x and min_y <= py <= max_y
def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
"""Euclidean distance between two points."""
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
def _aabb_overlap(
a: Tuple[float, float, float, float],
b: Tuple[float, float, float, float],
) -> bool:
"""Check if two axis-aligned bounding boxes overlap.
Each bbox is (min_x, min_y, max_x, max_y).
"""
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
def _transform_local_point(
lx: float,
ly: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Transform a point from local symbol coordinates to absolute schematic
coordinates using KiCad's transform order:
negate-y (lib y-up → schematic y-down) → mirror → rotate → translate.
"""
# Library symbols use y-up; schematic uses y-down
ly = -ly
# Apply mirroring in local coords
if mirror_x:
ly = -ly
if mirror_y:
lx = -lx
# Apply rotation
if rotation != 0:
lx, ly = PinLocator.rotate_point(lx, ly, rotation)
return (sym_x + lx, sym_y + ly)
def _compute_symbol_bbox_direct(
sym: Dict[str, Any],
pin_defs: Dict[str, Dict],
margin: float = 0.0,
graphics_points: Optional[List[Tuple[float, float]]] = None,
) -> Optional[Tuple[float, float, float, float]]:
"""
Compute bounding box of a symbol from its graphics and pin definitions.
When graphics_points are available (from lib_symbol body shapes), uses
those for the bbox and unions with pin positions. Falls back to
pin-only estimation with degenerate expansion when no graphics data
is available.
Args:
sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y.
pin_defs: Pin definitions from PinLocator.get_symbol_pins().
margin: Shrink bbox by this amount on each side (mm).
graphics_points: Local-coordinate points from symbol body graphics.
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins.
"""
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if not pin_positions:
return None
if graphics_points:
# Transform graphics points to absolute coordinates
sym_x, sym_y = sym["x"], sym["y"]
rotation = sym["rotation"]
mirror_x = sym.get("mirror_x", False)
mirror_y = sym.get("mirror_y", False)
abs_points = [
_transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y)
for lx, ly in graphics_points
]
# Union with pin positions so pins extending beyond body are included
all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()]
all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()]
min_x, min_y = min(all_xs), min(all_ys)
max_x, max_y = max(all_xs), max(all_ys)
else:
# Fallback: pin-only estimation with degenerate expansion
xs = [p[0] for p in pin_positions.values()]
ys = [p[1] for p in pin_positions.values()]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
min_body = 1.5 # mm minimum half-extent for component body
if max_x - min_x < 2 * min_body:
cx = (min_x + max_x) / 2
min_x = cx - min_body
max_x = cx + min_body
if max_y - min_y < 2 * min_body:
cy = (min_y + max_y) / 2
min_y = cy - min_body
max_y = cy + min_body
# Shrink bbox by margin
min_x += margin
min_y += margin
max_x -= margin
max_y -= margin
# Skip degenerate bboxes
if max_x <= min_x or max_y <= min_y:
return None
return (min_x, min_y, max_x, max_y)
# ---------------------------------------------------------------------------
# Tool 3: find_overlapping_elements
# ---------------------------------------------------------------------------
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
"""
Detect spatially overlapping symbols, wires, and labels.
Args:
schematic_path: Path to .kicad_sch file
tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection.
Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps}
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
overlapping_symbols = []
overlapping_labels = []
overlapping_wires = []
lib_defs = _extract_lib_symbols(sexp_data)
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
non_template_symbols = [
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
]
# Pre-compute bounding boxes for all non-template symbols
symbol_bboxes = []
for sym in non_template_symbols:
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
graphics_points = lib_data.get("graphics_points", [])
bbox = None
if pin_defs:
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)):
s1, bbox1 = symbol_bboxes[i]
for j in range(i + 1, len(symbol_bboxes)):
s2, bbox2 = symbol_bboxes[j]
dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"]))
overlap_detected = False
if bbox1 is not None and bbox2 is not None:
# Use bounding box intersection
overlap_detected = _aabb_overlap(bbox1, bbox2)
else:
# Fallback to center distance when pin data is unavailable
overlap_detected = dist < tolerance
if overlap_detected:
entry = {
"element1": {
"reference": s1["reference"],
"libId": s1["lib_id"],
"position": {"x": s1["x"], "y": s1["y"]},
},
"element2": {
"reference": s2["reference"],
"libId": s2["lib_id"],
"position": {"x": s2["x"], "y": s2["y"]},
},
"distance": round(dist, 4),
}
# Flag power symbol pairs specifically
if s1["is_power"] and s2["is_power"]:
entry["type"] = "power_symbol_overlap"
else:
entry["type"] = "symbol_overlap"
overlapping_symbols.append(entry)
# --- Label-label overlap ---
for i in range(len(labels)):
for j in range(i + 1, len(labels)):
l1 = labels[i]
l2 = labels[j]
dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"]))
if dist < tolerance:
overlapping_labels.append(
{
"element1": {
"name": l1["name"],
"type": l1["type"],
"position": {"x": l1["x"], "y": l1["y"]},
},
"element2": {
"name": l2["name"],
"type": l2["type"],
"position": {"x": l2["x"], "y": l2["y"]},
},
"distance": round(dist, 4),
}
)
# --- Wire-wire collinear overlap ---
for i in range(len(wires)):
for j in range(i + 1, len(wires)):
w1 = wires[i]
w2 = wires[j]
overlap = _check_wire_overlap(w1, w2, tolerance)
if overlap:
overlapping_wires.append(overlap)
total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires)
return {
"overlappingSymbols": overlapping_symbols,
"overlappingLabels": overlapping_labels,
"overlappingWires": overlapping_wires,
"totalOverlaps": total,
}
def _check_wire_overlap(
w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float
) -> Optional[Dict[str, Any]]:
"""
Check if two wire segments are collinear and overlapping.
Works for horizontal, vertical, and diagonal wires. Uses direction
vectors, cross-product parallelism, point-to-line distance for
collinearity, and 1D projection overlap.
Returns overlap info dict or None.
"""
s1, e1 = w1["start"], w1["end"]
s2, e2 = w2["start"], w2["end"]
d1 = (e1[0] - s1[0], e1[1] - s1[1])
d2 = (e2[0] - s2[0], e2[1] - s2[1])
len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2)
len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2)
if len1 < 1e-12 or len2 < 1e-12:
return None # degenerate zero-length segment
# Cross product to check parallel
cross = d1[0] * d2[1] - d1[1] * d2[0]
if abs(cross) > tolerance * max(len1, len2):
return None # not parallel
# Point-to-line distance: s2 relative to line through s1 along d1
ds = (s2[0] - s1[0], s2[1] - s1[1])
perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1
if perp_dist > tolerance:
return None # parallel but offset
# Project onto d1 direction for 1D overlap check
u1 = (d1[0] / len1, d1[1] / len1)
proj_s1 = s1[0] * u1[0] + s1[1] * u1[1]
proj_e1 = e1[0] * u1[0] + e1[1] * u1[1]
proj_s2 = s2[0] * u1[0] + s2[1] * u1[1]
proj_e2 = e2[0] * u1[0] + e2[1] * u1[1]
min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1)
min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2)
if min1 < max2 and min2 < max1:
return {
"wire1": {
"start": {"x": s1[0], "y": s1[1]},
"end": {"x": e1[0], "y": e1[1]},
},
"wire2": {
"start": {"x": s2[0], "y": s2[1]},
"end": {"x": e2[0], "y": e2[1]},
},
"type": "collinear_overlap",
}
return None
# ---------------------------------------------------------------------------
# Tool 4: get_elements_in_region
# ---------------------------------------------------------------------------
def get_elements_in_region(
schematic_path: Path,
x1: float,
y1: float,
x2: float,
y2: float,
) -> Dict[str, Any]:
"""
List all wires, labels, and symbols within a rectangular region.
Args:
schematic_path: Path to .kicad_sch file
x1, y1, x2, y2: Bounding box corners in schematic mm
Returns dict: {symbols, wires, labels, counts}
"""
min_x, max_x = min(x1, x2), max(x1, x2)
min_y, max_y = min(y1, y2), max(y1, y2)
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
# Symbols: include if position is within bounds
region_symbols = []
for sym in symbols:
if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"):
continue
if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y):
entry = {
"reference": sym["reference"],
"libId": sym["lib_id"],
"position": {"x": sym["x"], "y": sym["y"]},
"isPower": sym["is_power"],
}
# Include pin positions (compute directly to handle unannotated duplicates)
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
if pin_defs:
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if pin_positions:
entry["pins"] = {
pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)}
for pn, pos in pin_positions.items()
}
region_symbols.append(entry)
# Wires: include if any part of the wire intersects the region
region_wires = []
for w in wires:
s, e = w["start"], w["end"]
if (
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
):
region_wires.append(
{
"start": {"x": s[0], "y": s[1]},
"end": {"x": e[0], "y": e[1]},
}
)
# Labels: include if position is within bounds
region_labels = []
for lbl in labels:
if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y):
region_labels.append(
{
"name": lbl["name"],
"type": lbl["type"],
"position": {"x": lbl["x"], "y": lbl["y"]},
}
)
return {
"symbols": region_symbols,
"wires": region_wires,
"labels": region_labels,
"counts": {
"symbols": len(region_symbols),
"wires": len(region_wires),
"labels": len(region_labels),
},
}
# ---------------------------------------------------------------------------
# Tool 5: check_wire_collisions
# ---------------------------------------------------------------------------
def _compute_pin_positions_direct(
sym: Dict[str, Any], pin_defs: Dict[str, Dict]
) -> Dict[str, List[float]]:
"""
Compute absolute schematic pin positions for a symbol instance directly from
its parsed position/rotation/mirror data and pin definitions in local coords.
Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name
lookup in the schematic, so it works correctly when multiple symbols share
the same reference designator (e.g. unannotated "Q?").
KiCad transform order: mirror (in local coords) → rotate → translate.
"""
sym_x = sym["x"]
sym_y = sym["y"]
rotation = sym["rotation"]
mirror_x = sym.get("mirror_x", False)
mirror_y = sym.get("mirror_y", False)
result: Dict[str, List[float]] = {}
for pin_num, pin_data in pin_defs.items():
rel_x = float(pin_data["x"])
rel_y = float(pin_data["y"])
# Apply mirroring in local symbol coordinates
if mirror_x:
rel_y = -rel_y
if mirror_y:
rel_x = -rel_x
# Apply symbol rotation
if rotation != 0:
rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation)
result[pin_num] = [sym_x + rel_x, sym_y + rel_y]
return result
def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
"""
Find all wires that cross over component symbol bodies.
Wires passing over symbols are unacceptable in schematics — they indicate
routing mistakes where a wire was drawn across a component instead of
around it.
For each non-power, non-template symbol:
1. Compute bounding box from pin positions (shrunk by margin).
2. For each wire segment, test intersection with the bbox.
3. If intersects and the wire is not simply terminating at a pin from
outside, report it as a crossing.
Returns list of crossing dicts.
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips)
pin_tolerance = 0.05 # mm
collisions = []
# Pre-compute per-symbol data
symbol_data: List[Dict[str, Any]] = []
for sym in symbols:
ref = sym["reference"]
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
continue
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
if not pin_defs:
continue
graphics_points = lib_data.get("graphics_points", [])
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, margin=margin, graphics_points=graphics_points
)
if bbox is None:
continue
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
pin_set = set()
for pos in pin_positions.values():
pin_set.add((pos[0], pos[1]))
symbol_data.append(
{
"sym": sym,
"bbox": bbox,
"pin_set": pin_set,
}
)
# Test each wire against each symbol bbox
for w in wires:
sx, sy = w["start"]
ex, ey = w["end"]
for sd in symbol_data:
bx1, by1, bx2, by2 = sd["bbox"]
if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2):
continue
# Check which endpoints land on a pin of this symbol
start_at_pin = any(
abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance
for px, py in sd["pin_set"]
)
end_at_pin = any(
abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance
for px, py in sd["pin_set"]
)
# When exactly one endpoint is at a pin, check whether the wire
# just terminates at the pin (valid connection) or continues through
# the component body (pass-through → collision).
# Nudge the pin endpoint slightly toward the other end; if the
# shortened segment still intersects the bbox, the wire extends
# into/through the body.
if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin):
dx, dy = ex - sx, ey - sy
length = math.sqrt(dx * dx + dy * dy)
if length > 0:
nudge = min(0.2, length * 0.5)
ux, uy = dx / length, dy / length
if start_at_pin:
nsx, nsy = sx + ux * nudge, sy + uy * nudge
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
else:
nex, ney = ex - ux * nudge, ey - uy * nudge
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
sym = sd["sym"]
collisions.append(
{
"wire": {
"start": {"x": sx, "y": sy},
"end": {"x": ex, "y": ey},
},
"component": {
"reference": sym["reference"],
"libId": sym["lib_id"],
"position": {"x": sym["x"], "y": sym["y"]},
},
"intersectionType": "passes_through",
}
)
return collisions
def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]:
"""
Find wire segments with at least one dangling endpoint.
A wire endpoint is dangling when the IU point at that endpoint satisfies
all three conditions simultaneously:
1. No other wire shares that IU endpoint (would imply a junction / T-join)
2. No component pin is at that IU point
3. No net label or power symbol pin is at that IU point
Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as
wire_connectivity.py — to avoid floating-point tolerance issues.
Returns:
{
"orphaned_wires": [
{
"start": {"x": float, "y": float},
"end": {"x": float, "y": float},
"dangling_ends": [{"x": float, "y": float}, ...]
},
...
],
"count": int
}
"""
sexp_data = _load_sexp(schematic_path)
# --- wire endpoints in mm and IU ---
wires_mm = _parse_wires(sexp_data)
wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [
(_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm
]
# Count how many wires touch each IU endpoint
iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int)
for s_iu, e_iu in wires_iu:
iu_to_count[s_iu] += 1
iu_to_count[e_iu] += 1
# --- anchors: component pins ---
pin_iu: Set[Tuple[int, int]] = set()
try:
locator = PinLocator()
sch = Schematic(str(schematic_path))
for symbol in sch.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(schematic_path, ref)
for coords in all_pins.values():
pin_iu.add(_to_iu(float(coords[0]), float(coords[1])))
except Exception as e:
logger.warning(f"Error reading pins for symbol: {e}")
except Exception as e:
logger.warning(f"Could not load schematic via skip for pin extraction: {e}")
sch = None
# --- anchors: net labels and global_labels ---
labels = _parse_labels(sexp_data)
label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels}
# --- anchors: power symbol pins (VCC, GND …) ---
power_iu: Set[Tuple[int, int]] = set()
if sch is not None:
try:
point_to_label, _ = _parse_virtual_connections(sch, schematic_path)
power_iu = set(point_to_label.keys())
except Exception as e:
logger.warning(f"Could not extract power symbol anchors: {e}")
anchored_iu = pin_iu | label_iu | power_iu
# --- classify each wire ---
orphaned: List[Dict[str, Any]] = []
for i, (s_iu, e_iu) in enumerate(wires_iu):
w = wires_mm[i]
dangling_ends: List[Dict[str, float]] = []
for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]:
if iu_to_count[pt_iu] > 1:
continue # shared with another wire → connected
if pt_iu in anchored_iu:
continue # touches a pin or label → connected
dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]})
if dangling_ends:
orphaned.append(
{
"start": {"x": w["start"][0], "y": w["start"][1]},
"end": {"x": w["end"][0], "y": w["end"][1]},
"dangling_ends": dangling_ends,
}
)
return {"orphaned_wires": orphaned, "count": len(orphaned)}

View File

@@ -0,0 +1,211 @@
"""
Snap-to-grid tool for KiCAD schematics.
Snaps wire endpoints, junction positions, net labels, and optionally component
positions to the nearest grid point. Modifies the schematic file in place.
The standard KiCAD schematic grid is 50 mil (1.27 mm). Component pins are
placed at multiples of 1.27 mm relative to the symbol origin, so absolute pin
coordinates end up as odd multiples of 1.27 mm (e.g. 26.67 mm = 21 × 1.27 mm).
These are valid on-grid positions that must not be moved.
The coarser 2.54 mm (100-mil) grid is a common mistake: exactly half of all
valid 1.27 mm positions are not multiples of 2.54 mm and would be displaced by
1.27 mm — moving labels or wire endpoints off their pins and breaking
connectivity.
Off-grid coordinates cause wires that appear visually connected to fail ERC
connectivity checks because KiCAD uses exact integer (IU) matching internally.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
_DEFAULT_GRID_MM: float = 1.27
# Element type names exposed in the public API
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})
# Tags treated as net labels (all have (at x y angle) structure)
_LABEL_TAGS = frozenset(
{
Symbol("label"),
Symbol("global_label"),
Symbol("hierarchical_label"),
Symbol("net_tie"),
Symbol("no_connect"),
}
)
def _snap_mm(value: float, grid_mm: float) -> float:
"""Snap a single coordinate to the nearest grid multiple."""
return round(value / grid_mm) * grid_mm
def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
"""Return True if *value* is already within *eps* of a grid point."""
snapped = _snap_mm(value, grid_mm)
return abs(value - snapped) < eps
def _snap_xy_pair(item: list, grid_mm: float) -> int:
"""
Snap a ``(xy x y)`` S-expression item in place.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def _snap_at_xy(item: list, grid_mm: float) -> int:
"""
Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
Preserves rotation / angle at index 3+ unchanged.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def snap_to_grid(
schematic_path: Path,
grid_size: float = _DEFAULT_GRID_MM,
elements: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.
Modifies the file in place and returns statistics.
Args:
schematic_path: Path to the ``.kicad_sch`` file.
grid_size: Grid spacing in mm (default 1.27 mm = 50 mil).
Do NOT use 2.54 mm — half of all valid KiCAD pin
positions fall between 2.54 mm grid lines and would
be displaced 1.27 mm, breaking connectivity.
elements: List of element types to snap. Valid values:
``"wires"``, ``"junctions"``, ``"labels"``,
``"components"``. Defaults to
``["wires", "junctions", "labels"]`` when ``None``.
Returns:
``{"snapped": int, "already_on_grid": int, "grid_size": float}``
where *snapped* is the number of elements that had at least one
coordinate moved.
"""
if grid_size <= 0:
raise ValueError(f"grid_size must be positive, got {grid_size}")
if elements is None:
active: frozenset = frozenset({"wires", "junctions", "labels"})
else:
unknown = set(elements) - _VALID_ELEMENTS
if unknown:
raise ValueError(
f"Unknown element type(s): {sorted(unknown)}. "
f"Valid types: {sorted(_VALID_ELEMENTS)}"
)
active = frozenset(elements)
with open(schematic_path, "r", encoding="utf-8") as fh:
sch_data = sexpdata.loads(fh.read())
snapped = 0
already_on_grid = 0
snap_wires = "wires" in active
snap_junctions = "junctions" in active
snap_labels = "labels" in active
snap_components = "components" in active
for item in sch_data:
if not isinstance(item, list) or not item:
continue
tag = item[0]
# -----------------------------------------------------------------
# Wires: (wire (pts (xy x y) (xy x y)) ...)
# -----------------------------------------------------------------
if snap_wires and tag == Symbol("wire"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
for pt in sub[1:]:
changed += _snap_xy_pair(pt, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Junctions: (junction (at x y) ...)
# -----------------------------------------------------------------
if snap_junctions and tag == Symbol("junction"):
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
# -----------------------------------------------------------------
if snap_labels and tag in _LABEL_TAGS:
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Components: (symbol (lib_id …) (at x y rotation) …)
# Snap only the top-level (at …) — not property sub-positions.
# -----------------------------------------------------------------
if snap_components and tag == Symbol("symbol"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
changed += _snap_at_xy(sub, grid_size)
break # only the first (at …) belongs to the symbol itself
if changed:
snapped += 1
else:
already_on_grid += 1
continue
with open(schematic_path, "w", encoding="utf-8") as fh:
fh.write(sexpdata.dumps(sch_data))
return {
"snapped": snapped,
"already_on_grid": already_on_grid,
"grid_size": grid_size,
}

View File

@@ -15,13 +15,13 @@ Supported SVG elements:
SVG coordinate system: Y increases downward (same as KiCAD mm), so no Y-flip needed.
"""
import re
import math
import uuid
import os
import logging
from typing import List, Tuple, Dict, Any, Optional
import math
import os
import re
import uuid
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -34,9 +34,7 @@ Polygon = List[Point]
# ---------------------------------------------------------------------------
# SVG path tokenizer
# ---------------------------------------------------------------------------
_TOKEN_RE = re.compile(
r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)"
)
_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
def _tokenize_path(d: str) -> List[str]:
@@ -57,9 +55,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
"""
polygons: List[Polygon] = []
current: Polygon = []
cx, cy = 0.0, 0.0 # current point
sx, sy = 0.0, 0.0 # subpath start
last_ctrl = None # last bezier control point (for S/T commands)
cx, cy = 0.0, 0.0 # current point
sx, sy = 0.0, 0.0 # subpath start
last_ctrl = None # last bezier control point (for S/T commands)
last_cmd = ""
i = 0
@@ -73,13 +71,15 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
i += n
return vals
def cubic_bezier_points(p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16) -> List[Point]:
def cubic_bezier_points(
p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16
) -> List[Point]:
pts = []
for k in range(1, steps + 1):
t = k / steps
mt = 1 - t
x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0]
y = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1]
x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0]
y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1]
pts.append((x, y))
return pts
@@ -88,13 +88,23 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
for k in range(1, steps + 1):
t = k / steps
mt = 1 - t
x = mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0]
y = mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1]
x = mt**2 * p0[0] + 2 * mt * t * p1[0] + t**2 * p2[0]
y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1]
pts.append((x, y))
return pts
def arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float,
large_arc: int, sweep: int, x2: float, y2: float, steps: int = 20) -> List[Point]:
def arc_points(
x1: float,
y1: float,
rx: float,
ry: float,
phi_deg: float,
large_arc: int,
sweep: int,
x2: float,
y2: float,
steps: int = 20,
) -> List[Point]:
"""Approximate SVG arc as polygon points (endpoint parameterization → centre)."""
if rx == 0 or ry == 0:
return [(x2, y2)]
@@ -104,13 +114,13 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy
rx, ry = abs(rx), abs(ry)
lam = (x1p / rx)**2 + (y1p / ry)**2
lam = (x1p / rx) ** 2 + (y1p / ry) ** 2
if lam > 1:
lam = math.sqrt(lam)
rx *= lam
ry *= lam
num = max(0.0, (rx*ry)**2 - (rx*y1p)**2 - (ry*x1p)**2)
den = (rx*y1p)**2 + (ry*x1p)**2
num = max(0.0, (rx * ry) ** 2 - (rx * y1p) ** 2 - (ry * x1p) ** 2)
den = (rx * y1p) ** 2 + (ry * x1p) ** 2
sq = math.sqrt(num / den) if den != 0 else 0
if large_arc == sweep:
sq = -sq
@@ -119,9 +129,11 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
def angle(ux, uy, vx, vy):
a = math.acos(max(-1, min(1, (ux*vx + uy*vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))))
if ux*vy - uy*vx < 0:
def angle(ux: float, uy: float, vx: float, vy: float) -> float:
a = math.acos(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
)
if ux * vy - uy * vx < 0:
a = -a
return a
@@ -144,8 +156,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# --- main loop ---
while i < len(tokens):
tok = tokens[i]
if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \
re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok):
if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip(
"+-"
).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok):
# implicit repeat of last command
pass
else:
@@ -155,7 +168,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
rel = cmd.islower()
if cmd in ('M', 'm'):
if cmd in ("M", "m"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
@@ -166,9 +179,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
current = [(cx, cy)]
sx, sy = cx, cy
# subsequent coordinates are implicit L/l
cmd = 'l' if rel else 'L'
cmd = "l" if rel else "L"
elif cmd in ('L', 'l'):
elif cmd in ("L", "l"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
@@ -176,36 +189,46 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx, cy = x, y
current.append((cx, cy))
elif cmd in ('H', 'h'):
x = float(tokens[i]); i += 1
elif cmd in ("H", "h"):
x = float(tokens[i])
i += 1
cx = cx + x if rel else x
current.append((cx, cy))
elif cmd in ('V', 'v'):
y = float(tokens[i]); i += 1
elif cmd in ("V", "v"):
y = float(tokens[i])
i += 1
cy = cy + y if rel else y
current.append((cx, cy))
elif cmd in ('Z', 'z'):
elif cmd in ("Z", "z"):
current.append((sx, sy)) # close
polygons.append(current)
current = []
cx, cy = sx, sy
elif cmd in ('C', 'c'):
elif cmd in ("C", "c"):
x1, y1, x2, y2, x, y = consume(6)
if rel:
x1 += cx; y1 += cy; x2 += cx; y2 += cy; x += cx; y += cy
x1 += cx
y1 += cy
x2 += cx
y2 += cy
x += cx
y += cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('S', 's'):
elif cmd in ("S", "s"):
x2, y2, x, y = consume(4)
if rel:
x2 += cx; y2 += cy; x += cx; y += cy
if last_ctrl and last_cmd in ('C', 'c', 'S', 's'):
x2 += cx
y2 += cy
x += cx
y += cy
if last_ctrl and last_cmd in ("C", "c", "S", "s"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
@@ -215,20 +238,24 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('Q', 'q'):
elif cmd in ("Q", "q"):
x1, y1, x, y = consume(4)
if rel:
x1 += cx; y1 += cy; x += cx; y += cy
x1 += cx
y1 += cy
x += cx
y += cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('T', 't'):
elif cmd in ("T", "t"):
x, y = consume(2)
if rel:
x += cx; y += cy
if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'):
x += cx
y += cy
if last_ctrl and last_cmd in ("Q", "q", "T", "t"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
@@ -238,11 +265,12 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('A', 'a'):
elif cmd in ("A", "a"):
rx, ry, phi, large, sweep, x, y = consume(7)
large, sweep = int(large), int(sweep)
if rel:
x += cx; y += cy
x += cx
y += cy
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
current.extend(pts)
cx, cy = x, y
@@ -264,48 +292,45 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# ---------------------------------------------------------------------------
def _parse_transform(transform_str: str) -> List[List[float]]:
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
def identity():
def identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
def mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
result = identity()
for m in re.finditer(
r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)',
transform_str
r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
):
func = m.group(1)
args = [float(v) for v in re.split(r'[\s,]+', m.group(2).strip()) if v]
args = [float(v) for v in re.split(r"[\s,]+", m.group(2).strip()) if v]
mat = identity()
if func == 'matrix' and len(args) == 6:
if func == "matrix" and len(args) == 6:
a, b, c, d, e, f = args
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
elif func == 'translate':
elif func == "translate":
tx = args[0]
ty = args[1] if len(args) > 1 else 0
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
elif func == 'scale':
elif func == "scale":
sx = args[0]
sy = args[1] if len(args) > 1 else sx
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
elif func == 'rotate':
elif func == "rotate":
angle = math.radians(args[0])
cos, sin = math.cos(angle), math.sin(angle)
if len(args) == 3:
cx_, cy_ = args[1], args[2]
t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]]
r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]]
mat = mat_mul(mat_mul(t1, r), t2)
else:
mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
elif func == 'skewX':
elif func == "skewX":
mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]]
elif func == 'skewY':
elif func == "skewY":
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
result = mat_mul(result, mat)
return result
@@ -320,44 +345,41 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
return out
def _mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
def _mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
# ---------------------------------------------------------------------------
# SVG element → polygon extractor
# ---------------------------------------------------------------------------
SVG_NS = re.compile(r'\{[^}]+\}')
SVG_NS = re.compile(r"\{[^}]+\}")
def _tag(el: ET.Element) -> str:
return SVG_NS.sub('', el.tag)
return SVG_NS.sub("", el.tag)
def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]:
for key in el.attrib:
if SVG_NS.sub('', key) == name:
if SVG_NS.sub("", key) == name:
return el.attrib[key]
return default
def _identity():
def _identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
"""Recursively extract all polygons from an SVG element tree."""
tag = _tag(el)
display = _get_attr(el, 'display', 'inline')
visibility = _get_attr(el, 'visibility', 'visible')
if display == 'none' or visibility == 'hidden':
display = _get_attr(el, "display", "inline")
visibility = _get_attr(el, "visibility", "visible")
if display == "none" or visibility == "hidden":
return []
# Accumulate transform
transform_str = _get_attr(el, 'transform', '')
transform_str = _get_attr(el, "transform", "")
if transform_str:
local_mat = _parse_transform(transform_str)
mat = _mat_mul(parent_mat, local_mat)
@@ -366,65 +388,73 @@ def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]
result: List[Polygon] = []
if tag == 'g' or tag == 'svg':
if tag == "g" or tag == "svg":
for child in el:
result.extend(_extract_polygons_from_element(child, mat))
elif tag == 'path':
d = _get_attr(el, 'd', '')
elif tag == "path":
d = _get_attr(el, "d", "")
if d:
tokens = _tokenize_path(d)
polygons = _parse_path_tokens(tokens)
for poly in polygons:
result.append(_apply_transform(poly, mat))
elif tag == 'rect':
x = float(_get_attr(el, 'x', '0') or 0)
y = float(_get_attr(el, 'y', '0') or 0)
w = float(_get_attr(el, 'width', '0') or 0)
h = float(_get_attr(el, 'height', '0') or 0)
elif tag == "rect":
x = float(_get_attr(el, "x", "0") or 0)
y = float(_get_attr(el, "y", "0") or 0)
w = float(_get_attr(el, "width", "0") or 0)
h = float(_get_attr(el, "height", "0") or 0)
if w > 0 and h > 0:
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
result.append(_apply_transform(pts, mat))
elif tag == 'circle':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
r = float(_get_attr(el, 'r', '0') or 0)
elif tag == "circle":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
r = float(_get_attr(el, "r", "0") or 0)
if r > 0:
steps = 36
pts = [(cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
pts = [
(
cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat))
elif tag == 'ellipse':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
rx = float(_get_attr(el, 'rx', '0') or 0)
ry = float(_get_attr(el, 'ry', '0') or 0)
elif tag == "ellipse":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
rx = float(_get_attr(el, "rx", "0") or 0)
ry = float(_get_attr(el, "ry", "0") or 0)
if rx > 0 and ry > 0:
steps = 36
pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
pts = [
(
cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat))
elif tag in ('polygon', 'polyline'):
points_str = _get_attr(el, 'points', '')
elif tag in ("polygon", "polyline"):
points_str = _get_attr(el, "points", "")
if points_str:
nums = [float(v) for v in re.split(r'[\s,]+', points_str.strip()) if v]
nums = [float(v) for v in re.split(r"[\s,]+", points_str.strip()) if v]
pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
if tag == 'polygon' and pts:
if tag == "polygon" and pts:
pts.append(pts[0]) # close
if pts:
result.append(_apply_transform(pts, mat))
elif tag == 'line':
x1 = float(_get_attr(el, 'x1', '0') or 0)
y1 = float(_get_attr(el, 'y1', '0') or 0)
x2 = float(_get_attr(el, 'x2', '0') or 0)
y2 = float(_get_attr(el, 'y2', '0') or 0)
elif tag == "line":
x1 = float(_get_attr(el, "x1", "0") or 0)
y1 = float(_get_attr(el, "y1", "0") or 0)
x2 = float(_get_attr(el, "x2", "0") or 0)
y2 = float(_get_attr(el, "y2", "0") or 0)
pts = [(x1, y1), (x2, y2)]
result.append(_apply_transform(pts, mat))
@@ -453,20 +483,24 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
row = []
fill_str = "yes" if filled else "none"
uid = str(uuid.uuid4())
lines = [
"\t(gr_poly",
"\t\t(pts",
] + pts_lines + [
"\t\t)",
"\t\t(stroke",
f"\t\t\t(width {stroke_width:.4f})",
"\t\t\t(type solid)",
"\t\t)",
f"\t\t(fill {fill_str})",
f'\t\t(layer "{layer}")',
f'\t\t(uuid "{uid}")',
"\t)",
]
lines = (
[
"\t(gr_poly",
"\t\t(pts",
]
+ pts_lines
+ [
"\t\t)",
"\t\t(stroke",
f"\t\t\t(width {stroke_width:.4f})",
"\t\t\t(type solid)",
"\t\t)",
f"\t\t(fill {fill_str})",
f'\t\t(layer "{layer}")',
f'\t\t(uuid "{uid}")',
"\t)",
]
)
return "\n".join(lines)
@@ -510,15 +544,15 @@ def import_svg_to_pcb(
root = tree.getroot()
# Determine SVG viewport
vb = _get_attr(root, 'viewBox')
vb = _get_attr(root, "viewBox")
if vb:
parts = [float(v) for v in re.split(r'[\s,]+', vb.strip()) if v]
parts = [float(v) for v in re.split(r"[\s,]+", vb.strip()) if v]
svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3]
else:
w_str = _get_attr(root, 'width', '100') or '100'
h_str = _get_attr(root, 'height', '100') or '100'
svg_w = float(re.sub(r'[^\d.]', '', w_str) or 100)
svg_h = float(re.sub(r'[^\d.]', '', h_str) or 100)
w_str = _get_attr(root, "width", "100") or "100"
h_str = _get_attr(root, "height", "100") or "100"
svg_w = float(re.sub(r"[^\d.]", "", w_str) or 100)
svg_h = float(re.sub(r"[^\d.]", "", h_str) or 100)
svg_x0, svg_y0 = 0.0, 0.0
if svg_w == 0 or svg_h == 0:
@@ -569,7 +603,10 @@ def import_svg_to_pcb(
insert_block = "\n" + "\n".join(gr_lines) + "\n"
last_paren = pcb_content.rfind(")")
if last_paren == -1:
return {"success": False, "message": "PCB file format error: no closing parenthesis found"}
return {
"success": False,
"message": "PCB file format error: no closing parenthesis found",
}
new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:]
@@ -597,5 +634,6 @@ def import_svg_to_pcb(
except Exception as e:
logger.error(f"SVG import failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}

View File

@@ -12,9 +12,9 @@ KiCAD 9 .kicad_sym format:
- All coordinates in mm, 2.54mm grid typical for schematic symbols
"""
import logging
import os
import re
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -24,15 +24,31 @@ KICAD9_SYMBOL_LIB_VERSION = "20241209"
# Pin electrical types
PIN_TYPES = {
"input", "output", "bidirectional", "tri_state", "passive",
"free", "unspecified", "power_in", "power_out",
"open_collector", "open_emitter", "no_connect",
"input",
"output",
"bidirectional",
"tri_state",
"passive",
"free",
"unspecified",
"power_in",
"power_out",
"open_collector",
"open_emitter",
"no_connect",
}
# Pin graphic shapes
PIN_SHAPES = {
"line", "inverted", "clock", "inverted_clock", "input_low",
"clock_low", "output_low", "falling_edge_clock", "non_logic",
"line",
"inverted",
"clock",
"inverted_clock",
"input_low",
"clock_low",
"output_low",
"falling_edge_clock",
"non_logic",
}
@@ -125,11 +141,11 @@ class SymbolCreator:
lib_content = lib_path.read_text(encoding="utf-8")
else:
lib_content = (
f'(kicad_symbol_lib\n'
f' (version {KICAD9_SYMBOL_LIB_VERSION})\n'
f"(kicad_symbol_lib\n"
f" (version {KICAD9_SYMBOL_LIB_VERSION})\n"
f' (generator "kicad-mcp")\n'
f' (generator_version "9.0")\n'
f')\n'
f")\n"
)
# Check for duplicate
@@ -209,7 +225,7 @@ class SymbolCreator:
# Only top-level symbols (not sub-symbols like _0_1 or _1_1)
names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE)
# Filter out sub-symbols (contain _N_N suffix)
symbols = [n for n in names if not re.search(r'_\d+_\d+$', n)]
symbols = [n for n in names if not re.search(r"_\d+_\d+$", n)]
return {
"success": True,
"library_path": str(lib_path),
@@ -332,9 +348,9 @@ class SymbolCreator:
board_str = "yes" if on_board else "no"
lines.append(f' (symbol "{name}"')
lines.append(f' (exclude_from_sim no)')
lines.append(f' (in_bom {bom_str})')
lines.append(f' (on_board {board_str})')
lines.append(f" (exclude_from_sim no)")
lines.append(f" (in_bom {bom_str})")
lines.append(f" (on_board {board_str})")
# Properties
lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True))
@@ -351,15 +367,15 @@ class SymbolCreator:
lines.extend(_rect_sym_lines(rect))
for pl in polylines:
lines.extend(_polyline_lines(pl))
lines.append(f' )')
lines.append(f" )")
# Sub-symbol _1_1: pins
lines.append(f' (symbol "{name}_1_1"')
for pin in pins:
lines.extend(_pin_lines(pin))
lines.append(f' )')
lines.append(f" )")
lines.append(f' )')
lines.append(f" )")
return "\n".join(lines)
def _remove_symbol(self, content: str, name: str) -> str:
@@ -372,8 +388,9 @@ class SymbolCreator:
for line in lines:
stripped = line.strip()
if not skip:
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \
not re.search(r'_\d+_\d+"', line):
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and not re.search(
r'_\d+_\d+"', line
):
skip = True
depth = stripped.count("(") - stripped.count(")")
continue
@@ -390,17 +407,16 @@ class SymbolCreator:
# S-Expression helper functions #
# ------------------------------------------------------------------ #
def _property_block(
key: str, value: str, x: float, y: float, visible: bool = True
) -> List[str]:
def _property_block(key: str, value: str, x: float, y: float, visible: bool = True) -> List[str]:
hide = "" if visible else "\n (hide yes)"
return [
f' (property "{_esc(key)}" "{_esc(value)}"',
f' (at {_fmt(x)} {_fmt(y)} 0)',
f' (effects',
f' (font (size 1.27 1.27))',
f' ){hide}',
f' )',
f" (at {_fmt(x)} {_fmt(y)} 0)",
f" (effects",
f" (font (size 1.27 1.27))",
f" ){hide}",
f" )",
]
@@ -412,12 +428,12 @@ def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]:
w = _fmt(rect.get("width", 0.254))
fill = rect.get("fill", "background")
return [
f' (rectangle',
f' (start {x1} {y1})',
f' (end {x2} {y2})',
f' (stroke (width {w}) (type default))',
f' (fill (type {fill}))',
f' )',
f" (rectangle",
f" (start {x1} {y1})",
f" (end {x2} {y2})",
f" (stroke (width {w}) (type default))",
f" (fill (type {fill}))",
f" )",
]
@@ -426,16 +442,16 @@ def _polyline_lines(pl: Dict[str, Any]) -> List[str]:
w = _fmt(pl.get("width", 0.254))
fill = pl.get("fill", "none")
lines = [
f' (polyline',
f' (pts',
f" (polyline",
f" (pts",
]
for pt in pts:
lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})')
lines += [
f' )',
f' (stroke (width {w}) (type default))',
f' (fill (type {fill}))',
f' )',
f" )",
f" (stroke (width {w}) (type default))",
f" (fill (type {fill}))",
f" )",
]
return lines
@@ -452,14 +468,14 @@ def _pin_lines(pin: Dict[str, Any]) -> List[str]:
pin_number = str(pin.get("number", "1"))
return [
f' (pin {ptype} {shape}',
f' (at {x} {y} {angle})',
f' (length {length})',
f" (pin {ptype} {shape}",
f" (at {x} {y} {angle})",
f" (length {length})",
f' (name "{_esc(pin_name)}"',
f' (effects (font (size 1.27 1.27)))',
f' )',
f" (effects (font (size 1.27 1.27)))",
f" )",
f' (number "{_esc(pin_number)}"',
f' (effects (font (size 1.27 1.27)))',
f' )',
f' )',
f" (effects (font (size 1.27 1.27)))",
f" )",
f" )",
]

View File

@@ -0,0 +1,494 @@
"""
Wire Connectivity Analysis for KiCad Schematics
Traces wire networks from a point and finds connected component pins.
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
coordinate matching, mirroring KiCad's own connectivity algorithm.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from commands.pin_locator import PinLocator
logger = logging.getLogger("kicad_interface")
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
"""Convert mm coordinates to KiCad internal units (integer)."""
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
"""Extract wire endpoints from a schematic object as IU tuples."""
all_wires = []
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
pts = []
for point in wire.pts.xy:
if hasattr(point, "value"):
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
if len(pts) >= 2:
all_wires.append(pts)
return all_wires
def _build_adjacency(
all_wires: List[List[Tuple[int, int]]],
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
"""Build wire adjacency using exact IU coordinate matching.
Wires that share an endpoint are adjacent — this naturally handles
junctions since all wires meeting at the same point get connected.
Returns a tuple of:
- adjacency: list of sets, one per wire, containing adjacent wire indices
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
that have an endpoint at that exact coordinate (used for seed queries)
"""
# Map each IU endpoint to all wire indices that touch it
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
for i, pts in enumerate(all_wires):
for pt in pts:
iu_to_wires.setdefault(pt, set()).add(i)
# Wires that share an IU endpoint are adjacent
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
for wire_set in iu_to_wires.values():
wire_list = list(wire_set)
for a in wire_list:
for b in wire_list:
if a != b:
adjacency[a].add(b)
return adjacency, iu_to_wires
def _parse_virtual_connections(
schematic: Any, schematic_path: Any
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
"""Return virtual connectivity from net labels and power symbols.
Returns a tuple of:
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
"""
point_to_label: Dict[Tuple[int, int], str] = {}
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
if hasattr(schematic, "label"):
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
pt = _to_iu(float(coords[0]), float(coords[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing net label: {e}")
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if not ref.startswith("#PWR"):
continue
if ref.startswith("_TEMPLATE"):
continue
if not hasattr(symbol.property, "Value"):
continue
name = symbol.property.Value.value
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins or "1" not in all_pins:
continue
pin_data = all_pins["1"]
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing power symbol: {e}")
return point_to_label, label_to_points
def _find_connected_wires(
x_mm: float,
y_mm: float,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
) -> Tuple:
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
"""
query_iu = _to_iu(x_mm, y_mm)
# Find seed wires: exact IU match on the query endpoint
seed_set = iu_to_wires.get(query_iu)
if not seed_set:
return (None, None)
seed_indices: Set[int] = set(seed_set)
# BFS flood-fill using pre-compiled adjacency
visited: Set[int] = set(seed_indices)
queue = list(seed_indices)
net_points: Set[Tuple[int, int]] = set()
for i in seed_indices:
net_points.update(all_wires[i])
seen_labels: Set[str] = set()
while queue:
wire_idx = queue.pop()
for neighbor_idx in adjacency[wire_idx]:
if neighbor_idx not in visited:
visited.add(neighbor_idx)
queue.append(neighbor_idx)
net_points.update(all_wires[neighbor_idx])
if point_to_label and label_to_points:
for pt in all_wires[wire_idx]:
label_name = point_to_label.get(pt)
if label_name and label_name not in seen_labels:
seen_labels.add(label_name)
for other_pt in label_to_points.get(label_name, []):
if other_pt == pt:
continue
for idx in iu_to_wires.get(other_pt, set()):
if idx not in visited:
visited.add(idx)
queue.append(idx)
net_points.update(all_wires[idx])
return (visited, net_points)
def _find_pins_on_net(
net_points: Set[Tuple[int, int]],
schematic_path: Any,
schematic: Any,
) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching.
Returns a list of {"component": ref, "pin": pin_num} dicts.
"""
def _on_net(px_mm: float, py_mm: float) -> bool:
return _to_iu(px_mm, py_mm) in net_points
locator = PinLocator()
pins = []
seen: Set[Tuple] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
if _on_net(pin_data[0], pin_data[1]):
key = (ref, pin_num)
if key not in seen:
seen.add(key)
pins.append({"component": ref, "pin": pin_num})
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return pins
def get_wire_connections(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Optional[Dict]:
"""Find the net name and all component pins reachable from a point via connected wires.
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
Interior (mid-segment) points are not matched —
use wire endpoint coordinates obtained from the schematic data.
Net labels and power symbols are traversed: wires on the same named net are
treated as connected even when they are not geometrically adjacent.
Returns dict with keys:
- "net": str or None (net label/power name, None if unnamed)
- "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
- "query_point": {"x": float, "y": float}
Or None if no wire endpoint found within tolerance of the query point.
"""
all_wires = _parse_wires(schematic)
query_point = {"x": x_mm, "y": y_mm}
if not all_wires:
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if visited is None:
return None
# Resolve net name: first label anchor that falls on this net's IU points
net: Optional[str] = None
for pt in net_points:
label = point_to_label.get(pt)
if label is not None:
net = label
break
wires_out = [
{
"start": {
"x": all_wires[i][0][0] / _IU_PER_MM,
"y": all_wires[i][0][1] / _IU_PER_MM,
},
"end": {
"x": all_wires[i][-1][0] / _IU_PER_MM,
"y": all_wires[i][-1][1] / _IU_PER_MM,
},
}
for i in visited
]
if not hasattr(schematic, "symbol"):
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
def count_pins_on_net(
schematic: Any,
schematic_path: str,
net_name: str,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Dict[Tuple[int, int], str],
label_to_points: Dict[str, List[Tuple[int, int]]],
) -> int:
"""Count the number of component pins connected to the named net.
A pin is counted if its IU coordinate falls on the wire-network reachable
from any label anchor for *net_name*, or directly on a label anchor of that
net (pin directly touching a label with no intervening wire).
Returns the count of distinct (component, pin_num) pairs on this net.
"""
label_positions = label_to_points.get(net_name, [])
if not label_positions:
return 0
# Collect the union of all net-points across all label positions for this net
all_net_points: Set[Tuple[int, int]] = set()
for lx, ly in label_positions:
# Include the label anchor itself so pins directly at the label count
all_net_points.add((lx, ly))
# Trace from this label position into the wire graph
x_mm = lx / _IU_PER_MM
y_mm = ly / _IU_PER_MM
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if net_points:
all_net_points |= net_points
if not hasattr(schematic, "symbol"):
return 0
locator = PinLocator()
seen: Set[Tuple[str, str]] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
if pin_iu in all_net_points:
key = (ref, pin_num)
if key not in seen:
seen.add(key)
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return len(seen)
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
"""Return net labels that are not connected to any component pin.
A label is "floating" when no component pin's IU coordinate falls on the
wire-network reachable from the label's anchor position. These labels are
likely placed off-grid or incorrectly positioned and will cause ERC errors.
Returns a list of dicts with keys:
- "name": str — the net label text
- "x": float — label X position in mm
- "y": float — label Y position in mm
- "type": str — "label" or "global_label"
"""
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)
# Build a set of all pin IU positions for fast lookup
pin_iu_set: Set[Tuple[int, int]] = set()
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_data in all_pins.values():
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
except Exception as e:
logger.warning(f"Error reading pins for floating-label check: {e}")
floating: List[Dict[str, Any]] = []
if not hasattr(schematic, "label"):
return floating
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
lx_mm = float(coords[0])
ly_mm = float(coords[1])
label_iu = _to_iu(lx_mm, ly_mm)
# Check if the label anchor itself is a pin position
if label_iu in pin_iu_set:
continue
# Trace the wire-network from this label and check for pins
if all_wires:
_, net_points = _find_connected_wires(
lx_mm,
ly_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
else:
net_points = None
if net_points is not None and net_points & pin_iu_set:
continue # at least one pin on this net
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
except Exception as e:
logger.warning(f"Error checking label for floating status: {e}")
return floating
def get_net_at_point(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Dict[str, Any]:
"""Return the net name at the given coordinate, or null if none found.
Checks net label positions first (exact IU match within tolerance), then
wire endpoints. Returns a dict with keys:
- "net_name": str or None
- "position": {"x": float, "y": float}
- "source": "net_label" | "wire_endpoint" | None
"""
query_iu = _to_iu(x_mm, y_mm)
position = {"x": x_mm, "y": y_mm}
# Build label map from schematic
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
# Check if query point is exactly on a net label / power symbol position
label_name = point_to_label.get(query_iu)
if label_name is not None:
return {"net_name": label_name, "position": position, "source": "net_label"}
# Check if query point is on a wire endpoint
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
if query_iu in iu_to_wires:
# Found a wire endpoint — trace the net to get the name
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=None,
)
if visited is not None:
net: Optional[str] = None
if net_points:
for pt in net_points:
net = point_to_label.get(pt)
if net is not None:
break
return {"net_name": net, "position": position, "source": "wire_endpoint"}
return {"net_name": None, "position": position, "source": None}

View File

@@ -0,0 +1,439 @@
"""
WireDragger — drag connected wires when a schematic component is moved.
All methods operate on in-memory sexpdata lists (no disk I/O).
"""
import logging
import math
import uuid
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants
_K = {
name: Symbol(name)
for name in [
"symbol",
"at",
"lib_id",
"mirror",
"lib_symbols",
"pts",
"xy",
"wire",
"junction",
"property",
"stroke",
"width",
"type",
"uuid",
]
}
EPS = 1e-4 # mm — coordinate match tolerance
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
if angle_deg == 0:
return x, y
rad = math.radians(angle_deg)
c, s = math.cos(rad), math.sin(rad)
return x * c - y * s, x * s + y * c
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
return abs(ax - bx) < eps and abs(ay - by) < eps
class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod
def find_symbol(sch_data: list, reference: str) -> Any:
"""
Find a placed symbol by reference designator.
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
or None if the reference is not found.
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
at_k = _K["at"]
lib_id_k = _K["lib_id"]
mirror_k = _K["mirror"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Check Reference property
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val != reference:
continue
old_x = old_y = rotation = 0.0
lib_id = ""
mirror_x = mirror_y = False
for sub in item[1:]:
if not isinstance(sub, list) or not sub:
continue
tag = sub[0]
if tag == at_k:
if len(sub) >= 3:
old_x = float(sub[1])
old_y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif tag == lib_id_k and len(sub) >= 2:
lib_id = str(sub[1]).strip('"')
elif tag == mirror_k and len(sub) >= 2:
mv = str(sub[1])
if mv == "x":
mirror_x = True
elif mv == "y":
mirror_y = True
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
return None
@staticmethod
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
"""
Get pin definitions from lib_symbols for the given lib_id.
Returns the same dict format as PinLocator.parse_symbol_definition:
{pin_num: {"x": ..., "y": ..., ...}}.
"""
from commands.pin_locator import PinLocator
lib_sym_k = _K["lib_symbols"]
symbol_k = _K["symbol"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
continue
for sym_def in item[1:]:
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
continue
if len(sym_def) < 2:
continue
name = str(sym_def[1]).strip('"')
if name == lib_id:
return PinLocator.parse_symbol_definition(sym_def)
break # only one lib_symbols section
return {}
@staticmethod
def pin_world_xy(
px: float,
py: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Compute the world coordinate of a pin given the symbol transform.
KiCAD applies mirror first (in local space), then rotation, then translation.
mirror_x negates the local X axis; mirror_y negates the local Y axis.
"""
lx, ly = px, py
if mirror_x:
lx = -lx
if mirror_y:
ly = -ly
rx, ry = _rotate(lx, ly, rotation)
return sym_x + rx, sym_y + ry
@staticmethod
def compute_pin_positions(
sch_data: list,
reference: str,
new_x: float,
new_y: float,
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Compute world pin positions before and after a component move.
Returns {pin_num: (old_world_xy, new_world_xy)}.
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return {}
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
result: Dict[str, Tuple] = {}
for pin_num, pin in pins.items():
px, py = pin["x"], pin["y"]
old_wx, old_wy = WireDragger.pin_world_xy(
px, py, old_x, old_y, rotation, mirror_x, mirror_y
)
new_wx, new_wy = WireDragger.pin_world_xy(
px, py, new_x, new_y, rotation, mirror_x, mirror_y
)
result[pin_num] = (
(round(old_wx, 6), round(old_wy, 6)),
(round(new_wx, 6), round(new_wy, 6)),
)
return result
@staticmethod
def drag_wires(
sch_data: list,
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
eps: float = EPS,
) -> Dict:
"""
Move wire endpoints and junctions from old positions to new positions.
Removes zero-length wires that result from the move.
Modifies sch_data in place.
old_to_new: {(old_x, old_y): (new_x, new_y)}
Returns {'endpoints_moved': N, 'wires_removed': M}.
"""
wire_k = _K["wire"]
pts_k = _K["pts"]
xy_k = _K["xy"]
junction_k = _K["junction"]
at_k = _K["at"]
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps):
return nx, ny
return None
endpoints_moved = 0
zero_length_indices = []
# First pass: update wire endpoints
for idx, item in enumerate(sch_data):
if not (isinstance(item, list) and item and item[0] == wire_k):
continue
pts_sub = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == pts_k:
pts_sub = sub
break
if pts_sub is None:
continue
xy_items = [
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
]
for xy_item in xy_items:
nc = find_new(float(xy_item[1]), float(xy_item[2]))
if nc is not None:
xy_item[1] = nc[0]
xy_item[2] = nc[1]
endpoints_moved += 1
# Check if this wire is now zero-length
if len(xy_items) >= 2:
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
if _coords_match(x1, y1, x2, y2, eps):
zero_length_indices.append(idx)
# Remove zero-length wires (backwards to preserve indices)
for idx in reversed(zero_length_indices):
del sch_data[idx]
# Second pass: update junctions
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == junction_k):
continue
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
nc = find_new(float(sub[1]), float(sub[2]))
if nc is not None:
sub[1] = nc[0]
sub[2] = nc[1]
break
return {
"endpoints_moved": endpoints_moved,
"wires_removed": len(zero_length_indices),
}
@staticmethod
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
"""
Update the (at x y rot) of the named symbol in sch_data.
Returns True if the symbol was found and updated.
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return False
item = found[0]
at_k = _K["at"]
prop_k = _K["property"]
# Find current position and compute delta
old_x = old_y = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
old_x, old_y = sub[1], sub[2]
sub[1] = new_x
sub[2] = new_y
break
if old_x is None or old_y is None:
return False
dx = new_x - old_x
dy = new_y - old_y
# Shift all property label positions by the same delta
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == prop_k:
for psub in sub[1:]:
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
psub[1] += dx
psub[2] += dy
break
return True
@staticmethod
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
"""Build a wire s-expression list in KiCAD schematic format."""
wire_uuid = str(uuid.uuid4())
return [
_K["wire"],
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
[_K["uuid"], wire_uuid],
]
@staticmethod
def get_all_stationary_pin_positions(
sch_data: list,
moved_reference: str,
) -> Dict[Tuple[float, float], str]:
"""
Return a map of {world_xy: reference} for every pin of every symbol
in sch_data *except* moved_reference.
This is used to detect pins of stationary components that coincide
with pins of the moved component (touching-pin connections).
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
result: Dict[Tuple[float, float], str] = {}
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Determine reference
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val is None or ref_val == moved_reference:
continue
# Skip template / power symbols whose references start with special chars
# but we still want to handle them — no filtering needed here.
# Find lib_id and position for this symbol
found = WireDragger.find_symbol(sch_data, ref_val)
if found is None:
continue
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
for pin_num, pin in pins.items():
wx, wy = WireDragger.pin_world_xy(
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
)
key = (round(wx, 6), round(wy, 6))
result[key] = ref_val
return result
@staticmethod
def synthesize_touching_pin_wires(
sch_data: list,
moved_reference: str,
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
eps: float = EPS,
) -> int:
"""
Detect touching-pin connections and synthesize wire segments to bridge gaps
created by moving a component.
For each pin of *moved_reference* whose old world position coincides with
a pin of a stationary component:
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
- If the pin now lands on another stationary pin's position, skip (they touch again).
- If old_xy == new_xy, do nothing (no gap was created).
Modifies sch_data in place.
Returns the number of wire segments synthesized.
"""
if not pin_positions:
return 0
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
if not stationary_pins:
return 0
synthesized = 0
for pin_num, (old_xy, new_xy) in pin_positions.items():
# Check if a stationary pin touches this pin's old position
touching = any(
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if not touching:
continue
# The pin has moved — check if it actually separated
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
# Pin didn't actually move; no gap
continue
# Check if the pin's new position happens to touch another stationary pin
# (component moved into a different touching position — no wire needed)
rejoining = any(
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if rejoining:
logger.debug(
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
f"and rejoins another stationary pin; no wire synthesized"
)
continue
logger.info(
f"Synthesizing wire for touching-pin connection: "
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
)
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
# Insert before the last item (sheet_instances) to keep file tidy,
# but appending is also valid — just append.
sch_data.append(wire)
synthesized += 1
return synthesized

View File

@@ -6,17 +6,30 @@ kicad-skip's wire API doesn't support creating wires with standard parameters, s
manipulate the .kicad_sch file directly.
"""
import uuid
import logging
import math
import tempfile
import uuid
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from typing import Any, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants — avoids repeated allocation on every call
_SYM_WIRE = Symbol("wire")
_SYM_PTS = Symbol("pts")
_SYM_XY = Symbol("xy")
_SYM_AT = Symbol("at")
_SYM_LABEL = Symbol("label")
_SYM_STROKE = Symbol("stroke")
_SYM_WIDTH = Symbol("width")
_SYM_TYPE = Symbol("type")
_SYM_UUID = Symbol("uuid")
_SYM_SHEET_INSTANCES = Symbol("sheet_instances")
class WireManager:
"""Manage wires in KiCad schematics using S-expression manipulation"""
@@ -49,31 +62,22 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Break any existing wire that passes through a new endpoint (T-junction support)
for pt in (start_point, end_point):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}")
# Create wire S-expression
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
wire_sexp = [
Symbol("wire"),
[
Symbol("pts"),
[Symbol("xy"), start_point[0], start_point[1]],
[Symbol("xy"), end_point[0], end_point[1]],
],
[
Symbol("stroke"),
[Symbol("width"), stroke_width],
[Symbol("type"), Symbol(stroke_type)],
],
[Symbol("uuid"), str(uuid.uuid4())],
]
wire_sexp = WireManager._make_wire_sexp(
start_point, end_point, stroke_width, stroke_type
)
# Find insertion point (before sheet_instances)
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -130,31 +134,23 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Create pts list
pts_list = [Symbol("pts")]
for point in points:
pts_list.append([Symbol("xy"), point[0], point[1]])
# Break any existing wire at the outer endpoints of the new path
for pt in (points[0], points[-1]):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}")
# Create wire S-expression with multiple points
wire_sexp = [
Symbol("wire"),
pts_list,
[
Symbol("stroke"),
[Symbol("width"), stroke_width],
[Symbol("type"), Symbol(stroke_type)],
],
[Symbol("uuid"), str(uuid.uuid4())],
# KiCAD wire elements only support exactly 2 pts each.
# Split N waypoints into N-1 individual wire segments.
wire_sexps = [
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
for i in range(len(points) - 1)
]
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -162,9 +158,12 @@ class WireManager:
logger.error("No sheet_instances section found in schematic")
return False
# Insert wire
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(f"Injected polyline wire with {len(points)} points")
# Insert all segments (in reverse so order is preserved after inserts)
for wire_sexp in reversed(wire_sexps):
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(
f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline"
)
# Write back
with open(schematic_path, "w", encoding="utf-8") as f:
@@ -227,11 +226,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -259,11 +254,121 @@ class WireManager:
return False
@staticmethod
def add_junction(
schematic_path: Path, position: List[float], diameter: float = 0
def _parse_wire(
wire_item: Any,
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
"""
Parse a wire S-expression item in a single pass.
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
"""
if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
return None
start = end = None
stroke_width: float = 0
stroke_type: str = "default"
for part in wire_item[1:]:
if not isinstance(part, list) or not part:
continue
tag = part[0]
if tag == _SYM_PTS:
found: List[Tuple[float, float]] = []
for p in part[1:]:
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY:
found.append((float(p[1]), float(p[2])))
if len(found) == 2:
break
if len(found) == 2:
start, end = found[0], found[1]
elif tag == _SYM_STROKE:
for sp in part[1:]:
if isinstance(sp, list) and len(sp) >= 2:
if sp[0] == _SYM_WIDTH:
stroke_width = sp[1]
elif sp[0] == _SYM_TYPE:
stroke_type = str(sp[1])
if start is not None and end is not None:
return start, end, stroke_width, stroke_type
return None
@staticmethod
def _point_strictly_on_wire(
px: float,
py: float,
x1: float,
y1: float,
x2: float,
y2: float,
eps: float = 1e-6,
) -> bool:
"""
Add a junction (connection dot) to the schematic
Return True if (px, py) lies strictly between (x1,y1) and (x2,y2)
on a horizontal or vertical wire segment (not at either endpoint).
"""
if abs(y1 - y2) < eps: # horizontal wire
if abs(py - y1) > eps:
return False
lo, hi = min(x1, x2), max(x1, x2)
return lo + eps < px < hi - eps
if abs(x1 - x2) < eps: # vertical wire
if abs(px - x1) > eps:
return False
lo, hi = min(y1, y2), max(y1, y2)
return lo + eps < py < hi - eps
return False
@staticmethod
def _make_wire_sexp(
start: List[float],
end: List[float],
stroke_width: float = 0,
stroke_type: str = "default",
) -> list:
return [
_SYM_WIRE,
[_SYM_PTS, [_SYM_XY, start[0], start[1]], [_SYM_XY, end[0], end[1]]],
[_SYM_STROKE, [_SYM_WIDTH, stroke_width], [_SYM_TYPE, Symbol(stroke_type)]],
[_SYM_UUID, str(uuid.uuid4())],
]
@staticmethod
def _break_wires_at_point(sch_data: list, position: List[float]) -> int:
"""
Split any wire segment that passes through *position* as a strict
midpoint (i.e. position is not an existing endpoint). Mirrors
KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour.
Returns the number of wires split.
"""
px, py = float(position[0]), float(position[1])
splits = 0
i = 0
while i < len(sch_data):
parsed = WireManager._parse_wire(sch_data[i])
if parsed is not None:
(x1, y1), (x2, y2), stroke_width, stroke_type = parsed
if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2):
seg_a = WireManager._make_wire_sexp(
[x1, y1], [px, py], stroke_width, stroke_type
)
seg_b = WireManager._make_wire_sexp(
[px, py], [x2, y2], stroke_width, stroke_type
)
sch_data[i : i + 1] = [seg_a, seg_b]
logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})")
splits += 1
i += 2 # skip the two new segments
continue
i += 1
return splits
@staticmethod
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
"""
Add a junction (connection dot) to the schematic.
Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes
through *position* is split into two segments at that point so that
the BFS-based get_wire_connections tool can traverse the T/X branch.
Args:
schematic_path: Path to .kicad_sch file
@@ -280,6 +385,12 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Split any wire that passes through the junction as a midpoint
# (mirrors KiCAD's AddJunction / BreakSegments behaviour)
splits = WireManager._break_wires_at_point(sch_data, position)
if splits:
logger.info(f"Broke {splits} wire(s) at junction position {position}")
# Create junction S-expression
# Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...))
junction_sexp = [
@@ -293,11 +404,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -354,11 +461,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -414,21 +517,13 @@ class WireManager:
ex, ey = end_point
for i, item in enumerate(sch_data):
if not (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("wire")
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE):
continue
# Extract pts from the wire s-expression
pts_list = None
for part in item[1:]:
if (
isinstance(part, list)
and len(part) > 0
and part[0] == Symbol("pts")
):
if isinstance(part, list) and len(part) > 0 and part[0] == _SYM_PTS:
pts_list = part
break
@@ -438,7 +533,7 @@ class WireManager:
xy_points = [
p
for p in pts_list[1:]
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY
]
if len(xy_points) < 2:
continue
@@ -502,11 +597,7 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
for i, item in enumerate(sch_data):
if not (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("label")
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL):
continue
# Second element is the label text
@@ -519,9 +610,7 @@ class WireManager:
(
p
for p in item[1:]
if isinstance(p, list)
and len(p) >= 3
and p[0] == Symbol("at")
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT
),
None,
)
@@ -529,8 +618,7 @@ class WireManager:
continue
lx, ly = float(at_entry[1]), float(at_entry[2])
if not (
abs(lx - position[0]) < tolerance
and abs(ly - position[1]) < tolerance
abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance
):
continue
@@ -584,12 +672,11 @@ class WireManager:
if __name__ == "__main__":
# Test wire creation
import sys
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
print("=" * 80)
print("WIRE MANAGER TEST")
@@ -597,9 +684,7 @@ if __name__ == "__main__":
# Create test schematic (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch"
)
template_path = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
shutil.copy(template_path, test_path)
print(f"\n✓ Created test schematic: {test_path}")