style: apply Black formatting to all Python files
Add [tool.black] config to pyproject.toml and Black hook to .pre-commit-config.yaml (rev 26.3.1), then auto-format all Python source and test files with line-length=100, target-version=py310. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,10 @@ repos:
|
|||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
args: ['--maxkb=500']
|
args: ['--maxkb=500']
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 26.3.1
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3
|
||||||
|
args: [--config=pyproject.toml]
|
||||||
|
|||||||
@@ -2,3 +2,7 @@
|
|||||||
name = "kicad-mcp-server"
|
name = "kicad-mcp-server"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ["py310"]
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ from .design_rules import DesignRuleCommands
|
|||||||
from .export import ExportCommands
|
from .export import ExportCommands
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ProjectCommands',
|
"ProjectCommands",
|
||||||
'BoardCommands',
|
"BoardCommands",
|
||||||
'ComponentCommands',
|
"ComponentCommands",
|
||||||
'RoutingCommands',
|
"RoutingCommands",
|
||||||
'DesignRuleCommands',
|
"DesignRuleCommands",
|
||||||
'ExportCommands'
|
"ExportCommands",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ It imports and re-exports the BoardCommands class from the board package.
|
|||||||
from commands.board import BoardCommands
|
from commands.board import BoardCommands
|
||||||
|
|
||||||
# Re-export the BoardCommands class for backward compatibility
|
# Re-export the BoardCommands class for backward compatibility
|
||||||
__all__ = ['BoardCommands']
|
__all__ = ["BoardCommands"]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from .layers import BoardLayerCommands
|
|||||||
from .outline import BoardOutlineCommands
|
from .outline import BoardOutlineCommands
|
||||||
from .view import BoardViewCommands
|
from .view import BoardViewCommands
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class BoardCommands:
|
class BoardCommands:
|
||||||
"""Handles board-related KiCAD operations"""
|
"""Handles board-related KiCAD operations"""
|
||||||
@@ -75,8 +76,8 @@ class BoardCommands:
|
|||||||
"""Get a 2D image of the PCB"""
|
"""Get a 2D image of the PCB"""
|
||||||
self.view_commands.board = self.board
|
self.view_commands.board = self.board
|
||||||
return self.view_commands.get_board_2d_view(params)
|
return self.view_commands.get_board_2d_view(params)
|
||||||
|
|
||||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get the bounding box extents of the board"""
|
"""Get the bounding box extents of the board"""
|
||||||
self.view_commands.board = self.board
|
self.view_commands.board = self.board
|
||||||
return self.view_commands.get_board_extents(params)
|
return self.view_commands.get_board_extents(params)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import pcbnew
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class BoardLayerCommands:
|
class BoardLayerCommands:
|
||||||
"""Handles board layer operations"""
|
"""Handles board layer operations"""
|
||||||
@@ -22,7 +23,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
name = params.get("name")
|
name = params.get("name")
|
||||||
@@ -34,7 +35,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing parameters",
|
"message": "Missing parameters",
|
||||||
"errorDetails": "name, type, and position are required"
|
"errorDetails": "name, type, and position are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get layer stack
|
# Get layer stack
|
||||||
@@ -47,7 +48,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing layer number",
|
"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)
|
layer_id = pcbnew.In1_Cu + (number - 1)
|
||||||
elif position == "top":
|
elif position == "top":
|
||||||
@@ -59,7 +60,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Invalid layer position",
|
"message": "Invalid layer position",
|
||||||
"errorDetails": "position must be 'top', 'bottom', or 'inner'"
|
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set layer properties
|
# Set layer properties
|
||||||
@@ -72,21 +73,12 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Added layer: {name}",
|
"message": f"Added layer: {name}",
|
||||||
"layer": {
|
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
|
||||||
"name": name,
|
|
||||||
"type": layer_type,
|
|
||||||
"position": position,
|
|
||||||
"number": number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding layer: {str(e)}")
|
logger.error(f"Error adding layer: {str(e)}")
|
||||||
return {
|
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to add layer",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Set the active layer for PCB operations"""
|
"""Set the active layer for PCB operations"""
|
||||||
@@ -95,7 +87,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
layer = params.get("layer")
|
layer = params.get("layer")
|
||||||
@@ -103,7 +95,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No layer specified",
|
"message": "No layer specified",
|
||||||
"errorDetails": "layer parameter is required"
|
"errorDetails": "layer parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find layer ID by name
|
# Find layer ID by name
|
||||||
@@ -112,7 +104,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Layer not found",
|
"message": "Layer not found",
|
||||||
"errorDetails": f"Layer '{layer}' does not exist"
|
"errorDetails": f"Layer '{layer}' does not exist",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set active layer
|
# Set active layer
|
||||||
@@ -121,10 +113,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Set active layer to: {layer}",
|
"message": f"Set active layer to: {layer}",
|
||||||
"layer": {
|
"layer": {"name": layer, "id": layer_id},
|
||||||
"name": layer,
|
|
||||||
"id": layer_id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -132,7 +121,7 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to set active layer",
|
"message": "Failed to set active layer",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -142,32 +131,27 @@ class BoardLayerCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
layers = []
|
layers = []
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
if self.board.IsLayerEnabled(layer_id):
|
||||||
layers.append({
|
layers.append(
|
||||||
"name": self.board.GetLayerName(layer_id),
|
{
|
||||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
"name": self.board.GetLayerName(layer_id),
|
||||||
"id": layer_id
|
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||||
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
"id": layer_id,
|
||||||
# Active layer is a UI concept not applicable to headless scripting
|
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||||
})
|
# Active layer is a UI concept not applicable to headless scripting
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {"success": True, "layers": layers}
|
||||||
"success": True,
|
|
||||||
"layers": layers
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting layer list: {str(e)}")
|
logger.error(f"Error getting layer list: {str(e)}")
|
||||||
return {
|
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to get layer list",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_layer_type(self, type_name: str) -> int:
|
def _get_layer_type(self, type_name: str) -> int:
|
||||||
"""Convert layer type name to KiCAD layer type constant"""
|
"""Convert layer type name to KiCAD layer type constant"""
|
||||||
@@ -175,7 +159,7 @@ class BoardLayerCommands:
|
|||||||
"copper": pcbnew.LT_SIGNAL,
|
"copper": pcbnew.LT_SIGNAL,
|
||||||
"technical": pcbnew.LT_SIGNAL,
|
"technical": pcbnew.LT_SIGNAL,
|
||||||
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
|
"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)
|
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
||||||
|
|
||||||
@@ -185,7 +169,7 @@ class BoardLayerCommands:
|
|||||||
pcbnew.LT_SIGNAL: "signal",
|
pcbnew.LT_SIGNAL: "signal",
|
||||||
pcbnew.LT_POWER: "power",
|
pcbnew.LT_POWER: "power",
|
||||||
pcbnew.LT_MIXED: "mixed",
|
pcbnew.LT_MIXED: "mixed",
|
||||||
pcbnew.LT_JUMPER: "jumper"
|
pcbnew.LT_JUMPER: "jumper",
|
||||||
}
|
}
|
||||||
# Note: LT_USER was removed in KiCAD 9.0
|
# Note: LT_USER was removed in KiCAD 9.0
|
||||||
return type_map.get(type_id, "unknown")
|
return type_map.get(type_id, "unknown")
|
||||||
|
|||||||
@@ -224,9 +224,7 @@ class BoardOutlineCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Convert to internal units (nanometers)
|
# Convert to internal units (nanometers)
|
||||||
scale = (
|
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||||
1000000 if position.get("unit", "mm") == "mm" else 25400000
|
|
||||||
) # mm or inch to nm
|
|
||||||
x_nm = int(position["x"] * scale)
|
x_nm = int(position["x"] * scale)
|
||||||
y_nm = int(position["y"] * scale)
|
y_nm = int(position["y"] * scale)
|
||||||
diameter_nm = int(diameter * scale)
|
diameter_nm = int(diameter * scale)
|
||||||
@@ -252,9 +250,7 @@ class BoardOutlineCommands:
|
|||||||
pad = pcbnew.PAD(module)
|
pad = pcbnew.PAD(module)
|
||||||
pad.SetNumber(1)
|
pad.SetNumber(1)
|
||||||
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
|
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
|
||||||
pad.SetAttribute(
|
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
|
||||||
pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH
|
|
||||||
)
|
|
||||||
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
|
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
|
||||||
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
|
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
|
||||||
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
|
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
|
||||||
@@ -311,9 +307,7 @@ class BoardOutlineCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Convert to internal units (nanometers)
|
# Convert to internal units (nanometers)
|
||||||
scale = (
|
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||||
1000000 if position.get("unit", "mm") == "mm" else 25400000
|
|
||||||
) # mm or inch to nm
|
|
||||||
x_nm = int(position["x"] * scale)
|
x_nm = int(position["x"] * scale)
|
||||||
y_nm = int(position["y"] * scale)
|
y_nm = int(position["y"] * scale)
|
||||||
size_nm = int(size * scale)
|
size_nm = int(size * scale)
|
||||||
@@ -372,9 +366,7 @@ class BoardOutlineCommands:
|
|||||||
"errorDetails": str(e),
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _add_edge_line(
|
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
|
||||||
self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int
|
|
||||||
) -> None:
|
|
||||||
"""Add a line to the edge cuts layer"""
|
"""Add a line to the edge cuts layer"""
|
||||||
line = pcbnew.PCB_SHAPE(self.board)
|
line = pcbnew.PCB_SHAPE(self.board)
|
||||||
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
||||||
@@ -396,18 +388,12 @@ class BoardOutlineCommands:
|
|||||||
"""Add a rounded rectangle to the edge cuts layer"""
|
"""Add a rounded rectangle to the edge cuts layer"""
|
||||||
if radius_nm <= 0:
|
if radius_nm <= 0:
|
||||||
# If no radius, create regular rectangle
|
# If no radius, create regular rectangle
|
||||||
top_left = pcbnew.VECTOR2I(
|
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
|
||||||
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_right = pcbnew.VECTOR2I(
|
|
||||||
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
|
|
||||||
)
|
|
||||||
bottom_right = pcbnew.VECTOR2I(
|
bottom_right = pcbnew.VECTOR2I(
|
||||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||||
)
|
)
|
||||||
bottom_left = pcbnew.VECTOR2I(
|
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
|
||||||
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_left, top_right, layer)
|
||||||
self._add_edge_line(top_right, bottom_right, layer)
|
self._add_edge_line(top_right, bottom_right, layer)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import pcbnew
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class BoardSizeCommands:
|
class BoardSizeCommands:
|
||||||
"""Handles board size operations"""
|
"""Handles board size operations"""
|
||||||
@@ -22,7 +23,7 @@ class BoardSizeCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
width = params.get("width")
|
width = params.get("width")
|
||||||
@@ -33,41 +34,36 @@ class BoardSizeCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing dimensions",
|
"message": "Missing dimensions",
|
||||||
"errorDetails": "Both width and height are required"
|
"errorDetails": "Both width and height are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create board outline using BoardOutlineCommands
|
# Create board outline using BoardOutlineCommands
|
||||||
# This properly creates edge cuts on Edge.Cuts layer
|
# This properly creates edge cuts on Edge.Cuts layer
|
||||||
from commands.board.outline import BoardOutlineCommands
|
from commands.board.outline import BoardOutlineCommands
|
||||||
|
|
||||||
outline_commands = BoardOutlineCommands(self.board)
|
outline_commands = BoardOutlineCommands(self.board)
|
||||||
|
|
||||||
# Create rectangular outline centered at origin
|
# Create rectangular outline centered at origin
|
||||||
result = outline_commands.add_board_outline({
|
result = outline_commands.add_board_outline(
|
||||||
"shape": "rectangle",
|
{
|
||||||
"centerX": width / 2, # Center X
|
"shape": "rectangle",
|
||||||
"centerY": height / 2, # Center Y
|
"centerX": width / 2, # Center X
|
||||||
"width": width,
|
"centerY": height / 2, # Center Y
|
||||||
"height": height,
|
"width": width,
|
||||||
"unit": unit
|
"height": height,
|
||||||
})
|
"unit": unit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Created board outline: {width}x{height} {unit}",
|
"message": f"Created board outline: {width}x{height} {unit}",
|
||||||
"size": {
|
"size": {"width": width, "height": height, "unit": unit},
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"unit": unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error setting board size: {str(e)}")
|
logger.error(f"Error setting board size: {str(e)}")
|
||||||
return {
|
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to set board size",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from PIL import Image
|
|||||||
import io
|
import io
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class BoardViewCommands:
|
class BoardViewCommands:
|
||||||
"""Handles board viewing operations"""
|
"""Handles board viewing operations"""
|
||||||
@@ -26,7 +27,7 @@ class BoardViewCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get board dimensions
|
# Get board dimensions
|
||||||
@@ -42,26 +43,24 @@ class BoardViewCommands:
|
|||||||
layers = []
|
layers = []
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
if self.board.IsLayerEnabled(layer_id):
|
||||||
layers.append({
|
layers.append(
|
||||||
"name": self.board.GetLayerName(layer_id),
|
{
|
||||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
"name": self.board.GetLayerName(layer_id),
|
||||||
"id": layer_id
|
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||||
})
|
"id": layer_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"board": {
|
"board": {
|
||||||
"filename": self.board.GetFileName(),
|
"filename": self.board.GetFileName(),
|
||||||
"size": {
|
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
|
||||||
"width": width_mm,
|
|
||||||
"height": height_mm,
|
|
||||||
"unit": "mm"
|
|
||||||
},
|
|
||||||
"layers": layers,
|
"layers": layers,
|
||||||
"title": self.board.GetTitleBlock().GetTitle()
|
"title": self.board.GetTitleBlock().GetTitle(),
|
||||||
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||||
# Active layer is a UI concept not applicable to headless scripting
|
# Active layer is a UI concept not applicable to headless scripting
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -69,7 +68,7 @@ class BoardViewCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get board information",
|
"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]:
|
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -79,7 +78,7 @@ class BoardViewCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get parameters
|
# Get parameters
|
||||||
@@ -126,17 +125,14 @@ class BoardViewCommands:
|
|||||||
|
|
||||||
# Convert SVG to requested format
|
# Convert SVG to requested format
|
||||||
if format == "svg":
|
if format == "svg":
|
||||||
with open(temp_svg, 'r') as f:
|
with open(temp_svg, "r") as f:
|
||||||
svg_data = f.read()
|
svg_data = f.read()
|
||||||
os.remove(temp_svg)
|
os.remove(temp_svg)
|
||||||
return {
|
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||||
"success": True,
|
|
||||||
"imageData": svg_data,
|
|
||||||
"format": "svg"
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
# Use PIL to convert SVG to PNG/JPG
|
# Use PIL to convert SVG to PNG/JPG
|
||||||
from cairosvg import svg2png
|
from cairosvg import svg2png
|
||||||
|
|
||||||
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
||||||
os.remove(temp_svg)
|
os.remove(temp_svg)
|
||||||
|
|
||||||
@@ -144,18 +140,18 @@ class BoardViewCommands:
|
|||||||
# Convert PNG to JPG
|
# Convert PNG to JPG
|
||||||
img = Image.open(io.BytesIO(png_data))
|
img = Image.open(io.BytesIO(png_data))
|
||||||
jpg_buffer = io.BytesIO()
|
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()
|
jpg_data = jpg_buffer.getvalue()
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"imageData": base64.b64encode(jpg_data).decode('utf-8'),
|
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
|
||||||
"format": "jpg"
|
"format": "jpg",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"imageData": base64.b64encode(png_data).decode('utf-8'),
|
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||||
"format": "png"
|
"format": "png",
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -163,7 +159,7 @@ class BoardViewCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get board 2D view",
|
"message": "Failed to get board 2D view",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_layer_type_name(self, type_id: int) -> str:
|
def _get_layer_type_name(self, type_id: int) -> str:
|
||||||
@@ -172,61 +168,58 @@ class BoardViewCommands:
|
|||||||
pcbnew.LT_SIGNAL: "signal",
|
pcbnew.LT_SIGNAL: "signal",
|
||||||
pcbnew.LT_POWER: "power",
|
pcbnew.LT_POWER: "power",
|
||||||
pcbnew.LT_MIXED: "mixed",
|
pcbnew.LT_MIXED: "mixed",
|
||||||
pcbnew.LT_JUMPER: "jumper"
|
pcbnew.LT_JUMPER: "jumper",
|
||||||
}
|
}
|
||||||
# Note: LT_USER was removed in KiCAD 9.0
|
# Note: LT_USER was removed in KiCAD 9.0
|
||||||
return type_map.get(type_id, "unknown")
|
return type_map.get(type_id, "unknown")
|
||||||
|
|
||||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get the bounding box extents of the board"""
|
"""Get the bounding box extents of the board"""
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
if not self.board:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get unit preference (default to mm)
|
# Get unit preference (default to mm)
|
||||||
unit = params.get("unit", "mm")
|
unit = params.get("unit", "mm")
|
||||||
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
||||||
|
|
||||||
# Get board bounding box
|
# Get board bounding box
|
||||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||||
|
|
||||||
# Extract bounds in nanometers, then convert
|
# Extract bounds in nanometers, then convert
|
||||||
left = board_box.GetLeft() / scale
|
left = board_box.GetLeft() / scale
|
||||||
top = board_box.GetTop() / scale
|
top = board_box.GetTop() / scale
|
||||||
right = board_box.GetRight() / scale
|
right = board_box.GetRight() / scale
|
||||||
bottom = board_box.GetBottom() / scale
|
bottom = board_box.GetBottom() / scale
|
||||||
width = board_box.GetWidth() / scale
|
width = board_box.GetWidth() / scale
|
||||||
height = board_box.GetHeight() / scale
|
height = board_box.GetHeight() / scale
|
||||||
|
|
||||||
# Get center point
|
# Get center point
|
||||||
center_x = board_box.GetCenter().x / scale
|
center_x = board_box.GetCenter().x / scale
|
||||||
center_y = board_box.GetCenter().y / scale
|
center_y = board_box.GetCenter().y / scale
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"extents": {
|
"extents": {
|
||||||
"left": left,
|
"left": left,
|
||||||
"top": top,
|
"top": top,
|
||||||
"right": right,
|
"right": right,
|
||||||
"bottom": bottom,
|
"bottom": bottom,
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
"center": {
|
"center": {"x": center_x, "y": center_y},
|
||||||
"x": center_x,
|
"unit": unit,
|
||||||
"y": center_y
|
},
|
||||||
},
|
}
|
||||||
"unit": unit
|
|
||||||
}
|
except Exception as e:
|
||||||
}
|
logger.error(f"Error getting board extents: {str(e)}")
|
||||||
|
return {
|
||||||
except Exception as e:
|
"success": False,
|
||||||
logger.error(f"Error getting board extents: {str(e)}")
|
"message": "Failed to get board extents",
|
||||||
return {
|
"errorDetails": str(e),
|
||||||
"success": False,
|
}
|
||||||
"message": "Failed to get board extents",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ from typing import Dict, Any, Optional, List, Tuple
|
|||||||
import base64
|
import base64
|
||||||
from commands.library import LibraryManager
|
from commands.library import LibraryManager
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class ComponentCommands:
|
class ComponentCommands:
|
||||||
"""Handles component-related KiCAD operations"""
|
"""Handles component-related KiCAD operations"""
|
||||||
|
|
||||||
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None):
|
def __init__(
|
||||||
|
self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None
|
||||||
|
):
|
||||||
"""Initialize with optional board instance and library manager"""
|
"""Initialize with optional board instance and library manager"""
|
||||||
self.board = board
|
self.board = board
|
||||||
self.library_manager = library_manager or LibraryManager()
|
self.library_manager = library_manager or LibraryManager()
|
||||||
@@ -27,7 +30,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get parameters
|
# Get parameters
|
||||||
@@ -43,7 +46,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing parameters",
|
"message": "Missing parameters",
|
||||||
"errorDetails": "componentId and position are required"
|
"errorDetails": "componentId and position are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find footprint using library manager
|
# Find footprint using library manager
|
||||||
@@ -55,13 +58,14 @@ class ComponentCommands:
|
|||||||
suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5)
|
suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5)
|
||||||
suggestion_text = ""
|
suggestion_text = ""
|
||||||
if suggestions:
|
if suggestions:
|
||||||
suggestion_text = "\n\nDid you mean one of these?\n" + \
|
suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join(
|
||||||
"\n".join([f" - {s['full_name']}" for s in suggestions])
|
[f" - {s['full_name']}" for s in suggestions]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Footprint not found",
|
"message": "Footprint not found",
|
||||||
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}"
|
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}",
|
||||||
}
|
}
|
||||||
|
|
||||||
library_path, footprint_name = footprint_result
|
library_path, footprint_name = footprint_result
|
||||||
@@ -78,7 +82,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Internal error",
|
"message": "Internal error",
|
||||||
"errorDetails": "Could not determine library nickname"
|
"errorDetails": "Could not determine library nickname",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load the footprint
|
# Load the footprint
|
||||||
@@ -87,7 +91,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to load footprint",
|
"message": "Failed to load footprint",
|
||||||
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}"
|
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set position
|
# Set position
|
||||||
@@ -145,14 +149,10 @@ class ComponentCommands:
|
|||||||
"component": {
|
"component": {
|
||||||
"reference": module.GetReference(),
|
"reference": module.GetReference(),
|
||||||
"value": module.GetValue(),
|
"value": module.GetValue(),
|
||||||
"position": {
|
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
|
||||||
"x": position["x"],
|
|
||||||
"y": position["y"],
|
|
||||||
"unit": position["unit"]
|
|
||||||
},
|
|
||||||
"rotation": rotation,
|
"rotation": rotation,
|
||||||
"layer": layer
|
"layer": layer,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -160,7 +160,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to place component",
|
"message": "Failed to place component",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -170,7 +170,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -182,7 +182,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing parameters",
|
"message": "Missing parameters",
|
||||||
"errorDetails": "reference and position are required"
|
"errorDetails": "reference and position are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -191,7 +191,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set new position
|
# Set new position
|
||||||
@@ -218,23 +218,17 @@ class ComponentCommands:
|
|||||||
"message": f"Moved component: {reference}",
|
"message": f"Moved component: {reference}",
|
||||||
"component": {
|
"component": {
|
||||||
"reference": reference,
|
"reference": reference,
|
||||||
"position": {
|
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
|
||||||
"x": position["x"],
|
"rotation": (
|
||||||
"y": position["y"],
|
rotation if rotation is not None else module.GetOrientation().AsDegrees()
|
||||||
"unit": position["unit"]
|
),
|
||||||
},
|
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||||
"rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees(),
|
},
|
||||||
"layer": self.board.GetLayerName(module.GetLayer())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving component: {str(e)}")
|
logger.error(f"Error moving component: {str(e)}")
|
||||||
return {
|
return {"success": False, "message": "Failed to move component", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to move component",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Rotate an existing component"""
|
"""Rotate an existing component"""
|
||||||
@@ -243,7 +237,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -253,7 +247,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing parameters",
|
"message": "Missing parameters",
|
||||||
"errorDetails": "reference and angle are required"
|
"errorDetails": "reference and angle are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -262,7 +256,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set rotation
|
# Set rotation
|
||||||
@@ -272,10 +266,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Rotated component: {reference}",
|
"message": f"Rotated component: {reference}",
|
||||||
"component": {
|
"component": {"reference": reference, "rotation": angle},
|
||||||
"reference": reference,
|
|
||||||
"rotation": angle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -283,7 +274,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to rotate component",
|
"message": "Failed to rotate component",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -293,7 +284,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -301,7 +292,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing reference",
|
"message": "Missing reference",
|
||||||
"errorDetails": "reference parameter is required"
|
"errorDetails": "reference parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -310,23 +301,20 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove from board
|
# Remove from board
|
||||||
self.board.Remove(module)
|
self.board.Remove(module)
|
||||||
|
|
||||||
return {
|
return {"success": True, "message": f"Deleted component: {reference}"}
|
||||||
"success": True,
|
|
||||||
"message": f"Deleted component: {reference}"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting component: {str(e)}")
|
logger.error(f"Error deleting component: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to delete component",
|
"message": "Failed to delete component",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -336,7 +324,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -348,7 +336,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing reference",
|
"message": "Missing reference",
|
||||||
"errorDetails": "reference parameter is required"
|
"errorDetails": "reference parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -357,7 +345,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update properties
|
# Update properties
|
||||||
@@ -385,17 +373,13 @@ class ComponentCommands:
|
|||||||
"component": {
|
"component": {
|
||||||
"reference": new_reference or reference,
|
"reference": new_reference or reference,
|
||||||
"value": value or module.GetValue(),
|
"value": value or module.GetValue(),
|
||||||
"footprint": footprint or module.GetFPIDAsString()
|
"footprint": footprint or module.GetFPIDAsString(),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error editing component: {str(e)}")
|
logger.error(f"Error editing component: {str(e)}")
|
||||||
return {
|
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to edit component",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get detailed properties of a component"""
|
"""Get detailed properties of a component"""
|
||||||
@@ -404,7 +388,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -412,7 +396,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing reference",
|
"message": "Missing reference",
|
||||||
"errorDetails": "reference parameter is required"
|
"errorDetails": "reference parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -421,7 +405,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get position in mm
|
# Get position in mm
|
||||||
@@ -435,19 +419,15 @@ class ComponentCommands:
|
|||||||
"reference": module.GetReference(),
|
"reference": module.GetReference(),
|
||||||
"value": module.GetValue(),
|
"value": module.GetValue(),
|
||||||
"footprint": module.GetFPIDAsString(),
|
"footprint": module.GetFPIDAsString(),
|
||||||
"position": {
|
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
|
||||||
"x": x_mm,
|
|
||||||
"y": y_mm,
|
|
||||||
"unit": "mm"
|
|
||||||
},
|
|
||||||
"rotation": module.GetOrientation().AsDegrees(),
|
"rotation": module.GetOrientation().AsDegrees(),
|
||||||
"layer": self.board.GetLayerName(module.GetLayer()),
|
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
||||||
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
||||||
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY
|
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -455,7 +435,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get component properties",
|
"message": "Failed to get component properties",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -465,7 +445,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
components = []
|
components = []
|
||||||
@@ -474,30 +454,25 @@ class ComponentCommands:
|
|||||||
x_mm = pos.x / 1000000
|
x_mm = pos.x / 1000000
|
||||||
y_mm = pos.y / 1000000
|
y_mm = pos.y / 1000000
|
||||||
|
|
||||||
components.append({
|
components.append(
|
||||||
"reference": module.GetReference(),
|
{
|
||||||
"value": module.GetValue(),
|
"reference": module.GetReference(),
|
||||||
"footprint": module.GetFPIDAsString(),
|
"value": module.GetValue(),
|
||||||
"position": {
|
"footprint": module.GetFPIDAsString(),
|
||||||
"x": x_mm,
|
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
|
||||||
"y": y_mm,
|
"rotation": module.GetOrientation().AsDegrees(),
|
||||||
"unit": "mm"
|
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||||
},
|
}
|
||||||
"rotation": module.GetOrientation().AsDegrees(),
|
)
|
||||||
"layer": self.board.GetLayerName(module.GetLayer())
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {"success": True, "components": components}
|
||||||
"success": True,
|
|
||||||
"components": components
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting component list: {str(e)}")
|
logger.error(f"Error getting component list: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get component list",
|
"message": "Failed to get component list",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -507,7 +482,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get search parameters
|
# Get search parameters
|
||||||
@@ -519,7 +494,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing search criteria",
|
"message": "Missing search criteria",
|
||||||
"errorDetails": "At least one of reference, value, or footprint pattern is required"
|
"errorDetails": "At least one of reference, value, or footprint pattern is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
matches = []
|
matches = []
|
||||||
@@ -539,31 +514,25 @@ class ComponentCommands:
|
|||||||
|
|
||||||
if match:
|
if match:
|
||||||
pos = module.GetPosition()
|
pos = module.GetPosition()
|
||||||
matches.append({
|
matches.append(
|
||||||
"reference": module.GetReference(),
|
{
|
||||||
"value": module.GetValue(),
|
"reference": module.GetReference(),
|
||||||
"footprint": module.GetFPIDAsString(),
|
"value": module.GetValue(),
|
||||||
"position": {
|
"footprint": module.GetFPIDAsString(),
|
||||||
"x": pos.x / 1000000,
|
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
|
||||||
"y": pos.y / 1000000,
|
"rotation": module.GetOrientation().AsDegrees(),
|
||||||
"unit": "mm"
|
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||||
},
|
}
|
||||||
"rotation": module.GetOrientation().AsDegrees(),
|
)
|
||||||
"layer": self.board.GetLayerName(module.GetLayer())
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {"success": True, "matchCount": len(matches), "components": matches}
|
||||||
"success": True,
|
|
||||||
"matchCount": len(matches),
|
|
||||||
"components": matches
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error finding components: {str(e)}")
|
logger.error(f"Error finding components: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to find components",
|
"message": "Failed to find components",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -573,7 +542,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -581,7 +550,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing reference",
|
"message": "Missing reference",
|
||||||
"errorDetails": "reference parameter is required"
|
"errorDetails": "reference parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -590,7 +559,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
pads = []
|
pads = []
|
||||||
@@ -606,7 +575,7 @@ class ComponentCommands:
|
|||||||
pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid",
|
pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid",
|
||||||
pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect",
|
pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect",
|
||||||
pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect",
|
pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect",
|
||||||
pcbnew.PAD_SHAPE_CUSTOM: "custom"
|
pcbnew.PAD_SHAPE_CUSTOM: "custom",
|
||||||
}
|
}
|
||||||
shape = shape_map.get(pad.GetShape(), "unknown")
|
shape = shape_map.get(pad.GetShape(), "unknown")
|
||||||
|
|
||||||
@@ -615,29 +584,25 @@ class ComponentCommands:
|
|||||||
pcbnew.PAD_ATTRIB_PTH: "through_hole",
|
pcbnew.PAD_ATTRIB_PTH: "through_hole",
|
||||||
pcbnew.PAD_ATTRIB_SMD: "smd",
|
pcbnew.PAD_ATTRIB_SMD: "smd",
|
||||||
pcbnew.PAD_ATTRIB_CONN: "connector",
|
pcbnew.PAD_ATTRIB_CONN: "connector",
|
||||||
pcbnew.PAD_ATTRIB_NPTH: "npth"
|
pcbnew.PAD_ATTRIB_NPTH: "npth",
|
||||||
}
|
}
|
||||||
pad_type = type_map.get(pad.GetAttribute(), "unknown")
|
pad_type = type_map.get(pad.GetAttribute(), "unknown")
|
||||||
|
|
||||||
pads.append({
|
pads.append(
|
||||||
"name": pad.GetName(),
|
{
|
||||||
"number": pad.GetNumber(),
|
"name": pad.GetName(),
|
||||||
"position": {
|
"number": pad.GetNumber(),
|
||||||
"x": pos.x / 1000000,
|
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
|
||||||
"y": pos.y / 1000000,
|
"net": pad.GetNetname(),
|
||||||
"unit": "mm"
|
"netCode": pad.GetNetCode(),
|
||||||
},
|
"shape": shape,
|
||||||
"net": pad.GetNetname(),
|
"type": pad_type,
|
||||||
"netCode": pad.GetNetCode(),
|
"size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
|
||||||
"shape": shape,
|
"drillSize": (
|
||||||
"type": pad_type,
|
pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
|
||||||
"size": {
|
),
|
||||||
"x": size.x / 1000000,
|
}
|
||||||
"y": size.y / 1000000,
|
)
|
||||||
"unit": "mm"
|
|
||||||
},
|
|
||||||
"drillSize": pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get component position for reference
|
# Get component position for reference
|
||||||
comp_pos = module.GetPosition()
|
comp_pos = module.GetPosition()
|
||||||
@@ -648,10 +613,10 @@ class ComponentCommands:
|
|||||||
"componentPosition": {
|
"componentPosition": {
|
||||||
"x": comp_pos.x / 1000000,
|
"x": comp_pos.x / 1000000,
|
||||||
"y": comp_pos.y / 1000000,
|
"y": comp_pos.y / 1000000,
|
||||||
"unit": "mm"
|
"unit": "mm",
|
||||||
},
|
},
|
||||||
"padCount": len(pads),
|
"padCount": len(pads),
|
||||||
"pads": pads
|
"pads": pads,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -659,7 +624,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get component pads",
|
"message": "Failed to get component pads",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -669,7 +634,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -679,13 +644,13 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing reference",
|
"message": "Missing reference",
|
||||||
"errorDetails": "reference parameter is required"
|
"errorDetails": "reference parameter is required",
|
||||||
}
|
}
|
||||||
if not pad_name:
|
if not pad_name:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing pad identifier",
|
"message": "Missing pad identifier",
|
||||||
"errorDetails": "padName or padNumber parameter is required"
|
"errorDetails": "padName or padNumber parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the component
|
# Find the component
|
||||||
@@ -694,7 +659,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the specific pad
|
# Find the specific pad
|
||||||
@@ -705,7 +670,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Pad not found",
|
"message": "Pad not found",
|
||||||
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}"
|
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}",
|
||||||
}
|
}
|
||||||
|
|
||||||
pos = pad.GetPosition()
|
pos = pad.GetPosition()
|
||||||
@@ -715,18 +680,10 @@ class ComponentCommands:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"reference": reference,
|
"reference": reference,
|
||||||
"padName": pad.GetNumber(),
|
"padName": pad.GetNumber(),
|
||||||
"position": {
|
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
|
||||||
"x": pos.x / 1000000,
|
|
||||||
"y": pos.y / 1000000,
|
|
||||||
"unit": "mm"
|
|
||||||
},
|
|
||||||
"net": pad.GetNetname(),
|
"net": pad.GetNetname(),
|
||||||
"netCode": pad.GetNetCode(),
|
"netCode": pad.GetNetCode(),
|
||||||
"size": {
|
"size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
|
||||||
"x": size.x / 1000000,
|
|
||||||
"y": size.y / 1000000,
|
|
||||||
"unit": "mm"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -734,7 +691,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get pad position",
|
"message": "Failed to get pad position",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -744,7 +701,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
component_id = params.get("componentId")
|
component_id = params.get("componentId")
|
||||||
@@ -757,7 +714,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing parameters",
|
"message": "Missing parameters",
|
||||||
"errorDetails": "componentId and count are required"
|
"errorDetails": "componentId and count are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
if pattern == "grid":
|
if pattern == "grid":
|
||||||
@@ -773,14 +730,14 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing grid parameters",
|
"message": "Missing grid parameters",
|
||||||
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required"
|
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows * columns != count:
|
if rows * columns != count:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Invalid grid parameters",
|
"message": "Invalid grid parameters",
|
||||||
"errorDetails": "rows * columns must equal count"
|
"errorDetails": "rows * columns must equal count",
|
||||||
}
|
}
|
||||||
|
|
||||||
placed_components = self._place_grid_array(
|
placed_components = self._place_grid_array(
|
||||||
@@ -793,7 +750,7 @@ class ComponentCommands:
|
|||||||
reference_prefix,
|
reference_prefix,
|
||||||
value,
|
value,
|
||||||
rotation,
|
rotation,
|
||||||
layer
|
layer,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif pattern == "circular":
|
elif pattern == "circular":
|
||||||
@@ -808,7 +765,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing circular parameters",
|
"message": "Missing circular parameters",
|
||||||
"errorDetails": "For circular pattern, center, radius, and angleStep are required"
|
"errorDetails": "For circular pattern, center, radius, and angleStep are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
placed_components = self._place_circular_array(
|
placed_components = self._place_circular_array(
|
||||||
@@ -821,20 +778,20 @@ class ComponentCommands:
|
|||||||
reference_prefix,
|
reference_prefix,
|
||||||
value,
|
value,
|
||||||
rotation_offset,
|
rotation_offset,
|
||||||
layer
|
layer,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Invalid pattern",
|
"message": "Invalid pattern",
|
||||||
"errorDetails": "Pattern must be 'grid' or 'circular'"
|
"errorDetails": "Pattern must be 'grid' or 'circular'",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Placed {count} components in {pattern} pattern",
|
"message": f"Placed {count} components in {pattern} pattern",
|
||||||
"components": placed_components
|
"components": placed_components,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -842,7 +799,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to place component array",
|
"message": "Failed to place component array",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -852,7 +809,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
references = params.get("references", [])
|
references = params.get("references", [])
|
||||||
@@ -864,7 +821,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing references",
|
"message": "Missing references",
|
||||||
"errorDetails": "At least two component references are required"
|
"errorDetails": "At least two component references are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find all referenced components
|
# Find all referenced components
|
||||||
@@ -875,7 +832,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {ref}"
|
"errorDetails": f"Could not find component: {ref}",
|
||||||
}
|
}
|
||||||
components.append(module)
|
components.append(module)
|
||||||
|
|
||||||
@@ -890,36 +847,34 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing edge parameter",
|
"message": "Missing edge parameter",
|
||||||
"errorDetails": "Edge parameter is required for edge alignment"
|
"errorDetails": "Edge parameter is required for edge alignment",
|
||||||
}
|
}
|
||||||
self._align_components_to_edge(components, edge)
|
self._align_components_to_edge(components, edge)
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Invalid alignment option",
|
"message": "Invalid alignment option",
|
||||||
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'"
|
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prepare result data
|
# Prepare result data
|
||||||
aligned_components = []
|
aligned_components = []
|
||||||
for module in components:
|
for module in components:
|
||||||
pos = module.GetPosition()
|
pos = module.GetPosition()
|
||||||
aligned_components.append({
|
aligned_components.append(
|
||||||
"reference": module.GetReference(),
|
{
|
||||||
"position": {
|
"reference": module.GetReference(),
|
||||||
"x": pos.x / 1000000,
|
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
|
||||||
"y": pos.y / 1000000,
|
"rotation": module.GetOrientation().AsDegrees(),
|
||||||
"unit": "mm"
|
}
|
||||||
},
|
)
|
||||||
"rotation": module.GetOrientation().AsDegrees()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Aligned {len(components)} components",
|
"message": f"Aligned {len(components)} components",
|
||||||
"alignment": alignment,
|
"alignment": alignment,
|
||||||
"distribution": distribution,
|
"distribution": distribution,
|
||||||
"components": aligned_components
|
"components": aligned_components,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -927,7 +882,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to align components",
|
"message": "Failed to align components",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -937,7 +892,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "No board is loaded",
|
||||||
"errorDetails": "Load or create a board first"
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
reference = params.get("reference")
|
reference = params.get("reference")
|
||||||
@@ -949,7 +904,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing parameters",
|
"message": "Missing parameters",
|
||||||
"errorDetails": "reference and newReference are required"
|
"errorDetails": "reference and newReference are required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the source component
|
# Find the source component
|
||||||
@@ -958,7 +913,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Component not found",
|
"message": "Component not found",
|
||||||
"errorDetails": f"Could not find component: {reference}"
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if new reference already exists
|
# Check if new reference already exists
|
||||||
@@ -966,7 +921,7 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Reference already exists",
|
"message": "Reference already exists",
|
||||||
"errorDetails": f"A component with reference {new_reference} already exists"
|
"errorDetails": f"A component with reference {new_reference} already exists",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create new footprint with the same properties
|
# Create new footprint with the same properties
|
||||||
@@ -1014,14 +969,10 @@ class ComponentCommands:
|
|||||||
"reference": new_reference,
|
"reference": new_reference,
|
||||||
"value": new_module.GetValue(),
|
"value": new_module.GetValue(),
|
||||||
"footprint": new_module.GetFPIDAsString(),
|
"footprint": new_module.GetFPIDAsString(),
|
||||||
"position": {
|
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
|
||||||
"x": pos.x / 1000000,
|
|
||||||
"y": pos.y / 1000000,
|
|
||||||
"unit": "mm"
|
|
||||||
},
|
|
||||||
"rotation": new_module.GetOrientation().AsDegrees(),
|
"rotation": new_module.GetOrientation().AsDegrees(),
|
||||||
"layer": self.board.GetLayerName(new_module.GetLayer())
|
"layer": self.board.GetLayerName(new_module.GetLayer()),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1029,12 +980,22 @@ class ComponentCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to duplicate component",
|
"message": "Failed to duplicate component",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _place_grid_array(self, component_id: str, start_position: Dict[str, Any],
|
def _place_grid_array(
|
||||||
rows: int, columns: int, spacing_x: float, spacing_y: float,
|
self,
|
||||||
reference_prefix: str, value: str, rotation: float, layer: str) -> List[Dict[str, Any]]:
|
component_id: str,
|
||||||
|
start_position: Dict[str, Any],
|
||||||
|
rows: int,
|
||||||
|
columns: int,
|
||||||
|
spacing_x: float,
|
||||||
|
spacing_y: float,
|
||||||
|
reference_prefix: str,
|
||||||
|
value: str,
|
||||||
|
rotation: float,
|
||||||
|
layer: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""Place components in a grid pattern and return the list of placed components"""
|
"""Place components in a grid pattern and return the list of placed components"""
|
||||||
placed = []
|
placed = []
|
||||||
|
|
||||||
@@ -1058,24 +1019,35 @@ class ComponentCommands:
|
|||||||
component_reference = f"{reference_prefix}{index}"
|
component_reference = f"{reference_prefix}{index}"
|
||||||
|
|
||||||
# Place component
|
# Place component
|
||||||
result = self.place_component({
|
result = self.place_component(
|
||||||
"componentId": component_id,
|
{
|
||||||
"position": {"x": x, "y": y, "unit": unit},
|
"componentId": component_id,
|
||||||
"reference": component_reference,
|
"position": {"x": x, "y": y, "unit": unit},
|
||||||
"value": value,
|
"reference": component_reference,
|
||||||
"rotation": rotation,
|
"value": value,
|
||||||
"layer": layer
|
"rotation": rotation,
|
||||||
})
|
"layer": layer,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
placed.append(result["component"])
|
placed.append(result["component"])
|
||||||
|
|
||||||
return placed
|
return placed
|
||||||
|
|
||||||
def _place_circular_array(self, component_id: str, center: Dict[str, Any],
|
def _place_circular_array(
|
||||||
radius: float, count: int, angle_start: float,
|
self,
|
||||||
angle_step: float, reference_prefix: str,
|
component_id: str,
|
||||||
value: str, rotation_offset: float, layer: str) -> List[Dict[str, Any]]:
|
center: Dict[str, Any],
|
||||||
|
radius: float,
|
||||||
|
count: int,
|
||||||
|
angle_start: float,
|
||||||
|
angle_step: float,
|
||||||
|
reference_prefix: str,
|
||||||
|
value: str,
|
||||||
|
rotation_offset: float,
|
||||||
|
layer: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""Place components in a circular pattern and return the list of placed components"""
|
"""Place components in a circular pattern and return the list of placed components"""
|
||||||
placed = []
|
placed = []
|
||||||
|
|
||||||
@@ -1098,22 +1070,25 @@ class ComponentCommands:
|
|||||||
component_rotation = angle + rotation_offset
|
component_rotation = angle + rotation_offset
|
||||||
|
|
||||||
# Place component
|
# Place component
|
||||||
result = self.place_component({
|
result = self.place_component(
|
||||||
"componentId": component_id,
|
{
|
||||||
"position": {"x": x, "y": y, "unit": unit},
|
"componentId": component_id,
|
||||||
"reference": component_reference,
|
"position": {"x": x, "y": y, "unit": unit},
|
||||||
"value": value,
|
"reference": component_reference,
|
||||||
"rotation": component_rotation,
|
"value": value,
|
||||||
"layer": layer
|
"rotation": component_rotation,
|
||||||
})
|
"layer": layer,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
placed.append(result["component"])
|
placed.append(result["component"])
|
||||||
|
|
||||||
return placed
|
return placed
|
||||||
|
|
||||||
def _align_components_horizontally(self, components: List[pcbnew.FOOTPRINT],
|
def _align_components_horizontally(
|
||||||
distribution: str, spacing: Optional[float]) -> None:
|
self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
|
||||||
|
) -> None:
|
||||||
"""Align components horizontally and optionally distribute them"""
|
"""Align components horizontally and optionally distribute them"""
|
||||||
if not components:
|
if not components:
|
||||||
return
|
return
|
||||||
@@ -1157,8 +1132,9 @@ class ComponentCommands:
|
|||||||
x_current += spacing_nm
|
x_current += spacing_nm
|
||||||
components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y))
|
components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y))
|
||||||
|
|
||||||
def _align_components_vertically(self, components: List[pcbnew.FOOTPRINT],
|
def _align_components_vertically(
|
||||||
distribution: str, spacing: Optional[float]) -> None:
|
self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
|
||||||
|
) -> None:
|
||||||
"""Align components vertically and optionally distribute them"""
|
"""Align components vertically and optionally distribute them"""
|
||||||
if not components:
|
if not components:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ try:
|
|||||||
|
|
||||||
DYNAMIC_LOADING_AVAILABLE = True
|
DYNAMIC_LOADING_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning(
|
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
|
||||||
"Dynamic symbol loader not available - falling back to template-only mode"
|
|
||||||
)
|
|
||||||
DYNAMIC_LOADING_AVAILABLE = False
|
DYNAMIC_LOADING_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
@@ -135,32 +133,22 @@ class ComponentManager:
|
|||||||
|
|
||||||
# Check if schematic path is available
|
# Check if schematic path is available
|
||||||
if schematic_path is None:
|
if schematic_path is None:
|
||||||
logger.warning(
|
logger.warning("Dynamic loading requires schematic file path but none was provided")
|
||||||
"Dynamic loading requires schematic file path but none was provided"
|
|
||||||
)
|
|
||||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||||
return (fallback, False)
|
return (fallback, False)
|
||||||
|
|
||||||
# Determine library name
|
# Determine library name
|
||||||
if library is None:
|
if library is None:
|
||||||
# Default library for common component types
|
# Default library for common component types
|
||||||
library = (
|
library = "Device" # Most passives and basic components are in Device library
|
||||||
"Device" # Most passives and basic components are in Device library
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
|
||||||
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use dynamic symbol loader to inject symbol and create template
|
# Use dynamic symbol loader to inject symbol and create template
|
||||||
template_ref = loader.load_symbol_dynamically(
|
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
|
||||||
schematic_path, library, comp_type
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
|
||||||
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
|
|
||||||
)
|
|
||||||
# Signal that schematic needs reload to see new template
|
# Signal that schematic needs reload to see new template
|
||||||
return (template_ref, True)
|
return (template_ref, True)
|
||||||
|
|
||||||
@@ -198,9 +186,7 @@ class ComponentManager:
|
|||||||
|
|
||||||
# Get component type and determine template
|
# Get component type and determine template
|
||||||
comp_type = component_def.get("type", "R")
|
comp_type = component_def.get("type", "R")
|
||||||
library = component_def.get(
|
library = component_def.get("library", None) # Optional library specification
|
||||||
"library", None
|
|
||||||
) # Optional library specification
|
|
||||||
|
|
||||||
# Get template reference (static or dynamic)
|
# Get template reference (static or dynamic)
|
||||||
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
||||||
@@ -209,9 +195,7 @@ class ComponentManager:
|
|||||||
|
|
||||||
# If dynamic loading occurred, reload schematic to see new template
|
# If dynamic loading occurred, reload schematic to see new template
|
||||||
if needs_reload and schematic_path:
|
if needs_reload and schematic_path:
|
||||||
logger.info(
|
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
|
||||||
f"Reloading schematic after dynamic loading: {schematic_path}"
|
|
||||||
)
|
|
||||||
schematic = SchematicManager.load_schematic(str(schematic_path))
|
schematic = SchematicManager.load_schematic(str(schematic_path))
|
||||||
|
|
||||||
# Find template symbol by reference (handles special characters like +)
|
# Find template symbol by reference (handles special characters like +)
|
||||||
@@ -303,9 +287,7 @@ class ComponentManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_component(
|
def update_component(schematic: Schematic, component_ref: str, new_properties: dict):
|
||||||
schematic: Schematic, component_ref: str, new_properties: dict
|
|
||||||
):
|
|
||||||
"""Update component properties by reference designator"""
|
"""Update component properties by reference designator"""
|
||||||
try:
|
try:
|
||||||
symbol_to_update = None
|
symbol_to_update = None
|
||||||
@@ -401,19 +383,13 @@ if __name__ == "__main__":
|
|||||||
# Get a component
|
# Get a component
|
||||||
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
||||||
if retrieved_comp:
|
if retrieved_comp:
|
||||||
print(
|
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
|
||||||
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update a component
|
# Update a component
|
||||||
ComponentManager.update_component(
|
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
|
||||||
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Search components
|
# Search components
|
||||||
matching_comps = ComponentManager.search_components(
|
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
|
||||||
test_sch, "100"
|
|
||||||
) # Search by position
|
|
||||||
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
||||||
|
|
||||||
# Get all components
|
# Get all components
|
||||||
@@ -423,9 +399,7 @@ if __name__ == "__main__":
|
|||||||
# Remove a component
|
# Remove a component
|
||||||
ComponentManager.remove_component(test_sch, "D1")
|
ComponentManager.remove_component(test_sch, "D1")
|
||||||
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
||||||
print(
|
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
|
||||||
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save the schematic (optional)
|
# Save the schematic (optional)
|
||||||
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ class ConnectionManager:
|
|||||||
logger.error("Schematic does not have label collection")
|
logger.error("Schematic does not have label collection")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
label = schematic.label.append(
|
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
|
||||||
text=net_name, at={"x": position[0], "y": position[1]}
|
|
||||||
)
|
|
||||||
logger.info(f"Added net label '{net_name}' at {position}")
|
logger.info(f"Added net label '{net_name}' at {position}")
|
||||||
return label
|
return label
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -58,9 +56,7 @@ class ConnectionManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def connect_to_net(
|
def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str):
|
||||||
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
@@ -93,9 +89,7 @@ class ConnectionManager:
|
|||||||
# Stub direction follows the pin's outward angle from the PinLocator
|
# Stub direction follows the pin's outward angle from the PinLocator
|
||||||
pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
|
pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
|
||||||
try:
|
try:
|
||||||
pin_angle_deg = (
|
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
|
||||||
locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pin_angle_deg = 0
|
pin_angle_deg = 0
|
||||||
import math as _math
|
import math as _math
|
||||||
@@ -172,9 +166,7 @@ class ConnectionManager:
|
|||||||
connected = []
|
connected = []
|
||||||
failed = []
|
failed = []
|
||||||
|
|
||||||
for pin_num in sorted(
|
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
|
||||||
src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
net_name = (
|
net_name = (
|
||||||
f"{net_prefix}_{int(pin_num) + pin_offset}"
|
f"{net_prefix}_{int(pin_num) + pin_offset}"
|
||||||
@@ -200,15 +192,11 @@ class ConnectionManager:
|
|||||||
failed.append(f"{target_ref}/{pin_num} (pin not found)")
|
failed.append(f"{target_ref}/{pin_num} (pin not found)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
connected.append(
|
connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]")
|
||||||
f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed.append(f"{source_ref}/{pin_num}: {e}")
|
failed.append(f"{source_ref}/{pin_num}: {e}")
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed")
|
||||||
f"connect_passthrough: {len(connected)} connected, {len(failed)} failed"
|
|
||||||
)
|
|
||||||
return {"connected": connected, "failed": failed}
|
return {"connected": connected, "failed": failed}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -256,9 +244,7 @@ class ConnectionManager:
|
|||||||
logger.info(f"No labels found for net '{net_name}'")
|
logger.info(f"No labels found for net '{net_name}'")
|
||||||
return connections
|
return connections
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
|
||||||
f"Found {len(net_label_positions)} labels for net '{net_name}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Find all wires connected to these label positions
|
# 2. Find all wires connected to these label positions
|
||||||
if not hasattr(schematic, "wire"):
|
if not hasattr(schematic, "wire"):
|
||||||
@@ -272,9 +258,7 @@ class ConnectionManager:
|
|||||||
wire_points = []
|
wire_points = []
|
||||||
for point in wire.pts.xy:
|
for point in wire.pts.xy:
|
||||||
if hasattr(point, "value"):
|
if hasattr(point, "value"):
|
||||||
wire_points.append(
|
wire_points.append([float(point.value[0]), float(point.value[1])])
|
||||||
[float(point.value[0]), float(point.value[1])]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if any wire point touches a label
|
# Check if any wire point touches a label
|
||||||
wire_connected = False
|
wire_connected = False
|
||||||
@@ -334,18 +318,14 @@ class ConnectionManager:
|
|||||||
# Check each pin
|
# Check each pin
|
||||||
for pin_num, pin_data in pins.items():
|
for pin_num, pin_data in pins.items():
|
||||||
# Get pin location
|
# Get pin location
|
||||||
pin_loc = locator.get_pin_location(
|
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
|
||||||
schematic_path, ref, pin_num
|
|
||||||
)
|
|
||||||
if not pin_loc:
|
if not pin_loc:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if pin coincides with any wire point
|
# Check if pin coincides with any wire point
|
||||||
for wire_pt in connected_wire_points:
|
for wire_pt in connected_wire_points:
|
||||||
if points_coincide(pin_loc, list(wire_pt)):
|
if points_coincide(pin_loc, list(wire_pt)):
|
||||||
connections.append(
|
connections.append({"component": ref, "pin": pin_num})
|
||||||
{"component": ref, "pin": pin_num}
|
|
||||||
)
|
|
||||||
break # Pin found, no need to check more wire points
|
break # Pin found, no need to check more wire points
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -364,9 +344,7 @@ class ConnectionManager:
|
|||||||
|
|
||||||
# Check if symbol is near any wire point (within 10mm)
|
# Check if symbol is near any wire point (within 10mm)
|
||||||
for wire_pt in connected_wire_points:
|
for wire_pt in connected_wire_points:
|
||||||
dist = (
|
dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5
|
||||||
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
|
|
||||||
) ** 0.5
|
|
||||||
if dist < 10.0: # 10mm proximity threshold
|
if dist < 10.0: # 10mm proximity threshold
|
||||||
connections.append({"component": ref, "pin": "unknown"})
|
connections.append({"component": ref, "pin": "unknown"})
|
||||||
break # Only add once per component
|
break # Only add once per component
|
||||||
@@ -419,9 +397,7 @@ class ConnectionManager:
|
|||||||
component_info = {
|
component_info = {
|
||||||
"reference": symbol.property.Reference.value,
|
"reference": symbol.property.Reference.value,
|
||||||
"value": (
|
"value": (
|
||||||
symbol.property.Value.value
|
symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
|
||||||
if hasattr(symbol.property, "Value")
|
|
||||||
else ""
|
|
||||||
),
|
),
|
||||||
"footprint": (
|
"footprint": (
|
||||||
symbol.property.Footprint.value
|
symbol.property.Footprint.value
|
||||||
@@ -444,9 +420,7 @@ class ConnectionManager:
|
|||||||
schematic, net_name, schematic_path
|
schematic, net_name, schematic_path
|
||||||
)
|
)
|
||||||
if connections:
|
if connections:
|
||||||
netlist["nets"].append(
|
netlist["nets"].append({"name": net_name, "connections": connections})
|
||||||
{"name": net_name, "connections": connections}
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
|
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
|
||||||
|
|||||||
@@ -81,9 +81,7 @@ class DatasheetManager:
|
|||||||
return lib_sym_start, lib_sym_end
|
return lib_sym_start, lib_sym_end
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _process_symbol_block(
|
def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]:
|
||||||
lines: List[str], block_start: int, block_end: int
|
|
||||||
) -> Optional[Dict]:
|
|
||||||
"""
|
"""
|
||||||
Extract LCSC and Datasheet info from a placed symbol block.
|
Extract LCSC and Datasheet info from a placed symbol block.
|
||||||
|
|
||||||
@@ -114,9 +112,7 @@ class DatasheetManager:
|
|||||||
"datasheet_value": datasheet_current,
|
"datasheet_value": datasheet_current,
|
||||||
}
|
}
|
||||||
|
|
||||||
def enrich_schematic(
|
def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict:
|
||||||
self, schematic_path: Path, dry_run: bool = False
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
"""
|
||||||
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
|
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
|
||||||
|
|
||||||
@@ -223,9 +219,7 @@ class DatasheetManager:
|
|||||||
no_lcsc += 1
|
no_lcsc += 1
|
||||||
elif ds_value not in EMPTY_DATASHEET_VALUES:
|
elif ds_value not in EMPTY_DATASHEET_VALUES:
|
||||||
already_set += 1
|
already_set += 1
|
||||||
logger.debug(
|
logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}")
|
||||||
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
|
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
@@ -256,9 +250,7 @@ class DatasheetManager:
|
|||||||
if not dry_run and updated > 0:
|
if not dry_run and updated > 0:
|
||||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(new_lines))
|
f.write("\n".join(new_lines))
|
||||||
logger.info(
|
logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written")
|
||||||
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -58,13 +58,9 @@ class DesignRuleCommands:
|
|||||||
|
|
||||||
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
||||||
if "microViaDiameter" in params:
|
if "microViaDiameter" in params:
|
||||||
design_settings.m_MicroViasMinSize = int(
|
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
||||||
params["microViaDiameter"] * scale
|
|
||||||
)
|
|
||||||
if "microViaDrill" in params:
|
if "microViaDrill" in params:
|
||||||
design_settings.m_MicroViasMinDrill = int(
|
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
||||||
params["microViaDrill"] * scale
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set minimum values
|
# Set minimum values
|
||||||
if "minTrackWidth" in params:
|
if "minTrackWidth" in params:
|
||||||
@@ -77,19 +73,13 @@ class DesignRuleCommands:
|
|||||||
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
||||||
|
|
||||||
if "minMicroViaDiameter" in params:
|
if "minMicroViaDiameter" in params:
|
||||||
design_settings.m_MicroViasMinSize = int(
|
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
||||||
params["minMicroViaDiameter"] * scale
|
|
||||||
)
|
|
||||||
if "minMicroViaDrill" in params:
|
if "minMicroViaDrill" in params:
|
||||||
design_settings.m_MicroViasMinDrill = int(
|
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
||||||
params["minMicroViaDrill"] * scale
|
|
||||||
)
|
|
||||||
|
|
||||||
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
||||||
if "minHoleDiameter" in params:
|
if "minHoleDiameter" in params:
|
||||||
design_settings.m_MinThroughDrill = int(
|
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
||||||
params["minHoleDiameter"] * scale
|
|
||||||
)
|
|
||||||
|
|
||||||
# KiCAD 9.0: Added hole clearance settings
|
# KiCAD 9.0: Added hole clearance settings
|
||||||
if "holeClearance" in params:
|
if "holeClearance" in params:
|
||||||
@@ -216,9 +206,7 @@ class DesignRuleCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Create temporary JSON output file
|
# Create temporary JSON output file
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||||
mode="w", suffix=".json", delete=False
|
|
||||||
) as tmp:
|
|
||||||
json_output = tmp.name
|
json_output = tmp.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -297,9 +285,7 @@ class DesignRuleCommands:
|
|||||||
# Determine where to save the violations file
|
# Determine where to save the violations file
|
||||||
board_dir = os.path.dirname(board_file)
|
board_dir = os.path.dirname(board_file)
|
||||||
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
||||||
violations_file = os.path.join(
|
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
||||||
board_dir, f"{board_name}_drc_violations.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Always save violations to JSON file (for large result sets)
|
# Always save violations to JSON file (for large result sets)
|
||||||
with open(violations_file, "w", encoding="utf-8") as f:
|
with open(violations_file, "w", encoding="utf-8") as f:
|
||||||
@@ -453,9 +439,7 @@ class DesignRuleCommands:
|
|||||||
|
|
||||||
# Filter by severity if specified
|
# Filter by severity if specified
|
||||||
if severity != "all":
|
if severity != "all":
|
||||||
filtered_violations = [
|
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
|
||||||
v for v in all_violations if v.get("severity") == severity
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
filtered_violations = all_violations
|
filtered_violations = all_violations
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ class DynamicSymbolLoader:
|
|||||||
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
|
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
|
||||||
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
|
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
|
||||||
]
|
]
|
||||||
for env_var in ["KICAD10_SYMBOL_DIR", "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:
|
if env_var in os.environ:
|
||||||
possible_paths.insert(0, Path(os.environ[env_var]))
|
possible_paths.insert(0, Path(os.environ[env_var]))
|
||||||
|
|
||||||
@@ -83,7 +88,9 @@ class DynamicSymbolLoader:
|
|||||||
with open(table_path, "r", encoding="utf-8") as f:
|
with open(table_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
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):
|
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
|
||||||
nickname = match.group(1)
|
nickname = match.group(1)
|
||||||
if nickname != library_name:
|
if nickname != library_name:
|
||||||
@@ -208,9 +215,7 @@ class DynamicSymbolLoader:
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def _inline_extends_symbol(
|
def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
|
||||||
self, lib_content: str, symbol_name: str, child_block: str
|
|
||||||
) -> str:
|
|
||||||
"""
|
"""
|
||||||
Fully inline a child symbol that uses (extends "ParentName") by merging
|
Fully inline a child symbol that uses (extends "ParentName") by merging
|
||||||
the parent's pins / graphics into the child definition.
|
the parent's pins / graphics into the child definition.
|
||||||
@@ -255,22 +260,16 @@ class DynamicSymbolLoader:
|
|||||||
|
|
||||||
for item in self._iter_top_level_items(parent_block):
|
for item in self._iter_top_level_items(parent_block):
|
||||||
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
|
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
|
||||||
sub_match = re.search(
|
sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
|
||||||
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
|
|
||||||
)
|
|
||||||
|
|
||||||
if prop_match:
|
if prop_match:
|
||||||
pname = prop_match.group(1)
|
pname = prop_match.group(1)
|
||||||
parent_prop_names.add(pname)
|
parent_prop_names.add(pname)
|
||||||
body_lines.append(
|
body_lines.append(child_props[pname] if pname in child_props else item)
|
||||||
child_props[pname] if pname in child_props else item
|
|
||||||
)
|
|
||||||
elif sub_match:
|
elif sub_match:
|
||||||
# Rename ParentName_0_1 → ChildName_0_1
|
# Rename ParentName_0_1 → ChildName_0_1
|
||||||
body_lines.append(
|
body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
|
||||||
item.replace(f'"{parent_name}_', f'"{symbol_name}_')
|
elif re.match(r"[\s\t]*\(extends ", item):
|
||||||
)
|
|
||||||
elif re.match(r'[\s\t]*\(extends ', item):
|
|
||||||
pass # drop extends clause
|
pass # drop extends clause
|
||||||
else:
|
else:
|
||||||
body_lines.append(item) # pin_names, in_bom, on_board …
|
body_lines.append(item) # pin_names, in_bom, on_board …
|
||||||
@@ -280,16 +279,12 @@ class DynamicSymbolLoader:
|
|||||||
if pname not in parent_prop_names:
|
if pname not in parent_prop_names:
|
||||||
body_lines.append(pblock)
|
body_lines.append(pblock)
|
||||||
|
|
||||||
first_line = parent_block.split("\n")[0].replace(
|
first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
|
||||||
f'"{parent_name}"', f'"{symbol_name}"'
|
|
||||||
)
|
|
||||||
last_line = parent_block.split("\n")[-1]
|
last_line = parent_block.split("\n")[-1]
|
||||||
|
|
||||||
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
|
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
|
||||||
|
|
||||||
def extract_symbol_from_library(
|
def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
|
||||||
self, library_name: str, symbol_name: str
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
"""
|
||||||
Extract a symbol definition from a KiCad .kicad_sym library file.
|
Extract a symbol definition from a KiCad .kicad_sym library file.
|
||||||
Returns the raw text block, ready to be injected into a schematic.
|
Returns the raw text block, ready to be injected into a schematic.
|
||||||
@@ -311,9 +306,7 @@ class DynamicSymbolLoader:
|
|||||||
|
|
||||||
block = self._extract_symbol_block(lib_content, symbol_name)
|
block = self._extract_symbol_block(lib_content, symbol_name)
|
||||||
if block is None:
|
if block is None:
|
||||||
logger.warning(
|
logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
|
||||||
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# If the symbol uses (extends "ParentName"), inline the parent content
|
# If the symbol uses (extends "ParentName"), inline the parent content
|
||||||
@@ -322,9 +315,7 @@ class DynamicSymbolLoader:
|
|||||||
# load a schematic whose lib_symbols section contains it.
|
# load a schematic whose lib_symbols section contains it.
|
||||||
if re.search(r'\(extends "([^"]+)"\)', block):
|
if re.search(r'\(extends "([^"]+)"\)', block):
|
||||||
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
|
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
|
||||||
logger.info(
|
logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
|
||||||
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
|
|
||||||
)
|
|
||||||
block = self._inline_extends_symbol(lib_content, symbol_name, block)
|
block = self._inline_extends_symbol(lib_content, symbol_name, block)
|
||||||
|
|
||||||
# Prefix top-level symbol name with library
|
# Prefix top-level symbol name with library
|
||||||
@@ -362,9 +353,7 @@ class DynamicSymbolLoader:
|
|||||||
# Extract symbol from library
|
# Extract symbol from library
|
||||||
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
|
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
|
||||||
if not symbol_block:
|
if not symbol_block:
|
||||||
raise ValueError(
|
raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
|
||||||
f"Symbol '{symbol_name}' not found in library '{library_name}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
|
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
|
||||||
indented_lines = []
|
indented_lines = []
|
||||||
@@ -399,11 +388,7 @@ class DynamicSymbolLoader:
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
# Handle both Path objects and strings
|
# Handle both Path objects and strings
|
||||||
sch_name = (
|
sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
|
||||||
schematic_path.name
|
|
||||||
if hasattr(schematic_path, "name")
|
|
||||||
else str(schematic_path)
|
|
||||||
)
|
|
||||||
logger.info(f"Injected symbol {full_name} into {sch_name}")
|
logger.info(f"Injected symbol {full_name} into {sch_name}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -457,9 +442,7 @@ class DynamicSymbolLoader:
|
|||||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
|
||||||
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def load_symbol_dynamically(
|
def load_symbol_dynamically(
|
||||||
|
|||||||
@@ -105,22 +105,16 @@ class ExportCommands:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
cmd, capture_output=True, text=True, timeout=60
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Get list of generated drill files
|
# Get list of generated drill files
|
||||||
for file in os.listdir(output_dir):
|
for file in os.listdir(output_dir):
|
||||||
if file.endswith((".drl", ".cnc")):
|
if file.endswith((".drl", ".cnc")):
|
||||||
drill_files.append(file)
|
drill_files.append(file)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(f"Drill file generation failed: {result.stderr}")
|
||||||
f"Drill file generation failed: {result.stderr}"
|
|
||||||
)
|
|
||||||
except Exception as drill_error:
|
except Exception as drill_error:
|
||||||
logger.warning(
|
logger.warning(f"Could not generate drill files: {str(drill_error)}")
|
||||||
f"Could not generate drill files: {str(drill_error)}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.warning("kicad-cli not available for drill file generation")
|
logger.warning("kicad-cli not available for drill file generation")
|
||||||
|
|
||||||
@@ -236,9 +230,7 @@ class ExportCommands:
|
|||||||
# Get the actual output filename that was created
|
# Get the actual output filename that was created
|
||||||
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
|
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
|
||||||
actual_filename = f"{board_name}-{base_name}.pdf"
|
actual_filename = f"{board_name}-{base_name}.pdf"
|
||||||
actual_output_path = os.path.join(
|
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
|
||||||
os.path.dirname(output_path), actual_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -395,9 +387,7 @@ class ExportCommands:
|
|||||||
if not include_components:
|
if not include_components:
|
||||||
cmd.append("--no-components")
|
cmd.append("--no-components")
|
||||||
if include_copper:
|
if include_copper:
|
||||||
cmd.extend(
|
cmd.extend(["--include-tracks", "--include-pads", "--include-zones"])
|
||||||
["--include-tracks", "--include-pads", "--include-zones"]
|
|
||||||
)
|
|
||||||
if include_silkscreen:
|
if include_silkscreen:
|
||||||
cmd.append("--include-silkscreen")
|
cmd.append("--include-silkscreen")
|
||||||
if include_solder_mask:
|
if include_solder_mask:
|
||||||
@@ -696,6 +686,7 @@ class ExportCommands:
|
|||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logs_dir = Path(project_dir) / "logs"
|
logs_dir = Path(project_dir) / "logs"
|
||||||
logs_dir.mkdir(exist_ok=True)
|
logs_dir.mkdir(exist_ok=True)
|
||||||
dest = str(logs_dir / f"mcp_log_{timestamp}.txt")
|
dest = str(logs_dir / f"mcp_log_{timestamp}.txt")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
logger = logging.getLogger("kicad_interface")
|
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
|
KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ class FootprintCreator:
|
|||||||
|
|
||||||
# ---- header ----
|
# ---- header ----
|
||||||
lines.append(f'(footprint "{name}"')
|
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 "kicad-mcp")')
|
||||||
lines.append(f' (generator_version "9.0")')
|
lines.append(f' (generator_version "9.0")')
|
||||||
lines.append(f' (layer "F.Cu")')
|
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_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
|
val_y = value_position.get("y", 1.27) if value_position else 1.27
|
||||||
|
|
||||||
lines.append(
|
lines.append(f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)')
|
||||||
f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)'
|
|
||||||
)
|
|
||||||
lines.append(f' (layer "F.SilkS")')
|
lines.append(f' (layer "F.SilkS")')
|
||||||
lines.append(f' (uuid "{_new_uuid()}")')
|
lines.append(f' (uuid "{_new_uuid()}")')
|
||||||
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
|
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
|
||||||
lines.append(f' )')
|
lines.append(f" )")
|
||||||
lines.append(
|
lines.append(f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)')
|
||||||
f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)'
|
|
||||||
)
|
|
||||||
lines.append(f' (layer "F.Fab")')
|
lines.append(f' (layer "F.Fab")')
|
||||||
lines.append(f' (uuid "{_new_uuid()}")')
|
lines.append(f' (uuid "{_new_uuid()}")')
|
||||||
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
|
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
|
||||||
lines.append(f' )')
|
lines.append(f" )")
|
||||||
lines.append(f' (property "Datasheet" "" (at 0 0 0)')
|
lines.append(f' (property "Datasheet" "" (at 0 0 0)')
|
||||||
lines.append(f' (layer "F.Fab")')
|
lines.append(f' (layer "F.Fab")')
|
||||||
lines.append(f' (uuid "{_new_uuid()}")')
|
lines.append(f' (uuid "{_new_uuid()}")')
|
||||||
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
|
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
|
||||||
lines.append(f' )')
|
lines.append(f" )")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# ---- courtyard ----
|
# ---- courtyard ----
|
||||||
@@ -217,33 +213,32 @@ class FootprintCreator:
|
|||||||
changes = []
|
changes = []
|
||||||
if size:
|
if size:
|
||||||
new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})'
|
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:
|
if n:
|
||||||
changes.append(f"size→{new_size}")
|
changes.append(f"size→{new_size}")
|
||||||
if at:
|
if at:
|
||||||
angle = at.get("angle", 0)
|
angle = at.get("angle", 0)
|
||||||
new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})'
|
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:
|
if n:
|
||||||
changes.append(f"at→{new_at}")
|
changes.append(f"at→{new_at}")
|
||||||
if drill is not None:
|
if drill is not None:
|
||||||
if isinstance(drill, (int, float)):
|
if isinstance(drill, (int, float)):
|
||||||
new_drill = f'(drill {_fmt(drill)})'
|
new_drill = f"(drill {_fmt(drill)})"
|
||||||
else:
|
else:
|
||||||
new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})'
|
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:
|
if n:
|
||||||
changes.append(f"drill→{new_drill}")
|
changes.append(f"drill→{new_drill}")
|
||||||
else:
|
else:
|
||||||
# Insert drill before closing paren of pad block
|
# 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}")
|
changes.append(f"drill (inserted)→{new_drill}")
|
||||||
if shape:
|
if shape:
|
||||||
block, n = re.subn(
|
block, n = re.subn(
|
||||||
r'(pad\s+"[^"]*"\s+\w+\s+)\w+',
|
r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1
|
||||||
lambda m: m.group(1) + shape,
|
|
||||||
block,
|
|
||||||
count=1
|
|
||||||
)
|
)
|
||||||
if n:
|
if n:
|
||||||
changes.append(f"shape→{shape}")
|
changes.append(f"shape→{shape}")
|
||||||
@@ -280,7 +275,7 @@ class FootprintCreator:
|
|||||||
if not updated:
|
if not updated:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"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")
|
path.write_text("\n".join(result_lines), encoding="utf-8")
|
||||||
@@ -429,6 +424,7 @@ class FootprintCreator:
|
|||||||
# Internal helpers #
|
# Internal helpers #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
def _esc(s: str) -> str:
|
def _esc(s: str) -> str:
|
||||||
"""Escape double-quotes inside S-Expression string values."""
|
"""Escape double-quotes inside S-Expression string values."""
|
||||||
return s.replace('"', '\\"')
|
return s.replace('"', '\\"')
|
||||||
@@ -436,6 +432,7 @@ def _esc(s: str) -> str:
|
|||||||
|
|
||||||
def _new_uuid() -> str:
|
def _new_uuid() -> str:
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
@@ -445,8 +442,8 @@ _DEFAULT_THT_LAYERS = ["*.Cu", "*.Mask"]
|
|||||||
|
|
||||||
def _pad_lines(pad: Dict[str, Any]) -> List[str]:
|
def _pad_lines(pad: Dict[str, Any]) -> List[str]:
|
||||||
number = str(pad.get("number", "1"))
|
number = str(pad.get("number", "1"))
|
||||||
ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole
|
ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole
|
||||||
shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect
|
shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect
|
||||||
at = pad.get("at", {"x": 0.0, "y": 0.0})
|
at = pad.get("at", {"x": 0.0, "y": 0.0})
|
||||||
size = pad.get("size", {"w": 1.0, "h": 1.0})
|
size = pad.get("size", {"w": 1.0, "h": 1.0})
|
||||||
drill = pad.get("drill", None)
|
drill = pad.get("drill", None)
|
||||||
@@ -462,7 +459,9 @@ def _pad_lines(pad: Dict[str, Any]) -> List[str]:
|
|||||||
sh = _fmt(size.get("h", 1.0))
|
sh = _fmt(size.get("h", 1.0))
|
||||||
|
|
||||||
if layers is None:
|
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)
|
layers_str = " ".join(f'"{l}"' for l in layers)
|
||||||
|
|
||||||
lines = [f' (pad "{number}" {ptype} {shape}']
|
lines = [f' (pad "{number}" {ptype} {shape}']
|
||||||
@@ -494,13 +493,13 @@ def _rect_lines(rect: Dict[str, Any], layer: str, default_width: float = 0.05) -
|
|||||||
y2 = _fmt(rect.get("y2", 1.0))
|
y2 = _fmt(rect.get("y2", 1.0))
|
||||||
w = _fmt(rect.get("width", default_width))
|
w = _fmt(rect.get("width", default_width))
|
||||||
return [
|
return [
|
||||||
f' (fp_rect',
|
f" (fp_rect",
|
||||||
f' (start {x1} {y1})',
|
f" (start {x1} {y1})",
|
||||||
f' (end {x2} {y2})',
|
f" (end {x2} {y2})",
|
||||||
f' (stroke (width {w}) (type default))',
|
f" (stroke (width {w}) (type default))",
|
||||||
f' (fill none)',
|
f" (fill none)",
|
||||||
f' (layer "{layer}")',
|
f' (layer "{layer}")',
|
||||||
f' (uuid "{_new_uuid()}")',
|
f' (uuid "{_new_uuid()}")',
|
||||||
f' )',
|
f" )",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ logger = logging.getLogger("kicad_interface")
|
|||||||
# Default Freerouting JAR location
|
# Default Freerouting JAR location
|
||||||
DEFAULT_FREEROUTING_JAR = os.environ.get(
|
DEFAULT_FREEROUTING_JAR = os.environ.get(
|
||||||
"FREEROUTING_JAR",
|
"FREEROUTING_JAR",
|
||||||
os.path.join(
|
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
|
||||||
os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DOCKER_IMAGE = "eclipse-temurin:21-jre"
|
DOCKER_IMAGE = "eclipse-temurin:21-jre"
|
||||||
@@ -102,21 +100,36 @@ def _build_freerouting_cmd(
|
|||||||
ses_name = os.path.basename(ses_path)
|
ses_name = os.path.basename(ses_path)
|
||||||
jar_name = os.path.basename(jar_path)
|
jar_name = os.path.basename(jar_path)
|
||||||
return [
|
return [
|
||||||
docker_exe, "run", "--rm",
|
docker_exe,
|
||||||
"-v", f"{jar_path}:/app/{jar_name}:ro",
|
"run",
|
||||||
"-v", f"{board_dir}:/work",
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
f"{jar_path}:/app/{jar_name}:ro",
|
||||||
|
"-v",
|
||||||
|
f"{board_dir}:/work",
|
||||||
DOCKER_IMAGE,
|
DOCKER_IMAGE,
|
||||||
"java", "-jar", f"/app/{jar_name}",
|
"java",
|
||||||
"-de", f"/work/{dsn_name}",
|
"-jar",
|
||||||
"-do", f"/work/{ses_name}",
|
f"/app/{jar_name}",
|
||||||
"-mp", str(passes),
|
"-de",
|
||||||
|
f"/work/{dsn_name}",
|
||||||
|
"-do",
|
||||||
|
f"/work/{ses_name}",
|
||||||
|
"-mp",
|
||||||
|
str(passes),
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
java_exe = _find_java()
|
java_exe = _find_java()
|
||||||
return [
|
return [
|
||||||
java_exe, "-jar", jar_path,
|
java_exe,
|
||||||
"-de", dsn_path, "-do", ses_path,
|
"-jar",
|
||||||
"-mp", str(passes),
|
jar_path,
|
||||||
|
"-de",
|
||||||
|
dsn_path,
|
||||||
|
"-do",
|
||||||
|
ses_path,
|
||||||
|
"-mp",
|
||||||
|
str(passes),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -126,9 +139,7 @@ class FreeroutingCommands:
|
|||||||
def __init__(self, board=None):
|
def __init__(self, board=None):
|
||||||
self.board = board
|
self.board = board
|
||||||
|
|
||||||
def _resolve_execution_mode(
|
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:
|
||||||
self, jar_path: str
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Determine how to run Freerouting: direct or docker.
|
"""Determine how to run Freerouting: direct or docker.
|
||||||
|
|
||||||
Returns dict with 'mode', 'use_docker', or 'error'.
|
Returns dict with 'mode', 'use_docker', or 'error'.
|
||||||
@@ -152,8 +163,7 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"mode": "error",
|
"mode": "error",
|
||||||
"error": (
|
"error": (
|
||||||
"Neither Java 21+ nor Docker found. "
|
"Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting."
|
||||||
"Install one of them to use Freerouting."
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +200,10 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board file path available",
|
"message": "No board file path available",
|
||||||
"errorDetails": (
|
"errorDetails": ("Provide boardPath or open a project first"),
|
||||||
"Provide boardPath or open a project first"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jar_path = params.get(
|
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
|
||||||
"freeroutingJar", DEFAULT_FREEROUTING_JAR
|
|
||||||
)
|
|
||||||
timeout = params.get("timeout", 300)
|
timeout = params.get("timeout", 300)
|
||||||
passes = params.get("maxPasses", 20)
|
passes = params.get("maxPasses", 20)
|
||||||
|
|
||||||
@@ -238,9 +244,7 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "DSN export failed",
|
"message": "DSN export failed",
|
||||||
"errorDetails": (
|
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
|
||||||
f"ExportSpecctraDSN returned: {result}"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -260,14 +264,10 @@ class FreeroutingCommands:
|
|||||||
logger.info(f"DSN exported: {dsn_size} bytes")
|
logger.info(f"DSN exported: {dsn_size} bytes")
|
||||||
|
|
||||||
# Step 2: Run Freerouting
|
# Step 2: Run Freerouting
|
||||||
cmd = _build_freerouting_cmd(
|
cmd = _build_freerouting_cmd(jar_path, dsn_path, ses_path, passes, use_docker)
|
||||||
jar_path, dsn_path, ses_path, passes, use_docker
|
|
||||||
)
|
|
||||||
|
|
||||||
mode_label = "docker" if use_docker else "direct"
|
mode_label = "docker" if use_docker else "direct"
|
||||||
logger.info(
|
logger.info(f"Running Freerouting ({mode_label}): {' '.join(cmd)}")
|
||||||
f"Running Freerouting ({mode_label}): {' '.join(cmd)}"
|
|
||||||
)
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -283,10 +283,7 @@ class FreeroutingCommands:
|
|||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": (
|
"message": (f"Freerouting exited with code " f"{proc.returncode}"),
|
||||||
f"Freerouting exited with code "
|
|
||||||
f"{proc.returncode}"
|
|
||||||
),
|
|
||||||
"errorDetails": proc.stderr or proc.stdout,
|
"errorDetails": proc.stderr or proc.stdout,
|
||||||
"elapsed_seconds": elapsed,
|
"elapsed_seconds": elapsed,
|
||||||
"mode": mode_label,
|
"mode": mode_label,
|
||||||
@@ -294,12 +291,8 @@ class FreeroutingCommands:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": (
|
"message": (f"Freerouting timed out after {timeout}s"),
|
||||||
f"Freerouting timed out after {timeout}s"
|
"errorDetails": ("Increase timeout or reduce board complexity"),
|
||||||
),
|
|
||||||
"errorDetails": (
|
|
||||||
"Increase timeout or reduce board complexity"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -313,10 +306,7 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Freerouting did not produce SES output",
|
"message": "Freerouting did not produce SES output",
|
||||||
"errorDetails": (
|
"errorDetails": (f"Expected at: {ses_path}. " f"Stdout: {proc.stdout[:500]}"),
|
||||||
f"Expected at: {ses_path}. "
|
|
||||||
f"Stdout: {proc.stdout[:500]}"
|
|
||||||
),
|
|
||||||
"elapsed_seconds": elapsed,
|
"elapsed_seconds": elapsed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,9 +321,7 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "SES import failed",
|
"message": "SES import failed",
|
||||||
"errorDetails": (
|
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
|
||||||
f"ImportSpecctraSES returned: {result}"
|
|
||||||
),
|
|
||||||
"elapsed_seconds": elapsed,
|
"elapsed_seconds": elapsed,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -348,9 +336,7 @@ class FreeroutingCommands:
|
|||||||
try:
|
try:
|
||||||
self.board.Save(board_path)
|
self.board.Save(board_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"Board save after autoroute failed: {e}")
|
||||||
f"Board save after autoroute failed: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collect stats
|
# Collect stats
|
||||||
tracks = self.board.GetTracks()
|
tracks = self.board.GetTracks()
|
||||||
@@ -373,9 +359,7 @@ class FreeroutingCommands:
|
|||||||
"tracks": track_count,
|
"tracks": track_count,
|
||||||
"vias": via_count,
|
"vias": via_count,
|
||||||
},
|
},
|
||||||
"freerouting_stdout": (
|
"freerouting_stdout": (proc.stdout[:1000] if proc.stdout else ""),
|
||||||
proc.stdout[:1000] if proc.stdout else ""
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -396,36 +380,26 @@ class FreeroutingCommands:
|
|||||||
"errorDetails": "Load or create a board first",
|
"errorDetails": "Load or create a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
board_path = (
|
board_path = params.get("boardPath") or self.board.GetFileName()
|
||||||
params.get("boardPath") or self.board.GetFileName()
|
|
||||||
)
|
|
||||||
output_path = params.get("outputPath")
|
output_path = params.get("outputPath")
|
||||||
|
|
||||||
if not output_path:
|
if not output_path:
|
||||||
if board_path:
|
if board_path:
|
||||||
output_path = (
|
output_path = os.path.splitext(board_path)[0] + ".dsn"
|
||||||
os.path.splitext(board_path)[0] + ".dsn"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No output path",
|
"message": "No output path",
|
||||||
"errorDetails": (
|
"errorDetails": ("Provide outputPath or have a board open"),
|
||||||
"Provide outputPath or have a board open"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = pcbnew.ExportSpecctraDSN(
|
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
|
||||||
self.board, output_path
|
|
||||||
)
|
|
||||||
if result is not True and result != 0:
|
if result is not True and result != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "DSN export failed",
|
"message": "DSN export failed",
|
||||||
"errorDetails": (
|
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
|
||||||
f"ExportSpecctraDSN returned: {result}"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -434,11 +408,7 @@ class FreeroutingCommands:
|
|||||||
"errorDetails": str(e),
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
file_size = (
|
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
|
||||||
os.path.getsize(output_path)
|
|
||||||
if os.path.isfile(output_path)
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Exported DSN to {output_path}",
|
"message": f"Exported DSN to {output_path}",
|
||||||
@@ -469,9 +439,7 @@ class FreeroutingCommands:
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Missing sesPath parameter",
|
"message": "Missing sesPath parameter",
|
||||||
"errorDetails": (
|
"errorDetails": ("Provide the path to the .ses file"),
|
||||||
"Provide the path to the .ses file"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if not os.path.isfile(ses_path):
|
if not os.path.isfile(ses_path):
|
||||||
@@ -482,16 +450,12 @@ class FreeroutingCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = pcbnew.ImportSpecctraSES(
|
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
|
||||||
self.board, ses_path
|
|
||||||
)
|
|
||||||
if result is not True and result != 0:
|
if result is not True and result != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "SES import failed",
|
"message": "SES import failed",
|
||||||
"errorDetails": (
|
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
|
||||||
f"ImportSpecctraSES returned: {result}"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -500,24 +464,16 @@ class FreeroutingCommands:
|
|||||||
"errorDetails": str(e),
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
board_path = (
|
board_path = params.get("boardPath") or self.board.GetFileName()
|
||||||
params.get("boardPath") or self.board.GetFileName()
|
|
||||||
)
|
|
||||||
if board_path:
|
if board_path:
|
||||||
try:
|
try:
|
||||||
self.board.Save(board_path)
|
self.board.Save(board_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"Board save after SES import failed: {e}")
|
||||||
f"Board save after SES import failed: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
tracks = self.board.GetTracks()
|
tracks = self.board.GetTracks()
|
||||||
track_count = sum(
|
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
|
||||||
1 for t in tracks if t.GetClass() != "PCB_VIA"
|
via_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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -528,13 +484,9 @@ class FreeroutingCommands:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def check_freerouting(
|
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
self, params: Dict[str, Any]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Check if Freerouting and Java/Docker are available."""
|
"""Check if Freerouting and Java/Docker are available."""
|
||||||
jar_path = params.get(
|
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
|
||||||
"freeroutingJar", DEFAULT_FREEROUTING_JAR
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check local Java
|
# Check local Java
|
||||||
java_exe = _find_java()
|
java_exe = _find_java()
|
||||||
@@ -548,11 +500,7 @@ class FreeroutingCommands:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
java_version = (
|
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
|
||||||
(proc.stderr or proc.stdout)
|
|
||||||
.strip()
|
|
||||||
.split("\n")[0]
|
|
||||||
)
|
|
||||||
java_21_ok = _java_version_ok(java_exe)
|
java_21_ok = _java_version_ok(java_exe)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import json
|
|||||||
from typing import Optional, Dict, List, Callable
|
from typing import Optional, Dict, List, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class JLCPCBClient:
|
class JLCPCBClient:
|
||||||
@@ -31,7 +31,12 @@ class JLCPCBClient:
|
|||||||
|
|
||||||
BASE_URL = "https://jlcpcb.com/external"
|
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
|
Initialize JLCPCB API client
|
||||||
|
|
||||||
@@ -40,20 +45,24 @@ class JLCPCBClient:
|
|||||||
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
|
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)
|
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.app_id = app_id or os.getenv("JLCPCB_APP_ID")
|
||||||
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
|
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
|
||||||
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
|
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:
|
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
|
@staticmethod
|
||||||
def _generate_nonce() -> str:
|
def _generate_nonce() -> str:
|
||||||
"""Generate a 32-character random nonce"""
|
"""Generate a 32-character random nonce"""
|
||||||
chars = string.ascii_letters + string.digits
|
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
|
Build the signature string according to JLCPCB spec
|
||||||
|
|
||||||
@@ -87,11 +96,9 @@ class JLCPCBClient:
|
|||||||
Base64-encoded signature
|
Base64-encoded signature
|
||||||
"""
|
"""
|
||||||
signature_bytes = hmac.new(
|
signature_bytes = hmac.new(
|
||||||
self.secret_key.encode('utf-8'),
|
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
|
||||||
signature_string.encode('utf-8'),
|
|
||||||
hashlib.sha256
|
|
||||||
).digest()
|
).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:
|
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
@@ -106,7 +113,9 @@ class JLCPCBClient:
|
|||||||
Authorization header value
|
Authorization header value
|
||||||
"""
|
"""
|
||||||
if not self.app_id or not self.access_key or not self.secret_key:
|
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()
|
nonce = self._generate_nonce()
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
@@ -116,7 +125,9 @@ class JLCPCBClient:
|
|||||||
|
|
||||||
logger.debug(f"Signature string:\n{repr(signature_string)}")
|
logger.debug(f"Signature string:\n{repr(signature_string)}")
|
||||||
logger.debug(f"Signature: {signature}")
|
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}"'
|
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||||
|
|
||||||
@@ -138,22 +149,16 @@ class JLCPCBClient:
|
|||||||
|
|
||||||
# Convert payload to JSON string for signing
|
# Convert payload to JSON string for signing
|
||||||
# For POST requests, we always send JSON, even if empty dict
|
# 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
|
# Generate authorization header
|
||||||
auth_header = self._get_auth_header("POST", path, body_str)
|
auth_header = self._get_auth_header("POST", path, body_str)
|
||||||
|
|
||||||
headers = {
|
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
|
||||||
"Authorization": auth_header,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.BASE_URL}{path}",
|
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
timeout=60
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"Response status: {response.status_code}")
|
logger.debug(f"Response status: {response.status_code}")
|
||||||
@@ -163,18 +168,19 @@ class JLCPCBClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if data.get('code') != 200:
|
if data.get("code") != 200:
|
||||||
raise Exception(f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}")
|
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:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"Failed to fetch parts page: {e}")
|
logger.error(f"Failed to fetch parts page: {e}")
|
||||||
raise Exception(f"JLCPCB API request failed: {e}")
|
raise Exception(f"JLCPCB API request failed: {e}")
|
||||||
|
|
||||||
def download_full_database(
|
def download_full_database(
|
||||||
self,
|
self, callback: Optional[Callable[[int, int, str], None]] = None
|
||||||
callback: Optional[Callable[[int, int, str], None]] = None
|
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Download entire parts library from JLCPCB
|
Download entire parts library from JLCPCB
|
||||||
@@ -197,10 +203,10 @@ class JLCPCBClient:
|
|||||||
try:
|
try:
|
||||||
data = self.fetch_parts_page(last_key)
|
data = self.fetch_parts_page(last_key)
|
||||||
|
|
||||||
parts = data.get('componentInfos', [])
|
parts = data.get("componentInfos", [])
|
||||||
all_parts.extend(parts)
|
all_parts.extend(parts)
|
||||||
|
|
||||||
last_key = data.get('lastKey')
|
last_key = data.get("lastKey")
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||||
@@ -245,7 +251,9 @@ class JLCPCBClient:
|
|||||||
return None
|
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
|
Test JLCPCB API connection
|
||||||
|
|
||||||
@@ -268,7 +276,7 @@ def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[st
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
# Test the JLCPCB client
|
# Test the JLCPCB client
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -279,7 +287,7 @@ if __name__ == '__main__':
|
|||||||
client = JLCPCBClient()
|
client = JLCPCBClient()
|
||||||
print("\nFetching first page of parts...")
|
print("\nFetching first page of parts...")
|
||||||
data = client.fetch_parts_page()
|
data = client.fetch_parts_page()
|
||||||
parts = data.get('componentInfos', [])
|
parts = data.get("componentInfos", [])
|
||||||
print(f"✓ Retrieved {len(parts)} parts in first page")
|
print(f"✓ Retrieved {len(parts)} parts in first page")
|
||||||
|
|
||||||
if parts:
|
if parts:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class JLCPCBPartsManager:
|
class JLCPCBPartsManager:
|
||||||
@@ -49,7 +49,7 @@ class JLCPCBPartsManager:
|
|||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
# Create components table
|
# Create components table
|
||||||
cursor.execute('''
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS components (
|
CREATE TABLE IF NOT EXISTS components (
|
||||||
lcsc TEXT PRIMARY KEY,
|
lcsc TEXT PRIMARY KEY,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
@@ -65,17 +65,19 @@ class JLCPCBPartsManager:
|
|||||||
price_json TEXT,
|
price_json TEXT,
|
||||||
last_updated INTEGER
|
last_updated INTEGER
|
||||||
)
|
)
|
||||||
''')
|
""")
|
||||||
|
|
||||||
# Create indexes for fast searching
|
# Create indexes for fast searching
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)')
|
cursor.execute(
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)')
|
"CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)"
|
||||||
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_package ON components(package)")
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)')
|
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
|
# Full-text search index for descriptions
|
||||||
cursor.execute('''
|
cursor.execute("""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
|
||||||
lcsc,
|
lcsc,
|
||||||
description,
|
description,
|
||||||
@@ -83,7 +85,7 @@ class JLCPCBPartsManager:
|
|||||||
manufacturer,
|
manufacturer,
|
||||||
content=components
|
content=components
|
||||||
)
|
)
|
||||||
''')
|
""")
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
|
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
|
||||||
@@ -103,32 +105,35 @@ class JLCPCBPartsManager:
|
|||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
try:
|
try:
|
||||||
# Extract price breaks
|
# Extract price breaks
|
||||||
price_json = json.dumps(part.get('prices', []))
|
price_json = json.dumps(part.get("prices", []))
|
||||||
|
|
||||||
# Determine library type
|
# Determine library type
|
||||||
library_type = self._determine_library_type(part)
|
library_type = self._determine_library_type(part)
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute(
|
||||||
|
"""
|
||||||
INSERT OR REPLACE INTO components (
|
INSERT OR REPLACE INTO components (
|
||||||
lcsc, category, subcategory, mfr_part, package,
|
lcsc, category, subcategory, mfr_part, package,
|
||||||
solder_joints, manufacturer, library_type, description,
|
solder_joints, manufacturer, library_type, description,
|
||||||
datasheet, stock, price_json, last_updated
|
datasheet, stock, price_json, last_updated
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
""",
|
||||||
part.get('componentCode'), # lcsc
|
(
|
||||||
part.get('firstSortName'), # category
|
part.get("componentCode"), # lcsc
|
||||||
part.get('secondSortName'), # subcategory
|
part.get("firstSortName"), # category
|
||||||
part.get('componentModelEn'), # mfr_part
|
part.get("secondSortName"), # subcategory
|
||||||
part.get('componentSpecificationEn'), # package
|
part.get("componentModelEn"), # mfr_part
|
||||||
part.get('soldPoint'), # solder_joints
|
part.get("componentSpecificationEn"), # package
|
||||||
part.get('componentBrandEn'), # manufacturer
|
part.get("soldPoint"), # solder_joints
|
||||||
library_type, # library_type
|
part.get("componentBrandEn"), # manufacturer
|
||||||
part.get('describe'), # description
|
library_type, # library_type
|
||||||
part.get('dataManualUrl'), # datasheet
|
part.get("describe"), # description
|
||||||
part.get('stockCount', 0), # stock
|
part.get("dataManualUrl"), # datasheet
|
||||||
price_json, # price_json
|
part.get("stockCount", 0), # stock
|
||||||
int(datetime.now().timestamp()) # last_updated
|
price_json, # price_json
|
||||||
))
|
int(datetime.now().timestamp()), # last_updated
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
@@ -140,10 +145,10 @@ class JLCPCBPartsManager:
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
|
|
||||||
# Update FTS index
|
# Update FTS index
|
||||||
cursor.execute('''
|
cursor.execute("""
|
||||||
INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer)
|
INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer)
|
||||||
SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components
|
SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components
|
||||||
''')
|
""")
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
|
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
|
||||||
@@ -151,16 +156,16 @@ class JLCPCBPartsManager:
|
|||||||
def _determine_library_type(self, part: Dict) -> str:
|
def _determine_library_type(self, part: Dict) -> str:
|
||||||
"""Determine if part is Basic, Extended, or Preferred"""
|
"""Determine if part is Basic, Extended, or Preferred"""
|
||||||
# JLCPCB API should provide this, but if not, we infer from assembly type
|
# 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':
|
if "Basic" in assembly_type or part.get("libraryType") == "base":
|
||||||
return 'Basic'
|
return "Basic"
|
||||||
elif 'Extended' in assembly_type:
|
elif "Extended" in assembly_type:
|
||||||
return 'Extended'
|
return "Extended"
|
||||||
elif 'Prefer' in assembly_type:
|
elif "Prefer" in assembly_type:
|
||||||
return 'Preferred'
|
return "Preferred"
|
||||||
else:
|
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=None):
|
||||||
"""
|
"""
|
||||||
@@ -178,56 +183,59 @@ class JLCPCBPartsManager:
|
|||||||
try:
|
try:
|
||||||
# JLCSearch format is different from official API
|
# JLCSearch format is different from official API
|
||||||
# LCSC is an integer, we need to add 'C' prefix
|
# LCSC is an integer, we need to add 'C' prefix
|
||||||
lcsc = part.get('lcsc')
|
lcsc = part.get("lcsc")
|
||||||
if isinstance(lcsc, int):
|
if isinstance(lcsc, int):
|
||||||
lcsc = f"C{lcsc}"
|
lcsc = f"C{lcsc}"
|
||||||
|
|
||||||
# Build price JSON from jlcsearch single price
|
# 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 [])
|
price_json = json.dumps([{"qty": 1, "price": price}] if price else [])
|
||||||
|
|
||||||
# Determine library type from is_basic flag
|
# Determine library type from is_basic flag
|
||||||
library_type = 'Basic' if part.get('is_basic') else 'Extended'
|
library_type = "Basic" if part.get("is_basic") else "Extended"
|
||||||
if part.get('is_preferred'):
|
if part.get("is_preferred"):
|
||||||
library_type = 'Preferred'
|
library_type = "Preferred"
|
||||||
|
|
||||||
# Extract description from various fields
|
# Extract description from various fields
|
||||||
description_parts = []
|
description_parts = []
|
||||||
if 'resistance' in part:
|
if "resistance" in part:
|
||||||
description_parts.append(f"{part['resistance']}Ω")
|
description_parts.append(f"{part['resistance']}Ω")
|
||||||
if 'capacitance' in part:
|
if "capacitance" in part:
|
||||||
description_parts.append(f"{part['capacitance']}F")
|
description_parts.append(f"{part['capacitance']}F")
|
||||||
if 'tolerance_fraction' in part:
|
if "tolerance_fraction" in part:
|
||||||
tol = part['tolerance_fraction'] * 100
|
tol = part["tolerance_fraction"] * 100
|
||||||
description_parts.append(f"±{tol}%")
|
description_parts.append(f"±{tol}%")
|
||||||
if 'power_watts' in part:
|
if "power_watts" in part:
|
||||||
description_parts.append(f"{part['power_watts']}mW")
|
description_parts.append(f"{part['power_watts']}mW")
|
||||||
if 'voltage' in part:
|
if "voltage" in part:
|
||||||
description_parts.append(f"{part['voltage']}V")
|
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 (
|
INSERT OR REPLACE INTO components (
|
||||||
lcsc, category, subcategory, mfr_part, package,
|
lcsc, category, subcategory, mfr_part, package,
|
||||||
solder_joints, manufacturer, library_type, description,
|
solder_joints, manufacturer, library_type, description,
|
||||||
datasheet, stock, price_json, last_updated
|
datasheet, stock, price_json, last_updated
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
""",
|
||||||
lcsc, # lcsc with C prefix
|
(
|
||||||
part.get('category', ''), # category
|
lcsc, # lcsc with C prefix
|
||||||
part.get('subcategory', ''), # subcategory
|
part.get("category", ""), # category
|
||||||
part.get('mfr', ''), # mfr_part
|
part.get("subcategory", ""), # subcategory
|
||||||
part.get('package', ''), # package
|
part.get("mfr", ""), # mfr_part
|
||||||
0, # solder_joints (not in jlcsearch)
|
part.get("package", ""), # package
|
||||||
part.get('manufacturer', ''), # manufacturer
|
0, # solder_joints (not in jlcsearch)
|
||||||
library_type, # library_type
|
part.get("manufacturer", ""), # manufacturer
|
||||||
description, # description
|
library_type, # library_type
|
||||||
'', # datasheet (not in jlcsearch)
|
description, # description
|
||||||
part.get('stock', 0), # stock
|
"", # datasheet (not in jlcsearch)
|
||||||
price_json, # price_json
|
part.get("stock", 0), # stock
|
||||||
int(datetime.now().timestamp()) # last_updated
|
price_json, # price_json
|
||||||
))
|
int(datetime.now().timestamp()), # last_updated
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
@@ -239,10 +247,10 @@ class JLCPCBPartsManager:
|
|||||||
skipped += 1
|
skipped += 1
|
||||||
|
|
||||||
# Update FTS index
|
# Update FTS index
|
||||||
cursor.execute('''
|
cursor.execute("""
|
||||||
INSERT INTO components_fts(components_fts)
|
INSERT INTO components_fts(components_fts)
|
||||||
VALUES('rebuild')
|
VALUES('rebuild')
|
||||||
''')
|
""")
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
|
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
|
||||||
@@ -255,7 +263,7 @@ class JLCPCBPartsManager:
|
|||||||
library_type: Optional[str] = None,
|
library_type: Optional[str] = None,
|
||||||
manufacturer: Optional[str] = None,
|
manufacturer: Optional[str] = None,
|
||||||
in_stock: bool = True,
|
in_stock: bool = True,
|
||||||
limit: int = 20
|
limit: int = 20,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Search for parts with filters
|
Search for parts with filters
|
||||||
@@ -283,15 +291,14 @@ class JLCPCBPartsManager:
|
|||||||
# Add prefix wildcard to each term for partial matching
|
# Add prefix wildcard to each term for partial matching
|
||||||
# (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR")
|
# (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR")
|
||||||
fts_query = " ".join(
|
fts_query = " ".join(
|
||||||
f"{term}*" if not term.endswith("*") else term
|
f"{term}*" if not term.endswith("*") else term for term in query.strip().split()
|
||||||
for term in query.strip().split()
|
|
||||||
)
|
)
|
||||||
sql_parts.append('''
|
sql_parts.append("""
|
||||||
AND lcsc IN (
|
AND lcsc IN (
|
||||||
SELECT lcsc FROM components_fts
|
SELECT lcsc FROM components_fts
|
||||||
WHERE components_fts MATCH ?
|
WHERE components_fts MATCH ?
|
||||||
)
|
)
|
||||||
''')
|
""")
|
||||||
params.append(fts_query)
|
params.append(fts_query)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
@@ -343,11 +350,11 @@ class JLCPCBPartsManager:
|
|||||||
if row:
|
if row:
|
||||||
part = dict(row)
|
part = dict(row)
|
||||||
# Parse price JSON
|
# Parse price JSON
|
||||||
if part.get('price_json'):
|
if part.get("price_json"):
|
||||||
try:
|
try:
|
||||||
part['price_breaks'] = json.loads(part['price_json'])
|
part["price_breaks"] = json.loads(part["price_json"])
|
||||||
except:
|
except:
|
||||||
part['price_breaks'] = []
|
part["price_breaks"] = []
|
||||||
return part
|
return part
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -356,23 +363,25 @@ class JLCPCBPartsManager:
|
|||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) as total FROM components")
|
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'")
|
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'")
|
cursor.execute(
|
||||||
extended = cursor.fetchone()['extended']
|
"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")
|
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 {
|
return {
|
||||||
'total_parts': total,
|
"total_parts": total,
|
||||||
'basic_parts': basic,
|
"basic_parts": basic,
|
||||||
'extended_parts': extended,
|
"extended_parts": extended,
|
||||||
'in_stock': in_stock,
|
"in_stock": in_stock,
|
||||||
'db_path': self.db_path
|
"db_path": self.db_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
def map_package_to_footprint(self, package: str) -> List[str]:
|
def map_package_to_footprint(self, package: str) -> List[str]:
|
||||||
@@ -390,43 +399,22 @@ class JLCPCBPartsManager:
|
|||||||
"0402": [
|
"0402": [
|
||||||
"Resistor_SMD:R_0402_1005Metric",
|
"Resistor_SMD:R_0402_1005Metric",
|
||||||
"Capacitor_SMD:C_0402_1005Metric",
|
"Capacitor_SMD:C_0402_1005Metric",
|
||||||
"LED_SMD:LED_0402_1005Metric"
|
"LED_SMD:LED_0402_1005Metric",
|
||||||
],
|
],
|
||||||
"0603": [
|
"0603": [
|
||||||
"Resistor_SMD:R_0603_1608Metric",
|
"Resistor_SMD:R_0603_1608Metric",
|
||||||
"Capacitor_SMD:C_0603_1608Metric",
|
"Capacitor_SMD:C_0603_1608Metric",
|
||||||
"LED_SMD:LED_0603_1608Metric"
|
"LED_SMD:LED_0603_1608Metric",
|
||||||
],
|
],
|
||||||
"0805": [
|
"0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"],
|
||||||
"Resistor_SMD:R_0805_2012Metric",
|
"1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"],
|
||||||
"Capacitor_SMD:C_0805_2012Metric"
|
"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"],
|
||||||
"1206": [
|
"SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"],
|
||||||
"Resistor_SMD:R_1206_3216Metric",
|
"SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"],
|
||||||
"Capacitor_SMD:C_1206_3216Metric"
|
"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"],
|
||||||
"SOT-23": [
|
"QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"],
|
||||||
"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
|
# Normalize package name
|
||||||
@@ -457,24 +445,21 @@ class JLCPCBPartsManager:
|
|||||||
|
|
||||||
# Search for parts in same category with same package
|
# Search for parts in same category with same package
|
||||||
alternatives = self.search_parts(
|
alternatives = self.search_parts(
|
||||||
category=part['subcategory'],
|
category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3
|
||||||
package=part['package'],
|
|
||||||
in_stock=True,
|
|
||||||
limit=limit * 3
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter out the original part
|
# 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
|
# Sort by: Basic first, then by price, then by stock
|
||||||
def sort_key(p):
|
def sort_key(p):
|
||||||
is_basic = 1 if p.get('library_type') == 'Basic' else 0
|
is_basic = 1 if p.get("library_type") == "Basic" else 0
|
||||||
try:
|
try:
|
||||||
prices = json.loads(p.get('price_json', '[]'))
|
prices = json.loads(p.get("price_json", "[]"))
|
||||||
price = float(prices[0].get('price', 999)) if prices else 999
|
price = float(prices[0].get("price", 999)) if prices else 999
|
||||||
except:
|
except:
|
||||||
price = 999
|
price = 999
|
||||||
stock = p.get('stock', 0)
|
stock = p.get("stock", 0)
|
||||||
|
|
||||||
return (-is_basic, price, -stock)
|
return (-is_basic, price, -stock)
|
||||||
|
|
||||||
@@ -488,7 +473,7 @@ class JLCPCBPartsManager:
|
|||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
# Test the parts manager
|
# Test the parts manager
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -503,8 +488,10 @@ if __name__ == '__main__':
|
|||||||
print(f" In stock: {stats['in_stock']}")
|
print(f" In stock: {stats['in_stock']}")
|
||||||
print(f" Database: {stats['db_path']}")
|
print(f" Database: {stats['db_path']}")
|
||||||
|
|
||||||
if stats['total_parts'] > 0:
|
if stats["total_parts"] > 0:
|
||||||
print("\nSearching for '10k resistor'...")
|
print("\nSearching for '10k resistor'...")
|
||||||
results = manager.search_parts(query="10k resistor", limit=5)
|
results = manager.search_parts(query="10k resistor", limit=5)
|
||||||
for part in results:
|
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']})"
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import requests
|
|||||||
from typing import Optional, Dict, List, Callable
|
from typing import Optional, Dict, List, Callable
|
||||||
import time
|
import time
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
class JLCSearchClient:
|
class JLCSearchClient:
|
||||||
@@ -28,11 +28,7 @@ class JLCSearchClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def search_components(
|
def search_components(
|
||||||
self,
|
self, category: str = "components", limit: int = 100, offset: int = 0, **filters
|
||||||
category: str = "components",
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
**filters
|
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Search components in JLCSearch database
|
Search components in JLCSearch database
|
||||||
@@ -48,11 +44,7 @@ class JLCSearchClient:
|
|||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/{category}/list.json"
|
url = f"{self.BASE_URL}/{category}/list.json"
|
||||||
|
|
||||||
params = {
|
params = {"limit": limit, "offset": offset, **filters}
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
**filters
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, params=params, timeout=30)
|
response = requests.get(url, params=params, timeout=30)
|
||||||
@@ -71,7 +63,9 @@ class JLCSearchClient:
|
|||||||
logger.error(f"Failed to search JLCSearch: {e}")
|
logger.error(f"Failed to search JLCSearch: {e}")
|
||||||
raise Exception(f"JLCSearch API request failed: {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
|
Search for resistors
|
||||||
|
|
||||||
@@ -100,7 +94,9 @@ class JLCSearchClient:
|
|||||||
|
|
||||||
return self.search_components("resistors", limit=limit, **filters)
|
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
|
Search for capacitors
|
||||||
|
|
||||||
@@ -141,9 +137,7 @@ class JLCSearchClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def download_all_components(
|
def download_all_components(
|
||||||
self,
|
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
|
||||||
callback: Optional[Callable[[int, str], None]] = None,
|
|
||||||
batch_size: int = 100
|
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Download all components from jlcsearch database
|
Download all components from jlcsearch database
|
||||||
@@ -165,11 +159,7 @@ class JLCSearchClient:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
batch = self.search_components(
|
batch = self.search_components("components", limit=batch_size, offset=offset)
|
||||||
"components",
|
|
||||||
limit=batch_size,
|
|
||||||
offset=offset
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stop if no results returned (end of catalog)
|
# Stop if no results returned (end of catalog)
|
||||||
if not batch or len(batch) == 0:
|
if not batch or len(batch) == 0:
|
||||||
@@ -219,7 +209,7 @@ def test_jlcsearch_connection() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
# Test the JLCSearch client
|
# Test the JLCSearch client
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|||||||
@@ -116,9 +116,7 @@ class LibraryManager:
|
|||||||
self.libraries[nickname] = resolved_uri
|
self.libraries[nickname] = resolved_uri
|
||||||
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
|
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
|
||||||
f" Could not resolve URI for library {nickname}: {uri}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
|
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
|
||||||
@@ -221,12 +219,7 @@ class LibraryManager:
|
|||||||
/ "9.0"
|
/ "9.0"
|
||||||
/ "kicad_common.json", # macOS
|
/ "kicad_common.json", # macOS
|
||||||
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
|
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
|
||||||
Path.home()
|
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
|
||||||
/ "AppData"
|
|
||||||
/ "Roaming"
|
|
||||||
/ "kicad"
|
|
||||||
/ "9.0"
|
|
||||||
/ "kicad_common.json", # Windows
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for config_path in kicad_common_paths:
|
for config_path in kicad_common_paths:
|
||||||
@@ -352,9 +345,7 @@ class LibraryManager:
|
|||||||
for library_nickname, library_path in self.libraries.items():
|
for library_nickname, library_path in self.libraries.items():
|
||||||
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
|
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
|
||||||
if fp_file.exists():
|
if fp_file.exists():
|
||||||
logger.info(
|
logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
|
||||||
f"Found footprint {footprint_name} in library {library_nickname}"
|
|
||||||
)
|
|
||||||
return (library_path, footprint_name)
|
return (library_path, footprint_name)
|
||||||
|
|
||||||
logger.warning(f"Footprint not found in any library: {footprint_name}")
|
logger.warning(f"Footprint not found in any library: {footprint_name}")
|
||||||
@@ -461,9 +452,7 @@ class LibraryCommands:
|
|||||||
# Filter by library if specified
|
# Filter by library if specified
|
||||||
if library_filter:
|
if library_filter:
|
||||||
results = [
|
results = [
|
||||||
r
|
r for r in results if r.get("library", "").lower() == library_filter.lower()
|
||||||
for r in results
|
|
||||||
if r.get("library", "").lower() == library_filter.lower()
|
|
||||||
]
|
]
|
||||||
results = results[:limit]
|
results = results[:limit]
|
||||||
|
|
||||||
@@ -527,7 +516,6 @@ class LibraryCommands:
|
|||||||
info: Dict = {
|
info: Dict = {
|
||||||
"library": library_nickname,
|
"library": library_nickname,
|
||||||
"name": footprint_name,
|
"name": footprint_name,
|
||||||
|
|
||||||
"full_name": f"{library_nickname}:{footprint_name}",
|
"full_name": f"{library_nickname}:{footprint_name}",
|
||||||
"library_path": library_path,
|
"library_path": library_path,
|
||||||
}
|
}
|
||||||
@@ -536,19 +524,24 @@ class LibraryCommands:
|
|||||||
try:
|
try:
|
||||||
from parsers.kicad_mod_parser import parse_kicad_mod
|
from parsers.kicad_mod_parser import parse_kicad_mod
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
|
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
|
||||||
parsed = parse_kicad_mod(mod_file)
|
parsed = parse_kicad_mod(mod_file)
|
||||||
if parsed:
|
if parsed:
|
||||||
# Merge parser output into info; keep our resolved library context
|
# Merge parser output into info; keep our resolved library context
|
||||||
info.update(parsed)
|
info.update(parsed)
|
||||||
info["name"] = footprint_name # entry name wins over in-file name
|
info["name"] = footprint_name # entry name wins over in-file name
|
||||||
info["library"] = library_nickname
|
info["library"] = library_nickname
|
||||||
info["full_name"] = f"{library_nickname}:{footprint_name}"
|
info["full_name"] = f"{library_nickname}:{footprint_name}"
|
||||||
info["library_path"] = library_path
|
info["library_path"] = library_path
|
||||||
else:
|
else:
|
||||||
logger.warning(f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info")
|
logger.warning(
|
||||||
|
f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info"
|
||||||
|
)
|
||||||
except Exception as parse_err:
|
except Exception as parse_err:
|
||||||
logger.warning(f"get_footprint_info: parser error ({parse_err}), using minimal info")
|
logger.warning(
|
||||||
|
f"get_footprint_info: parser error ({parse_err}), using minimal info"
|
||||||
|
)
|
||||||
|
|
||||||
return {"success": True, "info": info}
|
return {"success": True, "info": info}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from skip import Schematic
|
from skip import Schematic
|
||||||
|
|
||||||
# Symbol class might not be directly importable in the current version
|
# Symbol class might not be directly importable in the current version
|
||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
|
|
||||||
|
|
||||||
class LibraryManager:
|
class LibraryManager:
|
||||||
"""Manage symbol libraries"""
|
"""Manage symbol libraries"""
|
||||||
|
|
||||||
@@ -14,9 +16,11 @@ class LibraryManager:
|
|||||||
# This would need to be configured for the specific environment
|
# This would need to be configured for the specific environment
|
||||||
search_paths = [
|
search_paths = [
|
||||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
|
"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
|
"/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 = []
|
libraries = []
|
||||||
@@ -30,7 +34,9 @@ class LibraryManager:
|
|||||||
|
|
||||||
# Extract library names from paths
|
# Extract library names from paths
|
||||||
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
|
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 ''}")
|
print(
|
||||||
|
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
# Return both full paths and library names
|
# Return both full paths and library names
|
||||||
return {"paths": libraries, "names": library_names}
|
return {"paths": libraries, "names": library_names}
|
||||||
@@ -47,7 +53,9 @@ class LibraryManager:
|
|||||||
# A potential approach would be to load the library file using KiCAD's Python API
|
# A potential approach would be to load the library file using KiCAD's Python API
|
||||||
# or by parsing the library file format.
|
# or by parsing the library file format.
|
||||||
# KiCAD symbol libraries are .kicad_sym files which are S-expression 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.")
|
print(
|
||||||
|
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error listing symbols in library {library_path}: {e}")
|
print(f"Error listing symbols in library {library_path}: {e}")
|
||||||
@@ -59,7 +67,9 @@ class LibraryManager:
|
|||||||
try:
|
try:
|
||||||
# Similar to list_library_symbols, this might require a more direct approach
|
# Similar to list_library_symbols, this might require a more direct approach
|
||||||
# using KiCAD's Python API or by parsing the symbol library.
|
# 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.")
|
print(
|
||||||
|
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||||
@@ -78,7 +88,9 @@ class LibraryManager:
|
|||||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
print(f"Searched for symbols matching '{query}'. This requires advanced implementation.")
|
print(
|
||||||
|
f"Searched for symbols matching '{query}'. This requires advanced implementation."
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error searching for symbols matching '{query}': {e}")
|
print(f"Error searching for symbols matching '{query}': {e}")
|
||||||
@@ -119,7 +131,8 @@ class LibraryManager:
|
|||||||
# Default fallback
|
# Default fallback
|
||||||
return {"library": "Device", "symbol": "R"}
|
return {"library": "Device", "symbol": "R"}
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
# Example Usage (for testing)
|
# Example Usage (for testing)
|
||||||
# List available libraries
|
# List available libraries
|
||||||
libraries = LibraryManager.list_available_libraries()
|
libraries = LibraryManager.list_available_libraries()
|
||||||
|
|||||||
@@ -12,26 +12,27 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SymbolInfo:
|
class SymbolInfo:
|
||||||
"""Information about a symbol in a library"""
|
"""Information about a symbol in a library"""
|
||||||
name: str # Symbol name (without library prefix)
|
|
||||||
library: str # Library nickname
|
name: str # Symbol name (without library prefix)
|
||||||
full_ref: str # "Library:SymbolName"
|
library: str # Library nickname
|
||||||
value: str = "" # Value property
|
full_ref: str # "Library:SymbolName"
|
||||||
description: str = "" # Description property
|
value: str = "" # Value property
|
||||||
footprint: str = "" # Footprint reference if present
|
description: str = "" # Description property
|
||||||
lcsc_id: str = "" # LCSC property if present
|
footprint: str = "" # Footprint reference if present
|
||||||
|
lcsc_id: str = "" # LCSC property if present
|
||||||
manufacturer: str = "" # Manufacturer property
|
manufacturer: str = "" # Manufacturer property
|
||||||
mpn: str = "" # Part/MPN property
|
mpn: str = "" # Part/MPN property
|
||||||
category: str = "" # Category property
|
category: str = "" # Category property
|
||||||
datasheet: str = "" # Datasheet URL
|
datasheet: str = "" # Datasheet URL
|
||||||
stock: str = "" # Stock (from JLCPCB libs)
|
stock: str = "" # Stock (from JLCPCB libs)
|
||||||
price: str = "" # Price (from JLCPCB libs)
|
price: str = "" # Price (from JLCPCB libs)
|
||||||
lib_class: str = "" # Basic/Preferred/Extended
|
lib_class: str = "" # Basic/Preferred/Extended
|
||||||
|
|
||||||
|
|
||||||
class SymbolLibraryManager:
|
class SymbolLibraryManager:
|
||||||
@@ -107,7 +108,7 @@ class SymbolLibraryManager:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(table_path, 'r', encoding='utf-8') as f:
|
with open(table_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Simple regex-based parser for lib entries
|
# Simple regex-based parser for lib entries
|
||||||
@@ -155,25 +156,25 @@ class SymbolLibraryManager:
|
|||||||
|
|
||||||
# Common KiCAD environment variables
|
# Common KiCAD environment variables
|
||||||
env_vars = {
|
env_vars = {
|
||||||
'KICAD10_SYMBOL_DIR': self._find_kicad_symbol_dir(),
|
"KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
||||||
'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(),
|
"KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
||||||
'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(),
|
"KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
||||||
'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(),
|
"KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
||||||
'KICAD10_3RD_PARTY': self._find_3rd_party_dir(),
|
"KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
|
||||||
'KICAD9_3RD_PARTY': self._find_3rd_party_dir(),
|
"KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
|
||||||
'KICAD8_3RD_PARTY': self._find_3rd_party_dir(),
|
"KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
|
||||||
'KISYSSYM': self._find_kicad_symbol_dir(),
|
"KISYSSYM": self._find_kicad_symbol_dir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Project directory
|
# Project directory
|
||||||
if self.project_path:
|
if self.project_path:
|
||||||
env_vars['KIPRJMOD'] = str(self.project_path)
|
env_vars["KIPRJMOD"] = str(self.project_path)
|
||||||
|
|
||||||
# Replace environment variables
|
# Replace environment variables
|
||||||
for var, value in env_vars.items():
|
for var, value in env_vars.items():
|
||||||
if value:
|
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
|
# Expand ~ to home directory
|
||||||
resolved = os.path.expanduser(resolved)
|
resolved = os.path.expanduser(resolved)
|
||||||
@@ -199,10 +200,10 @@ class SymbolLibraryManager:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Check environment variable
|
# Check environment variable
|
||||||
if 'KICAD9_SYMBOL_DIR' in os.environ:
|
if "KICAD9_SYMBOL_DIR" in os.environ:
|
||||||
possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR'])
|
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
|
||||||
if 'KICAD8_SYMBOL_DIR' in os.environ:
|
if "KICAD8_SYMBOL_DIR" in os.environ:
|
||||||
possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR'])
|
possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
|
||||||
|
|
||||||
for path in possible_paths:
|
for path in possible_paths:
|
||||||
if os.path.isdir(path):
|
if os.path.isdir(path):
|
||||||
@@ -219,12 +220,12 @@ class SymbolLibraryManager:
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Check environment variable
|
# Check environment variable
|
||||||
if 'KICAD10_3RD_PARTY' in os.environ:
|
if "KICAD10_3RD_PARTY" in os.environ:
|
||||||
possible_paths.insert(0, os.environ['KICAD10_3RD_PARTY'])
|
possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
|
||||||
if 'KICAD9_3RD_PARTY' in os.environ:
|
if "KICAD9_3RD_PARTY" in os.environ:
|
||||||
possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY'])
|
possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
|
||||||
if 'KICAD8_3RD_PARTY' in os.environ:
|
if "KICAD8_3RD_PARTY" in os.environ:
|
||||||
possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY'])
|
possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
|
||||||
|
|
||||||
for path in possible_paths:
|
for path in possible_paths:
|
||||||
if os.path.isdir(path):
|
if os.path.isdir(path):
|
||||||
@@ -246,7 +247,7 @@ class SymbolLibraryManager:
|
|||||||
symbols = []
|
symbols = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(library_path, 'r', encoding='utf-8') as f:
|
with open(library_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Find all top-level symbol definitions
|
# Find all top-level symbol definitions
|
||||||
@@ -261,7 +262,7 @@ class SymbolLibraryManager:
|
|||||||
symbol_name = match.group(1)
|
symbol_name = match.group(1)
|
||||||
|
|
||||||
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
|
# 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
|
continue
|
||||||
|
|
||||||
# Find the start position of this symbol
|
# Find the start position of this symbol
|
||||||
@@ -280,17 +281,17 @@ class SymbolLibraryManager:
|
|||||||
name=symbol_name,
|
name=symbol_name,
|
||||||
library=library_name,
|
library=library_name,
|
||||||
full_ref=f"{library_name}:{symbol_name}",
|
full_ref=f"{library_name}:{symbol_name}",
|
||||||
value=properties.get('Value', ''),
|
value=properties.get("Value", ""),
|
||||||
description=properties.get('Description', ''),
|
description=properties.get("Description", ""),
|
||||||
footprint=properties.get('Footprint', ''),
|
footprint=properties.get("Footprint", ""),
|
||||||
lcsc_id=properties.get('LCSC', ''),
|
lcsc_id=properties.get("LCSC", ""),
|
||||||
manufacturer=properties.get('Manufacturer', ''),
|
manufacturer=properties.get("Manufacturer", ""),
|
||||||
mpn=properties.get('Part', properties.get('MPN', '')),
|
mpn=properties.get("Part", properties.get("MPN", "")),
|
||||||
category=properties.get('Category', ''),
|
category=properties.get("Category", ""),
|
||||||
datasheet=properties.get('Datasheet', ''),
|
datasheet=properties.get("Datasheet", ""),
|
||||||
stock=properties.get('Stock', ''),
|
stock=properties.get("Stock", ""),
|
||||||
price=properties.get('Price', ''),
|
price=properties.get("Price", ""),
|
||||||
lib_class=properties.get('Class', ''),
|
lib_class=properties.get("Class", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
symbols.append(symbol_info)
|
symbols.append(symbol_info)
|
||||||
@@ -351,7 +352,9 @@ class SymbolLibraryManager:
|
|||||||
|
|
||||||
return symbols
|
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
|
Search for symbols matching a query
|
||||||
|
|
||||||
@@ -370,7 +373,9 @@ class SymbolLibraryManager:
|
|||||||
libraries_to_search = self.libraries.keys()
|
libraries_to_search = self.libraries.keys()
|
||||||
if library_filter:
|
if library_filter:
|
||||||
filter_lower = library_filter.lower()
|
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:
|
for library_nickname in libraries_to_search:
|
||||||
symbols = self.list_symbols(library_nickname)
|
symbols = self.list_symbols(library_nickname)
|
||||||
@@ -495,17 +500,13 @@ class SymbolLibraryCommands:
|
|||||||
"""List all available symbol libraries"""
|
"""List all available symbol libraries"""
|
||||||
try:
|
try:
|
||||||
libraries = self.library_manager.list_libraries()
|
libraries = self.library_manager.list_libraries()
|
||||||
return {
|
return {"success": True, "libraries": libraries, "count": len(libraries)}
|
||||||
"success": True,
|
|
||||||
"libraries": libraries,
|
|
||||||
"count": len(libraries)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing symbol libraries: {e}")
|
logger.error(f"Error listing symbol libraries: {e}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to list symbol libraries",
|
"message": "Failed to list symbol libraries",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def search_symbols(self, params: Dict) -> Dict:
|
def search_symbols(self, params: Dict) -> Dict:
|
||||||
@@ -513,10 +514,7 @@ class SymbolLibraryCommands:
|
|||||||
try:
|
try:
|
||||||
query = params.get("query", "")
|
query = params.get("query", "")
|
||||||
if not query:
|
if not query:
|
||||||
return {
|
return {"success": False, "message": "Missing query parameter"}
|
||||||
"success": False,
|
|
||||||
"message": "Missing query parameter"
|
|
||||||
}
|
|
||||||
|
|
||||||
limit = params.get("limit", 20)
|
limit = params.get("limit", 20)
|
||||||
library_filter = params.get("library")
|
library_filter = params.get("library")
|
||||||
@@ -527,25 +525,18 @@ class SymbolLibraryCommands:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"symbols": [asdict(s) for s in results],
|
"symbols": [asdict(s) for s in results],
|
||||||
"count": len(results),
|
"count": len(results),
|
||||||
"query": query
|
"query": query,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching symbols: {e}")
|
logger.error(f"Error searching symbols: {e}")
|
||||||
return {
|
return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to search symbols",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def list_library_symbols(self, params: Dict) -> Dict:
|
def list_library_symbols(self, params: Dict) -> Dict:
|
||||||
"""List all symbols in a specific library"""
|
"""List all symbols in a specific library"""
|
||||||
try:
|
try:
|
||||||
library = params.get("library")
|
library = params.get("library")
|
||||||
if not library:
|
if not library:
|
||||||
return {
|
return {"success": False, "message": "Missing library parameter"}
|
||||||
"success": False,
|
|
||||||
"message": "Missing library parameter"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if library exists in sym-lib-table
|
# Check if library exists in sym-lib-table
|
||||||
if library not in self.library_manager.libraries:
|
if library not in self.library_manager.libraries:
|
||||||
@@ -554,10 +545,10 @@ class SymbolLibraryCommands:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": f"Library '{library}' not found in sym-lib-table",
|
"message": f"Library '{library}' not found in sym-lib-table",
|
||||||
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
|
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
|
||||||
f"Found {len(available_libs)} 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.",
|
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
|
||||||
"available_libraries_count": len(available_libs),
|
"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)
|
symbols = self.library_manager.list_symbols(library)
|
||||||
@@ -566,14 +557,14 @@ class SymbolLibraryCommands:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"library": library,
|
"library": library,
|
||||||
"symbols": [asdict(s) for s in symbols],
|
"symbols": [asdict(s) for s in symbols],
|
||||||
"count": len(symbols)
|
"count": len(symbols),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing library symbols: {e}")
|
logger.error(f"Error listing library symbols: {e}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to list library symbols",
|
"message": "Failed to list library symbols",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_symbol_info(self, params: Dict) -> Dict:
|
def get_symbol_info(self, params: Dict) -> Dict:
|
||||||
@@ -581,34 +572,25 @@ class SymbolLibraryCommands:
|
|||||||
try:
|
try:
|
||||||
symbol_spec = params.get("symbol")
|
symbol_spec = params.get("symbol")
|
||||||
if not symbol_spec:
|
if not symbol_spec:
|
||||||
return {
|
return {"success": False, "message": "Missing symbol parameter"}
|
||||||
"success": False,
|
|
||||||
"message": "Missing symbol parameter"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = self.library_manager.find_symbol(symbol_spec)
|
result = self.library_manager.find_symbol(symbol_spec)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return {
|
return {"success": True, "symbol_info": asdict(result)}
|
||||||
"success": True,
|
|
||||||
"symbol_info": asdict(result)
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
return {
|
return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
|
||||||
"success": False,
|
|
||||||
"message": f"Symbol not found: {symbol_spec}"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting symbol info: {e}")
|
logger.error(f"Error getting symbol info: {e}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get symbol info",
|
"message": "Failed to get symbol info",
|
||||||
"errorDetails": str(e)
|
"errorDetails": str(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
# Test the symbol library manager
|
# Test the symbol library manager
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|||||||
@@ -117,11 +117,7 @@ class PinLocator:
|
|||||||
# Find lib_symbols section
|
# Find lib_symbols section
|
||||||
lib_symbols = None
|
lib_symbols = None
|
||||||
for item in sch_data:
|
for item in sch_data:
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == Symbol("lib_symbols")
|
|
||||||
):
|
|
||||||
lib_symbols = item
|
lib_symbols = item
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -131,11 +127,7 @@ class PinLocator:
|
|||||||
|
|
||||||
# Find the specific symbol definition
|
# Find the specific symbol definition
|
||||||
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
|
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
|
||||||
if (
|
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 1
|
|
||||||
and item[0] == Symbol("symbol")
|
|
||||||
):
|
|
||||||
symbol_name = str(item[1]).strip('"')
|
symbol_name = str(item[1]).strip('"')
|
||||||
if symbol_name == lib_id:
|
if symbol_name == lib_id:
|
||||||
# Found the symbol, parse pins
|
# Found the symbol, parse pins
|
||||||
@@ -220,20 +212,14 @@ class PinLocator:
|
|||||||
symbol_at = target_symbol.at.value
|
symbol_at = target_symbol.at.value
|
||||||
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
|
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
|
||||||
|
|
||||||
lib_id = (
|
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
|
||||||
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
|
|
||||||
)
|
|
||||||
if not lib_id:
|
if not lib_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pins = self.get_symbol_pins(schematic_path, lib_id)
|
pins = self.get_symbol_pins(schematic_path, lib_id)
|
||||||
if pin_number not in pins:
|
if pin_number not in pins:
|
||||||
matched_num = next(
|
matched_num = next(
|
||||||
(
|
(num for num, data in pins.items() if data.get("name") == pin_number),
|
||||||
num
|
|
||||||
for num, data in pins.items()
|
|
||||||
if data.get("name") == pin_number
|
|
||||||
),
|
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if matched_num:
|
if matched_num:
|
||||||
@@ -290,9 +276,7 @@ class PinLocator:
|
|||||||
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
|
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
|
||||||
|
|
||||||
# Get symbol lib_id
|
# Get symbol lib_id
|
||||||
lib_id = (
|
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
|
||||||
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
|
|
||||||
)
|
|
||||||
if not lib_id:
|
if not lib_id:
|
||||||
logger.error(f"Symbol {symbol_reference} has no lib_id")
|
logger.error(f"Symbol {symbol_reference} has no lib_id")
|
||||||
return None
|
return None
|
||||||
@@ -311,11 +295,7 @@ class PinLocator:
|
|||||||
if pin_number not in pins:
|
if pin_number not in pins:
|
||||||
# Try matching by pin name (e.g. "VCC1", "SDA", "GND")
|
# Try matching by pin name (e.g. "VCC1", "SDA", "GND")
|
||||||
matched_num = next(
|
matched_num = next(
|
||||||
(
|
(num for num, data in pins.items() if data.get("name") == pin_number),
|
||||||
num
|
|
||||||
for num, data in pins.items()
|
|
||||||
if data.get("name") == pin_number
|
|
||||||
),
|
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if matched_num:
|
if matched_num:
|
||||||
@@ -336,26 +316,18 @@ class PinLocator:
|
|||||||
pin_rel_x = pin_data["x"]
|
pin_rel_x = pin_data["x"]
|
||||||
pin_rel_y = pin_data["y"]
|
pin_rel_y = pin_data["y"]
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
|
||||||
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply symbol rotation to pin position
|
# Apply symbol rotation to pin position
|
||||||
if symbol_rotation != 0:
|
if symbol_rotation != 0:
|
||||||
pin_rel_x, pin_rel_y = self.rotate_point(
|
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
|
||||||
pin_rel_x, pin_rel_y, symbol_rotation
|
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate absolute position
|
# Calculate absolute position
|
||||||
abs_x = symbol_x + pin_rel_x
|
abs_x = symbol_x + pin_rel_x
|
||||||
abs_y = symbol_y + pin_rel_y
|
abs_y = symbol_y + pin_rel_y
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
|
||||||
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
|
|
||||||
)
|
|
||||||
return [abs_x, abs_y]
|
return [abs_x, abs_y]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -397,9 +369,7 @@ class PinLocator:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Get lib_id
|
# Get lib_id
|
||||||
lib_id = (
|
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
|
||||||
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
|
|
||||||
)
|
|
||||||
if not lib_id:
|
if not lib_id:
|
||||||
logger.error(f"Symbol {symbol_reference} has no lib_id")
|
logger.error(f"Symbol {symbol_reference} has no lib_id")
|
||||||
return {}
|
return {}
|
||||||
@@ -412,9 +382,7 @@ class PinLocator:
|
|||||||
# Calculate location for each pin
|
# Calculate location for each pin
|
||||||
result = {}
|
result = {}
|
||||||
for pin_num in pins.keys():
|
for pin_num in pins.keys():
|
||||||
location = self.get_pin_location(
|
location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
|
||||||
schematic_path, symbol_reference, pin_num
|
|
||||||
)
|
|
||||||
if location:
|
if location:
|
||||||
result[pin_num] = location
|
result[pin_num] = location
|
||||||
|
|
||||||
@@ -444,9 +412,7 @@ if __name__ == "__main__":
|
|||||||
# Create test schematic with components (cross-platform temp directory)
|
# Create test schematic with components (cross-platform temp directory)
|
||||||
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
|
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
|
||||||
template_path = (
|
template_path = (
|
||||||
Path(__file__).parent.parent
|
Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch"
|
||||||
/ "templates"
|
|
||||||
/ "template_with_symbols_expanded.kicad_sch"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.copy(template_path, test_path)
|
shutil.copy(template_path, test_path)
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ class ProjectCommands:
|
|||||||
"""Create a new KiCAD project"""
|
"""Create a new KiCAD project"""
|
||||||
try:
|
try:
|
||||||
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
|
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
|
||||||
project_name = params.get("name") or params.get(
|
project_name = params.get("name") or params.get("projectName", "New_Project")
|
||||||
"projectName", "New_Project"
|
|
||||||
)
|
|
||||||
path = params.get("path", os.getcwd())
|
path = params.get("path", os.getcwd())
|
||||||
template = params.get("template")
|
template = params.get("template")
|
||||||
|
|
||||||
@@ -101,9 +99,7 @@ class ProjectCommands:
|
|||||||
|
|
||||||
schematic_uuid = str(uuid_module.uuid4())
|
schematic_uuid = str(uuid_module.uuid4())
|
||||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||||
f.write(
|
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
|
||||||
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
|
|
||||||
)
|
|
||||||
f.write(f" (uuid {schematic_uuid})\n\n")
|
f.write(f" (uuid {schematic_uuid})\n\n")
|
||||||
f.write(' (paper "A4")\n\n')
|
f.write(' (paper "A4")\n\n')
|
||||||
f.write(" (lib_symbols\n )\n\n")
|
f.write(" (lib_symbols\n )\n\n")
|
||||||
@@ -207,9 +203,7 @@ class ProjectCommands:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Saved project to: {self.board.GetFileName()}",
|
"message": f"Saved project to: {self.board.GetFileName()}",
|
||||||
"project": {
|
"project": {
|
||||||
"name": os.path.splitext(
|
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
|
||||||
os.path.basename(self.board.GetFileName())
|
|
||||||
)[0],
|
|
||||||
"path": self.board.GetFileName(),
|
"path": self.board.GetFileName(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ class RoutingCommands:
|
|||||||
# KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects
|
# KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects
|
||||||
# the actual placed layer after Flip().
|
# the actual placed layer after Flip().
|
||||||
fp_start = footprints[from_ref]
|
fp_start = footprints[from_ref]
|
||||||
fp_end = footprints[to_ref]
|
fp_end = footprints[to_ref]
|
||||||
start_layer = self.board.GetLayerName(fp_start.GetLayer())
|
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"}
|
copper_layers = {"F.Cu", "B.Cu"}
|
||||||
needs_via = (
|
needs_via = (
|
||||||
start_layer in copper_layers
|
start_layer in copper_layers
|
||||||
@@ -168,24 +168,34 @@ class RoutingCommands:
|
|||||||
via_y = (start_pos.y + end_pos.y) / 2 / scale
|
via_y = (start_pos.y + end_pos.y) / 2 / scale
|
||||||
|
|
||||||
# Trace on start layer: start_pad → via
|
# Trace on start layer: start_pad → via
|
||||||
r1 = self.route_trace({
|
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"},
|
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
|
||||||
"layer": start_layer, "width": width, "net": net,
|
"end": {"x": via_x, "y": via_y, "unit": "mm"},
|
||||||
})
|
"layer": start_layer,
|
||||||
|
"width": width,
|
||||||
|
"net": net,
|
||||||
|
}
|
||||||
|
)
|
||||||
# Via connecting both layers
|
# Via connecting both layers
|
||||||
self.add_via({
|
self.add_via(
|
||||||
"position": {"x": via_x, "y": via_y, "unit": "mm"},
|
{
|
||||||
"net": net,
|
"position": {"x": via_x, "y": via_y, "unit": "mm"},
|
||||||
"from_layer": start_layer,
|
"net": net,
|
||||||
"to_layer": end_layer,
|
"from_layer": start_layer,
|
||||||
})
|
"to_layer": end_layer,
|
||||||
|
}
|
||||||
|
)
|
||||||
# Trace on end layer: via → end_pad
|
# Trace on end layer: via → end_pad
|
||||||
r2 = self.route_trace({
|
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"},
|
"start": {"x": via_x, "y": via_y, "unit": "mm"},
|
||||||
"layer": end_layer, "width": width, "net": net,
|
"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")
|
success = r1.get("success") and r2.get("success")
|
||||||
result = {
|
result = {
|
||||||
"success": success,
|
"success": success,
|
||||||
@@ -195,21 +205,28 @@ class RoutingCommands:
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Same layer — direct trace
|
# Same layer — direct trace
|
||||||
result = self.route_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"},
|
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
|
||||||
"layer": layer if layer else start_layer,
|
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
|
||||||
"width": width, "net": net,
|
"layer": layer if layer else start_layer,
|
||||||
})
|
"width": width,
|
||||||
|
"net": net,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
result["fromPad"] = {
|
result["fromPad"] = {
|
||||||
"ref": from_ref, "pad": from_pad,
|
"ref": from_ref,
|
||||||
"x": start_pos.x / scale, "y": start_pos.y / scale,
|
"pad": from_pad,
|
||||||
|
"x": start_pos.x / scale,
|
||||||
|
"y": start_pos.y / scale,
|
||||||
}
|
}
|
||||||
result["toPad"] = {
|
result["toPad"] = {
|
||||||
"ref": to_ref, "pad": to_pad,
|
"ref": to_ref,
|
||||||
"x": end_pos.x / scale, "y": end_pos.y / scale,
|
"pad": to_pad,
|
||||||
|
"x": end_pos.x / scale,
|
||||||
|
"y": end_pos.y / scale,
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -352,21 +369,15 @@ class RoutingCommands:
|
|||||||
via = pcbnew.PCB_VIA(self.board)
|
via = pcbnew.PCB_VIA(self.board)
|
||||||
|
|
||||||
# Set position
|
# Set position
|
||||||
scale = (
|
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
|
||||||
1000000 if position["unit"] == "mm" else 25400000
|
|
||||||
) # mm or inch to nm
|
|
||||||
x_nm = int(position["x"] * scale)
|
x_nm = int(position["x"] * scale)
|
||||||
y_nm = int(position["y"] * scale)
|
y_nm = int(position["y"] * scale)
|
||||||
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||||
|
|
||||||
# Set size and drill (default to board's current via settings)
|
# Set size and drill (default to board's current via settings)
|
||||||
design_settings = self.board.GetDesignSettings()
|
design_settings = self.board.GetDesignSettings()
|
||||||
via.SetWidth(
|
via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
|
||||||
int(size * 1000000) if size else design_settings.GetCurrentViaSize()
|
via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
|
||||||
)
|
|
||||||
via.SetDrill(
|
|
||||||
int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set layers
|
# Set layers
|
||||||
from_id = self.board.GetLayerID(from_layer)
|
from_id = self.board.GetLayerID(from_layer)
|
||||||
@@ -500,9 +511,7 @@ class RoutingCommands:
|
|||||||
|
|
||||||
# Find track by position
|
# Find track by position
|
||||||
if position:
|
if position:
|
||||||
scale = (
|
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
|
||||||
1000000 if position["unit"] == "mm" else 25400000
|
|
||||||
) # mm or inch to nm
|
|
||||||
x_nm = int(position["x"] * scale)
|
x_nm = int(position["x"] * scale)
|
||||||
y_nm = int(position["y"] * scale)
|
y_nm = int(position["y"] * scale)
|
||||||
point = pcbnew.VECTOR2I(x_nm, y_nm)
|
point = pcbnew.VECTOR2I(x_nm, y_nm)
|
||||||
@@ -940,9 +949,7 @@ class RoutingCommands:
|
|||||||
else:
|
else:
|
||||||
traces_to_copy.append(track)
|
traces_to_copy.append(track)
|
||||||
|
|
||||||
filter_method = (
|
filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
|
||||||
"net-based" if use_net_filter else "geometric (pads have no nets)"
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
|
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
|
||||||
f"{len(vias_to_copy)} vias selected via {filter_method}"
|
f"{len(vias_to_copy)} vias selected via {filter_method}"
|
||||||
@@ -958,9 +965,7 @@ class RoutingCommands:
|
|||||||
|
|
||||||
# Create new track
|
# Create new track
|
||||||
new_track = pcbnew.PCB_TRACK(self.board)
|
new_track = pcbnew.PCB_TRACK(self.board)
|
||||||
new_track.SetStart(
|
new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y))
|
||||||
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.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y))
|
||||||
new_track.SetLayer(track.GetLayer())
|
new_track.SetLayer(track.GetLayer())
|
||||||
|
|
||||||
@@ -1320,15 +1325,11 @@ class RoutingCommands:
|
|||||||
pos_start = pcbnew.VECTOR2I(
|
pos_start = pcbnew.VECTOR2I(
|
||||||
int(start_point.x + offset_x), int(start_point.y + offset_y)
|
int(start_point.x + offset_x), int(start_point.y + offset_y)
|
||||||
)
|
)
|
||||||
pos_end = pcbnew.VECTOR2I(
|
pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
|
||||||
int(end_point.x + offset_x), int(end_point.y + offset_y)
|
|
||||||
)
|
|
||||||
neg_start = pcbnew.VECTOR2I(
|
neg_start = pcbnew.VECTOR2I(
|
||||||
int(start_point.x - offset_x), int(start_point.y - offset_y)
|
int(start_point.x - offset_x), int(start_point.y - offset_y)
|
||||||
)
|
)
|
||||||
neg_end = pcbnew.VECTOR2I(
|
neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
|
||||||
int(end_point.x - offset_x), int(end_point.y - offset_y)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create positive trace
|
# Create positive trace
|
||||||
pos_track = pcbnew.PCB_TRACK(self.board)
|
pos_track = pcbnew.PCB_TRACK(self.board)
|
||||||
@@ -1395,9 +1396,7 @@ class RoutingCommands:
|
|||||||
return pad.GetPosition()
|
return pad.GetPosition()
|
||||||
raise ValueError("Invalid point specification")
|
raise ValueError("Invalid point specification")
|
||||||
|
|
||||||
def _point_to_track_distance(
|
def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
|
||||||
self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK
|
|
||||||
) -> float:
|
|
||||||
"""Calculate distance from point to track segment"""
|
"""Calculate distance from point to track segment"""
|
||||||
start = track.GetStart()
|
start = track.GetStart()
|
||||||
end = track.GetEnd()
|
end = track.GetEnd()
|
||||||
|
|||||||
@@ -31,31 +31,28 @@ class SchematicManager:
|
|||||||
|
|
||||||
# Regenerate UUID to ensure uniqueness for each created schematic
|
# Regenerate UUID to ensure uniqueness for each created schematic
|
||||||
import re
|
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()
|
content = f.read()
|
||||||
new_uuid = str(uuid.uuid4())
|
new_uuid = str(uuid.uuid4())
|
||||||
content = re.sub(
|
content = re.sub(
|
||||||
r'\(uuid [0-9a-fA-F-]+\)',
|
r"\(uuid [0-9a-fA-F-]+\)",
|
||||||
f'(uuid {new_uuid})',
|
f"(uuid {new_uuid})",
|
||||||
content,
|
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)
|
f.write(content)
|
||||||
|
|
||||||
logger.info(f"Created schematic from template: {output_path}")
|
logger.info(f"Created schematic from template: {output_path}")
|
||||||
else:
|
else:
|
||||||
# Fallback: create minimal schematic
|
# Fallback: create minimal schematic
|
||||||
logger.warning(
|
logger.warning(f"Template not found at {template_path}, creating minimal schematic")
|
||||||
f"Template not found at {template_path}, creating minimal schematic"
|
|
||||||
)
|
|
||||||
# Generate unique UUID for this schematic
|
# Generate unique UUID for this schematic
|
||||||
schematic_uuid = str(uuid.uuid4())
|
schematic_uuid = str(uuid.uuid4())
|
||||||
# Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility
|
# 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:
|
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
|
||||||
f.write(
|
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
|
||||||
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
|
|
||||||
)
|
|
||||||
f.write(f" (uuid {schematic_uuid})\n\n")
|
f.write(f" (uuid {schematic_uuid})\n\n")
|
||||||
f.write(' (paper "A4")\n\n')
|
f.write(' (paper "A4")\n\n')
|
||||||
f.write(" (lib_symbols\n )\n\n")
|
f.write(" (lib_symbols\n )\n\n")
|
||||||
|
|||||||
@@ -169,28 +169,16 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
|
|||||||
for sub in sexp[1:]:
|
for sub in sexp[1:]:
|
||||||
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
||||||
for pt in sub[1:]:
|
for pt in sub[1:]:
|
||||||
if (
|
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
|
||||||
isinstance(pt, list)
|
|
||||||
and len(pt) >= 3
|
|
||||||
and pt[0] == Symbol("xy")
|
|
||||||
):
|
|
||||||
points.append((float(pt[1]), float(pt[2])))
|
points.append((float(pt[1]), float(pt[2])))
|
||||||
|
|
||||||
elif tag == Symbol("circle"):
|
elif tag == Symbol("circle"):
|
||||||
# (circle (center x y) (radius r) ...)
|
# (circle (center x y) (radius r) ...)
|
||||||
cx, cy, r = 0.0, 0.0, 0.0
|
cx, cy, r = 0.0, 0.0, 0.0
|
||||||
for sub in sexp[1:]:
|
for sub in sexp[1:]:
|
||||||
if (
|
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
|
||||||
isinstance(sub, list)
|
|
||||||
and len(sub) >= 3
|
|
||||||
and sub[0] == Symbol("center")
|
|
||||||
):
|
|
||||||
cx, cy = float(sub[1]), float(sub[2])
|
cx, cy = float(sub[1]), float(sub[2])
|
||||||
elif (
|
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
|
||||||
isinstance(sub, list)
|
|
||||||
and len(sub) >= 2
|
|
||||||
and sub[0] == Symbol("radius")
|
|
||||||
):
|
|
||||||
r = float(sub[1])
|
r = float(sub[1])
|
||||||
if r > 0:
|
if r > 0:
|
||||||
points.extend(
|
points.extend(
|
||||||
@@ -212,11 +200,7 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
|
|||||||
for sub in sexp[1:]:
|
for sub in sexp[1:]:
|
||||||
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
|
||||||
for pt in sub[1:]:
|
for pt in sub[1:]:
|
||||||
if (
|
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
|
||||||
isinstance(pt, list)
|
|
||||||
and len(pt) >= 3
|
|
||||||
and pt[0] == Symbol("xy")
|
|
||||||
):
|
|
||||||
points.append((float(pt[1]), float(pt[2])))
|
points.append((float(pt[1]), float(pt[2])))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -243,11 +227,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
|
|||||||
"""
|
"""
|
||||||
lib_symbols_section = None
|
lib_symbols_section = None
|
||||||
for item in sexp_data:
|
for item in sexp_data:
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == Symbol("lib_symbols")
|
|
||||||
):
|
|
||||||
lib_symbols_section = item
|
lib_symbols_section = item
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -465,9 +445,7 @@ def _compute_symbol_bbox_direct(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def find_overlapping_elements(
|
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
|
||||||
schematic_path: Path, tolerance: float = 0.5
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Detect spatially overlapping symbols, wires, and labels.
|
Detect spatially overlapping symbols, wires, and labels.
|
||||||
|
|
||||||
@@ -490,9 +468,7 @@ def find_overlapping_elements(
|
|||||||
|
|
||||||
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
|
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
|
||||||
non_template_symbols = [
|
non_template_symbols = [
|
||||||
s
|
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
|
||||||
for s in symbols
|
|
||||||
if not s["reference"].startswith("_TEMPLATE") and s["reference"]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Pre-compute bounding boxes for all non-template symbols
|
# Pre-compute bounding boxes for all non-template symbols
|
||||||
@@ -503,9 +479,7 @@ def find_overlapping_elements(
|
|||||||
graphics_points = lib_data.get("graphics_points", [])
|
graphics_points = lib_data.get("graphics_points", [])
|
||||||
bbox = None
|
bbox = None
|
||||||
if pin_defs:
|
if pin_defs:
|
||||||
bbox = _compute_symbol_bbox_direct(
|
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
|
||||||
sym, pin_defs, graphics_points=graphics_points
|
|
||||||
)
|
|
||||||
symbol_bboxes.append((sym, bbox))
|
symbol_bboxes.append((sym, bbox))
|
||||||
|
|
||||||
for i in range(len(symbol_bboxes)):
|
for i in range(len(symbol_bboxes)):
|
||||||
@@ -706,9 +680,7 @@ def get_elements_in_region(
|
|||||||
if (
|
if (
|
||||||
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
|
_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 _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
|
||||||
or _line_segment_intersects_aabb(
|
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
|
||||||
s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
region_wires.append(
|
region_wires.append(
|
||||||
{
|
{
|
||||||
@@ -877,15 +849,11 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
|
|||||||
ux, uy = dx / length, dy / length
|
ux, uy = dx / length, dy / length
|
||||||
if start_at_pin:
|
if start_at_pin:
|
||||||
nsx, nsy = sx + ux * nudge, sy + uy * nudge
|
nsx, nsy = sx + ux * nudge, sy + uy * nudge
|
||||||
if not _line_segment_intersects_aabb(
|
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
|
||||||
nsx, nsy, ex, ey, bx1, by1, bx2, by2
|
|
||||||
):
|
|
||||||
continue # Wire terminates at pin from outside
|
continue # Wire terminates at pin from outside
|
||||||
else:
|
else:
|
||||||
nex, ney = ex - ux * nudge, ey - uy * nudge
|
nex, ney = ex - ux * nudge, ey - uy * nudge
|
||||||
if not _line_segment_intersects_aabb(
|
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
|
||||||
sx, sy, nex, ney, bx1, by1, bx2, by2
|
|
||||||
):
|
|
||||||
continue # Wire terminates at pin from outside
|
continue # Wire terminates at pin from outside
|
||||||
|
|
||||||
sym = sd["sym"]
|
sym = sd["sym"]
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ Polygon = List[Point]
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SVG path tokenizer
|
# SVG path tokenizer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_TOKEN_RE = re.compile(
|
_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
|
||||||
r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tokenize_path(d: str) -> List[str]:
|
def _tokenize_path(d: str) -> List[str]:
|
||||||
@@ -57,9 +55,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
"""
|
"""
|
||||||
polygons: List[Polygon] = []
|
polygons: List[Polygon] = []
|
||||||
current: Polygon = []
|
current: Polygon = []
|
||||||
cx, cy = 0.0, 0.0 # current point
|
cx, cy = 0.0, 0.0 # current point
|
||||||
sx, sy = 0.0, 0.0 # subpath start
|
sx, sy = 0.0, 0.0 # subpath start
|
||||||
last_ctrl = None # last bezier control point (for S/T commands)
|
last_ctrl = None # last bezier control point (for S/T commands)
|
||||||
last_cmd = ""
|
last_cmd = ""
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
@@ -73,13 +71,15 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
i += n
|
i += n
|
||||||
return vals
|
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 = []
|
pts = []
|
||||||
for k in range(1, steps + 1):
|
for k in range(1, steps + 1):
|
||||||
t = k / steps
|
t = k / steps
|
||||||
mt = 1 - t
|
mt = 1 - t
|
||||||
x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0]
|
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]
|
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))
|
pts.append((x, y))
|
||||||
return pts
|
return pts
|
||||||
|
|
||||||
@@ -88,13 +88,23 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
for k in range(1, steps + 1):
|
for k in range(1, steps + 1):
|
||||||
t = k / steps
|
t = k / steps
|
||||||
mt = 1 - t
|
mt = 1 - t
|
||||||
x = mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0]
|
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]
|
y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1]
|
||||||
pts.append((x, y))
|
pts.append((x, y))
|
||||||
return pts
|
return pts
|
||||||
|
|
||||||
def arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float,
|
def arc_points(
|
||||||
large_arc: int, sweep: int, x2: float, y2: float, steps: int = 20) -> List[Point]:
|
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)."""
|
"""Approximate SVG arc as polygon points (endpoint parameterization → centre)."""
|
||||||
if rx == 0 or ry == 0:
|
if rx == 0 or ry == 0:
|
||||||
return [(x2, y2)]
|
return [(x2, y2)]
|
||||||
@@ -104,13 +114,13 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
x1p = cos_phi * dx + sin_phi * dy
|
x1p = cos_phi * dx + sin_phi * dy
|
||||||
y1p = -sin_phi * dx + cos_phi * dy
|
y1p = -sin_phi * dx + cos_phi * dy
|
||||||
rx, ry = abs(rx), abs(ry)
|
rx, ry = abs(rx), abs(ry)
|
||||||
lam = (x1p / rx)**2 + (y1p / ry)**2
|
lam = (x1p / rx) ** 2 + (y1p / ry) ** 2
|
||||||
if lam > 1:
|
if lam > 1:
|
||||||
lam = math.sqrt(lam)
|
lam = math.sqrt(lam)
|
||||||
rx *= lam
|
rx *= lam
|
||||||
ry *= lam
|
ry *= lam
|
||||||
num = max(0.0, (rx*ry)**2 - (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
|
den = (rx * y1p) ** 2 + (ry * x1p) ** 2
|
||||||
sq = math.sqrt(num / den) if den != 0 else 0
|
sq = math.sqrt(num / den) if den != 0 else 0
|
||||||
if large_arc == sweep:
|
if large_arc == sweep:
|
||||||
sq = -sq
|
sq = -sq
|
||||||
@@ -120,8 +130,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
|
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
|
||||||
|
|
||||||
def angle(ux, uy, vx, vy):
|
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)))))
|
a = math.acos(
|
||||||
if ux*vy - uy*vx < 0:
|
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
|
||||||
|
)
|
||||||
|
if ux * vy - uy * vx < 0:
|
||||||
a = -a
|
a = -a
|
||||||
return a
|
return a
|
||||||
|
|
||||||
@@ -144,8 +156,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
# --- main loop ---
|
# --- main loop ---
|
||||||
while i < len(tokens):
|
while i < len(tokens):
|
||||||
tok = tokens[i]
|
tok = tokens[i]
|
||||||
if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \
|
if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip(
|
||||||
re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok):
|
"+-"
|
||||||
|
).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok):
|
||||||
# implicit repeat of last command
|
# implicit repeat of last command
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -155,7 +168,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
|
|
||||||
rel = cmd.islower()
|
rel = cmd.islower()
|
||||||
|
|
||||||
if cmd in ('M', 'm'):
|
if cmd in ("M", "m"):
|
||||||
x, y = consume(2)
|
x, y = consume(2)
|
||||||
if rel:
|
if rel:
|
||||||
cx, cy = cx + x, cy + y
|
cx, cy = cx + x, cy + y
|
||||||
@@ -166,9 +179,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
current = [(cx, cy)]
|
current = [(cx, cy)]
|
||||||
sx, sy = cx, cy
|
sx, sy = cx, cy
|
||||||
# subsequent coordinates are implicit L/l
|
# 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)
|
x, y = consume(2)
|
||||||
if rel:
|
if rel:
|
||||||
cx, cy = cx + x, cy + y
|
cx, cy = cx + x, cy + y
|
||||||
@@ -176,36 +189,46 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
cx, cy = x, y
|
cx, cy = x, y
|
||||||
current.append((cx, cy))
|
current.append((cx, cy))
|
||||||
|
|
||||||
elif cmd in ('H', 'h'):
|
elif cmd in ("H", "h"):
|
||||||
x = float(tokens[i]); i += 1
|
x = float(tokens[i])
|
||||||
|
i += 1
|
||||||
cx = cx + x if rel else x
|
cx = cx + x if rel else x
|
||||||
current.append((cx, cy))
|
current.append((cx, cy))
|
||||||
|
|
||||||
elif cmd in ('V', 'v'):
|
elif cmd in ("V", "v"):
|
||||||
y = float(tokens[i]); i += 1
|
y = float(tokens[i])
|
||||||
|
i += 1
|
||||||
cy = cy + y if rel else y
|
cy = cy + y if rel else y
|
||||||
current.append((cx, cy))
|
current.append((cx, cy))
|
||||||
|
|
||||||
elif cmd in ('Z', 'z'):
|
elif cmd in ("Z", "z"):
|
||||||
current.append((sx, sy)) # close
|
current.append((sx, sy)) # close
|
||||||
polygons.append(current)
|
polygons.append(current)
|
||||||
current = []
|
current = []
|
||||||
cx, cy = sx, sy
|
cx, cy = sx, sy
|
||||||
|
|
||||||
elif cmd in ('C', 'c'):
|
elif cmd in ("C", "c"):
|
||||||
x1, y1, x2, y2, x, y = consume(6)
|
x1, y1, x2, y2, x, y = consume(6)
|
||||||
if rel:
|
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))
|
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
|
||||||
current.extend(pts)
|
current.extend(pts)
|
||||||
last_ctrl = (x2, y2)
|
last_ctrl = (x2, y2)
|
||||||
cx, cy = x, y
|
cx, cy = x, y
|
||||||
|
|
||||||
elif cmd in ('S', 's'):
|
elif cmd in ("S", "s"):
|
||||||
x2, y2, x, y = consume(4)
|
x2, y2, x, y = consume(4)
|
||||||
if rel:
|
if rel:
|
||||||
x2 += cx; y2 += cy; x += cx; y += cy
|
x2 += cx
|
||||||
if last_ctrl and last_cmd in ('C', 'c', 'S', 's'):
|
y2 += cy
|
||||||
|
x += cx
|
||||||
|
y += cy
|
||||||
|
if last_ctrl and last_cmd in ("C", "c", "S", "s"):
|
||||||
x1 = 2 * cx - last_ctrl[0]
|
x1 = 2 * cx - last_ctrl[0]
|
||||||
y1 = 2 * cy - last_ctrl[1]
|
y1 = 2 * cy - last_ctrl[1]
|
||||||
else:
|
else:
|
||||||
@@ -215,20 +238,24 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
last_ctrl = (x2, y2)
|
last_ctrl = (x2, y2)
|
||||||
cx, cy = x, y
|
cx, cy = x, y
|
||||||
|
|
||||||
elif cmd in ('Q', 'q'):
|
elif cmd in ("Q", "q"):
|
||||||
x1, y1, x, y = consume(4)
|
x1, y1, x, y = consume(4)
|
||||||
if rel:
|
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))
|
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
|
||||||
current.extend(pts)
|
current.extend(pts)
|
||||||
last_ctrl = (x1, y1)
|
last_ctrl = (x1, y1)
|
||||||
cx, cy = x, y
|
cx, cy = x, y
|
||||||
|
|
||||||
elif cmd in ('T', 't'):
|
elif cmd in ("T", "t"):
|
||||||
x, y = consume(2)
|
x, y = consume(2)
|
||||||
if rel:
|
if rel:
|
||||||
x += cx; y += cy
|
x += cx
|
||||||
if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'):
|
y += cy
|
||||||
|
if last_ctrl and last_cmd in ("Q", "q", "T", "t"):
|
||||||
x1 = 2 * cx - last_ctrl[0]
|
x1 = 2 * cx - last_ctrl[0]
|
||||||
y1 = 2 * cy - last_ctrl[1]
|
y1 = 2 * cy - last_ctrl[1]
|
||||||
else:
|
else:
|
||||||
@@ -238,11 +265,12 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
|||||||
last_ctrl = (x1, y1)
|
last_ctrl = (x1, y1)
|
||||||
cx, cy = x, y
|
cx, cy = x, y
|
||||||
|
|
||||||
elif cmd in ('A', 'a'):
|
elif cmd in ("A", "a"):
|
||||||
rx, ry, phi, large, sweep, x, y = consume(7)
|
rx, ry, phi, large, sweep, x, y = consume(7)
|
||||||
large, sweep = int(large), int(sweep)
|
large, sweep = int(large), int(sweep)
|
||||||
if rel:
|
if rel:
|
||||||
x += cx; y += cy
|
x += cx
|
||||||
|
y += cy
|
||||||
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
|
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
|
||||||
current.extend(pts)
|
current.extend(pts)
|
||||||
cx, cy = x, y
|
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]]:
|
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]."""
|
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
|
||||||
|
|
||||||
def identity():
|
def identity():
|
||||||
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||||
|
|
||||||
def mat_mul(A, B):
|
def mat_mul(A, B):
|
||||||
return [
|
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
|
||||||
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
|
|
||||||
for r in range(3)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = identity()
|
result = identity()
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)',
|
r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
|
||||||
transform_str
|
|
||||||
):
|
):
|
||||||
func = m.group(1)
|
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()
|
mat = identity()
|
||||||
if func == 'matrix' and len(args) == 6:
|
if func == "matrix" and len(args) == 6:
|
||||||
a, b, c, d, e, f = args
|
a, b, c, d, e, f = args
|
||||||
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
|
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
|
||||||
elif func == 'translate':
|
elif func == "translate":
|
||||||
tx = args[0]
|
tx = args[0]
|
||||||
ty = args[1] if len(args) > 1 else 0
|
ty = args[1] if len(args) > 1 else 0
|
||||||
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
|
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
|
||||||
elif func == 'scale':
|
elif func == "scale":
|
||||||
sx = args[0]
|
sx = args[0]
|
||||||
sy = args[1] if len(args) > 1 else sx
|
sy = args[1] if len(args) > 1 else sx
|
||||||
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
|
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
|
||||||
elif func == 'rotate':
|
elif func == "rotate":
|
||||||
angle = math.radians(args[0])
|
angle = math.radians(args[0])
|
||||||
cos, sin = math.cos(angle), math.sin(angle)
|
cos, sin = math.cos(angle), math.sin(angle)
|
||||||
if len(args) == 3:
|
if len(args) == 3:
|
||||||
cx_, cy_ = args[1], args[2]
|
cx_, cy_ = args[1], args[2]
|
||||||
t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]]
|
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]]
|
t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]]
|
||||||
mat = mat_mul(mat_mul(t1, r), t2)
|
mat = mat_mul(mat_mul(t1, r), t2)
|
||||||
else:
|
else:
|
||||||
mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
|
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]]
|
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]]
|
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
|
||||||
result = mat_mul(result, mat)
|
result = mat_mul(result, mat)
|
||||||
return result
|
return result
|
||||||
@@ -321,25 +346,22 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
|
|||||||
|
|
||||||
|
|
||||||
def _mat_mul(A, B):
|
def _mat_mul(A, B):
|
||||||
return [
|
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
|
||||||
[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 element → polygon extractor
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SVG_NS = re.compile(r'\{[^}]+\}')
|
SVG_NS = re.compile(r"\{[^}]+\}")
|
||||||
|
|
||||||
|
|
||||||
def _tag(el: ET.Element) -> str:
|
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]:
|
def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
for key in el.attrib:
|
for key in el.attrib:
|
||||||
if SVG_NS.sub('', key) == name:
|
if SVG_NS.sub("", key) == name:
|
||||||
return el.attrib[key]
|
return el.attrib[key]
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@@ -351,13 +373,13 @@ def _identity():
|
|||||||
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
|
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
|
||||||
"""Recursively extract all polygons from an SVG element tree."""
|
"""Recursively extract all polygons from an SVG element tree."""
|
||||||
tag = _tag(el)
|
tag = _tag(el)
|
||||||
display = _get_attr(el, 'display', 'inline')
|
display = _get_attr(el, "display", "inline")
|
||||||
visibility = _get_attr(el, 'visibility', 'visible')
|
visibility = _get_attr(el, "visibility", "visible")
|
||||||
if display == 'none' or visibility == 'hidden':
|
if display == "none" or visibility == "hidden":
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Accumulate transform
|
# Accumulate transform
|
||||||
transform_str = _get_attr(el, 'transform', '')
|
transform_str = _get_attr(el, "transform", "")
|
||||||
if transform_str:
|
if transform_str:
|
||||||
local_mat = _parse_transform(transform_str)
|
local_mat = _parse_transform(transform_str)
|
||||||
mat = _mat_mul(parent_mat, local_mat)
|
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] = []
|
result: List[Polygon] = []
|
||||||
|
|
||||||
if tag == 'g' or tag == 'svg':
|
if tag == "g" or tag == "svg":
|
||||||
for child in el:
|
for child in el:
|
||||||
result.extend(_extract_polygons_from_element(child, mat))
|
result.extend(_extract_polygons_from_element(child, mat))
|
||||||
|
|
||||||
elif tag == 'path':
|
elif tag == "path":
|
||||||
d = _get_attr(el, 'd', '')
|
d = _get_attr(el, "d", "")
|
||||||
if d:
|
if d:
|
||||||
tokens = _tokenize_path(d)
|
tokens = _tokenize_path(d)
|
||||||
polygons = _parse_path_tokens(tokens)
|
polygons = _parse_path_tokens(tokens)
|
||||||
for poly in polygons:
|
for poly in polygons:
|
||||||
result.append(_apply_transform(poly, mat))
|
result.append(_apply_transform(poly, mat))
|
||||||
|
|
||||||
elif tag == 'rect':
|
elif tag == "rect":
|
||||||
x = float(_get_attr(el, 'x', '0') or 0)
|
x = float(_get_attr(el, "x", "0") or 0)
|
||||||
y = float(_get_attr(el, 'y', '0') or 0)
|
y = float(_get_attr(el, "y", "0") or 0)
|
||||||
w = float(_get_attr(el, 'width', '0') or 0)
|
w = float(_get_attr(el, "width", "0") or 0)
|
||||||
h = float(_get_attr(el, 'height', '0') or 0)
|
h = float(_get_attr(el, "height", "0") or 0)
|
||||||
if w > 0 and h > 0:
|
if w > 0 and h > 0:
|
||||||
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
|
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
|
||||||
result.append(_apply_transform(pts, mat))
|
result.append(_apply_transform(pts, mat))
|
||||||
|
|
||||||
elif tag == 'circle':
|
elif tag == "circle":
|
||||||
cx_ = float(_get_attr(el, 'cx', '0') or 0)
|
cx_ = float(_get_attr(el, "cx", "0") or 0)
|
||||||
cy_ = float(_get_attr(el, 'cy', '0') or 0)
|
cy_ = float(_get_attr(el, "cy", "0") or 0)
|
||||||
r = float(_get_attr(el, 'r', '0') or 0)
|
r = float(_get_attr(el, "r", "0") or 0)
|
||||||
if r > 0:
|
if r > 0:
|
||||||
steps = 36
|
steps = 36
|
||||||
pts = [(cx_ + r * math.cos(2 * math.pi * k / steps),
|
pts = [
|
||||||
cy_ + r * math.sin(2 * math.pi * k / steps))
|
(
|
||||||
for k in range(steps + 1)]
|
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))
|
result.append(_apply_transform(pts, mat))
|
||||||
|
|
||||||
elif tag == 'ellipse':
|
elif tag == "ellipse":
|
||||||
cx_ = float(_get_attr(el, 'cx', '0') or 0)
|
cx_ = float(_get_attr(el, "cx", "0") or 0)
|
||||||
cy_ = float(_get_attr(el, 'cy', '0') or 0)
|
cy_ = float(_get_attr(el, "cy", "0") or 0)
|
||||||
rx = float(_get_attr(el, 'rx', '0') or 0)
|
rx = float(_get_attr(el, "rx", "0") or 0)
|
||||||
ry = float(_get_attr(el, 'ry', '0') or 0)
|
ry = float(_get_attr(el, "ry", "0") or 0)
|
||||||
if rx > 0 and ry > 0:
|
if rx > 0 and ry > 0:
|
||||||
steps = 36
|
steps = 36
|
||||||
pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps),
|
pts = [
|
||||||
cy_ + ry * math.sin(2 * math.pi * k / steps))
|
(
|
||||||
for k in range(steps + 1)]
|
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))
|
result.append(_apply_transform(pts, mat))
|
||||||
|
|
||||||
elif tag in ('polygon', 'polyline'):
|
elif tag in ("polygon", "polyline"):
|
||||||
points_str = _get_attr(el, 'points', '')
|
points_str = _get_attr(el, "points", "")
|
||||||
if points_str:
|
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)]
|
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
|
pts.append(pts[0]) # close
|
||||||
if pts:
|
if pts:
|
||||||
result.append(_apply_transform(pts, mat))
|
result.append(_apply_transform(pts, mat))
|
||||||
|
|
||||||
elif tag == 'line':
|
elif tag == "line":
|
||||||
x1 = float(_get_attr(el, 'x1', '0') or 0)
|
x1 = float(_get_attr(el, "x1", "0") or 0)
|
||||||
y1 = float(_get_attr(el, 'y1', '0') or 0)
|
y1 = float(_get_attr(el, "y1", "0") or 0)
|
||||||
x2 = float(_get_attr(el, 'x2', '0') or 0)
|
x2 = float(_get_attr(el, "x2", "0") or 0)
|
||||||
y2 = float(_get_attr(el, 'y2', '0') or 0)
|
y2 = float(_get_attr(el, "y2", "0") or 0)
|
||||||
pts = [(x1, y1), (x2, y2)]
|
pts = [(x1, y1), (x2, y2)]
|
||||||
result.append(_apply_transform(pts, mat))
|
result.append(_apply_transform(pts, mat))
|
||||||
|
|
||||||
@@ -453,20 +483,24 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
|
|||||||
row = []
|
row = []
|
||||||
fill_str = "yes" if filled else "none"
|
fill_str = "yes" if filled else "none"
|
||||||
uid = str(uuid.uuid4())
|
uid = str(uuid.uuid4())
|
||||||
lines = [
|
lines = (
|
||||||
"\t(gr_poly",
|
[
|
||||||
"\t\t(pts",
|
"\t(gr_poly",
|
||||||
] + pts_lines + [
|
"\t\t(pts",
|
||||||
"\t\t)",
|
]
|
||||||
"\t\t(stroke",
|
+ pts_lines
|
||||||
f"\t\t\t(width {stroke_width:.4f})",
|
+ [
|
||||||
"\t\t\t(type solid)",
|
"\t\t)",
|
||||||
"\t\t)",
|
"\t\t(stroke",
|
||||||
f"\t\t(fill {fill_str})",
|
f"\t\t\t(width {stroke_width:.4f})",
|
||||||
f'\t\t(layer "{layer}")',
|
"\t\t\t(type solid)",
|
||||||
f'\t\t(uuid "{uid}")',
|
"\t\t)",
|
||||||
"\t)",
|
f"\t\t(fill {fill_str})",
|
||||||
]
|
f'\t\t(layer "{layer}")',
|
||||||
|
f'\t\t(uuid "{uid}")',
|
||||||
|
"\t)",
|
||||||
|
]
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -510,15 +544,15 @@ def import_svg_to_pcb(
|
|||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
|
|
||||||
# Determine SVG viewport
|
# Determine SVG viewport
|
||||||
vb = _get_attr(root, 'viewBox')
|
vb = _get_attr(root, "viewBox")
|
||||||
if vb:
|
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]
|
svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3]
|
||||||
else:
|
else:
|
||||||
w_str = _get_attr(root, 'width', '100') or '100'
|
w_str = _get_attr(root, "width", "100") or "100"
|
||||||
h_str = _get_attr(root, 'height', '100') or '100'
|
h_str = _get_attr(root, "height", "100") or "100"
|
||||||
svg_w = float(re.sub(r'[^\d.]', '', w_str) 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_h = float(re.sub(r"[^\d.]", "", h_str) or 100)
|
||||||
svg_x0, svg_y0 = 0.0, 0.0
|
svg_x0, svg_y0 = 0.0, 0.0
|
||||||
|
|
||||||
if svg_w == 0 or svg_h == 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"
|
insert_block = "\n" + "\n".join(gr_lines) + "\n"
|
||||||
last_paren = pcb_content.rfind(")")
|
last_paren = pcb_content.rfind(")")
|
||||||
if last_paren == -1:
|
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:]
|
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:
|
except Exception as e:
|
||||||
logger.error(f"SVG import failed: {e}")
|
logger.error(f"SVG import failed: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|||||||
@@ -24,15 +24,31 @@ KICAD9_SYMBOL_LIB_VERSION = "20241209"
|
|||||||
|
|
||||||
# Pin electrical types
|
# Pin electrical types
|
||||||
PIN_TYPES = {
|
PIN_TYPES = {
|
||||||
"input", "output", "bidirectional", "tri_state", "passive",
|
"input",
|
||||||
"free", "unspecified", "power_in", "power_out",
|
"output",
|
||||||
"open_collector", "open_emitter", "no_connect",
|
"bidirectional",
|
||||||
|
"tri_state",
|
||||||
|
"passive",
|
||||||
|
"free",
|
||||||
|
"unspecified",
|
||||||
|
"power_in",
|
||||||
|
"power_out",
|
||||||
|
"open_collector",
|
||||||
|
"open_emitter",
|
||||||
|
"no_connect",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pin graphic shapes
|
# Pin graphic shapes
|
||||||
PIN_SHAPES = {
|
PIN_SHAPES = {
|
||||||
"line", "inverted", "clock", "inverted_clock", "input_low",
|
"line",
|
||||||
"clock_low", "output_low", "falling_edge_clock", "non_logic",
|
"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")
|
lib_content = lib_path.read_text(encoding="utf-8")
|
||||||
else:
|
else:
|
||||||
lib_content = (
|
lib_content = (
|
||||||
f'(kicad_symbol_lib\n'
|
f"(kicad_symbol_lib\n"
|
||||||
f' (version {KICAD9_SYMBOL_LIB_VERSION})\n'
|
f" (version {KICAD9_SYMBOL_LIB_VERSION})\n"
|
||||||
f' (generator "kicad-mcp")\n'
|
f' (generator "kicad-mcp")\n'
|
||||||
f' (generator_version "9.0")\n'
|
f' (generator_version "9.0")\n'
|
||||||
f')\n'
|
f")\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate
|
# Check for duplicate
|
||||||
@@ -209,7 +225,7 @@ class SymbolCreator:
|
|||||||
# Only top-level symbols (not sub-symbols like _0_1 or _1_1)
|
# Only top-level symbols (not sub-symbols like _0_1 or _1_1)
|
||||||
names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE)
|
names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE)
|
||||||
# Filter out sub-symbols (contain _N_N suffix)
|
# 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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"library_path": str(lib_path),
|
"library_path": str(lib_path),
|
||||||
@@ -332,9 +348,9 @@ class SymbolCreator:
|
|||||||
board_str = "yes" if on_board else "no"
|
board_str = "yes" if on_board else "no"
|
||||||
|
|
||||||
lines.append(f' (symbol "{name}"')
|
lines.append(f' (symbol "{name}"')
|
||||||
lines.append(f' (exclude_from_sim no)')
|
lines.append(f" (exclude_from_sim no)")
|
||||||
lines.append(f' (in_bom {bom_str})')
|
lines.append(f" (in_bom {bom_str})")
|
||||||
lines.append(f' (on_board {board_str})')
|
lines.append(f" (on_board {board_str})")
|
||||||
|
|
||||||
# Properties
|
# Properties
|
||||||
lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True))
|
lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True))
|
||||||
@@ -351,15 +367,15 @@ class SymbolCreator:
|
|||||||
lines.extend(_rect_sym_lines(rect))
|
lines.extend(_rect_sym_lines(rect))
|
||||||
for pl in polylines:
|
for pl in polylines:
|
||||||
lines.extend(_polyline_lines(pl))
|
lines.extend(_polyline_lines(pl))
|
||||||
lines.append(f' )')
|
lines.append(f" )")
|
||||||
|
|
||||||
# Sub-symbol _1_1: pins
|
# Sub-symbol _1_1: pins
|
||||||
lines.append(f' (symbol "{name}_1_1"')
|
lines.append(f' (symbol "{name}_1_1"')
|
||||||
for pin in pins:
|
for pin in pins:
|
||||||
lines.extend(_pin_lines(pin))
|
lines.extend(_pin_lines(pin))
|
||||||
lines.append(f' )')
|
lines.append(f" )")
|
||||||
|
|
||||||
lines.append(f' )')
|
lines.append(f" )")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _remove_symbol(self, content: str, name: str) -> str:
|
def _remove_symbol(self, content: str, name: str) -> str:
|
||||||
@@ -372,8 +388,9 @@ class SymbolCreator:
|
|||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not skip:
|
if not skip:
|
||||||
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \
|
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and not re.search(
|
||||||
not re.search(r'_\d+_\d+"', line):
|
r'_\d+_\d+"', line
|
||||||
|
):
|
||||||
skip = True
|
skip = True
|
||||||
depth = stripped.count("(") - stripped.count(")")
|
depth = stripped.count("(") - stripped.count(")")
|
||||||
continue
|
continue
|
||||||
@@ -390,17 +407,16 @@ class SymbolCreator:
|
|||||||
# S-Expression helper functions #
|
# S-Expression helper functions #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def _property_block(
|
|
||||||
key: str, value: str, x: float, y: float, visible: bool = True
|
def _property_block(key: str, value: str, x: float, y: float, visible: bool = True) -> List[str]:
|
||||||
) -> List[str]:
|
|
||||||
hide = "" if visible else "\n (hide yes)"
|
hide = "" if visible else "\n (hide yes)"
|
||||||
return [
|
return [
|
||||||
f' (property "{_esc(key)}" "{_esc(value)}"',
|
f' (property "{_esc(key)}" "{_esc(value)}"',
|
||||||
f' (at {_fmt(x)} {_fmt(y)} 0)',
|
f" (at {_fmt(x)} {_fmt(y)} 0)",
|
||||||
f' (effects',
|
f" (effects",
|
||||||
f' (font (size 1.27 1.27))',
|
f" (font (size 1.27 1.27))",
|
||||||
f' ){hide}',
|
f" ){hide}",
|
||||||
f' )',
|
f" )",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -412,12 +428,12 @@ def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]:
|
|||||||
w = _fmt(rect.get("width", 0.254))
|
w = _fmt(rect.get("width", 0.254))
|
||||||
fill = rect.get("fill", "background")
|
fill = rect.get("fill", "background")
|
||||||
return [
|
return [
|
||||||
f' (rectangle',
|
f" (rectangle",
|
||||||
f' (start {x1} {y1})',
|
f" (start {x1} {y1})",
|
||||||
f' (end {x2} {y2})',
|
f" (end {x2} {y2})",
|
||||||
f' (stroke (width {w}) (type default))',
|
f" (stroke (width {w}) (type default))",
|
||||||
f' (fill (type {fill}))',
|
f" (fill (type {fill}))",
|
||||||
f' )',
|
f" )",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -426,16 +442,16 @@ def _polyline_lines(pl: Dict[str, Any]) -> List[str]:
|
|||||||
w = _fmt(pl.get("width", 0.254))
|
w = _fmt(pl.get("width", 0.254))
|
||||||
fill = pl.get("fill", "none")
|
fill = pl.get("fill", "none")
|
||||||
lines = [
|
lines = [
|
||||||
f' (polyline',
|
f" (polyline",
|
||||||
f' (pts',
|
f" (pts",
|
||||||
]
|
]
|
||||||
for pt in pts:
|
for pt in pts:
|
||||||
lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})')
|
lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})')
|
||||||
lines += [
|
lines += [
|
||||||
f' )',
|
f" )",
|
||||||
f' (stroke (width {w}) (type default))',
|
f" (stroke (width {w}) (type default))",
|
||||||
f' (fill (type {fill}))',
|
f" (fill (type {fill}))",
|
||||||
f' )',
|
f" )",
|
||||||
]
|
]
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
@@ -452,14 +468,14 @@ def _pin_lines(pin: Dict[str, Any]) -> List[str]:
|
|||||||
pin_number = str(pin.get("number", "1"))
|
pin_number = str(pin.get("number", "1"))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
f' (pin {ptype} {shape}',
|
f" (pin {ptype} {shape}",
|
||||||
f' (at {x} {y} {angle})',
|
f" (at {x} {y} {angle})",
|
||||||
f' (length {length})',
|
f" (length {length})",
|
||||||
f' (name "{_esc(pin_name)}"',
|
f' (name "{_esc(pin_name)}"',
|
||||||
f' (effects (font (size 1.27 1.27)))',
|
f" (effects (font (size 1.27 1.27)))",
|
||||||
f' )',
|
f" )",
|
||||||
f' (number "{_esc(pin_number)}"',
|
f' (number "{_esc(pin_number)}"',
|
||||||
f' (effects (font (size 1.27 1.27)))',
|
f" (effects (font (size 1.27 1.27)))",
|
||||||
f' )',
|
f" )",
|
||||||
f' )',
|
f" )",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -95,9 +95,7 @@ def _parse_virtual_connections(schematic, schematic_path):
|
|||||||
locator = PinLocator()
|
locator = PinLocator()
|
||||||
for symbol in schematic.symbol:
|
for symbol in schematic.symbol:
|
||||||
try:
|
try:
|
||||||
if not hasattr(symbol, "property") or not hasattr(
|
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||||
symbol.property, "Reference"
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
ref = symbol.property.Reference.value
|
ref = symbol.property.Reference.value
|
||||||
if not ref.startswith("#PWR"):
|
if not ref.startswith("#PWR"):
|
||||||
@@ -194,9 +192,7 @@ def _find_pins_on_net(
|
|||||||
ref = None
|
ref = None
|
||||||
for symbol in schematic.symbol:
|
for symbol in schematic.symbol:
|
||||||
try:
|
try:
|
||||||
if not hasattr(symbol, "property") or not hasattr(
|
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||||
symbol.property, "Reference"
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
ref = symbol.property.Reference.value
|
ref = symbol.property.Reference.value
|
||||||
if ref.startswith("_TEMPLATE"):
|
if ref.startswith("_TEMPLATE"):
|
||||||
@@ -241,9 +237,7 @@ def get_wire_connections(
|
|||||||
|
|
||||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||||
|
|
||||||
point_to_label, label_to_points = _parse_virtual_connections(
|
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||||
schematic, schematic_path
|
|
||||||
)
|
|
||||||
|
|
||||||
visited, net_points = _find_connected_wires(
|
visited, net_points = _find_connected_wires(
|
||||||
x_mm,
|
x_mm,
|
||||||
|
|||||||
@@ -76,11 +76,7 @@ class WireManager:
|
|||||||
# Find insertion point (before sheet_instances)
|
# Find insertion point (before sheet_instances)
|
||||||
sheet_instances_index = None
|
sheet_instances_index = None
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == _SYM_SHEET_INSTANCES
|
|
||||||
):
|
|
||||||
sheet_instances_index = i
|
sheet_instances_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -146,20 +142,14 @@ class WireManager:
|
|||||||
# KiCAD wire elements only support exactly 2 pts each.
|
# KiCAD wire elements only support exactly 2 pts each.
|
||||||
# Split N waypoints into N-1 individual wire segments.
|
# Split N waypoints into N-1 individual wire segments.
|
||||||
wire_sexps = [
|
wire_sexps = [
|
||||||
WireManager._make_wire_sexp(
|
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
|
||||||
points[i], points[i + 1], stroke_width, stroke_type
|
|
||||||
)
|
|
||||||
for i in range(len(points) - 1)
|
for i in range(len(points) - 1)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Find insertion point
|
# Find insertion point
|
||||||
sheet_instances_index = None
|
sheet_instances_index = None
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == _SYM_SHEET_INSTANCES
|
|
||||||
):
|
|
||||||
sheet_instances_index = i
|
sheet_instances_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -235,11 +225,7 @@ class WireManager:
|
|||||||
# Find insertion point
|
# Find insertion point
|
||||||
sheet_instances_index = None
|
sheet_instances_index = None
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == _SYM_SHEET_INSTANCES
|
|
||||||
):
|
|
||||||
sheet_instances_index = i
|
sheet_instances_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -274,11 +260,7 @@ class WireManager:
|
|||||||
Parse a wire S-expression item in a single pass.
|
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.
|
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
|
||||||
"""
|
"""
|
||||||
if not (
|
if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
|
||||||
isinstance(wire_item, list)
|
|
||||||
and len(wire_item) >= 2
|
|
||||||
and wire_item[0] == _SYM_WIRE
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
start = end = None
|
start = end = None
|
||||||
stroke_width: float = 0
|
stroke_width: float = 0
|
||||||
@@ -379,9 +361,7 @@ class WireManager:
|
|||||||
return splits
|
return splits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_junction(
|
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
|
||||||
schematic_path: Path, position: List[float], diameter: float = 0
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Add a junction (connection dot) to the schematic.
|
Add a junction (connection dot) to the schematic.
|
||||||
|
|
||||||
@@ -423,11 +403,7 @@ class WireManager:
|
|||||||
# Find insertion point
|
# Find insertion point
|
||||||
sheet_instances_index = None
|
sheet_instances_index = None
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == _SYM_SHEET_INSTANCES
|
|
||||||
):
|
|
||||||
sheet_instances_index = i
|
sheet_instances_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -484,11 +460,7 @@ class WireManager:
|
|||||||
# Find insertion point
|
# Find insertion point
|
||||||
sheet_instances_index = None
|
sheet_instances_index = None
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if (
|
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||||
isinstance(item, list)
|
|
||||||
and len(item) > 0
|
|
||||||
and item[0] == _SYM_SHEET_INSTANCES
|
|
||||||
):
|
|
||||||
sheet_instances_index = i
|
sheet_instances_index = i
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -544,9 +516,7 @@ class WireManager:
|
|||||||
ex, ey = end_point
|
ex, ey = end_point
|
||||||
|
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if not (
|
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE):
|
||||||
isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract pts from the wire s-expression
|
# Extract pts from the wire s-expression
|
||||||
@@ -626,9 +596,7 @@ class WireManager:
|
|||||||
sch_data = sexpdata.loads(sch_content)
|
sch_data = sexpdata.loads(sch_content)
|
||||||
|
|
||||||
for i, item in enumerate(sch_data):
|
for i, item in enumerate(sch_data):
|
||||||
if not (
|
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL):
|
||||||
isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Second element is the label text
|
# Second element is the label text
|
||||||
@@ -649,8 +617,7 @@ class WireManager:
|
|||||||
continue
|
continue
|
||||||
lx, ly = float(at_entry[1]), float(at_entry[2])
|
lx, ly = float(at_entry[1]), float(at_entry[2])
|
||||||
if not (
|
if not (
|
||||||
abs(lx - position[0]) < tolerance
|
abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance
|
||||||
and abs(ly - position[1]) < tolerance
|
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ Usage:
|
|||||||
from kicad_api.factory import create_backend
|
from kicad_api.factory import create_backend
|
||||||
from kicad_api.base import KiCADBackend
|
from kicad_api.base import KiCADBackend
|
||||||
|
|
||||||
__all__ = ['create_backend', 'KiCADBackend']
|
__all__ = ["create_backend", "KiCADBackend"]
|
||||||
__version__ = '2.0.0-alpha.1'
|
__version__ = "2.0.0-alpha.1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Abstract base class for KiCAD API backends
|
|||||||
|
|
||||||
Defines the interface that all KiCAD backends must implement.
|
Defines the interface that all KiCAD backends must implement.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
@@ -97,7 +98,7 @@ class KiCADBackend(ABC):
|
|||||||
|
|
||||||
# Board Operations
|
# Board Operations
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_board(self) -> 'BoardAPI':
|
def get_board(self) -> "BoardAPI":
|
||||||
"""
|
"""
|
||||||
Get board API for current project
|
Get board API for current project
|
||||||
|
|
||||||
@@ -167,7 +168,7 @@ class BoardAPI(ABC):
|
|||||||
x: float,
|
x: float,
|
||||||
y: float,
|
y: float,
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
layer: str = "F.Cu"
|
layer: str = "F.Cu",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Place a component on the board
|
Place a component on the board
|
||||||
@@ -194,7 +195,7 @@ class BoardAPI(ABC):
|
|||||||
end_y: float,
|
end_y: float,
|
||||||
width: float = 0.25,
|
width: float = 0.25,
|
||||||
layer: str = "F.Cu",
|
layer: str = "F.Cu",
|
||||||
net_name: Optional[str] = None
|
net_name: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Add a track (trace) to the board
|
Add a track (trace) to the board
|
||||||
@@ -220,7 +221,7 @@ class BoardAPI(ABC):
|
|||||||
diameter: float = 0.8,
|
diameter: float = 0.8,
|
||||||
drill: float = 0.4,
|
drill: float = 0.4,
|
||||||
net_name: Optional[str] = None,
|
net_name: Optional[str] = None,
|
||||||
via_type: str = "through"
|
via_type: str = "through",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Add a via to the board
|
Add a via to the board
|
||||||
@@ -275,14 +276,17 @@ class BoardAPI(ABC):
|
|||||||
|
|
||||||
class BackendError(Exception):
|
class BackendError(Exception):
|
||||||
"""Base exception for backend errors"""
|
"""Base exception for backend errors"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ConnectionError(BackendError):
|
class ConnectionError(BackendError):
|
||||||
"""Raised when connection to KiCAD fails"""
|
"""Raised when connection to KiCAD fails"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class APINotAvailableError(BackendError):
|
class APINotAvailableError(BackendError):
|
||||||
"""Raised when required API is not available"""
|
"""Raised when required API is not available"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Backend factory for creating appropriate KiCAD API backend
|
|||||||
|
|
||||||
Auto-detects available backends and provides fallback mechanism.
|
Auto-detects available backends and provides fallback mechanism.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -34,16 +35,16 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
|
|||||||
"""
|
"""
|
||||||
# Check environment variable override
|
# Check environment variable override
|
||||||
if backend_type is None:
|
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}")
|
logger.info(f"Requested backend: {backend_type}")
|
||||||
|
|
||||||
# Try specific backend if requested
|
# Try specific backend if requested
|
||||||
if backend_type == 'ipc':
|
if backend_type == "ipc":
|
||||||
return _create_ipc_backend()
|
return _create_ipc_backend()
|
||||||
elif backend_type == 'swig':
|
elif backend_type == "swig":
|
||||||
return _create_swig_backend()
|
return _create_swig_backend()
|
||||||
elif backend_type == 'auto':
|
elif backend_type == "auto":
|
||||||
return _auto_detect_backend()
|
return _auto_detect_backend()
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||||
@@ -61,13 +62,13 @@ def _create_ipc_backend() -> KiCADBackend:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from kicad_api.ipc_backend import IPCBackend
|
from kicad_api.ipc_backend import IPCBackend
|
||||||
|
|
||||||
logger.info("Creating IPC backend")
|
logger.info("Creating IPC backend")
|
||||||
return IPCBackend()
|
return IPCBackend()
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"IPC backend not available: {e}")
|
logger.error(f"IPC backend not available: {e}")
|
||||||
raise APINotAvailableError(
|
raise APINotAvailableError(
|
||||||
"IPC backend requires 'kicad-python' package. "
|
"IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
|
||||||
"Install with: pip install kicad-python"
|
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ def _create_swig_backend() -> KiCADBackend:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from kicad_api.swig_backend import SWIGBackend
|
from kicad_api.swig_backend import SWIGBackend
|
||||||
|
|
||||||
logger.info("Creating SWIG backend")
|
logger.info("Creating SWIG backend")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. "
|
"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:
|
except ImportError as e:
|
||||||
logger.error(f"SWIG backend not available: {e}")
|
logger.error(f"SWIG backend not available: {e}")
|
||||||
raise APINotAvailableError(
|
raise APINotAvailableError(
|
||||||
"SWIG backend requires 'pcbnew' module. "
|
"SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
|
||||||
"Ensure KiCAD Python module is in PYTHONPATH."
|
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@@ -129,8 +130,7 @@ def _auto_detect_backend() -> KiCADBackend:
|
|||||||
try:
|
try:
|
||||||
backend = _create_swig_backend()
|
backend = _create_swig_backend()
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Using deprecated SWIG backend. "
|
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
|
||||||
"For best results, use IPC API with KiCAD running."
|
|
||||||
)
|
)
|
||||||
return backend
|
return backend
|
||||||
except (ImportError, APINotAvailableError) as e:
|
except (ImportError, APINotAvailableError) as e:
|
||||||
@@ -160,22 +160,18 @@ def get_available_backends() -> dict:
|
|||||||
# Check IPC (kicad-python uses 'kipy' module name)
|
# Check IPC (kicad-python uses 'kipy' module name)
|
||||||
try:
|
try:
|
||||||
import kipy
|
import kipy
|
||||||
results['ipc'] = {
|
|
||||||
'available': True,
|
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
|
||||||
'version': getattr(kipy, '__version__', 'unknown')
|
|
||||||
}
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
results['ipc'] = {'available': False, 'version': None}
|
results["ipc"] = {"available": False, "version": None}
|
||||||
|
|
||||||
# Check SWIG
|
# Check SWIG
|
||||||
try:
|
try:
|
||||||
import pcbnew
|
import pcbnew
|
||||||
results['swig'] = {
|
|
||||||
'available': True,
|
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
|
||||||
'version': pcbnew.GetBuildVersion()
|
|
||||||
}
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
results['swig'] = {'available': False, 'version': None}
|
results["swig"] = {"available": False, "version": None}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -183,6 +179,7 @@ def get_available_backends() -> dict:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Quick diagnostic
|
# Quick diagnostic
|
||||||
import json
|
import json
|
||||||
|
|
||||||
print("KiCAD Backend Availability:")
|
print("KiCAD Backend Availability:")
|
||||||
print(json.dumps(get_available_backends(), indent=2))
|
print(json.dumps(get_available_backends(), indent=2))
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,14 @@ Key Benefits over SWIG:
|
|||||||
- Stable API that won't break between versions
|
- Stable API that won't break between versions
|
||||||
- Multi-language support
|
- Multi-language support
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List, Callable
|
from typing import Optional, Dict, Any, List, Callable
|
||||||
|
|
||||||
from kicad_api.base import (
|
from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError
|
||||||
KiCADBackend,
|
|
||||||
BoardAPI,
|
|
||||||
ConnectionError,
|
|
||||||
APINotAvailableError
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend):
|
|||||||
# Common socket locations (Unix-like systems only)
|
# Common socket locations (Unix-like systems only)
|
||||||
# Windows uses named pipes, handled by auto-detect
|
# Windows uses named pipes, handled by auto-detect
|
||||||
if platform.system() != "Windows":
|
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)
|
# XDG runtime directory (requires getuid, Unix only)
|
||||||
if hasattr(os, 'getuid'):
|
if hasattr(os, "getuid"):
|
||||||
socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock')
|
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)
|
# Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets)
|
||||||
socket_paths_to_try.append(None)
|
socket_paths_to_try.append(None)
|
||||||
@@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend):
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error("kicad-python library not found")
|
logger.error("kicad-python library not found")
|
||||||
raise APINotAvailableError(
|
raise APINotAvailableError(
|
||||||
"IPC backend requires kicad-python. "
|
"IPC backend requires kicad-python. " "Install with: pip install kicad-python"
|
||||||
"Install with: pip install kicad-python"
|
|
||||||
) from e
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect via IPC: {e}")
|
logger.error(f"Failed to connect via IPC: {e}")
|
||||||
@@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend):
|
|||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Direct project creation not supported via IPC",
|
"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]:
|
def open_project(self, path: Path) -> Dict[str, Any]:
|
||||||
@@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend):
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Project already open: {path}",
|
"message": f"Project already open: {path}",
|
||||||
"path": str(path)
|
"path": str(path),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Project not currently open in KiCAD",
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to check project: {e}")
|
logger.error(f"Failed to check project: {e}")
|
||||||
return {
|
return {"success": False, "message": "Failed to check project", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to check project",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
||||||
"""Save current project via IPC."""
|
"""Save current project via IPC."""
|
||||||
@@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend):
|
|||||||
|
|
||||||
self._notify_change("save", {"path": str(path) if path else "current"})
|
self._notify_change("save", {"path": str(path) if path else "current"})
|
||||||
|
|
||||||
return {
|
return {"success": True, "message": "Project saved successfully"}
|
||||||
"success": True,
|
|
||||||
"message": "Project saved successfully"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save project: {e}")
|
logger.error(f"Failed to save project: {e}")
|
||||||
return {
|
return {"success": False, "message": "Failed to save project", "errorDetails": str(e)}
|
||||||
"success": False,
|
|
||||||
"message": "Failed to save project",
|
|
||||||
"errorDetails": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def close_project(self) -> None:
|
def close_project(self) -> None:
|
||||||
"""Close current project (not supported via IPC)."""
|
"""Close current project (not supported via IPC)."""
|
||||||
@@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
# Find bounding box of edge cuts
|
# Find bounding box of edge cuts
|
||||||
from kipy.util.units import to_mm
|
from kipy.util.units import to_mm
|
||||||
|
|
||||||
min_x = min_y = float('inf')
|
min_x = min_y = float("inf")
|
||||||
max_x = max_y = float('-inf')
|
max_x = max_y = float("-inf")
|
||||||
|
|
||||||
for shape in shapes:
|
for shape in shapes:
|
||||||
# Check if on Edge.Cuts layer
|
# Check if on Edge.Cuts layer
|
||||||
@@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
max_x = max(max_x, bbox.max.x)
|
max_x = max(max_x, bbox.max.x)
|
||||||
max_y = max(max_y, bbox.max.y)
|
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": 0, "height": 0, "unit": "mm"}
|
||||||
|
|
||||||
return {
|
return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"}
|
||||||
"width": to_mm(max_x - min_x),
|
|
||||||
"height": to_mm(max_y - min_y),
|
|
||||||
"unit": "mm"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get board size: {e}")
|
logger.error(f"Failed to get board size: {e}")
|
||||||
@@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
for fp in footprints:
|
for fp in footprints:
|
||||||
try:
|
try:
|
||||||
pos = fp.position
|
pos = fp.position
|
||||||
components.append({
|
components.append(
|
||||||
"reference": fp.reference_field.text.value if fp.reference_field else "",
|
{
|
||||||
"value": fp.value_field.text.value if fp.value_field else "",
|
"reference": (
|
||||||
"footprint": str(fp.definition.library_link) if fp.definition else "",
|
fp.reference_field.text.value if fp.reference_field else ""
|
||||||
"position": {
|
),
|
||||||
"x": to_mm(pos.x) if pos else 0,
|
"value": fp.value_field.text.value if fp.value_field else "",
|
||||||
"y": to_mm(pos.y) if pos else 0,
|
"footprint": str(fp.definition.library_link) if fp.definition else "",
|
||||||
"unit": "mm"
|
"position": {
|
||||||
},
|
"x": to_mm(pos.x) if pos else 0,
|
||||||
"rotation": fp.orientation.degrees if fp.orientation else 0,
|
"y": to_mm(pos.y) if pos else 0,
|
||||||
"layer": str(fp.layer) if hasattr(fp, 'layer') else "F.Cu",
|
"unit": "mm",
|
||||||
"id": str(fp.id) if hasattr(fp, 'id') else ""
|
},
|
||||||
})
|
"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:
|
except Exception as e:
|
||||||
logger.warning(f"Error processing footprint: {e}")
|
logger.warning(f"Error processing footprint: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
y: float,
|
y: float,
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
layer: str = "F.Cu",
|
layer: str = "F.Cu",
|
||||||
value: str = ""
|
value: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Place a component on the board.
|
Place a component on the board.
|
||||||
@@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: Create a basic placeholder footprint via IPC
|
# 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(
|
return self._place_placeholder_footprint(
|
||||||
reference, footprint, x, y, rotation, layer, value
|
reference, footprint, x, y, rotation, layer, value
|
||||||
)
|
)
|
||||||
@@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
import pcbnew
|
import pcbnew
|
||||||
|
|
||||||
# Parse library and footprint name
|
# Parse library and footprint name
|
||||||
if ':' in footprint_path:
|
if ":" in footprint_path:
|
||||||
lib_name, fp_name = footprint_path.split(':', 1)
|
lib_name, fp_name = footprint_path.split(":", 1)
|
||||||
else:
|
else:
|
||||||
# Try to find the footprint in all libraries
|
# Try to find the footprint in all libraries
|
||||||
lib_name = None
|
lib_name = None
|
||||||
@@ -560,14 +546,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _place_loaded_footprint(
|
def _place_loaded_footprint(
|
||||||
self,
|
self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str
|
||||||
loaded_fp,
|
|
||||||
reference: str,
|
|
||||||
x: float,
|
|
||||||
y: float,
|
|
||||||
rotation: float,
|
|
||||||
layer: str,
|
|
||||||
value: str
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Place a loaded pcbnew footprint onto the board.
|
Place a loaded pcbnew footprint onto the board.
|
||||||
@@ -589,7 +568,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
try:
|
try:
|
||||||
docs = self._kicad.get_open_documents()
|
docs = self._kicad.get_open_documents()
|
||||||
for doc in docs:
|
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)
|
board_path = str(doc.path)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -637,24 +616,27 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not refresh IPC board: {e}")
|
logger.debug(f"Could not refresh IPC board: {e}")
|
||||||
|
|
||||||
self._notify("component_placed", {
|
self._notify(
|
||||||
"reference": reference,
|
"component_placed",
|
||||||
"footprint": loaded_fp.GetFPIDAsString(),
|
{
|
||||||
"position": {"x": x, "y": y},
|
"reference": reference,
|
||||||
"rotation": rotation,
|
"footprint": loaded_fp.GetFPIDAsString(),
|
||||||
"layer": layer,
|
"position": {"x": x, "y": y},
|
||||||
"loaded_from_library": True
|
"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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error placing loaded footprint: {e}")
|
logger.error(f"Error placing loaded footprint: {e}")
|
||||||
# Fall back to placeholder
|
# Fall back to placeholder
|
||||||
return self._place_placeholder_footprint(
|
return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value)
|
||||||
reference, "", x, y, rotation, layer, value
|
|
||||||
)
|
|
||||||
|
|
||||||
def _place_placeholder_footprint(
|
def _place_placeholder_footprint(
|
||||||
self,
|
self,
|
||||||
@@ -664,7 +646,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
y: float,
|
y: float,
|
||||||
rotation: float,
|
rotation: float,
|
||||||
layer: str,
|
layer: str,
|
||||||
value: str
|
value: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Place a placeholder footprint when library loading fails.
|
Place a placeholder footprint when library loading fails.
|
||||||
@@ -701,15 +683,18 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
board.create_items(fp)
|
board.create_items(fp)
|
||||||
board.push_commit(commit, f"Placed component {reference}")
|
board.push_commit(commit, f"Placed component {reference}")
|
||||||
|
|
||||||
self._notify("component_placed", {
|
self._notify(
|
||||||
"reference": reference,
|
"component_placed",
|
||||||
"footprint": footprint,
|
{
|
||||||
"position": {"x": x, "y": y},
|
"reference": reference,
|
||||||
"rotation": rotation,
|
"footprint": footprint,
|
||||||
"layer": layer,
|
"position": {"x": x, "y": y},
|
||||||
"loaded_from_library": False,
|
"rotation": rotation,
|
||||||
"is_placeholder": True
|
"layer": layer,
|
||||||
})
|
"loaded_from_library": False,
|
||||||
|
"is_placeholder": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
|
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
|
||||||
return True
|
return True
|
||||||
@@ -718,7 +703,9 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
logger.error(f"Failed to place placeholder component: {e}")
|
logger.error(f"Failed to place placeholder component: {e}")
|
||||||
return False
|
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)."""
|
"""Move a component to a new position (updates UI immediately)."""
|
||||||
try:
|
try:
|
||||||
from kipy.geometry import Vector2, Angle
|
from kipy.geometry import Vector2, Angle
|
||||||
@@ -749,11 +736,10 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
board.update_items([target_fp])
|
board.update_items([target_fp])
|
||||||
board.push_commit(commit, f"Moved component {reference}")
|
board.push_commit(commit, f"Moved component {reference}")
|
||||||
|
|
||||||
self._notify("component_moved", {
|
self._notify(
|
||||||
"reference": reference,
|
"component_moved",
|
||||||
"position": {"x": x, "y": y},
|
{"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation},
|
||||||
"rotation": rotation
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -799,7 +785,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
end_y: float,
|
end_y: float,
|
||||||
width: float = 0.25,
|
width: float = 0.25,
|
||||||
layer: str = "F.Cu",
|
layer: str = "F.Cu",
|
||||||
net_name: Optional[str] = None
|
net_name: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Add a track (trace) to the board.
|
Add a track (trace) to the board.
|
||||||
@@ -842,13 +828,16 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
board.create_items(track)
|
board.create_items(track)
|
||||||
board.push_commit(commit, "Added track")
|
board.push_commit(commit, "Added track")
|
||||||
|
|
||||||
self._notify("track_added", {
|
self._notify(
|
||||||
"start": {"x": start_x, "y": start_y},
|
"track_added",
|
||||||
"end": {"x": end_x, "y": end_y},
|
{
|
||||||
"width": width,
|
"start": {"x": start_x, "y": start_y},
|
||||||
"layer": layer,
|
"end": {"x": end_x, "y": end_y},
|
||||||
"net": net_name
|
"width": width,
|
||||||
})
|
"layer": layer,
|
||||||
|
"net": net_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
|
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
|
||||||
return True
|
return True
|
||||||
@@ -864,7 +853,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
diameter: float = 0.8,
|
diameter: float = 0.8,
|
||||||
drill: float = 0.4,
|
drill: float = 0.4,
|
||||||
net_name: Optional[str] = None,
|
net_name: Optional[str] = None,
|
||||||
via_type: str = "through"
|
via_type: str = "through",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Add a via to the board.
|
Add a via to the board.
|
||||||
@@ -906,13 +895,16 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
board.create_items(via)
|
board.create_items(via)
|
||||||
board.push_commit(commit, "Added via")
|
board.push_commit(commit, "Added via")
|
||||||
|
|
||||||
self._notify("via_added", {
|
self._notify(
|
||||||
"position": {"x": x, "y": y},
|
"via_added",
|
||||||
"diameter": diameter,
|
{
|
||||||
"drill": drill,
|
"position": {"x": x, "y": y},
|
||||||
"net": net_name,
|
"diameter": diameter,
|
||||||
"type": via_type
|
"drill": drill,
|
||||||
})
|
"net": net_name,
|
||||||
|
"type": via_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Added via at ({x}, {y}) mm")
|
logger.info(f"Added via at ({x}, {y}) mm")
|
||||||
return True
|
return True
|
||||||
@@ -928,7 +920,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
y: float,
|
y: float,
|
||||||
layer: str = "F.SilkS",
|
layer: str = "F.SilkS",
|
||||||
size: float = 1.0,
|
size: float = 1.0,
|
||||||
rotation: float = 0
|
rotation: float = 0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Add text to the board."""
|
"""Add text to the board."""
|
||||||
try:
|
try:
|
||||||
@@ -959,11 +951,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
board.create_items(board_text)
|
board.create_items(board_text)
|
||||||
board.push_commit(commit, f"Added text: {text}")
|
board.push_commit(commit, f"Added text: {text}")
|
||||||
|
|
||||||
self._notify("text_added", {
|
self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer})
|
||||||
"text": text,
|
|
||||||
"position": {"x": x, "y": y},
|
|
||||||
"layer": layer
|
|
||||||
})
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -982,20 +970,16 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
result = []
|
result = []
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
try:
|
try:
|
||||||
result.append({
|
result.append(
|
||||||
"start": {
|
{
|
||||||
"x": to_mm(track.start.x),
|
"start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)},
|
||||||
"y": to_mm(track.start.y)
|
"end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)},
|
||||||
},
|
"width": to_mm(track.width),
|
||||||
"end": {
|
"layer": str(track.layer),
|
||||||
"x": to_mm(track.end.x),
|
"net": track.net.name if track.net else "",
|
||||||
"y": to_mm(track.end.y)
|
"id": str(track.id) if hasattr(track, "id") else "",
|
||||||
},
|
}
|
||||||
"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:
|
except Exception as e:
|
||||||
logger.warning(f"Error processing track: {e}")
|
logger.warning(f"Error processing track: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1017,17 +1001,16 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
result = []
|
result = []
|
||||||
for via in vias:
|
for via in vias:
|
||||||
try:
|
try:
|
||||||
result.append({
|
result.append(
|
||||||
"position": {
|
{
|
||||||
"x": to_mm(via.position.x),
|
"position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)},
|
||||||
"y": to_mm(via.position.y)
|
"diameter": to_mm(via.diameter),
|
||||||
},
|
"drill": to_mm(via.drill_diameter),
|
||||||
"diameter": to_mm(via.diameter),
|
"net": via.net.name if via.net else "",
|
||||||
"drill": to_mm(via.drill_diameter),
|
"type": str(via.type),
|
||||||
"net": via.net.name if via.net else "",
|
"id": str(via.id) if hasattr(via, "id") else "",
|
||||||
"type": str(via.type),
|
}
|
||||||
"id": str(via.id) if hasattr(via, 'id') else ""
|
)
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error processing via: {e}")
|
logger.warning(f"Error processing via: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1047,10 +1030,9 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
result = []
|
result = []
|
||||||
for net in nets:
|
for net in nets:
|
||||||
try:
|
try:
|
||||||
result.append({
|
result.append(
|
||||||
"name": net.name,
|
{"name": net.name, "code": net.code if hasattr(net, "code") else 0}
|
||||||
"code": net.code if hasattr(net, 'code') else 0
|
)
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error processing net: {e}")
|
logger.warning(f"Error processing net: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1070,7 +1052,7 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
min_thickness: float = 0.25,
|
min_thickness: float = 0.25,
|
||||||
priority: int = 0,
|
priority: int = 0,
|
||||||
fill_mode: str = "solid",
|
fill_mode: str = "solid",
|
||||||
name: str = ""
|
name: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Add a copper pour zone to the board.
|
Add a copper pour zone to the board.
|
||||||
@@ -1157,12 +1139,10 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
board.create_items(zone)
|
board.create_items(zone)
|
||||||
board.push_commit(commit, f"Added copper zone on {layer}")
|
board.push_commit(commit, f"Added copper zone on {layer}")
|
||||||
|
|
||||||
self._notify("zone_added", {
|
self._notify(
|
||||||
"layer": layer,
|
"zone_added",
|
||||||
"net": net_name,
|
{"layer": layer, "net": net_name, "points": len(points), "priority": priority},
|
||||||
"points": len(points),
|
)
|
||||||
"priority": priority
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Added zone on {layer} with {len(points)} points")
|
logger.info(f"Added zone on {layer} with {len(points)} points")
|
||||||
return True
|
return True
|
||||||
@@ -1182,14 +1162,18 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
result = []
|
result = []
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
try:
|
try:
|
||||||
result.append({
|
result.append(
|
||||||
"name": zone.name if hasattr(zone, 'name') else "",
|
{
|
||||||
"net": zone.net.name if zone.net else "",
|
"name": zone.name if hasattr(zone, "name") else "",
|
||||||
"priority": zone.priority if hasattr(zone, 'priority') else 0,
|
"net": zone.net.name if zone.net else "",
|
||||||
"layers": [str(l) for l in zone.layers] if hasattr(zone, 'layers') else [],
|
"priority": zone.priority if hasattr(zone, "priority") else 0,
|
||||||
"filled": zone.filled if hasattr(zone, 'filled') else False,
|
"layers": (
|
||||||
"id": str(zone.id) if hasattr(zone, 'id') else ""
|
[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:
|
except Exception as e:
|
||||||
logger.warning(f"Error processing zone: {e}")
|
logger.warning(f"Error processing zone: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1219,10 +1203,9 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
|
|
||||||
result = []
|
result = []
|
||||||
for item in selection:
|
for item in selection:
|
||||||
result.append({
|
result.append(
|
||||||
"type": type(item).__name__,
|
{"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""}
|
||||||
"id": str(item.id) if hasattr(item, 'id') else ""
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1241,4 +1224,4 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
|
|
||||||
|
|
||||||
# Export for factory
|
# 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.
|
and will be removed in KiCAD 10.0.
|
||||||
Please migrate to IPC backend.
|
Please migrate to IPC backend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
from kicad_api.base import (
|
from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError
|
||||||
KiCADBackend,
|
|
||||||
BoardAPI,
|
|
||||||
ConnectionError,
|
|
||||||
APINotAvailableError
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import pcbnew
|
import pcbnew
|
||||||
|
|
||||||
self._pcbnew = pcbnew
|
self._pcbnew = pcbnew
|
||||||
version = pcbnew.GetBuildVersion()
|
version = pcbnew.GetBuildVersion()
|
||||||
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
|
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
|
||||||
@@ -191,7 +188,7 @@ class SWIGBoardAPI(BoardAPI):
|
|||||||
x: float,
|
x: float,
|
||||||
y: float,
|
y: float,
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
layer: str = "F.Cu"
|
layer: str = "F.Cu",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Place component using existing implementation"""
|
"""Place component using existing implementation"""
|
||||||
from commands.component import ComponentCommands
|
from commands.component import ComponentCommands
|
||||||
@@ -202,7 +199,7 @@ class SWIGBoardAPI(BoardAPI):
|
|||||||
position={"x": x, "y": y, "unit": "mm"},
|
position={"x": x, "y": y, "unit": "mm"},
|
||||||
reference=reference,
|
reference=reference,
|
||||||
rotation=rotation,
|
rotation=rotation,
|
||||||
layer=layer
|
layer=layer,
|
||||||
)
|
)
|
||||||
return result.get("success", False)
|
return result.get("success", False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ if sys.platform == "win32":
|
|||||||
# List versions
|
# List versions
|
||||||
try:
|
try:
|
||||||
versions = [
|
versions = [
|
||||||
d
|
d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d))
|
||||||
for d in os.listdir(base_path)
|
|
||||||
if os.path.isdir(os.path.join(base_path, d))
|
|
||||||
]
|
]
|
||||||
logger.info(f" Versions found: {', '.join(versions)}")
|
logger.info(f" Versions found: {', '.join(versions)}")
|
||||||
for version in versions:
|
for version in versions:
|
||||||
@@ -92,9 +90,7 @@ paths_added = PlatformHelper.add_kicad_to_python_path()
|
|||||||
if paths_added:
|
if paths_added:
|
||||||
logger.info("Successfully added KiCAD Python paths to sys.path")
|
logger.info("Successfully added KiCAD Python paths to sys.path")
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning("No KiCAD Python paths found - attempting to import pcbnew from system path")
|
||||||
"No KiCAD Python paths found - attempting to import pcbnew from system path"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Current Python path: {sys.path}")
|
logger.info(f"Current Python path: {sys.path}")
|
||||||
|
|
||||||
@@ -434,9 +430,7 @@ class KiCADInterface:
|
|||||||
"check_freerouting": self.freerouting_commands.check_freerouting,
|
"check_freerouting": self.freerouting_commands.check_freerouting,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})")
|
||||||
f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Commands that can be handled via IPC for real-time updates
|
# Commands that can be handled via IPC for real-time updates
|
||||||
IPC_CAPABLE_COMMANDS = {
|
IPC_CAPABLE_COMMANDS = {
|
||||||
@@ -475,11 +469,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if we can use IPC for this command (real-time UI sync)
|
# Check if we can use IPC for this command (real-time UI sync)
|
||||||
if (
|
if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS:
|
||||||
self.use_ipc
|
|
||||||
and self.ipc_board_api
|
|
||||||
and command in self.IPC_CAPABLE_COMMANDS
|
|
||||||
):
|
|
||||||
ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command]
|
ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command]
|
||||||
ipc_handler = getattr(self, ipc_handler_name, None)
|
ipc_handler = getattr(self, ipc_handler_name, None)
|
||||||
|
|
||||||
@@ -602,9 +592,7 @@ class KiCADInterface:
|
|||||||
# - TypeScript tools use: name, path
|
# - TypeScript tools use: name, path
|
||||||
# - Python schema uses: filename, title
|
# - Python schema uses: filename, title
|
||||||
# - Legacy uses: projectName, path, metadata
|
# - Legacy uses: projectName, path, metadata
|
||||||
project_name = (
|
project_name = params.get("projectName") or params.get("name") or params.get("title")
|
||||||
params.get("projectName") or params.get("name") or params.get("title")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle filename parameter - it may contain full path
|
# Handle filename parameter - it may contain full path
|
||||||
filename = params.get("filename")
|
filename = params.get("filename")
|
||||||
@@ -665,13 +653,9 @@ class KiCADInterface:
|
|||||||
board_path = params.get("boardPath")
|
board_path = params.get("boardPath")
|
||||||
if board_path:
|
if board_path:
|
||||||
board_path_norm = str(Path(board_path).resolve())
|
board_path_norm = str(Path(board_path).resolve())
|
||||||
current_board_file = (
|
current_board_file = str(Path(self.board.GetFileName()).resolve()) if self.board else ""
|
||||||
str(Path(self.board.GetFileName()).resolve()) if self.board else ""
|
|
||||||
)
|
|
||||||
if board_path_norm != current_board_file:
|
if board_path_norm != current_board_file:
|
||||||
logger.info(
|
logger.info(f"boardPath differs from current board — reloading: {board_path}")
|
||||||
f"boardPath differs from current board — reloading: {board_path}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
self.board = pcbnew.LoadBoard(board_path)
|
self.board = pcbnew.LoadBoard(board_path)
|
||||||
self._update_command_handlers()
|
self._update_command_handlers()
|
||||||
@@ -689,9 +673,7 @@ class KiCADInterface:
|
|||||||
self._current_project_path = project_path
|
self._current_project_path = project_path
|
||||||
local_lib = FootprintLibraryManager(project_path=project_path)
|
local_lib = FootprintLibraryManager(project_path=project_path)
|
||||||
self.component_commands = ComponentCommands(self.board, local_lib)
|
self.component_commands = ComponentCommands(self.board, local_lib)
|
||||||
logger.info(
|
logger.info(f"Reloaded FootprintLibraryManager with project_path={project_path}")
|
||||||
f"Reloaded FootprintLibraryManager with project_path={project_path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.component_commands.place_component(params)
|
return self.component_commands.place_component(params)
|
||||||
|
|
||||||
@@ -788,9 +770,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
# Skip lib_symbols section
|
# Skip lib_symbols section
|
||||||
lib_sym_pos = content.find("(lib_symbols")
|
lib_sym_pos = content.find("(lib_symbols")
|
||||||
lib_sym_end = (
|
lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||||
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find ALL placed symbol blocks matching the reference (handles duplicates).
|
# Find ALL placed symbol blocks matching the reference (handles duplicates).
|
||||||
# Use content-string search so multi-line KiCAD format is handled correctly:
|
# Use content-string search so multi-line KiCAD format is handled correctly:
|
||||||
@@ -840,9 +820,7 @@ class KiCADInterface:
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
deleted_count = len(blocks_to_delete)
|
deleted_count = len(blocks_to_delete)
|
||||||
logger.info(
|
logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}")
|
||||||
f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}"
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"reference": reference,
|
"reference": reference,
|
||||||
@@ -918,9 +896,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
# Skip lib_symbols section
|
# Skip lib_symbols section
|
||||||
lib_sym_pos = content.find("(lib_symbols")
|
lib_sym_pos = content.find("(lib_symbols")
|
||||||
lib_sym_end = (
|
lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||||
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find placed symbol blocks that match the reference
|
# Find placed symbol blocks that match the reference
|
||||||
# Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...)
|
# Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...)
|
||||||
@@ -1052,9 +1028,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
# Skip lib_symbols section
|
# Skip lib_symbols section
|
||||||
lib_sym_pos = content.find("(lib_symbols")
|
lib_sym_pos = content.find("(lib_symbols")
|
||||||
lib_sym_end = (
|
lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||||
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find the placed symbol block for this reference
|
# Find the placed symbol block for this reference
|
||||||
block_start = block_end = None
|
block_start = block_end = None
|
||||||
@@ -1215,9 +1189,7 @@ class KiCADInterface:
|
|||||||
match = find_nearest_pin(points[-1], snap_tolerance)
|
match = find_nearest_pin(points[-1], snap_tolerance)
|
||||||
if match:
|
if match:
|
||||||
ref, pin_num, coords = match
|
ref, pin_num, coords = match
|
||||||
logger.info(
|
logger.info(f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})")
|
||||||
f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})"
|
|
||||||
)
|
|
||||||
snapped_info.append(
|
snapped_info.append(
|
||||||
f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]"
|
f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]"
|
||||||
)
|
)
|
||||||
@@ -1312,9 +1284,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
def _handle_create_footprint(self, params):
|
def _handle_create_footprint(self, params):
|
||||||
"""Create a new .kicad_mod footprint file in a .pretty library."""
|
"""Create a new .kicad_mod footprint file in a .pretty library."""
|
||||||
logger.info(
|
logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}")
|
||||||
f"create_footprint: {params.get('name')} in {params.get('libraryPath')}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
creator = FootprintCreator()
|
creator = FootprintCreator()
|
||||||
return creator.create_footprint(
|
return creator.create_footprint(
|
||||||
@@ -1358,9 +1328,7 @@ class KiCADInterface:
|
|||||||
logger.info("list_footprint_libraries")
|
logger.info("list_footprint_libraries")
|
||||||
try:
|
try:
|
||||||
creator = FootprintCreator()
|
creator = FootprintCreator()
|
||||||
return creator.list_footprint_libraries(
|
return creator.list_footprint_libraries(search_paths=params.get("searchPaths"))
|
||||||
search_paths=params.get("searchPaths")
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"list_footprint_libraries error: {e}")
|
logger.error(f"list_footprint_libraries error: {e}")
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
@@ -1387,9 +1355,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
def _handle_create_symbol(self, params):
|
def _handle_create_symbol(self, params):
|
||||||
"""Create a new symbol in a .kicad_sym library."""
|
"""Create a new symbol in a .kicad_sym library."""
|
||||||
logger.info(
|
logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}")
|
||||||
f"create_symbol: {params.get('name')} in {params.get('libraryPath')}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
creator = SymbolCreator()
|
creator = SymbolCreator()
|
||||||
return creator.create_symbol(
|
return creator.create_symbol(
|
||||||
@@ -1413,9 +1379,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
def _handle_delete_symbol(self, params):
|
def _handle_delete_symbol(self, params):
|
||||||
"""Delete a symbol from a .kicad_sym library."""
|
"""Delete a symbol from a .kicad_sym library."""
|
||||||
logger.info(
|
logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}")
|
||||||
f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
creator = SymbolCreator()
|
creator = SymbolCreator()
|
||||||
return creator.delete_symbol(
|
return creator.delete_symbol(
|
||||||
@@ -1663,8 +1627,7 @@ class KiCADInterface:
|
|||||||
if pin_num in pins_def:
|
if pin_num in pins_def:
|
||||||
entry["name"] = pins_def[pin_num].get("name", pin_num)
|
entry["name"] = pins_def[pin_num].get("name", pin_num)
|
||||||
entry["angle"] = (
|
entry["angle"] = (
|
||||||
locator.get_pin_angle(Path(schematic_path), reference, pin_num)
|
locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
result[pin_num] = entry
|
result[pin_num] = entry
|
||||||
|
|
||||||
@@ -1747,9 +1710,7 @@ class KiCADInterface:
|
|||||||
"message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg",
|
"message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg",
|
||||||
}
|
}
|
||||||
|
|
||||||
png_data = svg2png(
|
png_data = svg2png(url=svg_path, output_width=width, output_height=height)
|
||||||
url=svg_path, output_width=width, output_height=height
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -1814,15 +1775,9 @@ class KiCADInterface:
|
|||||||
if ref_prefix_filter and not ref.startswith(ref_prefix_filter):
|
if ref_prefix_filter and not ref.startswith(ref_prefix_filter):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
value = (
|
value = symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
|
||||||
symbol.property.Value.value
|
|
||||||
if hasattr(symbol.property, "Value")
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
footprint = (
|
footprint = (
|
||||||
symbol.property.Footprint.value
|
symbol.property.Footprint.value if hasattr(symbol.property, "Footprint") else ""
|
||||||
if hasattr(symbol.property, "Footprint")
|
|
||||||
else ""
|
|
||||||
)
|
)
|
||||||
position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
|
position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
|
||||||
uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else ""
|
uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else ""
|
||||||
@@ -1849,9 +1804,7 @@ class KiCADInterface:
|
|||||||
"position": {"x": coords[0], "y": coords[1]},
|
"position": {"x": coords[0], "y": coords[1]},
|
||||||
}
|
}
|
||||||
if pin_num in pins_def:
|
if pin_num in pins_def:
|
||||||
pin_info["name"] = pins_def[pin_num].get(
|
pin_info["name"] = pins_def[pin_num].get("name", pin_num)
|
||||||
"name", pin_num
|
|
||||||
)
|
|
||||||
pin_list.append(pin_info)
|
pin_list.append(pin_info)
|
||||||
comp["pins"] = pin_list
|
comp["pins"] = pin_list
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2016,9 +1969,7 @@ class KiCADInterface:
|
|||||||
if not ref.startswith("#PWR"):
|
if not ref.startswith("#PWR"):
|
||||||
continue
|
continue
|
||||||
value = (
|
value = (
|
||||||
symbol.property.Value.value
|
symbol.property.Value.value if hasattr(symbol.property, "Value") else ref
|
||||||
if hasattr(symbol.property, "Value")
|
|
||||||
else ref
|
|
||||||
)
|
)
|
||||||
pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
|
pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
|
||||||
labels.append(
|
labels.append(
|
||||||
@@ -2239,9 +2190,7 @@ class KiCADInterface:
|
|||||||
start_point = [start.get("x", 0), start.get("y", 0)]
|
start_point = [start.get("x", 0), start.get("y", 0)]
|
||||||
end_point = [end.get("x", 0), end.get("y", 0)]
|
end_point = [end.get("x", 0), end.get("y", 0)]
|
||||||
|
|
||||||
deleted = WireManager.delete_wire(
|
deleted = WireManager.delete_wire(Path(schematic_path), start_point, end_point)
|
||||||
Path(schematic_path), start_point, end_point
|
|
||||||
)
|
|
||||||
if deleted:
|
if deleted:
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
else:
|
else:
|
||||||
@@ -2453,9 +2402,7 @@ class KiCADInterface:
|
|||||||
"errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.",
|
"errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.",
|
||||||
}
|
}
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||||
mode="w", suffix=".json", delete=False
|
|
||||||
) as tmp:
|
|
||||||
json_output = tmp.name
|
json_output = tmp.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2471,9 +2418,7 @@ class KiCADInterface:
|
|||||||
]
|
]
|
||||||
logger.info(f"Running ERC command: {' '.join(cmd)}")
|
logger.info(f"Running ERC command: {' '.join(cmd)}")
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
cmd, capture_output=True, text=True, timeout=120
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error(f"ERC command failed: {result.stderr}")
|
logger.error(f"ERC command failed: {result.stderr}")
|
||||||
@@ -2542,9 +2487,7 @@ class KiCADInterface:
|
|||||||
if not schematic:
|
if not schematic:
|
||||||
return {"success": False, "message": "Failed to load schematic"}
|
return {"success": False, "message": "Failed to load schematic"}
|
||||||
|
|
||||||
netlist = ConnectionManager.generate_netlist(
|
netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
|
||||||
schematic, schematic_path=schematic_path
|
|
||||||
)
|
|
||||||
return {"success": True, "netlist": netlist}
|
return {"success": True, "netlist": netlist}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating netlist: {str(e)}")
|
logger.error(f"Error generating netlist: {str(e)}")
|
||||||
@@ -2599,9 +2542,7 @@ class KiCADInterface:
|
|||||||
if not schematic:
|
if not schematic:
|
||||||
return {"success": False, "message": "Failed to load schematic"}
|
return {"success": False, "message": "Failed to load schematic"}
|
||||||
|
|
||||||
netlist = ConnectionManager.generate_netlist(
|
netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
|
||||||
schematic, schematic_path=schematic_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build (reference, pad_number) -> net_name map
|
# Build (reference, pad_number) -> net_name map
|
||||||
pad_net_map = {} # {(ref, pin_str): net_name}
|
pad_net_map = {} # {(ref, pin_str): net_name}
|
||||||
@@ -2890,9 +2831,7 @@ class KiCADInterface:
|
|||||||
"message": "Missing required parameters: pcbPath, svgPath",
|
"message": "Missing required parameters: pcbPath, svgPath",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = import_svg_to_pcb(
|
result = import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled)
|
||||||
pcb_path, svg_path, x, y, width, layer, stroke_width, filled
|
|
||||||
)
|
|
||||||
|
|
||||||
# import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file,
|
# import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file,
|
||||||
# bypassing the pcbnew in-memory board object. Any subsequent board.Save()
|
# bypassing the pcbnew in-memory board object. Any subsequent board.Save()
|
||||||
@@ -2950,9 +2889,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
prompt_file = None
|
prompt_file = None
|
||||||
if prompt_text:
|
if prompt_text:
|
||||||
prompt_filename = (
|
prompt_filename = f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md"
|
||||||
f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md"
|
|
||||||
)
|
|
||||||
prompt_file = logs_dir / prompt_filename
|
prompt_file = logs_dir / prompt_filename
|
||||||
prompt_file.write_text(prompt_text, encoding="utf-8")
|
prompt_file.write_text(prompt_text, encoding="utf-8")
|
||||||
logger.info(f"Prompt saved: {prompt_file}")
|
logger.info(f"Prompt saved: {prompt_file}")
|
||||||
@@ -2962,9 +2899,7 @@ class KiCADInterface:
|
|||||||
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
mcp_log_dir = os.path.join(
|
mcp_log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs")
|
||||||
os.environ.get("APPDATA", ""), "Claude", "logs"
|
|
||||||
)
|
|
||||||
elif system == "Darwin":
|
elif system == "Darwin":
|
||||||
mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude")
|
mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude")
|
||||||
else:
|
else:
|
||||||
@@ -2979,15 +2914,11 @@ class KiCADInterface:
|
|||||||
if "Initializing server" in line:
|
if "Initializing server" in line:
|
||||||
session_start = i
|
session_start = i
|
||||||
session_lines = all_lines[session_start:]
|
session_lines = all_lines[session_start:]
|
||||||
log_filename = (
|
log_filename = f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt"
|
||||||
f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt"
|
|
||||||
)
|
|
||||||
mcp_log_dest = logs_dir / log_filename
|
mcp_log_dest = logs_dir / log_filename
|
||||||
with open(mcp_log_dest, "w", encoding="utf-8") as f:
|
with open(mcp_log_dest, "w", encoding="utf-8") as f:
|
||||||
f.writelines(session_lines)
|
f.writelines(session_lines)
|
||||||
logger.info(
|
logger.info(f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)")
|
||||||
f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)"
|
|
||||||
)
|
|
||||||
|
|
||||||
base_name = Path(project_dir).name
|
base_name = Path(project_dir).name
|
||||||
suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p]
|
suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p]
|
||||||
@@ -2996,9 +2927,7 @@ class KiCADInterface:
|
|||||||
snapshots_base.mkdir(exist_ok=True)
|
snapshots_base.mkdir(exist_ok=True)
|
||||||
snapshot_dir = str(snapshots_base / snapshot_name)
|
snapshot_dir = str(snapshots_base / snapshot_name)
|
||||||
|
|
||||||
shutil.copytree(
|
shutil.copytree(project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots"))
|
||||||
project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots")
|
|
||||||
)
|
|
||||||
logger.info(f"Project snapshot saved: {snapshot_dir}")
|
logger.info(f"Project snapshot saved: {snapshot_dir}")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -3077,9 +3006,7 @@ class KiCADInterface:
|
|||||||
}
|
}
|
||||||
self.board.Save(board_path)
|
self.board.Save(board_path)
|
||||||
|
|
||||||
zone_count = (
|
zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
|
||||||
self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run pcbnew zone fill in an isolated subprocess to prevent crashes
|
# Run pcbnew zone fill in an isolated subprocess to prevent crashes
|
||||||
import subprocess, sys, textwrap
|
import subprocess, sys, textwrap
|
||||||
@@ -3117,11 +3044,7 @@ print("ok")
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.",
|
"message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.",
|
||||||
"zoneCount": zone_count,
|
"zoneCount": zone_count,
|
||||||
"details": (
|
"details": (result.stderr[:300] if result.stderr else result.stdout[:300]),
|
||||||
result.stderr[:300]
|
|
||||||
if result.stderr
|
|
||||||
else result.stdout[:300]
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning("Zone fill subprocess timed out after 60s")
|
logger.warning("Zone fill subprocess timed out after 60s")
|
||||||
@@ -3151,16 +3074,8 @@ print("ok")
|
|||||||
net = params.get("net")
|
net = params.get("net")
|
||||||
|
|
||||||
# Handle both dict format and direct x/y
|
# Handle both dict format and direct x/y
|
||||||
start_x = (
|
start_x = start.get("x", 0) if isinstance(start, dict) else params.get("startX", 0)
|
||||||
start.get("x", 0)
|
start_y = start.get("y", 0) if isinstance(start, dict) else params.get("startY", 0)
|
||||||
if isinstance(start, dict)
|
|
||||||
else params.get("startX", 0)
|
|
||||||
)
|
|
||||||
start_y = (
|
|
||||||
start.get("y", 0)
|
|
||||||
if isinstance(start, dict)
|
|
||||||
else params.get("startY", 0)
|
|
||||||
)
|
|
||||||
end_x = end.get("x", 0) if isinstance(end, dict) else params.get("endX", 0)
|
end_x = end.get("x", 0) if isinstance(end, dict) else params.get("endX", 0)
|
||||||
end_y = end.get("y", 0) if isinstance(end, dict) else params.get("endY", 0)
|
end_y = end.get("y", 0) if isinstance(end, dict) else params.get("endY", 0)
|
||||||
|
|
||||||
@@ -3177,9 +3092,7 @@ print("ok")
|
|||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": (
|
"message": (
|
||||||
"Added trace (visible in KiCAD UI)"
|
"Added trace (visible in KiCAD UI)" if success else "Failed to add trace"
|
||||||
if success
|
|
||||||
else "Failed to add trace"
|
|
||||||
),
|
),
|
||||||
"trace": {
|
"trace": {
|
||||||
"start": {"x": start_x, "y": start_y, "unit": "mm"},
|
"start": {"x": start_x, "y": start_y, "unit": "mm"},
|
||||||
@@ -3197,16 +3110,8 @@ print("ok")
|
|||||||
"""IPC handler for add_via - adds via with real-time UI update"""
|
"""IPC handler for add_via - adds via with real-time UI update"""
|
||||||
try:
|
try:
|
||||||
position = params.get("position", {})
|
position = params.get("position", {})
|
||||||
x = (
|
x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
|
||||||
position.get("x", 0)
|
y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0)
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("x", 0)
|
|
||||||
)
|
|
||||||
y = (
|
|
||||||
position.get("y", 0)
|
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("y", 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
size = params.get("size", 0.8)
|
size = params.get("size", 0.8)
|
||||||
drill = params.get("drill", 0.4)
|
drill = params.get("drill", 0.4)
|
||||||
@@ -3220,11 +3125,7 @@ print("ok")
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": (
|
"message": ("Added via (visible in KiCAD UI)" if success else "Failed to add via"),
|
||||||
"Added via (visible in KiCAD UI)"
|
|
||||||
if success
|
|
||||||
else "Failed to add via"
|
|
||||||
),
|
|
||||||
"via": {
|
"via": {
|
||||||
"position": {"x": x, "y": y, "unit": "mm"},
|
"position": {"x": x, "y": y, "unit": "mm"},
|
||||||
"size": size,
|
"size": size,
|
||||||
@@ -3271,9 +3172,7 @@ print("ok")
|
|||||||
# Convert points format if needed (handle both {x, y} and {x, y, unit})
|
# Convert points format if needed (handle both {x, y} and {x, y, unit})
|
||||||
formatted_points = []
|
formatted_points = []
|
||||||
for point in points:
|
for point in points:
|
||||||
formatted_points.append(
|
formatted_points.append({"x": point.get("x", 0), "y": point.get("y", 0)})
|
||||||
{"x": point.get("x", 0), "y": point.get("y", 0)}
|
|
||||||
)
|
|
||||||
|
|
||||||
success = self.ipc_board_api.add_zone(
|
success = self.ipc_board_api.add_zone(
|
||||||
points=formatted_points,
|
points=formatted_points,
|
||||||
@@ -3315,9 +3214,7 @@ print("ok")
|
|||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": (
|
"message": (
|
||||||
"Zones refilled (visible in KiCAD UI)"
|
"Zones refilled (visible in KiCAD UI)" if success else "Failed to refill zones"
|
||||||
if success
|
|
||||||
else "Failed to refill zones"
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -3329,16 +3226,8 @@ print("ok")
|
|||||||
try:
|
try:
|
||||||
text = params.get("text", "")
|
text = params.get("text", "")
|
||||||
position = params.get("position", {})
|
position = params.get("position", {})
|
||||||
x = (
|
x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
|
||||||
position.get("x", 0)
|
y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0)
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("x", 0)
|
|
||||||
)
|
|
||||||
y = (
|
|
||||||
position.get("y", 0)
|
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("y", 0)
|
|
||||||
)
|
|
||||||
layer = params.get("layer", "F.SilkS")
|
layer = params.get("layer", "F.SilkS")
|
||||||
size = params.get("size", 1.0)
|
size = params.get("size", 1.0)
|
||||||
rotation = params.get("rotation", 0)
|
rotation = params.get("rotation", 0)
|
||||||
@@ -3412,16 +3301,8 @@ print("ok")
|
|||||||
reference = params.get("reference", params.get("componentId", ""))
|
reference = params.get("reference", params.get("componentId", ""))
|
||||||
footprint = params.get("footprint", "")
|
footprint = params.get("footprint", "")
|
||||||
position = params.get("position", {})
|
position = params.get("position", {})
|
||||||
x = (
|
x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
|
||||||
position.get("x", 0)
|
y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0)
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("x", 0)
|
|
||||||
)
|
|
||||||
y = (
|
|
||||||
position.get("y", 0)
|
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("y", 0)
|
|
||||||
)
|
|
||||||
rotation = params.get("rotation", 0)
|
rotation = params.get("rotation", 0)
|
||||||
layer = params.get("layer", "F.Cu")
|
layer = params.get("layer", "F.Cu")
|
||||||
value = params.get("value", "")
|
value = params.get("value", "")
|
||||||
@@ -3460,16 +3341,8 @@ print("ok")
|
|||||||
try:
|
try:
|
||||||
reference = params.get("reference", params.get("componentId", ""))
|
reference = params.get("reference", params.get("componentId", ""))
|
||||||
position = params.get("position", {})
|
position = params.get("position", {})
|
||||||
x = (
|
x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
|
||||||
position.get("x", 0)
|
y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0)
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("x", 0)
|
|
||||||
)
|
|
||||||
y = (
|
|
||||||
position.get("y", 0)
|
|
||||||
if isinstance(position, dict)
|
|
||||||
else params.get("y", 0)
|
|
||||||
)
|
|
||||||
rotation = params.get("rotation")
|
rotation = params.get("rotation")
|
||||||
|
|
||||||
success = self.ipc_board_api.move_component(
|
success = self.ipc_board_api.move_component(
|
||||||
@@ -3534,9 +3407,7 @@ print("ok")
|
|||||||
"""IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet"""
|
"""IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet"""
|
||||||
# IPC API doesn't have a direct delete track method
|
# IPC API doesn't have a direct delete track method
|
||||||
# Fall back to SWIG for this operation
|
# Fall back to SWIG for this operation
|
||||||
logger.info(
|
logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
|
||||||
"delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)"
|
|
||||||
)
|
|
||||||
return self.routing_commands.delete_trace(params)
|
return self.routing_commands.delete_trace(params)
|
||||||
|
|
||||||
def _ipc_get_nets_list(self, params):
|
def _ipc_get_nets_list(self, params):
|
||||||
@@ -3594,9 +3465,7 @@ print("ok")
|
|||||||
segment.start = Vector2.from_xy(
|
segment.start = Vector2.from_xy(
|
||||||
from_mm(start.get("x", 0)), from_mm(start.get("y", 0))
|
from_mm(start.get("x", 0)), from_mm(start.get("y", 0))
|
||||||
)
|
)
|
||||||
segment.end = Vector2.from_xy(
|
segment.end = Vector2.from_xy(from_mm(end.get("x", 0)), from_mm(end.get("y", 0)))
|
||||||
from_mm(end.get("x", 0)), from_mm(end.get("y", 0))
|
|
||||||
)
|
|
||||||
segment.layer = BoardLayer.BL_Edge_Cuts
|
segment.layer = BoardLayer.BL_Edge_Cuts
|
||||||
segment.attributes.stroke.width = from_mm(width)
|
segment.attributes.stroke.width = from_mm(width)
|
||||||
|
|
||||||
@@ -3731,9 +3600,7 @@ print("ok")
|
|||||||
"success": True,
|
"success": True,
|
||||||
"backend": "ipc" if self.use_ipc else "swig",
|
"backend": "ipc" if self.use_ipc else "swig",
|
||||||
"realtime_sync": self.use_ipc,
|
"realtime_sync": self.use_ipc,
|
||||||
"ipc_connected": (
|
"ipc_connected": (self.ipc_backend.is_connected() if self.ipc_backend else False),
|
||||||
self.ipc_backend.is_connected() if self.ipc_backend else False
|
|
||||||
),
|
|
||||||
"version": self.ipc_backend.get_version() if self.ipc_backend else "N/A",
|
"version": self.ipc_backend.get_version() if self.ipc_backend else "N/A",
|
||||||
"message": (
|
"message": (
|
||||||
"Using IPC backend with real-time UI sync"
|
"Using IPC backend with real-time UI sync"
|
||||||
@@ -3760,9 +3627,7 @@ print("ok")
|
|||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": (
|
"message": (
|
||||||
"Track added (visible in KiCAD UI)"
|
"Track added (visible in KiCAD UI)" if success else "Failed to add track"
|
||||||
if success
|
|
||||||
else "Failed to add track"
|
|
||||||
),
|
),
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
}
|
}
|
||||||
@@ -3786,11 +3651,7 @@ print("ok")
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": (
|
"message": ("Via added (visible in KiCAD UI)" if success else "Failed to add via"),
|
||||||
"Via added (visible in KiCAD UI)"
|
|
||||||
if success
|
|
||||||
else "Failed to add via"
|
|
||||||
),
|
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -3814,9 +3675,7 @@ print("ok")
|
|||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": (
|
"message": (
|
||||||
"Text added (visible in KiCAD UI)"
|
"Text added (visible in KiCAD UI)" if success else "Failed to add text"
|
||||||
if success
|
|
||||||
else "Failed to add text"
|
|
||||||
),
|
),
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
}
|
}
|
||||||
@@ -3979,9 +3838,7 @@ print("ok")
|
|||||||
return {"success": False, "message": f"Part not found: {lcsc_number}"}
|
return {"success": False, "message": f"Part not found: {lcsc_number}"}
|
||||||
|
|
||||||
# Get suggested KiCAD footprints
|
# Get suggested KiCAD footprints
|
||||||
footprints = self.jlcpcb_parts.map_package_to_footprint(
|
footprints = self.jlcpcb_parts.map_package_to_footprint(part.get("package", ""))
|
||||||
part.get("package", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"success": True, "part": part, "footprints": footprints}
|
return {"success": True, "part": part, "footprints": footprints}
|
||||||
|
|
||||||
@@ -4013,9 +3870,7 @@ print("ok")
|
|||||||
reference_price = None
|
reference_price = None
|
||||||
if original_part and original_part.get("price_breaks"):
|
if original_part and original_part.get("price_breaks"):
|
||||||
try:
|
try:
|
||||||
reference_price = float(
|
reference_price = float(original_part["price_breaks"][0].get("price", 0))
|
||||||
original_part["price_breaks"][0].get("price", 0)
|
|
||||||
)
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -4141,9 +3996,7 @@ def main():
|
|||||||
tools.append(tool_def)
|
tools.append(tool_def)
|
||||||
else:
|
else:
|
||||||
# Fallback for tools without schemas
|
# Fallback for tools without schemas
|
||||||
logger.warning(
|
logger.warning(f"No schema defined for tool: {cmd_name}")
|
||||||
f"No schema defined for tool: {cmd_name}"
|
|
||||||
)
|
|
||||||
tools.append(
|
tools.append(
|
||||||
{
|
{
|
||||||
"name": cmd_name,
|
"name": cmd_name,
|
||||||
@@ -4172,11 +4025,7 @@ def main():
|
|||||||
response = {
|
response = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"result": {
|
"result": {"content": [{"type": "text", "text": json.dumps(result)}]},
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": json.dumps(result)}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
elif method == "resources/list":
|
elif method == "resources/list":
|
||||||
logger.info("Handling MCP resources/list")
|
logger.info("Handling MCP resources/list")
|
||||||
@@ -4201,9 +4050,7 @@ def main():
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Read the resource
|
# Read the resource
|
||||||
resource_data = handle_resource_read(
|
resource_data = handle_resource_read(resource_uri, interface)
|
||||||
resource_uri, interface
|
|
||||||
)
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ logger = logging.getLogger("kicad_interface")
|
|||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
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
|
Parse a .kicad_mod file and return a dict whose keys match the fields
|
||||||
@@ -56,7 +57,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
|||||||
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
||||||
if not m:
|
if not m:
|
||||||
# Older / unquoted format
|
# Older / unquoted format
|
||||||
m = re.search(r'^\s*\(footprint\s+(\S+)', content, re.MULTILINE)
|
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
|
||||||
result["name"] = _unescape(m.group(1)) if m else path.stem
|
result["name"] = _unescape(m.group(1)) if m else path.stem
|
||||||
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
|||||||
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
||||||
# TYPE is smd | through_hole (no quotes)
|
# TYPE is smd | through_hole (no quotes)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
m = re.search(r'\(attr\s+([^)]+)\)', content)
|
m = re.search(r"\(attr\s+([^)]+)\)", content)
|
||||||
if m:
|
if m:
|
||||||
tokens = m.group(1).split()
|
tokens = m.group(1).split()
|
||||||
result["attributes"] = {
|
result["attributes"] = {
|
||||||
@@ -107,7 +108,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
|||||||
layers: set = set()
|
layers: set = set()
|
||||||
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
||||||
layers.add(m.group(1))
|
layers.add(m.group(1))
|
||||||
for m in re.finditer(r'\(layers\s+([^)]+)\)', content):
|
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
|
||||||
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
||||||
layers.add(lyr)
|
layers.add(lyr)
|
||||||
result["layers"] = sorted(layers)
|
result["layers"] = sorted(layers)
|
||||||
@@ -128,6 +129,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
|||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Parse all (pad …) blocks and return a list of pad objects.
|
Parse all (pad …) blocks and return a list of pad objects.
|
||||||
@@ -147,8 +149,8 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
|||||||
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
||||||
quoted_pattern = re.compile(
|
quoted_pattern = re.compile(
|
||||||
r'\(pad\s+"([^"]*)"\s+'
|
r'\(pad\s+"([^"]*)"\s+'
|
||||||
r'(thru_hole|smd|np_thru_hole|connect)\s+'
|
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||||
r'(rect|circle|oval|roundrect|trapezoid|custom)\b'
|
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||||
)
|
)
|
||||||
for m in quoted_pattern.finditer(content):
|
for m in quoted_pattern.finditer(content):
|
||||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||||
@@ -159,9 +161,9 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
|||||||
if not pads:
|
if not pads:
|
||||||
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
||||||
unquoted_pattern = re.compile(
|
unquoted_pattern = re.compile(
|
||||||
r'\(pad\s+(\S+)\s+'
|
r"\(pad\s+(\S+)\s+"
|
||||||
r'(thru_hole|smd|np_thru_hole|connect)\s+'
|
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||||
r'(rect|circle|oval|roundrect|trapezoid|custom)\b'
|
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||||
)
|
)
|
||||||
for m in unquoted_pattern.finditer(content):
|
for m in unquoted_pattern.finditer(content):
|
||||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||||
@@ -183,7 +185,7 @@ def _extract_blocks(content: str, token: str) -> List[str]:
|
|||||||
parenthesis depth. This correctly handles nested parens inside blocks.
|
parenthesis depth. This correctly handles nested parens inside blocks.
|
||||||
"""
|
"""
|
||||||
blocks: List[str] = []
|
blocks: List[str] = []
|
||||||
pattern = re.compile(r'\(' + re.escape(token) + r'\b')
|
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
|
||||||
|
|
||||||
for match in pattern.finditer(content):
|
for match in pattern.finditer(content):
|
||||||
start = match.start()
|
start = match.start()
|
||||||
@@ -191,9 +193,9 @@ def _extract_blocks(content: str, token: str) -> List[str]:
|
|||||||
i = start
|
i = start
|
||||||
while i < len(content):
|
while i < len(content):
|
||||||
ch = content[i]
|
ch = content[i]
|
||||||
if ch == '(':
|
if ch == "(":
|
||||||
depth += 1
|
depth += 1
|
||||||
elif ch == ')':
|
elif ch == ")":
|
||||||
depth -= 1
|
depth -= 1
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
blocks.append(content[start : i + 1])
|
blocks.append(content[start : i + 1])
|
||||||
@@ -219,8 +221,8 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
|||||||
for block in _extract_blocks(content, "fp_rect"):
|
for block in _extract_blocks(content, "fp_rect"):
|
||||||
if "F.CrtYd" not in block:
|
if "F.CrtYd" not in block:
|
||||||
continue
|
continue
|
||||||
s = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', block)
|
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||||
e = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', block)
|
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||||
if s and e:
|
if s and e:
|
||||||
xs += [float(s.group(1)), float(e.group(1))]
|
xs += [float(s.group(1)), float(e.group(1))]
|
||||||
ys += [float(s.group(2)), float(e.group(2))]
|
ys += [float(s.group(2)), float(e.group(2))]
|
||||||
@@ -234,7 +236,7 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
|||||||
for block in _extract_blocks(content, "fp_line"):
|
for block in _extract_blocks(content, "fp_line"):
|
||||||
if "F.CrtYd" not in block:
|
if "F.CrtYd" not in block:
|
||||||
continue
|
continue
|
||||||
for m in re.finditer(r'\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)', block):
|
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
|
||||||
xs.append(float(m.group(1)))
|
xs.append(float(m.group(1)))
|
||||||
ys.append(float(m.group(2)))
|
ys.append(float(m.group(2)))
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ Resource definitions for KiCAD MCP Server
|
|||||||
|
|
||||||
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
||||||
|
|
||||||
__all__ = ['RESOURCE_DEFINITIONS', 'handle_resource_read']
|
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import base64
|
|||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger('kicad_interface')
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RESOURCE DEFINITIONS
|
# RESOURCE DEFINITIONS
|
||||||
@@ -21,56 +21,57 @@ RESOURCE_DEFINITIONS = [
|
|||||||
"uri": "kicad://project/current/info",
|
"uri": "kicad://project/current/info",
|
||||||
"name": "Current Project Information",
|
"name": "Current Project Information",
|
||||||
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
|
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
|
||||||
"mimeType": "application/json"
|
"mimeType": "application/json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "kicad://project/current/board",
|
"uri": "kicad://project/current/board",
|
||||||
"name": "Board Properties",
|
"name": "Board Properties",
|
||||||
"description": "Comprehensive board information including dimensions, layer count, and design rules",
|
"description": "Comprehensive board information including dimensions, layer count, and design rules",
|
||||||
"mimeType": "application/json"
|
"mimeType": "application/json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "kicad://project/current/components",
|
"uri": "kicad://project/current/components",
|
||||||
"name": "Component List",
|
"name": "Component List",
|
||||||
"description": "List of all components on the board with references, footprints, values, and positions",
|
"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",
|
"uri": "kicad://project/current/nets",
|
||||||
"name": "Electrical Nets",
|
"name": "Electrical Nets",
|
||||||
"description": "List of all electrical nets and their connections",
|
"description": "List of all electrical nets and their connections",
|
||||||
"mimeType": "application/json"
|
"mimeType": "application/json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "kicad://project/current/layers",
|
"uri": "kicad://project/current/layers",
|
||||||
"name": "Layer Stack",
|
"name": "Layer Stack",
|
||||||
"description": "Board layer configuration and properties",
|
"description": "Board layer configuration and properties",
|
||||||
"mimeType": "application/json"
|
"mimeType": "application/json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "kicad://project/current/design-rules",
|
"uri": "kicad://project/current/design-rules",
|
||||||
"name": "Design Rules",
|
"name": "Design Rules",
|
||||||
"description": "Current design rule settings for clearances, track widths, and constraints",
|
"description": "Current design rule settings for clearances, track widths, and constraints",
|
||||||
"mimeType": "application/json"
|
"mimeType": "application/json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "kicad://project/current/drc-report",
|
"uri": "kicad://project/current/drc-report",
|
||||||
"name": "DRC Violations",
|
"name": "DRC Violations",
|
||||||
"description": "Design Rule Check violations and warnings from last DRC run",
|
"description": "Design Rule Check violations and warnings from last DRC run",
|
||||||
"mimeType": "application/json"
|
"mimeType": "application/json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uri": "kicad://board/preview.png",
|
"uri": "kicad://board/preview.png",
|
||||||
"name": "Board Preview Image",
|
"name": "Board Preview Image",
|
||||||
"description": "2D rendering of the current board state",
|
"description": "2D rendering of the current board state",
|
||||||
"mimeType": "image/png"
|
"mimeType": "image/png",
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RESOURCE READ HANDLERS
|
# RESOURCE READ HANDLERS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Handle reading a resource by URI
|
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/layers": _get_layers,
|
||||||
"kicad://project/current/design-rules": _get_design_rules,
|
"kicad://project/current/design-rules": _get_design_rules,
|
||||||
"kicad://project/current/drc-report": _get_drc_report,
|
"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)
|
handler = handlers.get(uri)
|
||||||
@@ -102,67 +103,71 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading resource {uri}: {str(e)}")
|
logger.error(f"Error reading resource {uri}: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
|
||||||
"uri": uri,
|
|
||||||
"mimeType": "text/plain",
|
|
||||||
"text": f"Error: {str(e)}"
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
|
||||||
"uri": uri,
|
|
||||||
"mimeType": "text/plain",
|
|
||||||
"text": f"Unknown resource: {uri}"
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# INDIVIDUAL RESOURCE HANDLERS
|
# INDIVIDUAL RESOURCE HANDLERS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _get_project_info(interface) -> Dict[str, Any]:
|
def _get_project_info(interface) -> Dict[str, Any]:
|
||||||
"""Get current project information"""
|
"""Get current project information"""
|
||||||
result = interface.project_commands.get_project_info({})
|
result = interface.project_commands.get_project_info({})
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/info",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/info",
|
||||||
"text": json.dumps(result.get("project", {}), indent=2)
|
"mimeType": "application/json",
|
||||||
}]
|
"text": json.dumps(result.get("project", {}), indent=2),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/info",
|
{
|
||||||
"mimeType": "text/plain",
|
"uri": "kicad://project/current/info",
|
||||||
"text": "No project currently open"
|
"mimeType": "text/plain",
|
||||||
}]
|
"text": "No project currently open",
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_board_info(interface) -> Dict[str, Any]:
|
def _get_board_info(interface) -> Dict[str, Any]:
|
||||||
"""Get board properties and metadata"""
|
"""Get board properties and metadata"""
|
||||||
result = interface.board_commands.get_board_info({})
|
result = interface.board_commands.get_board_info({})
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/board",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/board",
|
||||||
"text": json.dumps(result.get("board", {}), indent=2)
|
"mimeType": "application/json",
|
||||||
}]
|
"text": json.dumps(result.get("board", {}), indent=2),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/board",
|
{
|
||||||
"mimeType": "text/plain",
|
"uri": "kicad://project/current/board",
|
||||||
"text": "No board currently loaded"
|
"mimeType": "text/plain",
|
||||||
}]
|
"text": "No board currently loaded",
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_components(interface) -> Dict[str, Any]:
|
def _get_components(interface) -> Dict[str, Any]:
|
||||||
"""Get list of all components"""
|
"""Get list of all components"""
|
||||||
result = interface.component_commands.get_component_list({})
|
result = interface.component_commands.get_component_list({})
|
||||||
@@ -170,24 +175,28 @@ def _get_components(interface) -> Dict[str, Any]:
|
|||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
components = result.get("components", [])
|
components = result.get("components", [])
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/components",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/components",
|
||||||
"text": json.dumps({
|
"mimeType": "application/json",
|
||||||
"count": len(components),
|
"text": json.dumps(
|
||||||
"components": components
|
{"count": len(components), "components": components}, indent=2
|
||||||
}, indent=2)
|
),
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/components",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/components",
|
||||||
"text": json.dumps({"count": 0, "components": []}, indent=2)
|
"mimeType": "application/json",
|
||||||
}]
|
"text": json.dumps({"count": 0, "components": []}, indent=2),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_nets(interface) -> Dict[str, Any]:
|
def _get_nets(interface) -> Dict[str, Any]:
|
||||||
"""Get list of electrical nets"""
|
"""Get list of electrical nets"""
|
||||||
result = interface.routing_commands.get_nets_list({})
|
result = interface.routing_commands.get_nets_list({})
|
||||||
@@ -195,24 +204,26 @@ def _get_nets(interface) -> Dict[str, Any]:
|
|||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
nets = result.get("nets", [])
|
nets = result.get("nets", [])
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/nets",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/nets",
|
||||||
"text": json.dumps({
|
"mimeType": "application/json",
|
||||||
"count": len(nets),
|
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
|
||||||
"nets": nets
|
}
|
||||||
}, indent=2)
|
]
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/nets",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/nets",
|
||||||
"text": json.dumps({"count": 0, "nets": []}, indent=2)
|
"mimeType": "application/json",
|
||||||
}]
|
"text": json.dumps({"count": 0, "nets": []}, indent=2),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_layers(interface) -> Dict[str, Any]:
|
def _get_layers(interface) -> Dict[str, Any]:
|
||||||
"""Get layer stack information"""
|
"""Get layer stack information"""
|
||||||
result = interface.board_commands.get_layer_list({})
|
result = interface.board_commands.get_layer_list({})
|
||||||
@@ -220,45 +231,52 @@ def _get_layers(interface) -> Dict[str, Any]:
|
|||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
layers = result.get("layers", [])
|
layers = result.get("layers", [])
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/layers",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/layers",
|
||||||
"text": json.dumps({
|
"mimeType": "application/json",
|
||||||
"count": len(layers),
|
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
|
||||||
"layers": layers
|
}
|
||||||
}, indent=2)
|
]
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/layers",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/layers",
|
||||||
"text": json.dumps({"count": 0, "layers": []}, indent=2)
|
"mimeType": "application/json",
|
||||||
}]
|
"text": json.dumps({"count": 0, "layers": []}, indent=2),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_design_rules(interface) -> Dict[str, Any]:
|
def _get_design_rules(interface) -> Dict[str, Any]:
|
||||||
"""Get design rule settings"""
|
"""Get design rule settings"""
|
||||||
result = interface.design_rule_commands.get_design_rules({})
|
result = interface.design_rule_commands.get_design_rules({})
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/design-rules",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/design-rules",
|
||||||
"text": json.dumps(result.get("rules", {}), indent=2)
|
"mimeType": "application/json",
|
||||||
}]
|
"text": json.dumps(result.get("rules", {}), indent=2),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/design-rules",
|
{
|
||||||
"mimeType": "text/plain",
|
"uri": "kicad://project/current/design-rules",
|
||||||
"text": "Design rules not available"
|
"mimeType": "text/plain",
|
||||||
}]
|
"text": "Design rules not available",
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_drc_report(interface) -> Dict[str, Any]:
|
def _get_drc_report(interface) -> Dict[str, Any]:
|
||||||
"""Get DRC violations"""
|
"""Get DRC violations"""
|
||||||
result = interface.design_rule_commands.get_drc_violations({})
|
result = interface.design_rule_commands.get_drc_violations({})
|
||||||
@@ -266,28 +284,35 @@ def _get_drc_report(interface) -> Dict[str, Any]:
|
|||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
violations = result.get("violations", [])
|
violations = result.get("violations", [])
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/drc-report",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/drc-report",
|
||||||
"text": json.dumps({
|
"mimeType": "application/json",
|
||||||
"count": len(violations),
|
"text": json.dumps(
|
||||||
"violations": violations
|
{"count": len(violations), "violations": violations}, indent=2
|
||||||
}, indent=2)
|
),
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://project/current/drc-report",
|
{
|
||||||
"mimeType": "application/json",
|
"uri": "kicad://project/current/drc-report",
|
||||||
"text": json.dumps({
|
"mimeType": "application/json",
|
||||||
"count": 0,
|
"text": json.dumps(
|
||||||
"violations": [],
|
{
|
||||||
"message": "Run DRC first to get violations"
|
"count": 0,
|
||||||
}, indent=2)
|
"violations": [],
|
||||||
}]
|
"message": "Run DRC first to get violations",
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_board_preview(interface) -> Dict[str, Any]:
|
def _get_board_preview(interface) -> Dict[str, Any]:
|
||||||
"""Get board preview as PNG image"""
|
"""Get board preview as PNG image"""
|
||||||
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
|
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 should already be base64 encoded
|
||||||
image_data = result.get("imageData", "")
|
image_data = result.get("imageData", "")
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://board/preview.png",
|
{
|
||||||
"mimeType": "image/png",
|
"uri": "kicad://board/preview.png",
|
||||||
"blob": image_data # Base64 encoded PNG
|
"mimeType": "image/png",
|
||||||
}]
|
"blob": image_data, # Base64 encoded PNG
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Return a placeholder message
|
# Return a placeholder message
|
||||||
return {
|
return {
|
||||||
"contents": [{
|
"contents": [
|
||||||
"uri": "kicad://board/preview.png",
|
{
|
||||||
"mimeType": "text/plain",
|
"uri": "kicad://board/preview.png",
|
||||||
"text": "Board preview not available"
|
"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
|
from .tool_schemas import TOOL_SCHEMAS
|
||||||
|
|
||||||
__all__ = ['TOOL_SCHEMAS']
|
__all__ = ["TOOL_SCHEMAS"]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Prerequisites:
|
|||||||
Usage:
|
Usage:
|
||||||
./venv/bin/python python/test_ipc_backend.py
|
./venv/bin/python python/test_ipc_backend.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -23,17 +24,16 @@ import logging
|
|||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def test_connection():
|
def test_connection():
|
||||||
"""Test basic IPC connection to KiCAD."""
|
"""Test basic IPC connection to KiCAD."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TEST 1: IPC Connection")
|
print("TEST 1: IPC Connection")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from kicad_api.ipc_backend import IPCBackend
|
from kicad_api.ipc_backend import IPCBackend
|
||||||
@@ -64,9 +64,9 @@ def test_connection():
|
|||||||
|
|
||||||
def test_board_access(backend):
|
def test_board_access(backend):
|
||||||
"""Test board access and component listing."""
|
"""Test board access and component listing."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TEST 2: Board Access")
|
print("TEST 2: Board Access")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
board_api = backend.get_board()
|
board_api = backend.get_board()
|
||||||
@@ -79,11 +79,11 @@ def test_board_access(backend):
|
|||||||
if components:
|
if components:
|
||||||
print("\n First 5 components:")
|
print("\n First 5 components:")
|
||||||
for comp in components[:5]:
|
for comp in components[:5]:
|
||||||
ref = comp.get('reference', 'N/A')
|
ref = comp.get("reference", "N/A")
|
||||||
val = comp.get('value', 'N/A')
|
val = comp.get("value", "N/A")
|
||||||
pos = comp.get('position', {})
|
pos = comp.get("position", {})
|
||||||
x = pos.get('x', 0)
|
x = pos.get("x", 0)
|
||||||
y = pos.get('y', 0)
|
y = pos.get("y", 0)
|
||||||
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
|
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
|
||||||
|
|
||||||
return board_api
|
return board_api
|
||||||
@@ -95,9 +95,9 @@ def test_board_access(backend):
|
|||||||
|
|
||||||
def test_board_info(board_api):
|
def test_board_info(board_api):
|
||||||
"""Test getting board information."""
|
"""Test getting board information."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TEST 3: Board Information")
|
print("TEST 3: Board Information")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get board size
|
# Get board size
|
||||||
@@ -136,28 +136,23 @@ def test_board_info(board_api):
|
|||||||
|
|
||||||
def test_realtime_track(board_api, interactive=False):
|
def test_realtime_track(board_api, interactive=False):
|
||||||
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
|
"""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("TEST 4: Real-time Track Addition")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
|
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
|
||||||
print("Watch the KiCAD window!")
|
print("Watch the KiCAD window!")
|
||||||
|
|
||||||
if interactive:
|
if interactive:
|
||||||
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
|
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
|
||||||
if response != 'y':
|
if response != "y":
|
||||||
print("Skipped track test")
|
print("Skipped track test")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Add a track
|
# Add a track
|
||||||
success = board_api.add_track(
|
success = board_api.add_track(
|
||||||
start_x=100.0,
|
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
|
||||||
start_y=100.0,
|
|
||||||
end_x=120.0,
|
|
||||||
end_y=100.0,
|
|
||||||
width=0.25,
|
|
||||||
layer="F.Cu"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -175,28 +170,22 @@ def test_realtime_track(board_api, interactive=False):
|
|||||||
|
|
||||||
def test_realtime_via(board_api, interactive=False):
|
def test_realtime_via(board_api, interactive=False):
|
||||||
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
|
"""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("TEST 5: Real-time Via Addition")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
|
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
|
||||||
print("Watch the KiCAD window!")
|
print("Watch the KiCAD window!")
|
||||||
|
|
||||||
if interactive:
|
if interactive:
|
||||||
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
|
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
|
||||||
if response != 'y':
|
if response != "y":
|
||||||
print("Skipped via test")
|
print("Skipped via test")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Add a via
|
# Add a via
|
||||||
success = board_api.add_via(
|
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
|
||||||
x=120.0,
|
|
||||||
y=100.0,
|
|
||||||
diameter=0.8,
|
|
||||||
drill=0.4,
|
|
||||||
via_type="through"
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
|
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
|
||||||
@@ -213,26 +202,20 @@ def test_realtime_via(board_api, interactive=False):
|
|||||||
|
|
||||||
def test_realtime_text(board_api, interactive=False):
|
def test_realtime_text(board_api, interactive=False):
|
||||||
"""Test adding text in real-time."""
|
"""Test adding text in real-time."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TEST 6: Real-time Text Addition")
|
print("TEST 6: Real-time Text Addition")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
|
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
|
||||||
|
|
||||||
if interactive:
|
if interactive:
|
||||||
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
|
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
|
||||||
if response != 'y':
|
if response != "y":
|
||||||
print("Skipped text test")
|
print("Skipped text test")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = board_api.add_text(
|
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
|
||||||
text="MCP Test",
|
|
||||||
x=100.0,
|
|
||||||
y=95.0,
|
|
||||||
layer="F.SilkS",
|
|
||||||
size=1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
|
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
|
||||||
@@ -248,9 +231,9 @@ def test_realtime_text(board_api, interactive=False):
|
|||||||
|
|
||||||
def test_selection(board_api, interactive=False):
|
def test_selection(board_api, interactive=False):
|
||||||
"""Test getting the current selection from KiCAD UI."""
|
"""Test getting the current selection from KiCAD UI."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TEST 7: UI Selection")
|
print("TEST 7: UI Selection")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
|
|
||||||
if interactive:
|
if interactive:
|
||||||
print("\nSelect some items in KiCAD, then press Enter...")
|
print("\nSelect some items in KiCAD, then press Enter...")
|
||||||
@@ -274,26 +257,26 @@ def test_selection(board_api, interactive=False):
|
|||||||
|
|
||||||
def run_all_tests(interactive=False):
|
def run_all_tests(interactive=False):
|
||||||
"""Run all IPC backend tests."""
|
"""Run all IPC backend tests."""
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("KiCAD IPC Backend Test Suite")
|
print("KiCAD IPC Backend Test Suite")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
print("\nThis script tests real-time communication with KiCAD via IPC API.")
|
print("\nThis script tests real-time communication with KiCAD via IPC API.")
|
||||||
print("Make sure KiCAD is running with a board open.\n")
|
print("Make sure KiCAD is running with a board open.\n")
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
backend = test_connection()
|
backend = test_connection()
|
||||||
if not backend:
|
if not backend:
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TESTS FAILED: Could not connect to KiCAD")
|
print("TESTS FAILED: Could not connect to KiCAD")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Test board access
|
# Test board access
|
||||||
board_api = test_board_access(backend)
|
board_api = test_board_access(backend)
|
||||||
if not board_api:
|
if not board_api:
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TESTS FAILED: Could not access board")
|
print("TESTS FAILED: Could not access board")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Test board info
|
# Test board info
|
||||||
@@ -307,9 +290,9 @@ def run_all_tests(interactive=False):
|
|||||||
# Test selection
|
# Test selection
|
||||||
test_selection(board_api, interactive)
|
test_selection(board_api, interactive)
|
||||||
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("TESTS COMPLETE")
|
print("TESTS COMPLETE")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
print("\nThe IPC backend is working! Changes appear in real-time.")
|
print("\nThe IPC backend is working! Changes appear in real-time.")
|
||||||
print("No manual reload required - this is the power of the IPC API!")
|
print("No manual reload required - this is the power of the IPC API!")
|
||||||
|
|
||||||
@@ -321,9 +304,14 @@ def run_all_tests(interactive=False):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend')
|
|
||||||
parser.add_argument('-i', '--interactive', action='store_true',
|
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
|
||||||
help='Run in interactive mode (prompts before modifications)')
|
parser.add_argument(
|
||||||
|
"-i",
|
||||||
|
"--interactive",
|
||||||
|
action="store_true",
|
||||||
|
help="Run in interactive mode (prompts before modifications)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
success = run_all_tests(interactive=args.interactive)
|
success = run_all_tests(interactive=args.interactive)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
`(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".
|
them on *separate* lines, so every real-world delete returned "not found".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -17,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||||
|
|
||||||
# Inline format (single line) – matches what tests previously used
|
# Inline format (single line) – matches what tests previously used
|
||||||
PLACED_RESISTOR_INLINE = '''\
|
PLACED_RESISTOR_INLINE = """\
|
||||||
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
||||||
(in_bom yes) (on_board yes) (dnp no)
|
(in_bom yes) (on_board yes) (dnp no)
|
||||||
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||||
@@ -34,11 +35,11 @@ PLACED_RESISTOR_INLINE = '''\
|
|||||||
(effects (font (size 1.27 1.27)) hide)
|
(effects (font (size 1.27 1.27)) hide)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
'''
|
"""
|
||||||
|
|
||||||
# Multi-line format – as KiCAD's own file writer produces it.
|
# Multi-line format – as KiCAD's own file writer produces it.
|
||||||
# (symbol and (lib_id are on separate lines, which broke the old regex.
|
# (symbol and (lib_id are on separate lines, which broke the old regex.
|
||||||
PLACED_RESISTOR_MULTILINE = '''\
|
PLACED_RESISTOR_MULTILINE = """\
|
||||||
\t(symbol
|
\t(symbol
|
||||||
\t\t(lib_id "Device:R")
|
\t\t(lib_id "Device:R")
|
||||||
\t\t(at 50 50 0)
|
\t\t(at 50 50 0)
|
||||||
@@ -64,10 +65,10 @@ PLACED_RESISTOR_MULTILINE = '''\
|
|||||||
\t\t\t)
|
\t\t\t)
|
||||||
\t\t)
|
\t\t)
|
||||||
\t)
|
\t)
|
||||||
'''
|
"""
|
||||||
|
|
||||||
# Multi-line power symbol – the exact scenario that was reported as broken.
|
# Multi-line power symbol – the exact scenario that was reported as broken.
|
||||||
PLACED_POWER_SYMBOL_MULTILINE = '''\
|
PLACED_POWER_SYMBOL_MULTILINE = """\
|
||||||
\t(symbol
|
\t(symbol
|
||||||
\t\t(lib_id "power:VCC")
|
\t\t(lib_id "power:VCC")
|
||||||
\t\t(at 365.6 38.1 0)
|
\t\t(at 365.6 38.1 0)
|
||||||
@@ -94,7 +95,7 @@ PLACED_POWER_SYMBOL_MULTILINE = '''\
|
|||||||
\t\t\t)
|
\t\t\t)
|
||||||
\t\t)
|
\t\t)
|
||||||
\t)
|
\t)
|
||||||
'''
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
||||||
@@ -112,6 +113,7 @@ def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
|
|||||||
# Unit tests – regression proof for the old regex vs the new approach
|
# Unit tests – regression proof for the old regex vs the new approach
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestDeleteDetectionRegex:
|
class TestDeleteDetectionRegex:
|
||||||
"""Verify that the new content-string pattern finds blocks in both formats."""
|
"""Verify that the new content-string pattern finds blocks in both formats."""
|
||||||
@@ -154,10 +156,12 @@ class TestDeleteDetectionRegex:
|
|||||||
# Integration tests – real file I/O using the handler
|
# Integration tests – real file I/O using the handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestDeleteSchematicComponentIntegration:
|
class TestDeleteSchematicComponentIntegration:
|
||||||
def _get_handler(self):
|
def _get_handler(self):
|
||||||
from kicad_interface import KiCADInterface
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
iface = KiCADInterface.__new__(KiCADInterface)
|
iface = KiCADInterface.__new__(KiCADInterface)
|
||||||
return iface._handle_delete_schematic_component
|
return iface._handle_delete_schematic_component
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ def reset_pcbnew_mock():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_board():
|
def mock_board():
|
||||||
board = MagicMock()
|
board = MagicMock()
|
||||||
board.GetFileName.return_value = (
|
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
|
||||||
"/tmp/test_project/test.kicad_pcb"
|
|
||||||
)
|
|
||||||
board.GetTracks.return_value = []
|
board.GetTracks.return_value = []
|
||||||
return board
|
return board
|
||||||
|
|
||||||
@@ -101,15 +99,14 @@ def _patch_no_runtime():
|
|||||||
|
|
||||||
class TestCheckFreerouting:
|
class TestCheckFreerouting:
|
||||||
def test_no_java_no_docker(self, cmds):
|
def test_no_java_no_docker(self, cmds):
|
||||||
with patch(
|
with (
|
||||||
"commands.freerouting._find_java", return_value=None
|
patch("commands.freerouting._find_java", return_value=None),
|
||||||
), patch(
|
patch(
|
||||||
"commands.freerouting._docker_available",
|
"commands.freerouting._docker_available",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
|
),
|
||||||
):
|
):
|
||||||
result = cmds.check_freerouting(
|
result = cmds.check_freerouting({"freeroutingJar": "/nonexistent.jar"})
|
||||||
{"freeroutingJar": "/nonexistent.jar"}
|
|
||||||
)
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["java"]["found"] is False
|
assert result["java"]["found"] is False
|
||||||
assert result["docker"]["available"] is False
|
assert result["docker"]["available"] is False
|
||||||
@@ -119,48 +116,46 @@ class TestCheckFreerouting:
|
|||||||
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
with patch(
|
with (
|
||||||
"commands.freerouting._find_java",
|
patch(
|
||||||
return_value="/usr/bin/java",
|
"commands.freerouting._find_java",
|
||||||
), patch(
|
return_value="/usr/bin/java",
|
||||||
"commands.freerouting._java_version_ok",
|
),
|
||||||
return_value=False,
|
patch(
|
||||||
), patch(
|
"commands.freerouting._java_version_ok",
|
||||||
"commands.freerouting._docker_available",
|
return_value=False,
|
||||||
return_value=True,
|
),
|
||||||
), patch(
|
patch(
|
||||||
"commands.freerouting.subprocess.run"
|
"commands.freerouting._docker_available",
|
||||||
) as mock_run:
|
return_value=True,
|
||||||
mock_run.return_value = MagicMock(
|
),
|
||||||
stderr='openjdk version "17.0.1"', stdout=""
|
patch("commands.freerouting.subprocess.run") as mock_run,
|
||||||
)
|
):
|
||||||
result = cmds.check_freerouting(
|
mock_run.return_value = MagicMock(stderr='openjdk version "17.0.1"', stdout="")
|
||||||
{"freeroutingJar": str(jar)}
|
result = cmds.check_freerouting({"freeroutingJar": str(jar)})
|
||||||
)
|
|
||||||
assert result["ready"] is True
|
assert result["ready"] is True
|
||||||
assert result["execution_mode"] == "docker"
|
assert result["execution_mode"] == "docker"
|
||||||
|
|
||||||
def test_java_21_direct(self, cmds, tmp_path):
|
def test_java_21_direct(self, cmds, tmp_path):
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
with patch(
|
with (
|
||||||
"commands.freerouting._find_java",
|
patch(
|
||||||
return_value="/usr/bin/java",
|
"commands.freerouting._find_java",
|
||||||
), patch(
|
return_value="/usr/bin/java",
|
||||||
"commands.freerouting._java_version_ok",
|
),
|
||||||
return_value=True,
|
patch(
|
||||||
), patch(
|
"commands.freerouting._java_version_ok",
|
||||||
"commands.freerouting._docker_available",
|
return_value=True,
|
||||||
return_value=False,
|
),
|
||||||
), patch(
|
patch(
|
||||||
"commands.freerouting.subprocess.run"
|
"commands.freerouting._docker_available",
|
||||||
) as mock_run:
|
return_value=False,
|
||||||
mock_run.return_value = MagicMock(
|
),
|
||||||
stderr='openjdk version "21.0.1"', stdout=""
|
patch("commands.freerouting.subprocess.run") as mock_run,
|
||||||
)
|
):
|
||||||
result = cmds.check_freerouting(
|
mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
|
||||||
{"freeroutingJar": str(jar)}
|
result = cmds.check_freerouting({"freeroutingJar": str(jar)})
|
||||||
)
|
|
||||||
assert result["ready"] is True
|
assert result["ready"] is True
|
||||||
assert result["execution_mode"] == "direct"
|
assert result["execution_mode"] == "direct"
|
||||||
|
|
||||||
@@ -198,9 +193,7 @@ class TestExportDsn:
|
|||||||
assert result["path"] == output
|
assert result["path"] == output
|
||||||
|
|
||||||
def test_export_failure(self, cmds):
|
def test_export_failure(self, cmds):
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
|
||||||
"DSN error"
|
|
||||||
)
|
|
||||||
result = cmds.export_dsn({})
|
result = cmds.export_dsn({})
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "DSN error" in result["errorDetails"]
|
assert "DSN error" in result["errorDetails"]
|
||||||
@@ -213,9 +206,7 @@ class TestExportDsn:
|
|||||||
|
|
||||||
class TestImportSes:
|
class TestImportSes:
|
||||||
def test_no_board(self, cmds_no_board):
|
def test_no_board(self, cmds_no_board):
|
||||||
result = cmds_no_board.import_ses(
|
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
|
||||||
{"sesPath": "/tmp/test.ses"}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "No board" in result["message"]
|
assert "No board" in result["message"]
|
||||||
|
|
||||||
@@ -225,9 +216,7 @@ class TestImportSes:
|
|||||||
assert "Missing sesPath" in result["message"]
|
assert "Missing sesPath" in result["message"]
|
||||||
|
|
||||||
def test_ses_file_not_found(self, cmds):
|
def test_ses_file_not_found(self, cmds):
|
||||||
result = cmds.import_ses(
|
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
|
||||||
{"sesPath": "/nonexistent/test.ses"}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "not found" in result["message"]
|
assert "not found" in result["message"]
|
||||||
|
|
||||||
@@ -245,9 +234,7 @@ class TestImportSes:
|
|||||||
ses_file = tmp_path / "test.ses"
|
ses_file = tmp_path / "test.ses"
|
||||||
ses_file.write_text("(session test)")
|
ses_file.write_text("(session test)")
|
||||||
|
|
||||||
pcbnew_mock.ImportSpecctraSES.side_effect = Exception(
|
pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error")
|
||||||
"SES error"
|
|
||||||
)
|
|
||||||
result = cmds.import_ses({"sesPath": str(ses_file)})
|
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "SES error" in result["errorDetails"]
|
assert "SES error" in result["errorDetails"]
|
||||||
@@ -268,16 +255,12 @@ class TestAutoroute:
|
|||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
with _patch_no_runtime():
|
with _patch_no_runtime():
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
{"freeroutingJar": str(jar)}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "No suitable Java runtime" in result["message"]
|
assert "No suitable Java runtime" in result["message"]
|
||||||
|
|
||||||
def test_no_jar(self, cmds):
|
def test_no_jar(self, cmds):
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
|
||||||
{"freeroutingJar": "/nonexistent/freerouting.jar"}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "JAR not found" in result["message"]
|
assert "JAR not found" in result["message"]
|
||||||
|
|
||||||
@@ -286,21 +269,15 @@ class TestAutoroute:
|
|||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
|
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("export fail")
|
||||||
"export fail"
|
|
||||||
)
|
|
||||||
|
|
||||||
with _patch_direct_java():
|
with _patch_direct_java():
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
{"freeroutingJar": str(jar)}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "DSN export failed" in result["message"]
|
assert "DSN export failed" in result["message"]
|
||||||
|
|
||||||
@patch("commands.freerouting.subprocess.run")
|
@patch("commands.freerouting.subprocess.run")
|
||||||
def test_freerouting_timeout(
|
def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
|
||||||
self, mock_run, cmds, tmp_path
|
|
||||||
):
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
@@ -312,24 +289,19 @@ class TestAutoroute:
|
|||||||
dsn_file = board_dir / "test.dsn"
|
dsn_file = board_dir / "test.dsn"
|
||||||
|
|
||||||
cmds.board.GetFileName.return_value = str(board_file)
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
|
||||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
dsn_file.write_text("(pcb)"),
|
||||||
)
|
True,
|
||||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
)[1]
|
||||||
cmd="", timeout=10
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd="", timeout=10)
|
||||||
)
|
|
||||||
|
|
||||||
with _patch_direct_java():
|
with _patch_direct_java():
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": str(jar), "timeout": 10})
|
||||||
{"freeroutingJar": str(jar), "timeout": 10}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "timed out" in result["message"]
|
assert "timed out" in result["message"]
|
||||||
|
|
||||||
@patch("commands.freerouting.subprocess.run")
|
@patch("commands.freerouting.subprocess.run")
|
||||||
def test_full_success_direct(
|
def test_full_success_direct(self, mock_run, cmds, tmp_path):
|
||||||
self, mock_run, cmds, tmp_path
|
|
||||||
):
|
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
board_dir = tmp_path / "project"
|
board_dir = tmp_path / "project"
|
||||||
@@ -341,12 +313,11 @@ class TestAutoroute:
|
|||||||
|
|
||||||
cmds.board.GetFileName.return_value = str(board_file)
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
|
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
|
||||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
dsn_file.write_text("(pcb)"),
|
||||||
)
|
True,
|
||||||
mock_run.return_value = MagicMock(
|
)[1]
|
||||||
returncode=0, stdout="Routing completed", stderr=""
|
mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="")
|
||||||
)
|
|
||||||
ses_file.write_text("(session)")
|
ses_file.write_text("(session)")
|
||||||
pcbnew_mock.ImportSpecctraSES.return_value = True
|
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||||
|
|
||||||
@@ -357,9 +328,7 @@ class TestAutoroute:
|
|||||||
cmds.board.GetTracks.return_value = [track, track, via]
|
cmds.board.GetTracks.return_value = [track, track, via]
|
||||||
|
|
||||||
with _patch_direct_java():
|
with _patch_direct_java():
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
{"freeroutingJar": str(jar)}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["mode"] == "direct"
|
assert result["mode"] == "direct"
|
||||||
@@ -368,9 +337,7 @@ class TestAutoroute:
|
|||||||
assert "elapsed_seconds" in result
|
assert "elapsed_seconds" in result
|
||||||
|
|
||||||
@patch("commands.freerouting.subprocess.run")
|
@patch("commands.freerouting.subprocess.run")
|
||||||
def test_full_success_docker(
|
def test_full_success_docker(self, mock_run, cmds, tmp_path):
|
||||||
self, mock_run, cmds, tmp_path
|
|
||||||
):
|
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
board_dir = tmp_path / "project"
|
board_dir = tmp_path / "project"
|
||||||
@@ -382,24 +349,24 @@ class TestAutoroute:
|
|||||||
|
|
||||||
cmds.board.GetFileName.return_value = str(board_file)
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
|
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
|
||||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
dsn_file.write_text("(pcb)"),
|
||||||
)
|
True,
|
||||||
mock_run.return_value = MagicMock(
|
)[1]
|
||||||
returncode=0, stdout="Routing completed", stderr=""
|
mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="")
|
||||||
)
|
|
||||||
ses_file.write_text("(session)")
|
ses_file.write_text("(session)")
|
||||||
pcbnew_mock.ImportSpecctraSES.return_value = True
|
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||||
|
|
||||||
cmds.board.GetTracks.return_value = [MagicMock()]
|
cmds.board.GetTracks.return_value = [MagicMock()]
|
||||||
|
|
||||||
with _patch_docker_mode(), patch(
|
with (
|
||||||
"commands.freerouting._find_docker",
|
_patch_docker_mode(),
|
||||||
return_value="/usr/bin/docker",
|
patch(
|
||||||
|
"commands.freerouting._find_docker",
|
||||||
|
return_value="/usr/bin/docker",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
{"freeroutingJar": str(jar)}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["mode"] == "docker"
|
assert result["mode"] == "docker"
|
||||||
@@ -409,9 +376,7 @@ class TestAutoroute:
|
|||||||
assert "--rm" in call_args
|
assert "--rm" in call_args
|
||||||
|
|
||||||
@patch("commands.freerouting.subprocess.run")
|
@patch("commands.freerouting.subprocess.run")
|
||||||
def test_freerouting_nonzero_exit(
|
def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path):
|
||||||
self, mock_run, cmds, tmp_path
|
|
||||||
):
|
|
||||||
jar = tmp_path / "freerouting.jar"
|
jar = tmp_path / "freerouting.jar"
|
||||||
jar.touch()
|
jar.touch()
|
||||||
board_dir = tmp_path / "project"
|
board_dir = tmp_path / "project"
|
||||||
@@ -421,17 +386,14 @@ class TestAutoroute:
|
|||||||
dsn_file = board_dir / "test.dsn"
|
dsn_file = board_dir / "test.dsn"
|
||||||
|
|
||||||
cmds.board.GetFileName.return_value = str(board_file)
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
|
||||||
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
dsn_file.write_text("(pcb)"),
|
||||||
)
|
True,
|
||||||
mock_run.return_value = MagicMock(
|
)[1]
|
||||||
returncode=1, stdout="", stderr="OutOfMemoryError"
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="OutOfMemoryError")
|
||||||
)
|
|
||||||
|
|
||||||
with _patch_direct_java():
|
with _patch_direct_java():
|
||||||
result = cmds.autoroute(
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
{"freeroutingJar": str(jar)}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "exited with code 1" in result["message"]
|
assert "exited with code 1" in result["message"]
|
||||||
@@ -451,10 +413,13 @@ class TestFindJava:
|
|||||||
assert _find_java() == "/usr/bin/java"
|
assert _find_java() == "/usr/bin/java"
|
||||||
|
|
||||||
def test_none_when_not_found(self):
|
def test_none_when_not_found(self):
|
||||||
with patch(
|
with (
|
||||||
"commands.freerouting.shutil.which",
|
patch(
|
||||||
return_value=None,
|
"commands.freerouting.shutil.which",
|
||||||
), patch("os.path.isfile", return_value=False):
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("os.path.isfile", return_value=False),
|
||||||
|
):
|
||||||
assert _find_java() is None
|
assert _find_java() is None
|
||||||
|
|
||||||
|
|
||||||
@@ -462,18 +427,14 @@ class TestFindDocker:
|
|||||||
def test_finds_docker(self):
|
def test_finds_docker(self):
|
||||||
with patch(
|
with patch(
|
||||||
"commands.freerouting.shutil.which",
|
"commands.freerouting.shutil.which",
|
||||||
side_effect=lambda x: "/usr/bin/docker"
|
side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
|
||||||
if x == "docker"
|
|
||||||
else None,
|
|
||||||
):
|
):
|
||||||
assert _find_docker() == "/usr/bin/docker"
|
assert _find_docker() == "/usr/bin/docker"
|
||||||
|
|
||||||
def test_finds_podman(self):
|
def test_finds_podman(self):
|
||||||
with patch(
|
with patch(
|
||||||
"commands.freerouting.shutil.which",
|
"commands.freerouting.shutil.which",
|
||||||
side_effect=lambda x: "/usr/bin/podman"
|
side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
|
||||||
if x == "podman"
|
|
||||||
else None,
|
|
||||||
):
|
):
|
||||||
assert _find_docker() == "/usr/bin/podman"
|
assert _find_docker() == "/usr/bin/podman"
|
||||||
|
|
||||||
@@ -487,12 +448,13 @@ class TestFindDocker:
|
|||||||
|
|
||||||
class TestDockerAvailable:
|
class TestDockerAvailable:
|
||||||
def test_docker_found(self):
|
def test_docker_found(self):
|
||||||
with patch(
|
with (
|
||||||
"commands.freerouting._find_docker",
|
patch(
|
||||||
return_value="/usr/bin/docker",
|
"commands.freerouting._find_docker",
|
||||||
), patch(
|
return_value="/usr/bin/docker",
|
||||||
"commands.freerouting.subprocess.run"
|
),
|
||||||
) as mock_run:
|
patch("commands.freerouting.subprocess.run") as mock_run,
|
||||||
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
assert _docker_available() is True
|
assert _docker_available() is True
|
||||||
|
|
||||||
@@ -504,33 +466,26 @@ class TestDockerAvailable:
|
|||||||
assert _docker_available() is False
|
assert _docker_available() is False
|
||||||
|
|
||||||
def test_docker_not_running(self):
|
def test_docker_not_running(self):
|
||||||
with patch(
|
with (
|
||||||
"commands.freerouting._find_docker",
|
patch(
|
||||||
return_value="/usr/bin/docker",
|
"commands.freerouting._find_docker",
|
||||||
), patch(
|
return_value="/usr/bin/docker",
|
||||||
"commands.freerouting.subprocess.run"
|
),
|
||||||
) as mock_run:
|
patch("commands.freerouting.subprocess.run") as mock_run,
|
||||||
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=1)
|
mock_run.return_value = MagicMock(returncode=1)
|
||||||
assert _docker_available() is False
|
assert _docker_available() is False
|
||||||
|
|
||||||
|
|
||||||
class TestJavaVersionOk:
|
class TestJavaVersionOk:
|
||||||
def test_java_21(self):
|
def test_java_21(self):
|
||||||
with patch(
|
with patch("commands.freerouting.subprocess.run") as mock_run:
|
||||||
"commands.freerouting.subprocess.run"
|
mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
|
||||||
) as mock_run:
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
stderr='openjdk version "21.0.1"', stdout=""
|
|
||||||
)
|
|
||||||
assert _java_version_ok("/usr/bin/java") is True
|
assert _java_version_ok("/usr/bin/java") is True
|
||||||
|
|
||||||
def test_java_17(self):
|
def test_java_17(self):
|
||||||
with patch(
|
with patch("commands.freerouting.subprocess.run") as mock_run:
|
||||||
"commands.freerouting.subprocess.run"
|
mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="")
|
||||||
) as mock_run:
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
stderr='openjdk version "17.0.18"', stdout=""
|
|
||||||
)
|
|
||||||
assert _java_version_ok("/usr/bin/java") is False
|
assert _java_version_ok("/usr/bin/java") is False
|
||||||
|
|
||||||
def test_java_error(self):
|
def test_java_error(self):
|
||||||
|
|||||||
@@ -256,18 +256,14 @@ class TestFindOverlappingElements:
|
|||||||
|
|
||||||
def test_overlapping_symbols_detected(self):
|
def test_overlapping_symbols_detected(self):
|
||||||
# Two resistors at nearly the same position — bboxes fully overlap
|
# Two resistors at nearly the same position — bboxes fully overlap
|
||||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
|
||||||
"R2", 100.1, 100
|
|
||||||
)
|
|
||||||
tmp = _make_temp_schematic(extra)
|
tmp = _make_temp_schematic(extra)
|
||||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
assert result["totalOverlaps"] >= 1
|
assert result["totalOverlaps"] >= 1
|
||||||
assert len(result["overlappingSymbols"]) >= 1
|
assert len(result["overlappingSymbols"]) >= 1
|
||||||
|
|
||||||
def test_well_separated_symbols_not_flagged(self):
|
def test_well_separated_symbols_not_flagged(self):
|
||||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200)
|
||||||
"R2", 200, 200
|
|
||||||
)
|
|
||||||
tmp = _make_temp_schematic(extra)
|
tmp = _make_temp_schematic(extra)
|
||||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
assert result["totalOverlaps"] == 0
|
assert result["totalOverlaps"] == 0
|
||||||
@@ -294,9 +290,7 @@ class TestFindOverlappingElements:
|
|||||||
"""
|
"""
|
||||||
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
|
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
|
||||||
# These overlap in Y from 101.19 to 103.81
|
# These overlap in Y from 101.19 to 103.81
|
||||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105)
|
||||||
"R2", 100, 105
|
|
||||||
)
|
|
||||||
tmp = _make_temp_schematic(extra)
|
tmp = _make_temp_schematic(extra)
|
||||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
assert result["totalOverlaps"] >= 1, (
|
assert result["totalOverlaps"] >= 1, (
|
||||||
@@ -310,9 +304,7 @@ class TestFindOverlappingElements:
|
|||||||
|
|
||||||
R pins at y ±3.81, but different X positions far enough apart.
|
R pins at y ±3.81, but different X positions far enough apart.
|
||||||
"""
|
"""
|
||||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
|
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100)
|
||||||
"R2", 110, 100
|
|
||||||
)
|
|
||||||
tmp = _make_temp_schematic(extra)
|
tmp = _make_temp_schematic(extra)
|
||||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||||
assert result["totalOverlaps"] == 0
|
assert result["totalOverlaps"] == 0
|
||||||
@@ -419,9 +411,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
|||||||
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
|
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
|
||||||
result = find_wires_crossing_symbols(tmp)
|
result = find_wires_crossing_symbols(tmp)
|
||||||
# The wire must not be reported against the far-away R? at (200, 100)
|
# The wire must not be reported against the far-away R? at (200, 100)
|
||||||
collisions_at_200 = [
|
collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5]
|
||||||
c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5
|
|
||||||
]
|
|
||||||
assert len(collisions_at_200) == 0, (
|
assert len(collisions_at_200) == 0, (
|
||||||
"Wire at x≈100 must not be flagged against the R? at x=200; "
|
"Wire at x≈100 must not be flagged against the R? at x=200; "
|
||||||
"likely caused by reference-lookup always returning the first 'R?'"
|
"likely caused by reference-lookup always returning the first 'R?'"
|
||||||
@@ -458,9 +448,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
|||||||
tmp = _make_temp_schematic(extra)
|
tmp = _make_temp_schematic(extra)
|
||||||
result = find_wires_crossing_symbols(tmp)
|
result = find_wires_crossing_symbols(tmp)
|
||||||
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
|
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
|
||||||
assert (
|
assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged"
|
||||||
len(d1_crossings) == 0
|
|
||||||
), "Wire terminating at pin from outside should not be flagged"
|
|
||||||
|
|
||||||
def test_wire_shorts_component_pins_detected_as_collision(self):
|
def test_wire_shorts_component_pins_detected_as_collision(self):
|
||||||
"""Regression: a wire connecting pin1→pin2 of the same component
|
"""Regression: a wire connecting pin1→pin2 of the same component
|
||||||
@@ -825,9 +813,7 @@ class TestComputeSymbolBboxWithGraphics:
|
|||||||
}
|
}
|
||||||
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
||||||
|
|
||||||
bbox = _compute_symbol_bbox_direct(
|
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
|
||||||
sym, pin_defs, graphics_points=graphics_points
|
|
||||||
)
|
|
||||||
assert bbox is not None
|
assert bbox is not None
|
||||||
min_x, min_y, max_x, max_y = bbox
|
min_x, min_y, max_x, max_y = bbox
|
||||||
# X should come from rectangle: 100 ± 1.016
|
# X should come from rectangle: 100 ± 1.016
|
||||||
@@ -902,9 +888,7 @@ class TestComputeSymbolBboxWithGraphics:
|
|||||||
# Rectangle corners in local coords
|
# Rectangle corners in local coords
|
||||||
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
|
||||||
|
|
||||||
bbox = _compute_symbol_bbox_direct(
|
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
|
||||||
sym, pin_defs, graphics_points=graphics_points
|
|
||||||
)
|
|
||||||
assert bbox is not None
|
assert bbox is not None
|
||||||
min_x, min_y, max_x, max_y = bbox
|
min_x, min_y, max_x, max_y = bbox
|
||||||
# After 90° rotation, X and Y swap roles
|
# After 90° rotation, X and Y swap roles
|
||||||
@@ -935,9 +919,7 @@ class TestIntegrationGraphicsBbox:
|
|||||||
|
|
||||||
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
|
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
|
||||||
|
|
||||||
bbox = _compute_symbol_bbox_direct(
|
bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points)
|
||||||
r1, pin_defs, graphics_points=graphics_points
|
|
||||||
)
|
|
||||||
assert bbox is not None
|
assert bbox is not None
|
||||||
min_x, min_y, max_x, max_y = bbox
|
min_x, min_y, max_x, max_y = bbox
|
||||||
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
|
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Covers:
|
|||||||
(tested by calling _handle_* methods on a lightweight stub that avoids
|
(tested by calling _handle_* methods on a lightweight stub that avoids
|
||||||
importing the full kicad_interface module).
|
importing the full kicad_interface module).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -43,9 +44,7 @@ _WIRE_SCH = """\
|
|||||||
|
|
||||||
def _write_temp_sch(content: str) -> Path:
|
def _write_temp_sch(content: str) -> Path:
|
||||||
"""Write *content* to a temp file and return its Path."""
|
"""Write *content* to a temp file and return its Path."""
|
||||||
tmp = tempfile.NamedTemporaryFile(
|
tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
|
||||||
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
|
|
||||||
)
|
|
||||||
tmp.write(content)
|
tmp.write(content)
|
||||||
tmp.close()
|
tmp.close()
|
||||||
return Path(tmp.name)
|
return Path(tmp.name)
|
||||||
@@ -66,9 +65,7 @@ class TestDeleteWireUnit:
|
|||||||
self.WireManager = WireManager
|
self.WireManager = WireManager
|
||||||
|
|
||||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||||
result = self.WireManager.delete_wire(
|
result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10])
|
||||||
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
|
|
||||||
)
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_no_matching_wire_returns_false(self, tmp_path):
|
def test_no_matching_wire_returns_false(self, tmp_path):
|
||||||
@@ -100,9 +97,7 @@ class TestDeleteLabelUnit:
|
|||||||
self.WireManager = WireManager
|
self.WireManager = WireManager
|
||||||
|
|
||||||
def test_nonexistent_file_returns_false(self, tmp_path):
|
def test_nonexistent_file_returns_false(self, tmp_path):
|
||||||
result = self.WireManager.delete_label(
|
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
|
||||||
tmp_path / "nope.kicad_sch", "VCC"
|
|
||||||
)
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_missing_label_returns_false(self, tmp_path):
|
def test_missing_label_returns_false(self, tmp_path):
|
||||||
@@ -114,9 +109,7 @@ class TestDeleteLabelUnit:
|
|||||||
def test_position_kwarg_accepted(self, tmp_path):
|
def test_position_kwarg_accepted(self, tmp_path):
|
||||||
sch = tmp_path / "test.kicad_sch"
|
sch = tmp_path / "test.kicad_sch"
|
||||||
shutil.copy(EMPTY_SCH, sch)
|
shutil.copy(EMPTY_SCH, sch)
|
||||||
result = self.WireManager.delete_label(
|
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
|
||||||
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
|
|
||||||
)
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@@ -145,9 +138,7 @@ class TestDeleteWireIntegration:
|
|||||||
wire_items = [
|
wire_items = [
|
||||||
item
|
item
|
||||||
for item in data
|
for item in data
|
||||||
if isinstance(item, list)
|
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||||
and item
|
|
||||||
and item[0] == sexpdata.Symbol("wire")
|
|
||||||
]
|
]
|
||||||
assert wire_items == [], "Wire should have been removed from the file"
|
assert wire_items == [], "Wire should have been removed from the file"
|
||||||
|
|
||||||
@@ -165,18 +156,14 @@ class TestDeleteWireIntegration:
|
|||||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||||
|
|
||||||
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
|
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
|
||||||
result = self.WireManager.delete_wire(
|
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
|
||||||
sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5
|
|
||||||
)
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_outside_tolerance_no_delete(self, tmp_path):
|
def test_outside_tolerance_no_delete(self, tmp_path):
|
||||||
sch = tmp_path / "test.kicad_sch"
|
sch = tmp_path / "test.kicad_sch"
|
||||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||||
|
|
||||||
result = self.WireManager.delete_wire(
|
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
|
||||||
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
|
|
||||||
)
|
|
||||||
# tolerance=0.0 means exact float equality — may still match on most
|
# tolerance=0.0 means exact float equality — may still match on most
|
||||||
# platforms, but the key thing is that a *distant* miss is rejected
|
# platforms, but the key thing is that a *distant* miss is rejected
|
||||||
sch2 = tmp_path / "test2.kicad_sch"
|
sch2 = tmp_path / "test2.kicad_sch"
|
||||||
@@ -200,9 +187,7 @@ class TestDeleteWireIntegration:
|
|||||||
labels = [
|
labels = [
|
||||||
item
|
item
|
||||||
for item in data
|
for item in data
|
||||||
if isinstance(item, list)
|
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||||
and item
|
|
||||||
and item[0] == sexpdata.Symbol("label")
|
|
||||||
]
|
]
|
||||||
assert len(labels) == 1
|
assert len(labels) == 1
|
||||||
|
|
||||||
@@ -230,9 +215,7 @@ class TestDeleteLabelIntegration:
|
|||||||
labels = [
|
labels = [
|
||||||
item
|
item
|
||||||
for item in data
|
for item in data
|
||||||
if isinstance(item, list)
|
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
|
||||||
and item
|
|
||||||
and item[0] == sexpdata.Symbol("label")
|
|
||||||
]
|
]
|
||||||
assert labels == [], "Label should have been removed"
|
assert labels == [], "Label should have been removed"
|
||||||
|
|
||||||
@@ -247,9 +230,7 @@ class TestDeleteLabelIntegration:
|
|||||||
sch = tmp_path / "test.kicad_sch"
|
sch = tmp_path / "test.kicad_sch"
|
||||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||||
|
|
||||||
result = self.WireManager.delete_label(
|
result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
|
||||||
sch, "VCC", position=[99.0, 99.0], tolerance=0.5
|
|
||||||
)
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_wire_preserved_after_label_deletion(self, tmp_path):
|
def test_wire_preserved_after_label_deletion(self, tmp_path):
|
||||||
@@ -260,9 +241,7 @@ class TestDeleteLabelIntegration:
|
|||||||
wires = [
|
wires = [
|
||||||
item
|
item
|
||||||
for item in data
|
for item in data
|
||||||
if isinstance(item, list)
|
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
|
||||||
and item
|
|
||||||
and item[0] == sexpdata.Symbol("wire")
|
|
||||||
]
|
]
|
||||||
assert len(wires) == 1
|
assert len(wires) == 1
|
||||||
|
|
||||||
@@ -387,9 +366,7 @@ class TestHandlerParamValidation:
|
|||||||
params = {}
|
params = {}
|
||||||
schematic_path = params.get("schematicPath")
|
schematic_path = params.get("schematicPath")
|
||||||
result = (
|
result = (
|
||||||
{"success": False, "message": "schematicPath is required"}
|
{"success": False, "message": "schematicPath is required"} if not schematic_path else {}
|
||||||
if not schematic_path
|
|
||||||
else {}
|
|
||||||
)
|
)
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|||||||
@@ -265,18 +265,14 @@ class TestCoreLogic:
|
|||||||
def test_find_connected_wires_no_wire_at_point(self):
|
def test_find_connected_wires_no_wire_at_point(self):
|
||||||
wires = [[(0, 0), (10_000, 0)]]
|
wires = [[(0, 0), (10_000, 0)]]
|
||||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||||
visited, net_points = _find_connected_wires(
|
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
|
||||||
5.0, 0.0, wires, iu_to_wires, adjacency
|
|
||||||
)
|
|
||||||
assert visited is None
|
assert visited is None
|
||||||
assert net_points is None
|
assert net_points is None
|
||||||
|
|
||||||
def test_find_connected_wires_single_wire(self):
|
def test_find_connected_wires_single_wire(self):
|
||||||
wires = [[(0, 0), (10_000, 0)]]
|
wires = [[(0, 0), (10_000, 0)]]
|
||||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||||
visited, net_points = _find_connected_wires(
|
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||||
0.0, 0.0, wires, iu_to_wires, adjacency
|
|
||||||
)
|
|
||||||
assert visited == {0}
|
assert visited == {0}
|
||||||
assert (0, 0) in net_points
|
assert (0, 0) in net_points
|
||||||
assert (10_000, 0) in net_points
|
assert (10_000, 0) in net_points
|
||||||
@@ -289,9 +285,7 @@ class TestCoreLogic:
|
|||||||
[(20_000, 0), (30_000, 0)],
|
[(20_000, 0), (30_000, 0)],
|
||||||
]
|
]
|
||||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||||
visited, net_points = _find_connected_wires(
|
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||||
0.0, 0.0, wires, iu_to_wires, adjacency
|
|
||||||
)
|
|
||||||
assert visited == {0, 1, 2}
|
assert visited == {0, 1, 2}
|
||||||
|
|
||||||
def test_find_connected_wires_does_not_cross_gap(self):
|
def test_find_connected_wires_does_not_cross_gap(self):
|
||||||
|
|||||||
@@ -44,11 +44,7 @@ def _parse_sch(path: Path):
|
|||||||
|
|
||||||
def _find_elements(sch_data, tag: str):
|
def _find_elements(sch_data, tag: str):
|
||||||
"""Return all top-level S-expression elements with the given tag Symbol."""
|
"""Return all top-level S-expression elements with the given tag Symbol."""
|
||||||
return [
|
return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)]
|
||||||
item
|
|
||||||
for item in sch_data
|
|
||||||
if isinstance(item, list) and item and item[0] == Symbol(tag)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -188,9 +184,7 @@ class TestHandleAddSchematicWireValidation:
|
|||||||
assert "Schematic path" in result["message"]
|
assert "Schematic path" in result["message"]
|
||||||
|
|
||||||
def test_missing_waypoints(self):
|
def test_missing_waypoints(self):
|
||||||
result = self.iface._handle_add_schematic_wire(
|
result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"})
|
||||||
{"schematicPath": "/tmp/x.kicad_sch"}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "waypoint" in result["message"].lower()
|
assert "waypoint" in result["message"].lower()
|
||||||
|
|
||||||
@@ -334,9 +328,7 @@ class TestPinSnapping:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
skip_mod.Schematic = lambda path: type(
|
skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})()
|
||||||
"FakeSch", (), {"symbol": [FakeSymbol()]}
|
|
||||||
)()
|
|
||||||
|
|
||||||
mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]}
|
mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]}
|
||||||
|
|
||||||
@@ -350,9 +342,7 @@ class TestPinSnapping:
|
|||||||
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
|
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
|
||||||
iface = KiCADInterface.__new__(KiCADInterface)
|
iface = KiCADInterface.__new__(KiCADInterface)
|
||||||
|
|
||||||
with patch(
|
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
|
||||||
"commands.wire_manager.WireManager.add_wire", return_value=True
|
|
||||||
) as mw:
|
|
||||||
result = iface._handle_add_schematic_wire(
|
result = iface._handle_add_schematic_wire(
|
||||||
{
|
{
|
||||||
"schematicPath": str(self.sch_path),
|
"schematicPath": str(self.sch_path),
|
||||||
@@ -371,9 +361,10 @@ class TestPinSnapping:
|
|||||||
|
|
||||||
def test_snap_disabled_passes_original_coords(self):
|
def test_snap_disabled_passes_original_coords(self):
|
||||||
"""With snapToPins=False the handler should not load PinLocator at all."""
|
"""With snapToPins=False the handler should not load PinLocator at all."""
|
||||||
with patch(
|
with (
|
||||||
"commands.wire_manager.WireManager.add_wire", return_value=True
|
patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw,
|
||||||
) as mw, patch("commands.pin_locator.PinLocator") as mock_locator_cls:
|
patch("commands.pin_locator.PinLocator") as mock_locator_cls,
|
||||||
|
):
|
||||||
result = self.iface._handle_add_schematic_wire(
|
result = self.iface._handle_add_schematic_wire(
|
||||||
{
|
{
|
||||||
"schematicPath": str(self.sch_path),
|
"schematicPath": str(self.sch_path),
|
||||||
@@ -389,9 +380,7 @@ class TestPinSnapping:
|
|||||||
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
|
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
|
||||||
def test_snap_miss_leaves_coords_unchanged(self, mock_wire):
|
def test_snap_miss_leaves_coords_unchanged(self, mock_wire):
|
||||||
"""Point beyond tolerance should not be snapped."""
|
"""Point beyond tolerance should not be snapped."""
|
||||||
with patch(
|
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
|
||||||
"commands.wire_manager.WireManager.add_wire", return_value=True
|
|
||||||
) as mw:
|
|
||||||
result = self.iface._handle_add_schematic_wire(
|
result = self.iface._handle_add_schematic_wire(
|
||||||
{
|
{
|
||||||
"schematicPath": str(self.sch_path),
|
"schematicPath": str(self.sch_path),
|
||||||
@@ -409,9 +398,7 @@ class TestPinSnapping:
|
|||||||
def test_intermediate_waypoints_not_snapped(self, mock_poly):
|
def test_intermediate_waypoints_not_snapped(self, mock_poly):
|
||||||
"""Middle waypoints must remain unchanged even with snapToPins=True."""
|
"""Middle waypoints must remain unchanged even with snapToPins=True."""
|
||||||
mid = [50.0, 50.0]
|
mid = [50.0, 50.0]
|
||||||
with patch(
|
with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp:
|
||||||
"commands.wire_manager.WireManager.add_polyline_wire", return_value=True
|
|
||||||
) as mp:
|
|
||||||
result = self.iface._handle_add_schematic_wire(
|
result = self.iface._handle_add_schematic_wire(
|
||||||
{
|
{
|
||||||
"schematicPath": str(self.sch_path),
|
"schematicPath": str(self.sch_path),
|
||||||
@@ -451,9 +438,7 @@ class TestHandleAddSchematicJunction:
|
|||||||
assert "Schematic path" in result["message"]
|
assert "Schematic path" in result["message"]
|
||||||
|
|
||||||
def test_missing_position(self):
|
def test_missing_position(self):
|
||||||
result = self.iface._handle_add_schematic_junction(
|
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
|
||||||
{"schematicPath": "/tmp/x.kicad_sch"}
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "Position" in result["message"]
|
assert "Position" in result["message"]
|
||||||
|
|
||||||
@@ -551,9 +536,7 @@ class TestIntegrationWireManager:
|
|||||||
assert ok is True
|
assert ok is True
|
||||||
data = _parse_sch(sch)
|
data = _parse_sch(sch)
|
||||||
wires = _find_elements(data, "wire")
|
wires = _find_elements(data, "wire")
|
||||||
assert (
|
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||||
len(wires) == 3
|
|
||||||
), f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
|
||||||
|
|
||||||
def test_add_junction_writes_junction_element(self, sch):
|
def test_add_junction_writes_junction_element(self, sch):
|
||||||
from commands.wire_manager import WireManager
|
from commands.wire_manager import WireManager
|
||||||
@@ -575,14 +558,10 @@ class TestIntegrationWireManager:
|
|||||||
data = _parse_sch(sch)
|
data = _parse_sch(sch)
|
||||||
wire = _find_elements(data, "wire")[0]
|
wire = _find_elements(data, "wire")[0]
|
||||||
pts = [
|
pts = [
|
||||||
item
|
item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts")
|
||||||
for item in wire
|
|
||||||
if isinstance(item, list) and item and item[0] == Symbol("pts")
|
|
||||||
][0]
|
][0]
|
||||||
xy_entries = [
|
xy_entries = [
|
||||||
item
|
item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy")
|
||||||
for item in pts
|
|
||||||
if isinstance(item, list) and item and item[0] == Symbol("xy")
|
|
||||||
]
|
]
|
||||||
assert xy_entries[0][1] == 5.0
|
assert xy_entries[0][1] == 5.0
|
||||||
assert xy_entries[0][2] == 7.5
|
assert xy_entries[0][2] == 7.5
|
||||||
@@ -644,9 +623,7 @@ class TestIntegrationHandlerEndToEnd:
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
data = _parse_sch(self.sch)
|
data = _parse_sch(self.sch)
|
||||||
wires = _find_elements(data, "wire")
|
wires = _find_elements(data, "wire")
|
||||||
assert (
|
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||||
len(wires) == 3
|
|
||||||
), f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -812,9 +789,7 @@ class TestMakeWireSexp:
|
|||||||
def test_custom_stroke(self):
|
def test_custom_stroke(self):
|
||||||
from commands.wire_manager import WireManager
|
from commands.wire_manager import WireManager
|
||||||
|
|
||||||
sexp = WireManager._make_wire_sexp(
|
sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
|
||||||
[0, 0], [5, 0], stroke_width=0.5, stroke_type="dash"
|
|
||||||
)
|
|
||||||
parsed = WireManager._parse_wire(sexp)
|
parsed = WireManager._parse_wire(sexp)
|
||||||
assert parsed is not None
|
assert parsed is not None
|
||||||
_, _, width, stype = parsed
|
_, _, width, stype = parsed
|
||||||
@@ -922,9 +897,7 @@ class TestBreakWiresAtPoint:
|
|||||||
|
|
||||||
data = [Symbol("kicad_sch")]
|
data = [Symbol("kicad_sch")]
|
||||||
data.append(
|
data.append(
|
||||||
WireManager._make_wire_sexp(
|
WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash")
|
||||||
[0, 0], [20, 0], stroke_width=0.5, stroke_type="dash"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
data.append([Symbol("sheet_instances")])
|
data.append([Symbol("sheet_instances")])
|
||||||
splits = WireManager._break_wires_at_point(data, [10, 0])
|
splits = WireManager._break_wires_at_point(data, [10, 0])
|
||||||
@@ -998,9 +971,7 @@ class TestIntegrationTJunction:
|
|||||||
WireManager.add_junction(sch, [15, 0])
|
WireManager.add_junction(sch, [15, 0])
|
||||||
data = _parse_sch(sch)
|
data = _parse_sch(sch)
|
||||||
wires = _find_elements(data, "wire")
|
wires = _find_elements(data, "wire")
|
||||||
assert (
|
assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}"
|
||||||
len(wires) == 2
|
|
||||||
), f"Expected 2 wires after junction split, got {len(wires)}"
|
|
||||||
junctions = _find_elements(data, "junction")
|
junctions = _find_elements(data, "junction")
|
||||||
assert len(junctions) == 1
|
assert len(junctions) == 1
|
||||||
|
|
||||||
@@ -1012,9 +983,7 @@ class TestIntegrationTJunction:
|
|||||||
WireManager.add_junction(sch, [20, 0])
|
WireManager.add_junction(sch, [20, 0])
|
||||||
data = _parse_sch(sch)
|
data = _parse_sch(sch)
|
||||||
wires = _find_elements(data, "wire")
|
wires = _find_elements(data, "wire")
|
||||||
assert (
|
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
|
||||||
len(wires) == 1
|
|
||||||
), f"Expected 1 wire (no split at endpoint), got {len(wires)}"
|
|
||||||
|
|
||||||
def test_polyline_breaks_existing_wire(self, sch):
|
def test_polyline_breaks_existing_wire(self, sch):
|
||||||
"""Polyline whose start/end hits mid-wire should break it."""
|
"""Polyline whose start/end hits mid-wire should break it."""
|
||||||
|
|||||||
@@ -3,77 +3,82 @@ KiCAD Process Management Utilities
|
|||||||
|
|
||||||
Detects if KiCAD is running and provides auto-launch functionality.
|
Detects if KiCAD is running and provides auto-launch functionality.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import subprocess
|
import os
|
||||||
import logging
|
import subprocess
|
||||||
import platform
|
import logging
|
||||||
import time
|
import platform
|
||||||
import ctypes
|
import time
|
||||||
from ctypes import wintypes
|
import ctypes
|
||||||
from pathlib import Path
|
from ctypes import wintypes
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class KiCADProcessManager:
|
class KiCADProcessManager:
|
||||||
"""Manages KiCAD process detection and launching"""
|
"""Manages KiCAD process detection and launching"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _windows_list_processes() -> List[dict]:
|
def _windows_list_processes() -> List[dict]:
|
||||||
"""List running processes on Windows using Toolhelp API."""
|
"""List running processes on Windows using Toolhelp API."""
|
||||||
processes: List[dict] = []
|
processes: List[dict] = []
|
||||||
try:
|
try:
|
||||||
TH32CS_SNAPPROCESS = 0x00000002
|
TH32CS_SNAPPROCESS = 0x00000002
|
||||||
try:
|
try:
|
||||||
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
|
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
ulong_ptr = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
|
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),
|
class PROCESSENTRY32W(ctypes.Structure):
|
||||||
("cntUsage", wintypes.DWORD),
|
_fields_ = [
|
||||||
("th32ProcessID", wintypes.DWORD),
|
("dwSize", wintypes.DWORD),
|
||||||
("th32DefaultHeapID", ulong_ptr),
|
("cntUsage", wintypes.DWORD),
|
||||||
("th32ModuleID", wintypes.DWORD),
|
("th32ProcessID", wintypes.DWORD),
|
||||||
("cntThreads", wintypes.DWORD),
|
("th32DefaultHeapID", ulong_ptr),
|
||||||
("th32ParentProcessID", wintypes.DWORD),
|
("th32ModuleID", wintypes.DWORD),
|
||||||
("pcPriClassBase", wintypes.LONG),
|
("cntThreads", wintypes.DWORD),
|
||||||
("dwFlags", wintypes.DWORD),
|
("th32ParentProcessID", wintypes.DWORD),
|
||||||
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
|
("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
|
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
|
||||||
CloseHandle = ctypes.windll.kernel32.CloseHandle
|
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
|
||||||
|
Process32NextW = ctypes.windll.kernel32.Process32NextW
|
||||||
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
CloseHandle = ctypes.windll.kernel32.CloseHandle
|
||||||
if snapshot == wintypes.HANDLE(-1).value:
|
|
||||||
return processes
|
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||||
|
if snapshot == wintypes.HANDLE(-1).value:
|
||||||
entry = PROCESSENTRY32W()
|
return processes
|
||||||
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
|
||||||
|
entry = PROCESSENTRY32W()
|
||||||
if Process32FirstW(snapshot, ctypes.byref(entry)):
|
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||||
while True:
|
|
||||||
processes.append({
|
if Process32FirstW(snapshot, ctypes.byref(entry)):
|
||||||
"pid": str(entry.th32ProcessID),
|
while True:
|
||||||
"name": entry.szExeFile,
|
processes.append(
|
||||||
"command": entry.szExeFile
|
{
|
||||||
})
|
"pid": str(entry.th32ProcessID),
|
||||||
if not Process32NextW(snapshot, ctypes.byref(entry)):
|
"name": entry.szExeFile,
|
||||||
break
|
"command": entry.szExeFile,
|
||||||
|
}
|
||||||
CloseHandle(snapshot)
|
)
|
||||||
except Exception as e:
|
if not Process32NextW(snapshot, ctypes.byref(entry)):
|
||||||
logger.error(f"Error listing Windows processes: {e}")
|
break
|
||||||
|
|
||||||
return processes
|
CloseHandle(snapshot)
|
||||||
|
except Exception as e:
|
||||||
@staticmethod
|
logger.error(f"Error listing Windows processes: {e}")
|
||||||
def is_running() -> bool:
|
|
||||||
|
return processes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_running() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if KiCAD is currently running
|
Check if KiCAD is currently running
|
||||||
|
|
||||||
@@ -87,27 +92,21 @@ class KiCADProcessManager:
|
|||||||
# Check for actual pcbnew/kicad binaries (not python scripts)
|
# Check for actual pcbnew/kicad binaries (not python scripts)
|
||||||
# Use exact process name matching to avoid matching our own kicad_interface.py
|
# Use exact process name matching to avoid matching our own kicad_interface.py
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-x", "pcbnew|kicad"],
|
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return True
|
return True
|
||||||
# Also check with -f for full path matching, but exclude our script
|
# Also check with -f for full path matching, but exclude our script
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-f", "/pcbnew|/kicad"],
|
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
# Double-check it's not our own process
|
# Double-check it's not our own process
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
pids = result.stdout.strip().split('\n')
|
pids = result.stdout.strip().split("\n")
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
try:
|
try:
|
||||||
cmdline = subprocess.run(
|
cmdline = subprocess.run(
|
||||||
["ps", "-p", pid, "-o", "command="],
|
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
if "kicad_interface.py" not in cmdline.stdout:
|
if "kicad_interface.py" not in cmdline.stdout:
|
||||||
return True
|
return True
|
||||||
@@ -117,18 +116,16 @@ class KiCADProcessManager:
|
|||||||
|
|
||||||
elif system == "Darwin": # macOS
|
elif system == "Darwin": # macOS
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-f", "KiCad|pcbnew"],
|
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
elif system == "Windows":
|
elif system == "Windows":
|
||||||
processes = KiCADProcessManager._windows_list_processes()
|
processes = KiCADProcessManager._windows_list_processes()
|
||||||
for proc in processes:
|
for proc in processes:
|
||||||
name = (proc.get("name") or "").lower()
|
name = (proc.get("name") or "").lower()
|
||||||
if name in ("pcbnew.exe", "kicad.exe"):
|
if name in ("pcbnew.exe", "kicad.exe"):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -151,14 +148,14 @@ class KiCADProcessManager:
|
|||||||
|
|
||||||
# Try to find executable in PATH first
|
# Try to find executable in PATH first
|
||||||
for cmd in ["pcbnew", "kicad"]:
|
for cmd in ["pcbnew", "kicad"]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["which", cmd] if system != "Windows" else ["where", cmd],
|
["which", cmd] if system != "Windows" else ["where", cmd],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="mbcs" if system == "Windows" else None,
|
encoding="mbcs" if system == "Windows" else None,
|
||||||
errors="ignore" if system == "Windows" else None,
|
errors="ignore" if system == "Windows" else None,
|
||||||
timeout=5 if system == "Windows" else None
|
timeout=5 if system == "Windows" else None,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
path = result.stdout.strip().split("\n")[0]
|
path = result.stdout.strip().split("\n")[0]
|
||||||
logger.info(f"Found KiCAD executable: {path}")
|
logger.info(f"Found KiCAD executable: {path}")
|
||||||
@@ -232,7 +229,7 @@ class KiCADProcessManager:
|
|||||||
cmd,
|
cmd,
|
||||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Unix: Use nohup or start in background
|
# Unix: Use nohup or start in background
|
||||||
@@ -240,7 +237,7 @@ class KiCADProcessManager:
|
|||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
start_new_session=True
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for process to start
|
# Wait for process to start
|
||||||
@@ -275,28 +272,30 @@ class KiCADProcessManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if system in ["Linux", "Darwin"]:
|
if system in ["Linux", "Darwin"]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
|
||||||
["ps", "aux"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
for line in result.stdout.split("\n"):
|
for line in result.stdout.split("\n"):
|
||||||
# Only match actual KiCAD binaries, not our MCP server processes
|
# 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
|
# More specific check: must have /pcbnew or /kicad in the path
|
||||||
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
|
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 11:
|
if len(parts) >= 11:
|
||||||
processes.append({
|
processes.append(
|
||||||
"pid": parts[1],
|
{
|
||||||
"name": parts[10],
|
"pid": parts[1],
|
||||||
"command": " ".join(parts[10:])
|
"name": parts[10],
|
||||||
})
|
"command": " ".join(parts[10:]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
elif system == "Windows":
|
elif system == "Windows":
|
||||||
for proc in KiCADProcessManager._windows_list_processes():
|
for proc in KiCADProcessManager._windows_list_processes():
|
||||||
name = (proc.get("name") or "").lower()
|
name = (proc.get("name") or "").lower()
|
||||||
if "pcbnew" in name or "kicad" in name:
|
if "pcbnew" in name or "kicad" in name:
|
||||||
processes.append(proc)
|
processes.append(proc)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -304,6 +303,7 @@ class KiCADProcessManager:
|
|||||||
|
|
||||||
return processes
|
return processes
|
||||||
|
|
||||||
|
|
||||||
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
|
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
|
||||||
"""
|
"""
|
||||||
Check if KiCAD is running and optionally launch it
|
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,
|
"running": True,
|
||||||
"launched": False,
|
"launched": False,
|
||||||
"processes": processes,
|
"processes": processes,
|
||||||
"message": "KiCAD is already running"
|
"message": "KiCAD is already running",
|
||||||
}
|
}
|
||||||
|
|
||||||
if not auto_launch:
|
if not auto_launch:
|
||||||
@@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
|
|||||||
"running": False,
|
"running": False,
|
||||||
"launched": False,
|
"launched": False,
|
||||||
"processes": [],
|
"processes": [],
|
||||||
"message": "KiCAD is not running (auto-launch disabled)"
|
"message": "KiCAD is not running (auto-launch disabled)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to launch
|
# Try to launch
|
||||||
@@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
|
|||||||
"launched": success,
|
"launched": success,
|
||||||
"processes": manager.get_process_info() if success else [],
|
"processes": manager.get_process_info() if success else [],
|
||||||
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
|
"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,6 +4,7 @@ Platform detection and path utilities for cross-platform compatibility
|
|||||||
This module provides helpers for detecting the current platform and
|
This module provides helpers for detecting the current platform and
|
||||||
getting appropriate paths for KiCAD, configuration, logs, etc.
|
getting appropriate paths for KiCAD, configuration, logs, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
@@ -74,19 +75,23 @@ class PlatformHelper:
|
|||||||
|
|
||||||
# Also check based on Python version
|
# Also check based on Python version
|
||||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||||
candidates.extend([
|
candidates.extend(
|
||||||
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
|
[
|
||||||
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
|
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)
|
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
|
||||||
# This is where pcbnew.py typically lives on modern systems
|
# This is where pcbnew.py typically lives on modern systems
|
||||||
candidates.extend([
|
candidates.extend(
|
||||||
Path(f"/usr/lib/python3/dist-packages"),
|
[
|
||||||
Path(f"/usr/lib/python{py_version}/dist-packages"),
|
Path(f"/usr/lib/python3/dist-packages"),
|
||||||
Path(f"/usr/local/lib/python3/dist-packages"),
|
Path(f"/usr/lib/python{py_version}/dist-packages"),
|
||||||
Path(f"/usr/local/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()]
|
paths = [p for p in candidates if p.exists()]
|
||||||
|
|
||||||
@@ -102,7 +107,17 @@ class PlatformHelper:
|
|||||||
for kicad_app in kicad_app_paths:
|
for kicad_app in kicad_app_paths:
|
||||||
if kicad_app.exists():
|
if kicad_app.exists():
|
||||||
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
|
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():
|
if path.exists():
|
||||||
paths.append(path)
|
paths.append(path)
|
||||||
|
|
||||||
@@ -110,7 +125,7 @@ class PlatformHelper:
|
|||||||
homebrew_paths = [
|
homebrew_paths = [
|
||||||
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
|
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
|
||||||
Path("/opt/homebrew/lib/python3.11/site-packages"),
|
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"),
|
Path("/usr/local/lib/python3.11/site-packages"),
|
||||||
]
|
]
|
||||||
for hp in homebrew_paths:
|
for hp in homebrew_paths:
|
||||||
@@ -161,7 +176,10 @@ class PlatformHelper:
|
|||||||
patterns = [
|
patterns = [
|
||||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||||
"/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
|
# Add user library paths for all platforms
|
||||||
@@ -301,6 +319,7 @@ def detect_platform() -> dict:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Quick test/diagnostic
|
# Quick test/diagnostic
|
||||||
import json
|
import json
|
||||||
|
|
||||||
info = detect_platform()
|
info = detect_platform()
|
||||||
print("Platform Information:")
|
print("Platform Information:")
|
||||||
print(json.dumps(info, indent=2))
|
print(json.dumps(info, indent=2))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Tests for platform_helper utility
|
|||||||
|
|
||||||
These are unit tests that work on all platforms.
|
These are unit tests that work on all platforms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import platform
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -176,6 +177,7 @@ class TestKiCADPathDetection:
|
|||||||
PlatformHelper.add_kicad_to_python_path()
|
PlatformHelper.add_kicad_to_python_path()
|
||||||
try:
|
try:
|
||||||
import pcbnew
|
import pcbnew
|
||||||
|
|
||||||
# If we get here, pcbnew is available
|
# If we get here, pcbnew is available
|
||||||
assert pcbnew is not None
|
assert pcbnew is not None
|
||||||
version = pcbnew.GetBuildVersion()
|
version = pcbnew.GetBuildVersion()
|
||||||
|
|||||||
Reference in New Issue
Block a user