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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" )",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']})"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
976
python/commands/schematic_analysis.py
Normal file
976
python/commands/schematic_analysis.py
Normal 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)}
|
||||
211
python/commands/schematic_snap.py
Normal file
211
python/commands/schematic_snap.py
Normal 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,
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" )",
|
||||
]
|
||||
|
||||
494
python/commands/wire_connectivity.py
Normal file
494
python/commands/wire_connectivity.py
Normal 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}
|
||||
439
python/commands/wire_dragger.py
Normal file
439
python/commands/wire_dragger.py
Normal 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
|
||||
@@ -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}")
|
||||
|
||||
@@ -20,8 +20,8 @@ Usage:
|
||||
board.set_size(100, 80)
|
||||
"""
|
||||
|
||||
from kicad_api.factory import create_backend
|
||||
from kicad_api.base import KiCADBackend
|
||||
from kicad_api.factory import create_backend
|
||||
|
||||
__all__ = ['create_backend', 'KiCADBackend']
|
||||
__version__ = '2.0.0-alpha.1'
|
||||
__all__ = ["create_backend", "KiCADBackend"]
|
||||
__version__ = "2.0.0-alpha.1"
|
||||
|
||||
@@ -3,10 +3,11 @@ Abstract base class for KiCAD API backends
|
||||
|
||||
Defines the interface that all KiCAD backends must implement.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +98,7 @@ class KiCADBackend(ABC):
|
||||
|
||||
# Board Operations
|
||||
@abstractmethod
|
||||
def get_board(self) -> 'BoardAPI':
|
||||
def get_board(self) -> "BoardAPI":
|
||||
"""
|
||||
Get board API for current project
|
||||
|
||||
@@ -126,7 +127,7 @@ class BoardAPI(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_size(self) -> Dict[str, float]:
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current board size
|
||||
|
||||
@@ -167,7 +168,8 @@ class BoardAPI(ABC):
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu"
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Place a component on the board
|
||||
@@ -194,7 +196,7 @@ class BoardAPI(ABC):
|
||||
end_y: float,
|
||||
width: float = 0.25,
|
||||
layer: str = "F.Cu",
|
||||
net_name: Optional[str] = None
|
||||
net_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a track (trace) to the board
|
||||
@@ -220,7 +222,7 @@ class BoardAPI(ABC):
|
||||
diameter: float = 0.8,
|
||||
drill: float = 0.4,
|
||||
net_name: Optional[str] = None,
|
||||
via_type: str = "through"
|
||||
via_type: str = "through",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a via to the board
|
||||
@@ -275,14 +277,17 @@ class BoardAPI(ABC):
|
||||
|
||||
class BackendError(Exception):
|
||||
"""Base exception for backend errors"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionError(BackendError):
|
||||
"""Raised when connection to KiCAD fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APINotAvailableError(BackendError):
|
||||
"""Raised when required API is not available"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -3,12 +3,13 @@ Backend factory for creating appropriate KiCAD API backend
|
||||
|
||||
Auto-detects available backends and provides fallback mechanism.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from kicad_api.base import KiCADBackend, APINotAvailableError
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kicad_api.base import APINotAvailableError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,16 +35,16 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
|
||||
"""
|
||||
# Check environment variable override
|
||||
if backend_type is None:
|
||||
backend_type = os.environ.get('KICAD_BACKEND', 'auto').lower()
|
||||
backend_type = os.environ.get("KICAD_BACKEND", "auto").lower()
|
||||
|
||||
logger.info(f"Requested backend: {backend_type}")
|
||||
|
||||
# Try specific backend if requested
|
||||
if backend_type == 'ipc':
|
||||
if backend_type == "ipc":
|
||||
return _create_ipc_backend()
|
||||
elif backend_type == 'swig':
|
||||
elif backend_type == "swig":
|
||||
return _create_swig_backend()
|
||||
elif backend_type == 'auto':
|
||||
elif backend_type == "auto":
|
||||
return _auto_detect_backend()
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
@@ -61,13 +62,13 @@ def _create_ipc_backend() -> KiCADBackend:
|
||||
"""
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
|
||||
logger.info("Creating IPC backend")
|
||||
return IPCBackend()
|
||||
except ImportError as e:
|
||||
logger.error(f"IPC backend not available: {e}")
|
||||
raise APINotAvailableError(
|
||||
"IPC backend requires 'kicad-python' package. "
|
||||
"Install with: pip install kicad-python"
|
||||
"IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
|
||||
) from e
|
||||
|
||||
|
||||
@@ -83,6 +84,7 @@ def _create_swig_backend() -> KiCADBackend:
|
||||
"""
|
||||
try:
|
||||
from kicad_api.swig_backend import SWIGBackend
|
||||
|
||||
logger.info("Creating SWIG backend")
|
||||
logger.warning(
|
||||
"SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. "
|
||||
@@ -92,8 +94,7 @@ def _create_swig_backend() -> KiCADBackend:
|
||||
except ImportError as e:
|
||||
logger.error(f"SWIG backend not available: {e}")
|
||||
raise APINotAvailableError(
|
||||
"SWIG backend requires 'pcbnew' module. "
|
||||
"Ensure KiCAD Python module is in PYTHONPATH."
|
||||
"SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
|
||||
) from e
|
||||
|
||||
|
||||
@@ -129,8 +130,7 @@ def _auto_detect_backend() -> KiCADBackend:
|
||||
try:
|
||||
backend = _create_swig_backend()
|
||||
logger.warning(
|
||||
"Using deprecated SWIG backend. "
|
||||
"For best results, use IPC API with KiCAD running."
|
||||
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
|
||||
)
|
||||
return backend
|
||||
except (ImportError, APINotAvailableError) as e:
|
||||
@@ -160,22 +160,18 @@ def get_available_backends() -> dict:
|
||||
# Check IPC (kicad-python uses 'kipy' module name)
|
||||
try:
|
||||
import kipy
|
||||
results['ipc'] = {
|
||||
'available': True,
|
||||
'version': getattr(kipy, '__version__', 'unknown')
|
||||
}
|
||||
|
||||
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
|
||||
except ImportError:
|
||||
results['ipc'] = {'available': False, 'version': None}
|
||||
results["ipc"] = {"available": False, "version": None}
|
||||
|
||||
# Check SWIG
|
||||
try:
|
||||
import pcbnew
|
||||
results['swig'] = {
|
||||
'available': True,
|
||||
'version': pcbnew.GetBuildVersion()
|
||||
}
|
||||
|
||||
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
|
||||
except ImportError:
|
||||
results['swig'] = {'available': False, 'version': None}
|
||||
results["swig"] = {"available": False, "version": None}
|
||||
|
||||
return results
|
||||
|
||||
@@ -183,6 +179,7 @@ def get_available_backends() -> dict:
|
||||
if __name__ == "__main__":
|
||||
# Quick diagnostic
|
||||
import json
|
||||
|
||||
print("KiCAD Backend Availability:")
|
||||
print(json.dumps(get_available_backends(), indent=2))
|
||||
|
||||
|
||||
@@ -13,18 +13,14 @@ Key Benefits over SWIG:
|
||||
- Stable API that won't break between versions
|
||||
- Multi-language support
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Callable
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from kicad_api.base import (
|
||||
KiCADBackend,
|
||||
BoardAPI,
|
||||
ConnectionError,
|
||||
APINotAvailableError
|
||||
)
|
||||
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,10 +40,10 @@ class IPCBackend(KiCADBackend):
|
||||
without requiring manual reload.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._kicad = None
|
||||
self._connected = False
|
||||
self._version = None
|
||||
self._version: Optional[str] = None
|
||||
self._on_change_callbacks: List[Callable] = []
|
||||
|
||||
def connect(self, socket_path: Optional[str] = None) -> bool:
|
||||
@@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend):
|
||||
# Common socket locations (Unix-like systems only)
|
||||
# Windows uses named pipes, handled by auto-detect
|
||||
if platform.system() != "Windows":
|
||||
socket_paths_to_try.append('ipc:///tmp/kicad/api.sock') # Linux default
|
||||
socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default
|
||||
# XDG runtime directory (requires getuid, Unix only)
|
||||
if hasattr(os, 'getuid'):
|
||||
socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock')
|
||||
if hasattr(os, "getuid"):
|
||||
socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock")
|
||||
|
||||
# Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets)
|
||||
socket_paths_to_try.append(None)
|
||||
@@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend):
|
||||
except ImportError as e:
|
||||
logger.error("kicad-python library not found")
|
||||
raise APINotAvailableError(
|
||||
"IPC backend requires kicad-python. "
|
||||
"Install with: pip install kicad-python"
|
||||
"IPC backend requires kicad-python. " "Install with: pip install kicad-python"
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect via IPC: {e}")
|
||||
@@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Direct project creation not supported via IPC",
|
||||
"suggestion": "Open KiCAD and create a new project, or use SWIG backend"
|
||||
"suggestion": "Open KiCAD and create a new project, or use SWIG backend",
|
||||
}
|
||||
|
||||
def open_project(self, path: Path) -> Dict[str, Any]:
|
||||
@@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend):
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Project already open: {path}",
|
||||
"path": str(path)
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Project not currently open in KiCAD",
|
||||
"suggestion": "Open the project in KiCAD first, then connect via IPC"
|
||||
"suggestion": "Open the project in KiCAD first, then connect via IPC",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check project: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to check project",
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
return {"success": False, "message": "Failed to check project", "errorDetails": str(e)}
|
||||
|
||||
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""Save current project via IPC."""
|
||||
@@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend):
|
||||
|
||||
self._notify_change("save", {"path": str(path) if path else "current"})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Project saved successfully"
|
||||
}
|
||||
return {"success": True, "message": "Project saved successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save project: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to save project",
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
return {"success": False, "message": "Failed to save project", "errorDetails": str(e)}
|
||||
|
||||
def close_project(self) -> None:
|
||||
"""Close current project (not supported via IPC)."""
|
||||
@@ -273,13 +257,13 @@ class IPCBoardAPI(BoardAPI):
|
||||
Uses transactions for proper undo/redo support.
|
||||
"""
|
||||
|
||||
def __init__(self, kicad_instance, notify_callback: Callable):
|
||||
def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None:
|
||||
self._kicad = kicad_instance
|
||||
self._board = None
|
||||
self._notify = notify_callback
|
||||
self._current_commit = None
|
||||
|
||||
def _get_board(self):
|
||||
def _get_board(self) -> Any:
|
||||
"""Get board instance, connecting if needed."""
|
||||
if self._board is None:
|
||||
try:
|
||||
@@ -333,8 +317,8 @@ class IPCBoardAPI(BoardAPI):
|
||||
try:
|
||||
from kipy.board_types import BoardRectangle
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.units import from_mm
|
||||
from kipy.proto.board.board_types_pb2 import BoardLayer
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
|
||||
@@ -366,7 +350,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
logger.error(f"Failed to set board size: {e}")
|
||||
return False
|
||||
|
||||
def get_size(self) -> Dict[str, float]:
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""Get current board size from bounding box."""
|
||||
try:
|
||||
board = self._get_board()
|
||||
@@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI):
|
||||
# Find bounding box of edge cuts
|
||||
from kipy.util.units import to_mm
|
||||
|
||||
min_x = min_y = float('inf')
|
||||
max_x = max_y = float('-inf')
|
||||
min_x = min_y = float("inf")
|
||||
max_x = max_y = float("-inf")
|
||||
|
||||
for shape in shapes:
|
||||
# Check if on Edge.Cuts layer
|
||||
@@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI):
|
||||
max_x = max(max_x, bbox.max.x)
|
||||
max_y = max(max_y, bbox.max.y)
|
||||
|
||||
if min_x == float('inf'):
|
||||
if min_x == float("inf"):
|
||||
return {"width": 0, "height": 0, "unit": "mm"}
|
||||
|
||||
return {
|
||||
"width": to_mm(max_x - min_x),
|
||||
"height": to_mm(max_y - min_y),
|
||||
"unit": "mm"
|
||||
}
|
||||
return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get board size: {e}")
|
||||
@@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI):
|
||||
for fp in footprints:
|
||||
try:
|
||||
pos = fp.position
|
||||
components.append({
|
||||
"reference": fp.reference_field.text.value if fp.reference_field else "",
|
||||
"value": fp.value_field.text.value if fp.value_field else "",
|
||||
"footprint": str(fp.definition.library_link) if fp.definition else "",
|
||||
"position": {
|
||||
"x": to_mm(pos.x) if pos else 0,
|
||||
"y": to_mm(pos.y) if pos else 0,
|
||||
"unit": "mm"
|
||||
},
|
||||
"rotation": fp.orientation.degrees if fp.orientation else 0,
|
||||
"layer": str(fp.layer) if hasattr(fp, 'layer') else "F.Cu",
|
||||
"id": str(fp.id) if hasattr(fp, 'id') else ""
|
||||
})
|
||||
components.append(
|
||||
{
|
||||
"reference": (
|
||||
fp.reference_field.text.value if fp.reference_field else ""
|
||||
),
|
||||
"value": fp.value_field.text.value if fp.value_field else "",
|
||||
"footprint": str(fp.definition.library_link) if fp.definition else "",
|
||||
"position": {
|
||||
"x": to_mm(pos.x) if pos else 0,
|
||||
"y": to_mm(pos.y) if pos else 0,
|
||||
"unit": "mm",
|
||||
},
|
||||
"rotation": fp.orientation.degrees if fp.orientation else 0,
|
||||
"layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu",
|
||||
"id": str(fp.id) if hasattr(fp, "id") else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing footprint: {e}")
|
||||
continue
|
||||
@@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = ""
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Place a component on the board.
|
||||
@@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI):
|
||||
)
|
||||
else:
|
||||
# Fallback: Create a basic placeholder footprint via IPC
|
||||
logger.warning(f"Could not load footprint '{footprint}' from library, creating placeholder")
|
||||
logger.warning(
|
||||
f"Could not load footprint '{footprint}' from library, creating placeholder"
|
||||
)
|
||||
return self._place_placeholder_footprint(
|
||||
reference, footprint, x, y, rotation, layer, value
|
||||
)
|
||||
@@ -504,7 +490,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
logger.error(f"Failed to place component: {e}")
|
||||
return False
|
||||
|
||||
def _load_footprint_from_library(self, footprint_path: str):
|
||||
def _load_footprint_from_library(self, footprint_path: str) -> Any:
|
||||
"""
|
||||
Load a footprint from the library using pcbnew SWIG API.
|
||||
|
||||
@@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI):
|
||||
import pcbnew
|
||||
|
||||
# Parse library and footprint name
|
||||
if ':' in footprint_path:
|
||||
lib_name, fp_name = footprint_path.split(':', 1)
|
||||
if ":" in footprint_path:
|
||||
lib_name, fp_name = footprint_path.split(":", 1)
|
||||
else:
|
||||
# Try to find the footprint in all libraries
|
||||
lib_name = None
|
||||
@@ -561,13 +547,13 @@ class IPCBoardAPI(BoardAPI):
|
||||
|
||||
def _place_loaded_footprint(
|
||||
self,
|
||||
loaded_fp,
|
||||
loaded_fp: Any,
|
||||
reference: str,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float,
|
||||
layer: str,
|
||||
value: str
|
||||
value: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Place a loaded pcbnew footprint onto the board.
|
||||
@@ -589,7 +575,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
try:
|
||||
docs = self._kicad.get_open_documents()
|
||||
for doc in docs:
|
||||
if hasattr(doc, 'path') and str(doc.path).endswith('.kicad_pcb'):
|
||||
if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"):
|
||||
board_path = str(doc.path)
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -637,24 +623,27 @@ class IPCBoardAPI(BoardAPI):
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not refresh IPC board: {e}")
|
||||
|
||||
self._notify("component_placed", {
|
||||
"reference": reference,
|
||||
"footprint": loaded_fp.GetFPIDAsString(),
|
||||
"position": {"x": x, "y": y},
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
"loaded_from_library": True
|
||||
})
|
||||
self._notify(
|
||||
"component_placed",
|
||||
{
|
||||
"reference": reference,
|
||||
"footprint": loaded_fp.GetFPIDAsString(),
|
||||
"position": {"x": x, "y": y},
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
"loaded_from_library": True,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm")
|
||||
logger.info(
|
||||
f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing loaded footprint: {e}")
|
||||
# Fall back to placeholder
|
||||
return self._place_placeholder_footprint(
|
||||
reference, "", x, y, rotation, layer, value
|
||||
)
|
||||
return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value)
|
||||
|
||||
def _place_placeholder_footprint(
|
||||
self,
|
||||
@@ -664,7 +653,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
y: float,
|
||||
rotation: float,
|
||||
layer: str,
|
||||
value: str
|
||||
value: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Place a placeholder footprint when library loading fails.
|
||||
@@ -673,9 +662,9 @@ class IPCBoardAPI(BoardAPI):
|
||||
"""
|
||||
try:
|
||||
from kipy.board_types import Footprint
|
||||
from kipy.geometry import Vector2, Angle
|
||||
from kipy.util.units import from_mm
|
||||
from kipy.geometry import Angle, Vector2
|
||||
from kipy.proto.board.board_types_pb2 import BoardLayer
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
|
||||
@@ -701,15 +690,18 @@ class IPCBoardAPI(BoardAPI):
|
||||
board.create_items(fp)
|
||||
board.push_commit(commit, f"Placed component {reference}")
|
||||
|
||||
self._notify("component_placed", {
|
||||
"reference": reference,
|
||||
"footprint": footprint,
|
||||
"position": {"x": x, "y": y},
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
"loaded_from_library": False,
|
||||
"is_placeholder": True
|
||||
})
|
||||
self._notify(
|
||||
"component_placed",
|
||||
{
|
||||
"reference": reference,
|
||||
"footprint": footprint,
|
||||
"position": {"x": x, "y": y},
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
"loaded_from_library": False,
|
||||
"is_placeholder": True,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
|
||||
return True
|
||||
@@ -718,10 +710,12 @@ class IPCBoardAPI(BoardAPI):
|
||||
logger.error(f"Failed to place placeholder component: {e}")
|
||||
return False
|
||||
|
||||
def move_component(self, reference: str, x: float, y: float, rotation: Optional[float] = None) -> bool:
|
||||
def move_component(
|
||||
self, reference: str, x: float, y: float, rotation: Optional[float] = None
|
||||
) -> bool:
|
||||
"""Move a component to a new position (updates UI immediately)."""
|
||||
try:
|
||||
from kipy.geometry import Vector2, Angle
|
||||
from kipy.geometry import Angle, Vector2
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
@@ -749,11 +743,10 @@ class IPCBoardAPI(BoardAPI):
|
||||
board.update_items([target_fp])
|
||||
board.push_commit(commit, f"Moved component {reference}")
|
||||
|
||||
self._notify("component_moved", {
|
||||
"reference": reference,
|
||||
"position": {"x": x, "y": y},
|
||||
"rotation": rotation
|
||||
})
|
||||
self._notify(
|
||||
"component_moved",
|
||||
{"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -799,7 +792,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
end_y: float,
|
||||
width: float = 0.25,
|
||||
layer: str = "F.Cu",
|
||||
net_name: Optional[str] = None
|
||||
net_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a track (trace) to the board.
|
||||
@@ -809,8 +802,8 @@ class IPCBoardAPI(BoardAPI):
|
||||
try:
|
||||
from kipy.board_types import Track
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.units import from_mm
|
||||
from kipy.proto.board.board_types_pb2 import BoardLayer
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
|
||||
@@ -842,13 +835,16 @@ class IPCBoardAPI(BoardAPI):
|
||||
board.create_items(track)
|
||||
board.push_commit(commit, "Added track")
|
||||
|
||||
self._notify("track_added", {
|
||||
"start": {"x": start_x, "y": start_y},
|
||||
"end": {"x": end_x, "y": end_y},
|
||||
"width": width,
|
||||
"layer": layer,
|
||||
"net": net_name
|
||||
})
|
||||
self._notify(
|
||||
"track_added",
|
||||
{
|
||||
"start": {"x": start_x, "y": start_y},
|
||||
"end": {"x": end_x, "y": end_y},
|
||||
"width": width,
|
||||
"layer": layer,
|
||||
"net": net_name,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
|
||||
return True
|
||||
@@ -864,7 +860,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
diameter: float = 0.8,
|
||||
drill: float = 0.4,
|
||||
net_name: Optional[str] = None,
|
||||
via_type: str = "through"
|
||||
via_type: str = "through",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a via to the board.
|
||||
@@ -874,8 +870,8 @@ class IPCBoardAPI(BoardAPI):
|
||||
try:
|
||||
from kipy.board_types import Via
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.units import from_mm
|
||||
from kipy.proto.board.board_types_pb2 import ViaType
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
|
||||
@@ -906,13 +902,16 @@ class IPCBoardAPI(BoardAPI):
|
||||
board.create_items(via)
|
||||
board.push_commit(commit, "Added via")
|
||||
|
||||
self._notify("via_added", {
|
||||
"position": {"x": x, "y": y},
|
||||
"diameter": diameter,
|
||||
"drill": drill,
|
||||
"net": net_name,
|
||||
"type": via_type
|
||||
})
|
||||
self._notify(
|
||||
"via_added",
|
||||
{
|
||||
"position": {"x": x, "y": y},
|
||||
"diameter": diameter,
|
||||
"drill": drill,
|
||||
"net": net_name,
|
||||
"type": via_type,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Added via at ({x}, {y}) mm")
|
||||
return True
|
||||
@@ -928,14 +927,14 @@ class IPCBoardAPI(BoardAPI):
|
||||
y: float,
|
||||
layer: str = "F.SilkS",
|
||||
size: float = 1.0,
|
||||
rotation: float = 0
|
||||
rotation: float = 0,
|
||||
) -> bool:
|
||||
"""Add text to the board."""
|
||||
try:
|
||||
from kipy.board_types import BoardText
|
||||
from kipy.geometry import Vector2, Angle
|
||||
from kipy.util.units import from_mm
|
||||
from kipy.geometry import Angle, Vector2
|
||||
from kipy.proto.board.board_types_pb2 import BoardLayer
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
|
||||
@@ -959,11 +958,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
board.create_items(board_text)
|
||||
board.push_commit(commit, f"Added text: {text}")
|
||||
|
||||
self._notify("text_added", {
|
||||
"text": text,
|
||||
"position": {"x": x, "y": y},
|
||||
"layer": layer
|
||||
})
|
||||
self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer})
|
||||
|
||||
return True
|
||||
|
||||
@@ -982,20 +977,16 @@ class IPCBoardAPI(BoardAPI):
|
||||
result = []
|
||||
for track in tracks:
|
||||
try:
|
||||
result.append({
|
||||
"start": {
|
||||
"x": to_mm(track.start.x),
|
||||
"y": to_mm(track.start.y)
|
||||
},
|
||||
"end": {
|
||||
"x": to_mm(track.end.x),
|
||||
"y": to_mm(track.end.y)
|
||||
},
|
||||
"width": to_mm(track.width),
|
||||
"layer": str(track.layer),
|
||||
"net": track.net.name if track.net else "",
|
||||
"id": str(track.id) if hasattr(track, 'id') else ""
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)},
|
||||
"end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)},
|
||||
"width": to_mm(track.width),
|
||||
"layer": str(track.layer),
|
||||
"net": track.net.name if track.net else "",
|
||||
"id": str(track.id) if hasattr(track, "id") else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing track: {e}")
|
||||
continue
|
||||
@@ -1017,17 +1008,16 @@ class IPCBoardAPI(BoardAPI):
|
||||
result = []
|
||||
for via in vias:
|
||||
try:
|
||||
result.append({
|
||||
"position": {
|
||||
"x": to_mm(via.position.x),
|
||||
"y": to_mm(via.position.y)
|
||||
},
|
||||
"diameter": to_mm(via.diameter),
|
||||
"drill": to_mm(via.drill_diameter),
|
||||
"net": via.net.name if via.net else "",
|
||||
"type": str(via.type),
|
||||
"id": str(via.id) if hasattr(via, 'id') else ""
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)},
|
||||
"diameter": to_mm(via.diameter),
|
||||
"drill": to_mm(via.drill_diameter),
|
||||
"net": via.net.name if via.net else "",
|
||||
"type": str(via.type),
|
||||
"id": str(via.id) if hasattr(via, "id") else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing via: {e}")
|
||||
continue
|
||||
@@ -1047,10 +1037,9 @@ class IPCBoardAPI(BoardAPI):
|
||||
result = []
|
||||
for net in nets:
|
||||
try:
|
||||
result.append({
|
||||
"name": net.name,
|
||||
"code": net.code if hasattr(net, 'code') else 0
|
||||
})
|
||||
result.append(
|
||||
{"name": net.name, "code": net.code if hasattr(net, "code") else 0}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing net: {e}")
|
||||
continue
|
||||
@@ -1070,7 +1059,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
min_thickness: float = 0.25,
|
||||
priority: int = 0,
|
||||
fill_mode: str = "solid",
|
||||
name: str = ""
|
||||
name: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a copper pour zone to the board.
|
||||
@@ -1090,8 +1079,8 @@ class IPCBoardAPI(BoardAPI):
|
||||
try:
|
||||
from kipy.board_types import Zone, ZoneFillMode, ZoneType
|
||||
from kipy.geometry import PolyLine, PolyLineNode, Vector2
|
||||
from kipy.util.units import from_mm
|
||||
from kipy.proto.board.board_types_pb2 import BoardLayer
|
||||
from kipy.util.units import from_mm
|
||||
|
||||
board = self._get_board()
|
||||
|
||||
@@ -1157,12 +1146,10 @@ class IPCBoardAPI(BoardAPI):
|
||||
board.create_items(zone)
|
||||
board.push_commit(commit, f"Added copper zone on {layer}")
|
||||
|
||||
self._notify("zone_added", {
|
||||
"layer": layer,
|
||||
"net": net_name,
|
||||
"points": len(points),
|
||||
"priority": priority
|
||||
})
|
||||
self._notify(
|
||||
"zone_added",
|
||||
{"layer": layer, "net": net_name, "points": len(points), "priority": priority},
|
||||
)
|
||||
|
||||
logger.info(f"Added zone on {layer} with {len(points)} points")
|
||||
return True
|
||||
@@ -1182,14 +1169,18 @@ class IPCBoardAPI(BoardAPI):
|
||||
result = []
|
||||
for zone in zones:
|
||||
try:
|
||||
result.append({
|
||||
"name": zone.name if hasattr(zone, 'name') else "",
|
||||
"net": zone.net.name if zone.net else "",
|
||||
"priority": zone.priority if hasattr(zone, 'priority') else 0,
|
||||
"layers": [str(l) for l in zone.layers] if hasattr(zone, 'layers') else [],
|
||||
"filled": zone.filled if hasattr(zone, 'filled') else False,
|
||||
"id": str(zone.id) if hasattr(zone, 'id') else ""
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"name": zone.name if hasattr(zone, "name") else "",
|
||||
"net": zone.net.name if zone.net else "",
|
||||
"priority": zone.priority if hasattr(zone, "priority") else 0,
|
||||
"layers": (
|
||||
[str(l) for l in zone.layers] if hasattr(zone, "layers") else []
|
||||
),
|
||||
"filled": zone.filled if hasattr(zone, "filled") else False,
|
||||
"id": str(zone.id) if hasattr(zone, "id") else "",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing zone: {e}")
|
||||
continue
|
||||
@@ -1219,10 +1210,9 @@ class IPCBoardAPI(BoardAPI):
|
||||
|
||||
result = []
|
||||
for item in selection:
|
||||
result.append({
|
||||
"type": type(item).__name__,
|
||||
"id": str(item.id) if hasattr(item, 'id') else ""
|
||||
})
|
||||
result.append(
|
||||
{"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""}
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
@@ -1241,4 +1231,4 @@ class IPCBoardAPI(BoardAPI):
|
||||
|
||||
|
||||
# Export for factory
|
||||
__all__ = ['IPCBackend', 'IPCBoardAPI']
|
||||
__all__ = ["IPCBackend", "IPCBoardAPI"]
|
||||
|
||||
@@ -8,16 +8,12 @@ WARNING: SWIG bindings are deprecated as of KiCAD 9.0
|
||||
and will be removed in KiCAD 10.0.
|
||||
Please migrate to IPC backend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kicad_api.base import (
|
||||
KiCADBackend,
|
||||
BoardAPI,
|
||||
ConnectionError,
|
||||
APINotAvailableError
|
||||
)
|
||||
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,7 +26,7 @@ class SWIGBackend(KiCADBackend):
|
||||
for compatibility during migration period.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
self._pcbnew = None
|
||||
logger.warning(
|
||||
@@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend):
|
||||
"""
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
self._pcbnew = pcbnew
|
||||
version = pcbnew.GetBuildVersion()
|
||||
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
|
||||
@@ -101,7 +98,7 @@ class SWIGBackend(KiCADBackend):
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
result = ProjectCommands.open_project(str(path))
|
||||
result = ProjectCommands().open_project({"filename": str(path)})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open project: {e}")
|
||||
@@ -115,8 +112,10 @@ class SWIGBackend(KiCADBackend):
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
path_str = str(path) if path else None
|
||||
result = ProjectCommands.save_project(path_str)
|
||||
params: Dict[str, Any] = {}
|
||||
if path:
|
||||
params["filename"] = str(path)
|
||||
result = ProjectCommands().save_project(params)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save project: {e}")
|
||||
@@ -140,7 +139,7 @@ class SWIGBackend(KiCADBackend):
|
||||
class SWIGBoardAPI(BoardAPI):
|
||||
"""Board API implementation wrapping SWIG/pcbnew"""
|
||||
|
||||
def __init__(self, pcbnew_module):
|
||||
def __init__(self, pcbnew_module: Any) -> None:
|
||||
self.pcbnew = pcbnew_module
|
||||
self._board = None
|
||||
|
||||
@@ -149,13 +148,15 @@ class SWIGBoardAPI(BoardAPI):
|
||||
from commands.board import BoardCommands
|
||||
|
||||
try:
|
||||
result = BoardCommands.set_board_size(width, height, unit)
|
||||
result = BoardCommands(board=self._board).set_board_size(
|
||||
{"width": width, "height": height, "unit": unit}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set board size: {e}")
|
||||
return False
|
||||
|
||||
def get_size(self) -> Dict[str, float]:
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""Get board size"""
|
||||
# TODO: Implement using existing SWIG code
|
||||
raise NotImplementedError("get_size not yet wrapped")
|
||||
@@ -176,7 +177,7 @@ class SWIGBoardAPI(BoardAPI):
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands.get_component_list()
|
||||
result = ComponentCommands(board=self._board).get_component_list({})
|
||||
if result.get("success"):
|
||||
return result.get("components", [])
|
||||
return []
|
||||
@@ -191,18 +192,21 @@ class SWIGBoardAPI(BoardAPI):
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu"
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""Place component using existing implementation"""
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands.place_component(
|
||||
component_id=footprint,
|
||||
position={"x": x, "y": y, "unit": "mm"},
|
||||
reference=reference,
|
||||
rotation=rotation,
|
||||
layer=layer
|
||||
result = ComponentCommands(board=self._board).place_component(
|
||||
{
|
||||
"componentId": footprint,
|
||||
"position": {"x": x, "y": y, "unit": "mm"},
|
||||
"reference": reference,
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
python/parsers/__init__.py
Normal file
1
python/parsers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# parsers package
|
||||
250
python/parsers/kicad_mod_parser.py
Normal file
250
python/parsers/kicad_mod_parser.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Parser for KiCad .kicad_mod footprint files.
|
||||
|
||||
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
|
||||
name – footprint name (str)
|
||||
library – library nickname, injected by caller (str)
|
||||
description – (descr "…") token (str | None)
|
||||
keywords – (tags "…") token (str | None)
|
||||
pads – list of pad objects: [{number, type, shape}, …] (list[dict])
|
||||
layers – sorted unique list of canonical layer names used (list[str])
|
||||
courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None)
|
||||
attributes – {"type": str, "board_only": bool, …} (dict | None)
|
||||
|
||||
KiCad S-expression file format reference:
|
||||
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a .kicad_mod file and return a dict whose keys match the fields
|
||||
expected by the TypeScript MCP tool handler (src/tools/library.ts).
|
||||
|
||||
Returns None if the file does not exist or cannot be read.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Footprint name: (footprint "NAME" …
|
||||
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
||||
if not m:
|
||||
# Older / unquoted format
|
||||
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
|
||||
result["name"] = _unescape(m.group(1)) if m else path.stem
|
||||
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Description: (descr "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["description"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Keywords / tags: (tags "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["keywords"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
||||
# TYPE is smd | through_hole (no quotes)
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r"\(attr\s+([^)]+)\)", content)
|
||||
if m:
|
||||
tokens = m.group(1).split()
|
||||
result["attributes"] = {
|
||||
"type": tokens[0] if tokens else "unspecified",
|
||||
"board_only": "board_only" in tokens,
|
||||
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
|
||||
"exclude_from_bom": "exclude_from_bom" in tokens,
|
||||
}
|
||||
else:
|
||||
result["attributes"] = None
|
||||
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pads: (pad "NUMBER" TYPE SHAPE …)
|
||||
# Return each pad as an object; deduplicate by number (first wins).
|
||||
# ------------------------------------------------------------------
|
||||
result["pads"] = _extract_pads(content)
|
||||
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Layers: all unique canonical layer names across the whole file.
|
||||
# Sources:
|
||||
# (layer "NAME") – single-layer items (fp_line, fp_text, …)
|
||||
# (layers "A" "B" …) – pad layer lists
|
||||
# ------------------------------------------------------------------
|
||||
layers: set = set()
|
||||
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
||||
layers.add(m.group(1))
|
||||
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
|
||||
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
||||
layers.add(lyr)
|
||||
result["layers"] = sorted(layers)
|
||||
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Courtyard: derive bounding box from F.CrtYd geometry.
|
||||
# Prefer fp_rect (most common for standard footprints), fall back to
|
||||
# fp_line segments.
|
||||
# ------------------------------------------------------------------
|
||||
result["courtyard"] = _extract_courtyard(content)
|
||||
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse all (pad …) blocks and return a list of pad objects.
|
||||
|
||||
Each object has:
|
||||
number – pad number string, e.g. "1", "A1", "GND"
|
||||
type – thru_hole | smd | np_thru_hole | connect
|
||||
shape – rect | circle | oval | roundrect | trapezoid | custom
|
||||
|
||||
Pads are deduplicated by number (first occurrence wins) so that the
|
||||
list represents the logical pads of the footprint, not duplicated
|
||||
copper entries.
|
||||
"""
|
||||
pads: List[Dict[str, Any]] = []
|
||||
seen_numbers: dict = {}
|
||||
|
||||
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
||||
quoted_pattern = re.compile(
|
||||
r'\(pad\s+"([^"]*)"\s+'
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in quoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
if not pads:
|
||||
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
||||
unquoted_pattern = re.compile(
|
||||
r"\(pad\s+(\S+)\s+"
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in unquoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
return pads
|
||||
|
||||
|
||||
def _unescape(s: str) -> str:
|
||||
"""Reverse KiCad S-expression string escaping."""
|
||||
return s.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _extract_blocks(content: str, token: str) -> List[str]:
|
||||
"""
|
||||
Return all S-expression blocks that start with `(token ` by tracking
|
||||
parenthesis depth. This correctly handles nested parens inside blocks.
|
||||
"""
|
||||
blocks: List[str] = []
|
||||
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
|
||||
|
||||
for match in pattern.finditer(content):
|
||||
start = match.start()
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
blocks.append(content[start : i + 1])
|
||||
break
|
||||
i += 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Compute the courtyard bounding box from F.CrtYd geometry.
|
||||
|
||||
Strategy:
|
||||
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
|
||||
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
|
||||
all endpoints.
|
||||
"""
|
||||
xs: List[float] = []
|
||||
ys: List[float] = []
|
||||
|
||||
# --- fp_rect pass ---
|
||||
for block in _extract_blocks(content, "fp_rect"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
if s and e:
|
||||
xs += [float(s.group(1)), float(e.group(1))]
|
||||
ys += [float(s.group(2)), float(e.group(2))]
|
||||
logger.debug(
|
||||
f"_extract_courtyard: fp_rect F.CrtYd "
|
||||
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
|
||||
)
|
||||
|
||||
# --- fp_line pass (only if fp_rect found nothing) ---
|
||||
if not xs:
|
||||
for block in _extract_blocks(content, "fp_line"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
|
||||
xs.append(float(m.group(1)))
|
||||
ys.append(float(m.group(2)))
|
||||
|
||||
if not xs:
|
||||
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
|
||||
return None
|
||||
|
||||
width = round(abs(max(xs) - min(xs)), 6)
|
||||
height = round(abs(max(ys) - min(ys)), 6)
|
||||
logger.debug(f"_extract_courtyard: result width={width} height={height}")
|
||||
return {"width": width, "height": height}
|
||||
@@ -4,4 +4,4 @@ Resource definitions for KiCAD MCP Server
|
||||
|
||||
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
|
||||
__all__ = ['RESOURCE_DEFINITIONS', 'handle_resource_read']
|
||||
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
|
||||
|
||||
@@ -5,12 +5,12 @@ Resources follow the MCP 2025-06-18 specification, providing
|
||||
read-only access to project data for LLM context.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from typing import Dict, Any, List, Optional
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger('kicad_interface')
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE DEFINITIONS
|
||||
@@ -21,57 +21,58 @@ RESOURCE_DEFINITIONS = [
|
||||
"uri": "kicad://project/current/info",
|
||||
"name": "Current Project Information",
|
||||
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"name": "Board Properties",
|
||||
"description": "Comprehensive board information including dimensions, layer count, and design rules",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"name": "Component List",
|
||||
"description": "List of all components on the board with references, footprints, values, and positions",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"name": "Electrical Nets",
|
||||
"description": "List of all electrical nets and their connections",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"name": "Layer Stack",
|
||||
"description": "Board layer configuration and properties",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"name": "Design Rules",
|
||||
"description": "Current design rule settings for clearances, track widths, and constraints",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"name": "DRC Violations",
|
||||
"description": "Design Rule Check violations and warnings from last DRC run",
|
||||
"mimeType": "application/json"
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"name": "Board Preview Image",
|
||||
"description": "2D rendering of the current board state",
|
||||
"mimeType": "image/png"
|
||||
}
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE READ HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
||||
|
||||
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle reading a resource by URI
|
||||
|
||||
@@ -92,7 +93,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
||||
"kicad://project/current/layers": _get_layers,
|
||||
"kicad://project/current/design-rules": _get_design_rules,
|
||||
"kicad://project/current/drc-report": _get_drc_report,
|
||||
"kicad://board/preview.png": _get_board_preview
|
||||
"kicad://board/preview.png": _get_board_preview,
|
||||
}
|
||||
|
||||
handler = handlers.get(uri)
|
||||
@@ -102,193 +103,217 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading resource {uri}: {str(e)}")
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": uri,
|
||||
"mimeType": "text/plain",
|
||||
"text": f"Error: {str(e)}"
|
||||
}]
|
||||
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": uri,
|
||||
"mimeType": "text/plain",
|
||||
"text": f"Unknown resource: {uri}"
|
||||
}]
|
||||
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INDIVIDUAL RESOURCE HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
def _get_project_info(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_project_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get current project information"""
|
||||
result = interface.project_commands.get_project_info({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("project", {}), indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("project", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No project currently open"
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No project currently open",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_board_info(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_board_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board properties and metadata"""
|
||||
result = interface.board_commands.get_board_info({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("board", {}), indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("board", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No board currently loaded"
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No board currently loaded",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_components(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_components(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of all components"""
|
||||
result = interface.component_commands.get_component_list({})
|
||||
|
||||
if result.get("success"):
|
||||
components = result.get("components", [])
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({
|
||||
"count": len(components),
|
||||
"components": components
|
||||
}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{"count": len(components), "components": components}, indent=2
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "components": []}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "components": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_nets(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_nets(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of electrical nets"""
|
||||
result = interface.routing_commands.get_nets_list({})
|
||||
|
||||
if result.get("success"):
|
||||
nets = result.get("nets", [])
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({
|
||||
"count": len(nets),
|
||||
"nets": nets
|
||||
}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "nets": []}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "nets": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_layers(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_layers(interface: Any) -> Dict[str, Any]:
|
||||
"""Get layer stack information"""
|
||||
result = interface.board_commands.get_layer_list({})
|
||||
|
||||
if result.get("success"):
|
||||
layers = result.get("layers", [])
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({
|
||||
"count": len(layers),
|
||||
"layers": layers
|
||||
}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "layers": []}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "layers": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_design_rules(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_design_rules(interface: Any) -> Dict[str, Any]:
|
||||
"""Get design rule settings"""
|
||||
result = interface.design_rule_commands.get_design_rules({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("rules", {}), indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("rules", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Design rules not available"
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Design rules not available",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_drc_report(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_drc_report(interface: Any) -> Dict[str, Any]:
|
||||
"""Get DRC violations"""
|
||||
result = interface.design_rule_commands.get_drc_violations({})
|
||||
|
||||
if result.get("success"):
|
||||
violations = result.get("violations", [])
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({
|
||||
"count": len(violations),
|
||||
"violations": violations
|
||||
}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{"count": len(violations), "violations": violations}, indent=2
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({
|
||||
"count": 0,
|
||||
"violations": [],
|
||||
"message": "Run DRC first to get violations"
|
||||
}, indent=2)
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{
|
||||
"count": 0,
|
||||
"violations": [],
|
||||
"message": "Run DRC first to get violations",
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _get_board_preview(interface) -> Dict[str, Any]:
|
||||
|
||||
def _get_board_preview(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board preview as PNG image"""
|
||||
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
|
||||
|
||||
@@ -296,18 +321,22 @@ def _get_board_preview(interface) -> Dict[str, Any]:
|
||||
# Image data should already be base64 encoded
|
||||
image_data = result.get("imageData", "")
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "image/png",
|
||||
"blob": image_data # Base64 encoded PNG
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "image/png",
|
||||
"blob": image_data, # Base64 encoded PNG
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
# Return a placeholder message
|
||||
return {
|
||||
"contents": [{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Board preview not available"
|
||||
}]
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Board preview not available",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ Tool schema definitions for KiCAD MCP Server
|
||||
|
||||
from .tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
__all__ = ['TOOL_SCHEMAS']
|
||||
__all__ = ["TOOL_SCHEMAS"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,10 @@ Prerequisites:
|
||||
Usage:
|
||||
./venv/bin/python python/test_ipc_backend.py
|
||||
"""
|
||||
import sys
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -23,17 +25,16 @@ import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_connection():
|
||||
def test_connection() -> Optional[Any]:
|
||||
"""Test basic IPC connection to KiCAD."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 1: IPC Connection")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
@@ -62,11 +63,11 @@ def test_connection():
|
||||
return None
|
||||
|
||||
|
||||
def test_board_access(backend):
|
||||
def test_board_access(backend: Any) -> Optional[Any]:
|
||||
"""Test board access and component listing."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 2: Board Access")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
board_api = backend.get_board()
|
||||
@@ -79,11 +80,11 @@ def test_board_access(backend):
|
||||
if components:
|
||||
print("\n First 5 components:")
|
||||
for comp in components[:5]:
|
||||
ref = comp.get('reference', 'N/A')
|
||||
val = comp.get('value', 'N/A')
|
||||
pos = comp.get('position', {})
|
||||
x = pos.get('x', 0)
|
||||
y = pos.get('y', 0)
|
||||
ref = comp.get("reference", "N/A")
|
||||
val = comp.get("value", "N/A")
|
||||
pos = comp.get("position", {})
|
||||
x = pos.get("x", 0)
|
||||
y = pos.get("y", 0)
|
||||
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
|
||||
|
||||
return board_api
|
||||
@@ -93,11 +94,11 @@ def test_board_access(backend):
|
||||
return None
|
||||
|
||||
|
||||
def test_board_info(board_api):
|
||||
def test_board_info(board_api: Any) -> bool:
|
||||
"""Test getting board information."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 3: Board Information")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Get board size
|
||||
@@ -134,30 +135,25 @@ def test_board_info(board_api):
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_track(board_api, interactive=False):
|
||||
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 4: Real-time Track Addition")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
|
||||
print("Watch the KiCAD window!")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
|
||||
if response != 'y':
|
||||
if response != "y":
|
||||
print("Skipped track test")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add a track
|
||||
success = board_api.add_track(
|
||||
start_x=100.0,
|
||||
start_y=100.0,
|
||||
end_x=120.0,
|
||||
end_y=100.0,
|
||||
width=0.25,
|
||||
layer="F.Cu"
|
||||
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -173,30 +169,24 @@ def test_realtime_track(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_via(board_api, interactive=False):
|
||||
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 5: Real-time Via Addition")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
|
||||
print("Watch the KiCAD window!")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
|
||||
if response != 'y':
|
||||
if response != "y":
|
||||
print("Skipped via test")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add a via
|
||||
success = board_api.add_via(
|
||||
x=120.0,
|
||||
y=100.0,
|
||||
diameter=0.8,
|
||||
drill=0.4,
|
||||
via_type="through"
|
||||
)
|
||||
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
|
||||
|
||||
if success:
|
||||
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
|
||||
@@ -211,28 +201,22 @@ def test_realtime_via(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_text(board_api, interactive=False):
|
||||
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding text in real-time."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 6: Real-time Text Addition")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
|
||||
if response != 'y':
|
||||
if response != "y":
|
||||
print("Skipped text test")
|
||||
return False
|
||||
|
||||
try:
|
||||
success = board_api.add_text(
|
||||
text="MCP Test",
|
||||
x=100.0,
|
||||
y=95.0,
|
||||
layer="F.SilkS",
|
||||
size=1.0
|
||||
)
|
||||
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
|
||||
|
||||
if success:
|
||||
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
|
||||
@@ -246,11 +230,11 @@ def test_realtime_text(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def test_selection(board_api, interactive=False):
|
||||
def test_selection(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test getting the current selection from KiCAD UI."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 7: UI Selection")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
|
||||
if interactive:
|
||||
print("\nSelect some items in KiCAD, then press Enter...")
|
||||
@@ -272,28 +256,28 @@ def test_selection(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def run_all_tests(interactive=False):
|
||||
def run_all_tests(interactive: bool = False) -> bool:
|
||||
"""Run all IPC backend tests."""
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("KiCAD IPC Backend Test Suite")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
print("\nThis script tests real-time communication with KiCAD via IPC API.")
|
||||
print("Make sure KiCAD is running with a board open.\n")
|
||||
|
||||
# Test connection
|
||||
backend = test_connection()
|
||||
if not backend:
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS FAILED: Could not connect to KiCAD")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
return False
|
||||
|
||||
# Test board access
|
||||
board_api = test_board_access(backend)
|
||||
if not board_api:
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS FAILED: Could not access board")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
return False
|
||||
|
||||
# Test board info
|
||||
@@ -307,9 +291,9 @@ def run_all_tests(interactive=False):
|
||||
# Test selection
|
||||
test_selection(board_api, interactive)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS COMPLETE")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
print("\nThe IPC backend is working! Changes appear in real-time.")
|
||||
print("No manual reload required - this is the power of the IPC API!")
|
||||
|
||||
@@ -321,9 +305,14 @@ def run_all_tests(interactive=False):
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend')
|
||||
parser.add_argument('-i', '--interactive', action='store_true',
|
||||
help='Run in interactive mode (prompts before modifications)')
|
||||
|
||||
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="Run in interactive mode (prompts before modifications)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
success = run_all_tests(interactive=args.interactive)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
Pytest configuration for python/tests.
|
||||
|
||||
Sets up sys.path so that the python/ package root is importable without
|
||||
installing the project, and provides shared fixtures.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make the python/ package root importable
|
||||
PYTHON_ROOT = Path(__file__).parent.parent
|
||||
if str(PYTHON_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PYTHON_ROOT))
|
||||
|
||||
# Stub out heavy KiCAD C-extension modules so tests can run without a real
|
||||
# KiCAD installation. Extend this list whenever a new import fails.
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard)
|
||||
# returns another MagicMock rather than raising AttributeError.
|
||||
for _stub_name in ("pcbnew", "skip"):
|
||||
if _stub_name not in sys.modules:
|
||||
_m = MagicMock(spec_set=None)
|
||||
_m.__name__ = _stub_name
|
||||
sys.modules[_stub_name] = _m
|
||||
@@ -1,203 +0,0 @@
|
||||
"""
|
||||
Regression tests for delete_schematic_component.
|
||||
|
||||
Key regression: the handler previously used a line-by-line regex that required
|
||||
`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts
|
||||
them on *separate* lines, so every real-world delete returned "not found".
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||
|
||||
# Inline format (single line) – matches what tests previously used
|
||||
PLACED_RESISTOR_INLINE = '''\
|
||||
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
||||
(in_bom yes) (on_board yes) (dnp no)
|
||||
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
(property "Reference" "R1" (at 51.27 47.46 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "10k" (at 51.27 52.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
)
|
||||
'''
|
||||
|
||||
# Multi-line format – as KiCAD's own file writer produces it.
|
||||
# (symbol and (lib_id are on separate lines, which broke the old regex.
|
||||
PLACED_RESISTOR_MULTILINE = '''\
|
||||
\t(symbol
|
||||
\t\t(lib_id "Device:R")
|
||||
\t\t(at 50 50 0)
|
||||
\t\t(unit 1)
|
||||
\t\t(in_bom yes)
|
||||
\t\t(on_board yes)
|
||||
\t\t(dnp no)
|
||||
\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
|
||||
\t\t(property "Reference" "R2"
|
||||
\t\t\t(at 51.27 47.46 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t\t(property "Value" "4.7k"
|
||||
\t\t\t(at 51.27 52.54 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
'''
|
||||
|
||||
# Multi-line power symbol – the exact scenario that was reported as broken.
|
||||
PLACED_POWER_SYMBOL_MULTILINE = '''\
|
||||
\t(symbol
|
||||
\t\t(lib_id "power:VCC")
|
||||
\t\t(at 365.6 38.1 0)
|
||||
\t\t(unit 1)
|
||||
\t\t(in_bom yes)
|
||||
\t\t(on_board yes)
|
||||
\t\t(dnp no)
|
||||
\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030")
|
||||
\t\t(property "Reference" "#PWR030"
|
||||
\t\t\t(at 365.6 41.91 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t\t(hide yes)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t\t(property "Value" "VCC"
|
||||
\t\t\t(at 365.6 35.56 0)
|
||||
\t\t\t(effects
|
||||
\t\t\t\t(font
|
||||
\t\t\t\t\t(size 1.27 1.27)
|
||||
\t\t\t\t)
|
||||
\t\t\t)
|
||||
\t\t)
|
||||
\t)
|
||||
'''
|
||||
|
||||
|
||||
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
||||
dest = tmp_path / "test.kicad_sch"
|
||||
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
||||
if extra_block:
|
||||
src_content = src_content.rstrip()
|
||||
if src_content.endswith(")"):
|
||||
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
|
||||
dest.write_text(src_content, encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – regression proof for the old regex vs the new approach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteDetectionRegex:
|
||||
"""Verify that the new content-string pattern finds blocks in both formats."""
|
||||
|
||||
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
|
||||
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
|
||||
|
||||
def test_old_regex_fails_on_multiline_format(self):
|
||||
"""Regression: old line-by-line regex must NOT match the multi-line format."""
|
||||
# The old code used re.match on individual lines; simulate that here.
|
||||
lines = PLACED_RESISTOR_MULTILINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert matches == [], "Old regex should not match multi-line KiCAD format"
|
||||
|
||||
def test_old_regex_matches_inline_format(self):
|
||||
"""Old regex did work on single-line (inline) format."""
|
||||
lines = PLACED_RESISTOR_INLINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert len(matches) == 1
|
||||
|
||||
def test_new_pattern_matches_multiline_format(self):
|
||||
"""New content-string pattern must find blocks in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
|
||||
|
||||
def test_new_pattern_matches_inline_format(self):
|
||||
"""New content-string pattern also works on inline format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
|
||||
|
||||
def test_new_pattern_matches_power_symbol_multiline(self):
|
||||
"""New pattern must find #PWR030 power symbol in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
def test_reference_extraction_from_multiline_block(self):
|
||||
"""Reference property can be found inside a multi-line block."""
|
||||
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
|
||||
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – real file I/O using the handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteSchematicComponentIntegration:
|
||||
def _get_handler(self):
|
||||
from kicad_interface import KiCADInterface
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_delete_schematic_component
|
||||
|
||||
def test_delete_inline_format_succeeds(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_multiline_format_succeeds(self, tmp_path):
|
||||
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_power_symbol_multiline_succeeds(self, tmp_path):
|
||||
"""Regression: #PWR030 multi-line power symbol must be deletable."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_component_absent_after_delete(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
remaining = sch.read_text(encoding="utf-8")
|
||||
assert '"#PWR030"' not in remaining
|
||||
|
||||
def test_unknown_reference_returns_failure(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_missing_schematic_path_returns_failure(self, tmp_path):
|
||||
result = self._get_handler()({"reference": "R1"})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_reference_returns_failure(self, tmp_path):
|
||||
sch = _make_test_schematic(tmp_path)
|
||||
result = self._get_handler()({"schematicPath": str(sch)})
|
||||
assert result["success"] is False
|
||||
@@ -1,541 +0,0 @@
|
||||
"""
|
||||
Tests for the Freerouting autoroute integration.
|
||||
|
||||
Covers:
|
||||
- FreeroutingCommands.check_freerouting (dependency detection)
|
||||
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
|
||||
- FreeroutingCommands.import_ses (SES import via pcbnew)
|
||||
- FreeroutingCommands.autoroute (full pipeline, direct + docker)
|
||||
- Error handling: missing board, no runtime, missing JAR, timeouts
|
||||
- _find_java, _docker_available, _java_version_ok helpers
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from commands.freerouting import (
|
||||
FreeroutingCommands,
|
||||
_find_java,
|
||||
_find_docker,
|
||||
_docker_available,
|
||||
_java_version_ok,
|
||||
)
|
||||
|
||||
# pcbnew mock from conftest
|
||||
pcbnew_mock = sys.modules["pcbnew"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_pcbnew_mock():
|
||||
"""Reset pcbnew mock before each test."""
|
||||
pcbnew_mock.reset_mock()
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = None
|
||||
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
|
||||
pcbnew_mock.ImportSpecctraSES.side_effect = None
|
||||
pcbnew_mock.ImportSpecctraSES.return_value = MagicMock()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_board():
|
||||
board = MagicMock()
|
||||
board.GetFileName.return_value = (
|
||||
"/tmp/test_project/test.kicad_pcb"
|
||||
)
|
||||
board.GetTracks.return_value = []
|
||||
return board
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmds(mock_board):
|
||||
return FreeroutingCommands(board=mock_board)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmds_no_board():
|
||||
return FreeroutingCommands(board=None)
|
||||
|
||||
|
||||
def _patch_direct_java():
|
||||
"""Patch to simulate Java 21+ available locally."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
"_resolve_execution_mode",
|
||||
return_value={"mode": "direct", "use_docker": False},
|
||||
)
|
||||
|
||||
|
||||
def _patch_docker_mode():
|
||||
"""Patch to simulate Docker execution mode."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
"_resolve_execution_mode",
|
||||
return_value={"mode": "docker", "use_docker": True},
|
||||
)
|
||||
|
||||
|
||||
def _patch_no_runtime():
|
||||
"""Patch to simulate no Java and no Docker."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
"_resolve_execution_mode",
|
||||
return_value={
|
||||
"mode": "error",
|
||||
"error": "Neither Java 21+ nor Docker found.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_freerouting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckFreerouting:
|
||||
def test_no_java_no_docker(self, cmds):
|
||||
with patch(
|
||||
"commands.freerouting._find_java", return_value=None
|
||||
), patch(
|
||||
"commands.freerouting._docker_available",
|
||||
return_value=False,
|
||||
):
|
||||
result = cmds.check_freerouting(
|
||||
{"freeroutingJar": "/nonexistent.jar"}
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["java"]["found"] is False
|
||||
assert result["docker"]["available"] is False
|
||||
assert result["ready"] is False
|
||||
assert result["execution_mode"] == "none"
|
||||
|
||||
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with patch(
|
||||
"commands.freerouting._find_java",
|
||||
return_value="/usr/bin/java",
|
||||
), patch(
|
||||
"commands.freerouting._java_version_ok",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"commands.freerouting._docker_available",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr='openjdk version "17.0.1"', stdout=""
|
||||
)
|
||||
result = cmds.check_freerouting(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
assert result["ready"] is True
|
||||
assert result["execution_mode"] == "docker"
|
||||
|
||||
def test_java_21_direct(self, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with patch(
|
||||
"commands.freerouting._find_java",
|
||||
return_value="/usr/bin/java",
|
||||
), patch(
|
||||
"commands.freerouting._java_version_ok",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"commands.freerouting._docker_available",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr='openjdk version "21.0.1"', stdout=""
|
||||
)
|
||||
result = cmds.check_freerouting(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
assert result["ready"] is True
|
||||
assert result["execution_mode"] == "direct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export_dsn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportDsn:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
result = cmds_no_board.export_dsn({})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_export_success(self, cmds, tmp_path):
|
||||
board_path = str(tmp_path / "test.kicad_pcb")
|
||||
dsn_path = str(tmp_path / "test.dsn")
|
||||
cmds.board.GetFileName.return_value = board_path
|
||||
|
||||
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||
Path(dsn_path).write_text("(pcb test)")
|
||||
|
||||
result = cmds.export_dsn({})
|
||||
assert result["success"] is True
|
||||
assert result["path"] == dsn_path
|
||||
|
||||
def test_export_custom_path(self, cmds, tmp_path):
|
||||
output = str(tmp_path / "custom.dsn")
|
||||
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||
Path(output).write_text("(pcb test)")
|
||||
|
||||
result = cmds.export_dsn({"outputPath": output})
|
||||
assert result["success"] is True
|
||||
assert result["path"] == output
|
||||
|
||||
def test_export_failure(self, cmds):
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
||||
"DSN error"
|
||||
)
|
||||
result = cmds.export_dsn({})
|
||||
assert result["success"] is False
|
||||
assert "DSN error" in result["errorDetails"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# import_ses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImportSes:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
result = cmds_no_board.import_ses(
|
||||
{"sesPath": "/tmp/test.ses"}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_missing_ses_path(self, cmds):
|
||||
result = cmds.import_ses({})
|
||||
assert result["success"] is False
|
||||
assert "Missing sesPath" in result["message"]
|
||||
|
||||
def test_ses_file_not_found(self, cmds):
|
||||
result = cmds.import_ses(
|
||||
{"sesPath": "/nonexistent/test.ses"}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_import_success(self, cmds, tmp_path):
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||
cmds.board.GetTracks.return_value = []
|
||||
|
||||
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||
assert result["success"] is True
|
||||
|
||||
def test_import_failure(self, cmds, tmp_path):
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
pcbnew_mock.ImportSpecctraSES.side_effect = Exception(
|
||||
"SES error"
|
||||
)
|
||||
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||
assert result["success"] is False
|
||||
assert "SES error" in result["errorDetails"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# autoroute (full pipeline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoroute:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
result = cmds_no_board.autoroute({})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_no_runtime(self, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with _patch_no_runtime():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "No suitable Java runtime" in result["message"]
|
||||
|
||||
def test_no_jar(self, cmds):
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": "/nonexistent/freerouting.jar"}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "JAR not found" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
||||
"export fail"
|
||||
)
|
||||
|
||||
with _patch_direct_java():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "DSN export failed" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_timeout(
|
||||
self, mock_run, cmds, tmp_path
|
||||
):
|
||||
import subprocess
|
||||
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
board_dir.mkdir()
|
||||
board_file = board_dir / "test.kicad_pcb"
|
||||
board_file.touch()
|
||||
dsn_file = board_dir / "test.dsn"
|
||||
|
||||
cmds.board.GetFileName.return_value = str(board_file)
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||
)
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd="", timeout=10
|
||||
)
|
||||
|
||||
with _patch_direct_java():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar), "timeout": 10}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "timed out" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_direct(
|
||||
self, mock_run, cmds, tmp_path
|
||||
):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
board_dir.mkdir()
|
||||
board_file = board_dir / "test.kicad_pcb"
|
||||
board_file.touch()
|
||||
dsn_file = board_dir / "test.dsn"
|
||||
ses_file = board_dir / "test.ses"
|
||||
|
||||
cmds.board.GetFileName.return_value = str(board_file)
|
||||
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||
)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout="Routing completed", stderr=""
|
||||
)
|
||||
ses_file.write_text("(session)")
|
||||
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||
|
||||
track = MagicMock()
|
||||
track.GetClass.return_value = "PCB_TRACK"
|
||||
via = MagicMock()
|
||||
via.GetClass.return_value = "PCB_VIA"
|
||||
cmds.board.GetTracks.return_value = [track, track, via]
|
||||
|
||||
with _patch_direct_java():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "direct"
|
||||
assert result["board_stats"]["tracks"] == 2
|
||||
assert result["board_stats"]["vias"] == 1
|
||||
assert "elapsed_seconds" in result
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_docker(
|
||||
self, mock_run, cmds, tmp_path
|
||||
):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
board_dir.mkdir()
|
||||
board_file = board_dir / "test.kicad_pcb"
|
||||
board_file.touch()
|
||||
dsn_file = board_dir / "test.dsn"
|
||||
ses_file = board_dir / "test.ses"
|
||||
|
||||
cmds.board.GetFileName.return_value = str(board_file)
|
||||
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||
)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout="Routing completed", stderr=""
|
||||
)
|
||||
ses_file.write_text("(session)")
|
||||
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||
|
||||
cmds.board.GetTracks.return_value = [MagicMock()]
|
||||
|
||||
with _patch_docker_mode(), patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value="/usr/bin/docker",
|
||||
):
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "docker"
|
||||
# Verify a container runtime was invoked
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "run" in call_args
|
||||
assert "--rm" in call_args
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_nonzero_exit(
|
||||
self, mock_run, cmds, tmp_path
|
||||
):
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
board_dir.mkdir()
|
||||
board_file = board_dir / "test.kicad_pcb"
|
||||
board_file.touch()
|
||||
dsn_file = board_dir / "test.dsn"
|
||||
|
||||
cmds.board.GetFileName.return_value = str(board_file)
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||
)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="OutOfMemoryError"
|
||||
)
|
||||
|
||||
with _patch_direct_java():
|
||||
result = cmds.autoroute(
|
||||
{"freeroutingJar": str(jar)}
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "exited with code 1" in result["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindJava:
|
||||
def test_finds_via_which(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value="/usr/bin/java",
|
||||
):
|
||||
assert _find_java() == "/usr/bin/java"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value=None,
|
||||
), patch("os.path.isfile", return_value=False):
|
||||
assert _find_java() is None
|
||||
|
||||
|
||||
class TestFindDocker:
|
||||
def test_finds_docker(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/docker"
|
||||
if x == "docker"
|
||||
else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/docker"
|
||||
|
||||
def test_finds_podman(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/podman"
|
||||
if x == "podman"
|
||||
else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/podman"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
assert _find_docker() is None
|
||||
|
||||
|
||||
class TestDockerAvailable:
|
||||
def test_docker_found(self):
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value="/usr/bin/docker",
|
||||
), patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert _docker_available() is True
|
||||
|
||||
def test_docker_not_installed(self):
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value=None,
|
||||
):
|
||||
assert _docker_available() is False
|
||||
|
||||
def test_docker_not_running(self):
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value="/usr/bin/docker",
|
||||
), patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
assert _docker_available() is False
|
||||
|
||||
|
||||
class TestJavaVersionOk:
|
||||
def test_java_21(self):
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr='openjdk version "21.0.1"', stdout=""
|
||||
)
|
||||
assert _java_version_ok("/usr/bin/java") is True
|
||||
|
||||
def test_java_17(self):
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stderr='openjdk version "17.0.18"', stdout=""
|
||||
)
|
||||
assert _java_version_ok("/usr/bin/java") is False
|
||||
|
||||
def test_java_error(self):
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run",
|
||||
side_effect=Exception("not found"),
|
||||
):
|
||||
assert _java_version_ok("/usr/bin/java") is False
|
||||
@@ -1,369 +0,0 @@
|
||||
"""
|
||||
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure python/ directory is on path so kicad_interface can be imported
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers shared across tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||
|
||||
# Minimal placed-symbol block we can embed into a schematic for testing
|
||||
PLACED_RESISTOR_BLOCK = """\
|
||||
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
||||
(in_bom yes) (on_board yes) (dnp no)
|
||||
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
(property "Reference" "R1" (at 51.27 47.46 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "10k" (at 51.27 52.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
|
||||
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
|
||||
dest = tmp_dir / "test.kicad_sch"
|
||||
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
|
||||
# Insert placed symbol block before the closing paren of the top-level form
|
||||
if extra_block:
|
||||
src_content = src_content.rstrip()
|
||||
if src_content.endswith(")"):
|
||||
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
|
||||
dest.write_text(src_content, encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSchematicComponentParsing:
|
||||
"""Unit tests for the regex logic used by _handle_get_schematic_component."""
|
||||
|
||||
def _parse_fields(self, block_text: str) -> dict:
|
||||
"""Mirrors the regex used in _handle_get_schematic_component."""
|
||||
prop_pattern = re.compile(
|
||||
r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)'
|
||||
)
|
||||
fields = {}
|
||||
for m in prop_pattern.finditer(block_text):
|
||||
name, value, x, y, angle = (
|
||||
m.group(1),
|
||||
m.group(2),
|
||||
m.group(3),
|
||||
m.group(4),
|
||||
m.group(5),
|
||||
)
|
||||
fields[name] = {
|
||||
"value": value,
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"angle": float(angle),
|
||||
}
|
||||
return fields
|
||||
|
||||
def _parse_comp_pos(self, block_text: str):
|
||||
"""Mirrors the regex used to extract symbol position."""
|
||||
m = re.search(
|
||||
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
|
||||
block_text,
|
||||
)
|
||||
if m:
|
||||
return {
|
||||
"x": float(m.group(1)),
|
||||
"y": float(m.group(2)),
|
||||
"angle": float(m.group(3)),
|
||||
}
|
||||
return None
|
||||
|
||||
def test_parses_reference_field(self):
|
||||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||
assert "Reference" in fields
|
||||
assert fields["Reference"]["value"] == "R1"
|
||||
assert fields["Reference"]["x"] == pytest.approx(51.27)
|
||||
assert fields["Reference"]["y"] == pytest.approx(47.46)
|
||||
assert fields["Reference"]["angle"] == pytest.approx(0.0)
|
||||
|
||||
def test_parses_value_field(self):
|
||||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||
assert "Value" in fields
|
||||
assert fields["Value"]["value"] == "10k"
|
||||
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||||
assert fields["Value"]["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_parses_all_four_standard_fields(self):
|
||||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
|
||||
|
||||
def test_parses_component_position(self):
|
||||
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
|
||||
assert pos is not None
|
||||
assert pos["x"] == pytest.approx(50.0)
|
||||
assert pos["y"] == pytest.approx(50.0)
|
||||
assert pos["angle"] == pytest.approx(0.0)
|
||||
|
||||
def test_field_position_regex_replaces_correctly(self):
|
||||
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
|
||||
field_name = "Reference"
|
||||
new_x, new_y, new_angle = 99.0, 88.0, 0
|
||||
block = PLACED_RESISTOR_BLOCK
|
||||
block = re.sub(
|
||||
r'(\(property\s+"'
|
||||
+ re.escape(field_name)
|
||||
+ r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
rf"\1(at {new_x} {new_y} {new_angle})",
|
||||
block,
|
||||
)
|
||||
fields = self._parse_fields(block)
|
||||
assert fields["Reference"]["x"] == pytest.approx(99.0)
|
||||
assert fields["Reference"]["y"] == pytest.approx(88.0)
|
||||
# Value should be unchanged
|
||||
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||||
|
||||
def test_field_position_regex_preserves_value(self):
|
||||
"""Replacing position must not change the field value string."""
|
||||
block = PLACED_RESISTOR_BLOCK
|
||||
block = re.sub(
|
||||
r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
r"\1(at 0.0 0.0 0)",
|
||||
block,
|
||||
)
|
||||
fields = self._parse_fields(block)
|
||||
assert fields["Value"]["value"] == "10k"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – real file I/O using the empty.kicad_sch template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestGetSchematicComponentIntegration:
|
||||
"""Integration tests: write a real .kicad_sch and call the handler."""
|
||||
|
||||
@pytest.fixture
|
||||
def sch_with_r1(self, tmp_path):
|
||||
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||||
|
||||
def _get_interface(self):
|
||||
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface()
|
||||
|
||||
def test_get_returns_success(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_get_returns_correct_reference(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["reference"] == "R1"
|
||||
|
||||
def test_get_returns_component_position(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["position"] is not None
|
||||
assert result["position"]["x"] == pytest.approx(50.0)
|
||||
assert result["position"]["y"] == pytest.approx(50.0)
|
||||
|
||||
def test_get_returns_reference_field_position(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
ref_field = result["fields"]["Reference"]
|
||||
assert ref_field["value"] == "R1"
|
||||
assert ref_field["x"] == pytest.approx(51.27)
|
||||
assert ref_field["y"] == pytest.approx(47.46)
|
||||
|
||||
def test_get_returns_value_field(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
val_field = result["fields"]["Value"]
|
||||
assert val_field["value"] == "10k"
|
||||
assert val_field["x"] == pytest.approx(51.27)
|
||||
assert val_field["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R99",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "R99" in result["message"]
|
||||
|
||||
def test_get_missing_path_returns_failure(self):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestEditSchematicComponentFieldPositions:
|
||||
"""Integration tests for the new fieldPositions parameter."""
|
||||
|
||||
@pytest.fixture
|
||||
def sch_with_r1(self, tmp_path):
|
||||
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
|
||||
|
||||
def _get_interface(self):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface()
|
||||
|
||||
def test_reposition_reference_label(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify the position was actually written
|
||||
get_result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
|
||||
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
|
||||
|
||||
def test_reposition_does_not_change_value(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
|
||||
},
|
||||
)
|
||||
get_result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
# Value field position must be unchanged
|
||||
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
|
||||
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_reposition_multiple_fields(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {
|
||||
"Reference": {"x": 10.0, "y": 20.0, "angle": 0},
|
||||
"Value": {"x": 10.0, "y": 30.0, "angle": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
get_result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
|
||||
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
|
||||
|
||||
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
|
||||
"""fieldPositions without value/footprint/newReference should succeed."""
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_no_params_still_fails(self, sch_with_r1):
|
||||
"""Providing no update params should return an error."""
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
@@ -1,498 +0,0 @@
|
||||
"""
|
||||
Tests for schematic inspection and editing tools added in the schematic_tools branch.
|
||||
|
||||
Covers:
|
||||
- WireManager.delete_wire (unit + integration)
|
||||
- WireManager.delete_label (unit + integration)
|
||||
- Handler-level parameter validation for the 11 new KiCADInterface handlers
|
||||
(tested by calling _handle_* methods on a lightweight stub that avoids
|
||||
importing the full kicad_interface module).
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import sexpdata
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
|
||||
|
||||
# Minimal schematic content used by integration tests
|
||||
_WIRE_SCH = """\
|
||||
(kicad_sch (version 20250114) (generator "test")
|
||||
(uuid aaaaaaaa-0000-0000-0000-000000000000)
|
||||
(paper "A4")
|
||||
(wire (pts (xy 10 20) (xy 30 20))
|
||||
(stroke (width 0) (type default))
|
||||
(uuid bbbbbbbb-0000-0000-0000-000000000001)
|
||||
)
|
||||
(label "VCC" (at 50 50 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left bottom))
|
||||
(uuid cccccccc-0000-0000-0000-000000000002)
|
||||
)
|
||||
(sheet_instances (path "/" (page "1")))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _write_temp_sch(content: str) -> Path:
|
||||
"""Write *content* to a temp file and return its Path."""
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
|
||||
)
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteWireUnit:
|
||||
"""Unit-level tests for WireManager.delete_wire."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_wire(
|
||||
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_no_matching_wire_returns_false(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
|
||||
assert result is False
|
||||
|
||||
def test_tolerance_argument_accepted(self, tmp_path):
|
||||
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1)
|
||||
assert result is False # no wire in empty sch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteLabelUnit:
|
||||
"""Unit-level tests for WireManager.delete_label."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||
result = self.WireManager.delete_label(
|
||||
tmp_path / "nope.kicad_sch", "VCC"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_missing_label_returns_false(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "NONEXISTENT")
|
||||
assert result is False
|
||||
|
||||
def test_position_kwarg_accepted(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(
|
||||
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_wire
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteWireIntegration:
|
||||
"""Integration tests that read/write real .kicad_sch files."""
|
||||
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_exact_match_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wire_items = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert wire_items == [], "Wire should have been removed from the file"
|
||||
|
||||
def test_reverse_direction_match_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Pass end/start swapped – should still match
|
||||
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0])
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_within_tolerance_deletes_wire(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
|
||||
result = self.WireManager.delete_wire(
|
||||
sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_outside_tolerance_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_wire(
|
||||
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
|
||||
)
|
||||
# tolerance=0.0 means exact float equality — may still match on most
|
||||
# platforms, but the key thing is that a *distant* miss is rejected
|
||||
sch2 = tmp_path / "test2.kicad_sch"
|
||||
sch2.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5)
|
||||
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance"
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
# Must parse without exception
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
def test_label_preserved_after_wire_deletion(self, tmp_path):
|
||||
"""Deleting a wire must not remove unrelated elements."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert len(labels) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – WireManager.delete_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteLabelIntegration:
|
||||
def setup_method(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
self.WireManager = WireManager
|
||||
|
||||
def test_deletes_label_by_name(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC")
|
||||
|
||||
assert result is True
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
labels = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("label")
|
||||
]
|
||||
assert labels == [], "Label should have been removed"
|
||||
|
||||
def test_deletes_label_with_matching_position(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
|
||||
assert result is True
|
||||
|
||||
def test_position_mismatch_no_delete(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(
|
||||
sch, "VCC", position=[99.0, 99.0], tolerance=0.5
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_wire_preserved_after_label_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
wires = [
|
||||
item
|
||||
for item in data
|
||||
if isinstance(item, list)
|
||||
and item
|
||||
and item[0] == sexpdata.Symbol("wire")
|
||||
]
|
||||
assert len(wires) == 1
|
||||
|
||||
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – handler parameter validation (via lightweight handler stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
# We test the validation logic of the new _handle_* methods without importing
|
||||
# the full kicad_interface module (which pulls in pcbnew and calls sys.exit).
|
||||
# Each handler is extracted as a standalone function for testing.
|
||||
|
||||
|
||||
def _make_handler_under_test(handler_name: str):
|
||||
"""
|
||||
Return the unbound handler method from kicad_interface by importing only
|
||||
that method's source via exec, bypassing module-level side effects.
|
||||
|
||||
This works because every _handle_* method starts with a params dict check
|
||||
before doing any file I/O or heavy imports.
|
||||
"""
|
||||
import importlib.util, types
|
||||
|
||||
# We monkey-patch sys.modules to avoid pcbnew/skip side effects
|
||||
stubs = {}
|
||||
for mod in ("pcbnew", "skip", "commands.schematic"):
|
||||
stubs[mod] = types.ModuleType(mod)
|
||||
|
||||
# Provide a minimal SchematicManager stub so attribute lookups don't fail
|
||||
schema_stub = types.ModuleType("commands.schematic")
|
||||
schema_stub.SchematicManager = MagicMock()
|
||||
stubs["commands.schematic"] = schema_stub
|
||||
|
||||
with patch.dict("sys.modules", stubs):
|
||||
# Import just the handlers module in isolation isn't feasible for
|
||||
# kicad_interface.py (module-level sys.exit). Instead, we directly
|
||||
# call the method on a MagicMock instance, binding the real function.
|
||||
pass
|
||||
|
||||
return None # Not used; see TestHandlerParamValidation below
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandlerParamValidation:
|
||||
"""
|
||||
Verify that each new handler returns success=False with an informative
|
||||
message when required parameters are missing, without needing real files.
|
||||
|
||||
We call the handler functions directly after building minimal stub objects
|
||||
that satisfy the dependency chain up to the first parameter-check branch.
|
||||
"""
|
||||
|
||||
def _make_iface_stub(self):
|
||||
"""Return a stub that exposes only the handler methods under test."""
|
||||
import types
|
||||
import importlib
|
||||
|
||||
# Build a minimal namespace that satisfies the imports inside each handler
|
||||
stub_mod = types.ModuleType("_handler_stubs")
|
||||
stub_mod.os = __import__("os")
|
||||
|
||||
class _Stub:
|
||||
pass
|
||||
|
||||
return _Stub()
|
||||
|
||||
# --- delete_schematic_wire ---
|
||||
|
||||
def test_delete_wire_missing_schematic_path(self):
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
with patch.object(WireManager, "delete_wire", return_value=False):
|
||||
# Simulate the handler logic inline
|
||||
params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}}
|
||||
schematic_path = params.get("schematicPath")
|
||||
assert schematic_path is None
|
||||
# Handler should short-circuit before calling WireManager
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"]
|
||||
|
||||
# --- delete_schematic_net_label ---
|
||||
|
||||
def test_delete_label_missing_net_name(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
net_name = params.get("netName")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not net_name
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_delete_label_missing_schematic_path(self):
|
||||
params = {"netName": "VCC"}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and netName are required",
|
||||
}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_components ---
|
||||
|
||||
def test_list_components_missing_path(self):
|
||||
params = {}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_nets ---
|
||||
|
||||
def test_list_nets_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_wires ---
|
||||
|
||||
def test_list_wires_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- list_schematic_labels ---
|
||||
|
||||
def test_list_labels_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- move_schematic_component ---
|
||||
|
||||
def test_move_component_missing_reference(self):
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"position": {"x": 10, "y": 20},
|
||||
}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_move_component_missing_position(self):
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"reference": "R1",
|
||||
"position": {},
|
||||
}
|
||||
new_x = params["position"].get("x")
|
||||
new_y = params["position"].get("y")
|
||||
result = (
|
||||
{"success": False, "message": "position with x and y is required"}
|
||||
if new_x is None or new_y is None
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- rotate_schematic_component ---
|
||||
|
||||
def test_rotate_component_missing_reference(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and reference are required",
|
||||
}
|
||||
if not params.get("reference")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- annotate_schematic ---
|
||||
|
||||
def test_annotate_missing_path(self):
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not params.get("schematicPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
# --- export_schematic_svg ---
|
||||
|
||||
def test_export_svg_missing_output_path(self):
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
"success": False,
|
||||
"message": "schematicPath and outputPath are required",
|
||||
}
|
||||
if not params.get("outputPath")
|
||||
else {}
|
||||
)
|
||||
assert result["success"] is False
|
||||
@@ -3,77 +3,82 @@ KiCAD Process Management Utilities
|
||||
|
||||
Detects if KiCAD is running and provides auto-launch functionality.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KiCADProcessManager:
|
||||
"""Manages KiCAD process detection and launching"""
|
||||
|
||||
@staticmethod
|
||||
def _windows_list_processes() -> List[dict]:
|
||||
"""List running processes on Windows using Toolhelp API."""
|
||||
processes: List[dict] = []
|
||||
try:
|
||||
TH32CS_SNAPPROCESS = 0x00000002
|
||||
try:
|
||||
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
ulong_ptr = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
|
||||
|
||||
class PROCESSENTRY32W(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", wintypes.DWORD),
|
||||
("cntUsage", wintypes.DWORD),
|
||||
("th32ProcessID", wintypes.DWORD),
|
||||
("th32DefaultHeapID", ulong_ptr),
|
||||
("th32ModuleID", wintypes.DWORD),
|
||||
("cntThreads", wintypes.DWORD),
|
||||
("th32ParentProcessID", wintypes.DWORD),
|
||||
("pcPriClassBase", wintypes.LONG),
|
||||
("dwFlags", wintypes.DWORD),
|
||||
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
|
||||
]
|
||||
|
||||
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
|
||||
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
|
||||
Process32NextW = ctypes.windll.kernel32.Process32NextW
|
||||
CloseHandle = ctypes.windll.kernel32.CloseHandle
|
||||
|
||||
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
if snapshot == wintypes.HANDLE(-1).value:
|
||||
return processes
|
||||
|
||||
entry = PROCESSENTRY32W()
|
||||
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||
|
||||
if Process32FirstW(snapshot, ctypes.byref(entry)):
|
||||
while True:
|
||||
processes.append({
|
||||
"pid": str(entry.th32ProcessID),
|
||||
"name": entry.szExeFile,
|
||||
"command": entry.szExeFile
|
||||
})
|
||||
if not Process32NextW(snapshot, ctypes.byref(entry)):
|
||||
break
|
||||
|
||||
CloseHandle(snapshot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing Windows processes: {e}")
|
||||
|
||||
return processes
|
||||
|
||||
@staticmethod
|
||||
def is_running() -> bool:
|
||||
class KiCADProcessManager:
|
||||
"""Manages KiCAD process detection and launching"""
|
||||
|
||||
@staticmethod
|
||||
def _windows_list_processes() -> List[dict]:
|
||||
"""List running processes on Windows using Toolhelp API."""
|
||||
processes: List[dict] = []
|
||||
try:
|
||||
TH32CS_SNAPPROCESS = 0x00000002
|
||||
try:
|
||||
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
ulong_ptr = (
|
||||
ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
|
||||
)
|
||||
|
||||
class PROCESSENTRY32W(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", wintypes.DWORD),
|
||||
("cntUsage", wintypes.DWORD),
|
||||
("th32ProcessID", wintypes.DWORD),
|
||||
("th32DefaultHeapID", ulong_ptr),
|
||||
("th32ModuleID", wintypes.DWORD),
|
||||
("cntThreads", wintypes.DWORD),
|
||||
("th32ParentProcessID", wintypes.DWORD),
|
||||
("pcPriClassBase", wintypes.LONG),
|
||||
("dwFlags", wintypes.DWORD),
|
||||
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
|
||||
]
|
||||
|
||||
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
|
||||
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
|
||||
Process32NextW = ctypes.windll.kernel32.Process32NextW
|
||||
CloseHandle = ctypes.windll.kernel32.CloseHandle
|
||||
|
||||
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
if snapshot == wintypes.HANDLE(-1).value:
|
||||
return processes
|
||||
|
||||
entry = PROCESSENTRY32W()
|
||||
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||
|
||||
if Process32FirstW(snapshot, ctypes.byref(entry)):
|
||||
while True:
|
||||
processes.append(
|
||||
{
|
||||
"pid": str(entry.th32ProcessID),
|
||||
"name": entry.szExeFile,
|
||||
"command": entry.szExeFile,
|
||||
}
|
||||
)
|
||||
if not Process32NextW(snapshot, ctypes.byref(entry)):
|
||||
break
|
||||
|
||||
CloseHandle(snapshot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing Windows processes: {e}")
|
||||
|
||||
return processes
|
||||
|
||||
@staticmethod
|
||||
def is_running() -> bool:
|
||||
"""
|
||||
Check if KiCAD is currently running
|
||||
|
||||
@@ -87,27 +92,21 @@ class KiCADProcessManager:
|
||||
# Check for actual pcbnew/kicad binaries (not python scripts)
|
||||
# Use exact process name matching to avoid matching our own kicad_interface.py
|
||||
result = subprocess.run(
|
||||
["pgrep", "-x", "pcbnew|kicad"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
# Also check with -f for full path matching, but exclude our script
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "/pcbnew|/kicad"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
|
||||
)
|
||||
# Double-check it's not our own process
|
||||
if result.returncode == 0:
|
||||
pids = result.stdout.strip().split('\n')
|
||||
pids = result.stdout.strip().split("\n")
|
||||
for pid in pids:
|
||||
try:
|
||||
cmdline = subprocess.run(
|
||||
["ps", "-p", pid, "-o", "command="],
|
||||
capture_output=True,
|
||||
text=True
|
||||
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
|
||||
)
|
||||
if "kicad_interface.py" not in cmdline.stdout:
|
||||
return True
|
||||
@@ -117,18 +116,16 @@ class KiCADProcessManager:
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "KiCad|pcbnew"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
elif system == "Windows":
|
||||
processes = KiCADProcessManager._windows_list_processes()
|
||||
for proc in processes:
|
||||
name = (proc.get("name") or "").lower()
|
||||
if name in ("pcbnew.exe", "kicad.exe"):
|
||||
return True
|
||||
elif system == "Windows":
|
||||
processes = KiCADProcessManager._windows_list_processes()
|
||||
for proc in processes:
|
||||
name = (proc.get("name") or "").lower()
|
||||
if name in ("pcbnew.exe", "kicad.exe"):
|
||||
return True
|
||||
return False
|
||||
|
||||
else:
|
||||
@@ -151,18 +148,18 @@ class KiCADProcessManager:
|
||||
|
||||
# Try to find executable in PATH first
|
||||
for cmd in ["pcbnew", "kicad"]:
|
||||
result = subprocess.run(
|
||||
["which", cmd] if system != "Windows" else ["where", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="mbcs" if system == "Windows" else None,
|
||||
errors="ignore" if system == "Windows" else None,
|
||||
timeout=5 if system == "Windows" else None
|
||||
)
|
||||
result = subprocess.run(
|
||||
["which", cmd] if system != "Windows" else ["where", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="mbcs" if system == "Windows" else None,
|
||||
errors="ignore" if system == "Windows" else None,
|
||||
timeout=5 if system == "Windows" else None,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
path = result.stdout.strip().split("\n")[0]
|
||||
logger.info(f"Found KiCAD executable: {path}")
|
||||
return Path(path)
|
||||
exe_path = result.stdout.strip().split("\n")[0]
|
||||
logger.info(f"Found KiCAD executable: {exe_path}")
|
||||
return Path(exe_path)
|
||||
|
||||
# Platform-specific default paths
|
||||
if system == "Linux":
|
||||
@@ -232,7 +229,7 @@ class KiCADProcessManager:
|
||||
cmd,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
# Unix: Use nohup or start in background
|
||||
@@ -240,7 +237,7 @@ class KiCADProcessManager:
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# Wait for process to start
|
||||
@@ -275,28 +272,30 @@ class KiCADProcessManager:
|
||||
|
||||
try:
|
||||
if system in ["Linux", "Darwin"]:
|
||||
result = subprocess.run(
|
||||
["ps", "aux"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
|
||||
for line in result.stdout.split("\n"):
|
||||
# Only match actual KiCAD binaries, not our MCP server processes
|
||||
if ("pcbnew" in line.lower() or "kicad" in line.lower()) and "kicad_interface.py" not in line and "grep" not in line:
|
||||
if (
|
||||
("pcbnew" in line.lower() or "kicad" in line.lower())
|
||||
and "kicad_interface.py" not in line
|
||||
and "grep" not in line
|
||||
):
|
||||
# More specific check: must have /pcbnew or /kicad in the path
|
||||
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 11:
|
||||
processes.append({
|
||||
"pid": parts[1],
|
||||
"name": parts[10],
|
||||
"command": " ".join(parts[10:])
|
||||
})
|
||||
processes.append(
|
||||
{
|
||||
"pid": parts[1],
|
||||
"name": parts[10],
|
||||
"command": " ".join(parts[10:]),
|
||||
}
|
||||
)
|
||||
|
||||
elif system == "Windows":
|
||||
for proc in KiCADProcessManager._windows_list_processes():
|
||||
name = (proc.get("name") or "").lower()
|
||||
if "pcbnew" in name or "kicad" in name:
|
||||
elif system == "Windows":
|
||||
for proc in KiCADProcessManager._windows_list_processes():
|
||||
name = (proc.get("name") or "").lower()
|
||||
if "pcbnew" in name or "kicad" in name:
|
||||
processes.append(proc)
|
||||
|
||||
except Exception as e:
|
||||
@@ -304,6 +303,7 @@ class KiCADProcessManager:
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
|
||||
"""
|
||||
Check if KiCAD is running and optionally launch it
|
||||
@@ -325,7 +325,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
|
||||
"running": True,
|
||||
"launched": False,
|
||||
"processes": processes,
|
||||
"message": "KiCAD is already running"
|
||||
"message": "KiCAD is already running",
|
||||
}
|
||||
|
||||
if not auto_launch:
|
||||
@@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
|
||||
"running": False,
|
||||
"launched": False,
|
||||
"processes": [],
|
||||
"message": "KiCAD is not running (auto-launch disabled)"
|
||||
"message": "KiCAD is not running (auto-launch disabled)",
|
||||
}
|
||||
|
||||
# Try to launch
|
||||
@@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
|
||||
"launched": success,
|
||||
"processes": manager.get_process_info() if success else [],
|
||||
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
|
||||
"project": str(project_path) if project_path else None
|
||||
"project": str(project_path) if project_path else None,
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ Platform detection and path utilities for cross-platform compatibility
|
||||
This module provides helpers for detecting the current platform and
|
||||
getting appropriate paths for KiCAD, configuration, logs, etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,19 +75,23 @@ class PlatformHelper:
|
||||
|
||||
# Also check based on Python version
|
||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
candidates.extend([
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
|
||||
])
|
||||
candidates.extend(
|
||||
[
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
|
||||
]
|
||||
)
|
||||
|
||||
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
|
||||
# This is where pcbnew.py typically lives on modern systems
|
||||
candidates.extend([
|
||||
Path(f"/usr/lib/python3/dist-packages"),
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages"),
|
||||
Path(f"/usr/local/lib/python3/dist-packages"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
|
||||
])
|
||||
candidates.extend(
|
||||
[
|
||||
Path(f"/usr/lib/python3/dist-packages"),
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages"),
|
||||
Path(f"/usr/local/lib/python3/dist-packages"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
|
||||
]
|
||||
)
|
||||
|
||||
paths = [p for p in candidates if p.exists()]
|
||||
|
||||
@@ -102,7 +107,17 @@ class PlatformHelper:
|
||||
for kicad_app in kicad_app_paths:
|
||||
if kicad_app.exists():
|
||||
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
|
||||
path = kicad_app / "Contents" / "Frameworks" / "Python.framework" / "Versions" / version / "lib" / f"python{version}" / "site-packages"
|
||||
path = (
|
||||
kicad_app
|
||||
/ "Contents"
|
||||
/ "Frameworks"
|
||||
/ "Python.framework"
|
||||
/ "Versions"
|
||||
/ version
|
||||
/ "lib"
|
||||
/ f"python{version}"
|
||||
/ "site-packages"
|
||||
)
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
@@ -110,7 +125,7 @@ class PlatformHelper:
|
||||
homebrew_paths = [
|
||||
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
|
||||
Path("/opt/homebrew/lib/python3.11/site-packages"),
|
||||
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
|
||||
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
|
||||
Path("/usr/local/lib/python3.11/site-packages"),
|
||||
]
|
||||
for hp in homebrew_paths:
|
||||
@@ -161,7 +176,10 @@ class PlatformHelper:
|
||||
patterns = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||
str(Path.home() / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"),
|
||||
str(
|
||||
Path.home()
|
||||
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
|
||||
),
|
||||
]
|
||||
|
||||
# Add user library paths for all platforms
|
||||
@@ -301,6 +319,7 @@ def detect_platform() -> dict:
|
||||
if __name__ == "__main__":
|
||||
# Quick test/diagnostic
|
||||
import json
|
||||
|
||||
info = detect_platform()
|
||||
print("Platform Information:")
|
||||
print(json.dumps(info, indent=2))
|
||||
|
||||
Reference in New Issue
Block a user