chore: normalize all tracked files to LF line endings
Mechanical application of the `.gitattributes` rules from the prior commit. All 50 files differ only in line endings — verified by `git diff --cached --ignore-all-space` being empty. Before: main had 42 CRLF + 27 LF Python files plus mixed-ending in YAML, templates, and shell scripts. After: every text file is LF (except the Windows-native *.ps1, *.bat scripts which remain CRLF per gitattributes). This eliminates the noisy-diff failure mode seen in PR #102, where a small logic change produced a 918-line diff due to whole-file CRLF→LF conversion.
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
"""
|
||||
KiCAD command implementations package
|
||||
"""
|
||||
|
||||
from .board import BoardCommands
|
||||
from .component import ComponentCommands
|
||||
from .design_rules import DesignRuleCommands
|
||||
from .export import ExportCommands
|
||||
from .project import ProjectCommands
|
||||
from .routing import RoutingCommands
|
||||
|
||||
__all__ = [
|
||||
"ProjectCommands",
|
||||
"BoardCommands",
|
||||
"ComponentCommands",
|
||||
"RoutingCommands",
|
||||
"DesignRuleCommands",
|
||||
"ExportCommands",
|
||||
]
|
||||
"""
|
||||
KiCAD command implementations package
|
||||
"""
|
||||
|
||||
from .board import BoardCommands
|
||||
from .component import ComponentCommands
|
||||
from .design_rules import DesignRuleCommands
|
||||
from .export import ExportCommands
|
||||
from .project import ProjectCommands
|
||||
from .routing import RoutingCommands
|
||||
|
||||
__all__ = [
|
||||
"ProjectCommands",
|
||||
"BoardCommands",
|
||||
"ComponentCommands",
|
||||
"RoutingCommands",
|
||||
"DesignRuleCommands",
|
||||
"ExportCommands",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
|
||||
This file is maintained for backward compatibility.
|
||||
It imports and re-exports the BoardCommands class from the board package.
|
||||
"""
|
||||
|
||||
from commands.board import BoardCommands
|
||||
|
||||
# Re-export the BoardCommands class for backward compatibility
|
||||
__all__ = ["BoardCommands"]
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
|
||||
This file is maintained for backward compatibility.
|
||||
It imports and re-exports the BoardCommands class from the board package.
|
||||
"""
|
||||
|
||||
from commands.board import BoardCommands
|
||||
|
||||
# Re-export the BoardCommands class for backward compatibility
|
||||
__all__ = ["BoardCommands"]
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
from .layers import BoardLayerCommands
|
||||
from .outline import BoardOutlineCommands
|
||||
|
||||
# Import specialized modules
|
||||
from .size import BoardSizeCommands
|
||||
from .view import BoardViewCommands
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardCommands:
|
||||
"""Handles board-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
# Initialize specialized command classes
|
||||
self.size_commands = BoardSizeCommands(board)
|
||||
self.layer_commands = BoardLayerCommands(board)
|
||||
self.outline_commands = BoardOutlineCommands(board)
|
||||
self.view_commands = BoardViewCommands(board)
|
||||
|
||||
# Delegate board size commands
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board"""
|
||||
self.size_commands.board = self.board
|
||||
return self.size_commands.set_board_size(params)
|
||||
|
||||
# Delegate layer commands
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.add_layer(params)
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.set_active_layer(params)
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.get_layer_list(params)
|
||||
|
||||
# Delegate board outline commands
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_board_outline(params)
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_mounting_hole(params)
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_text(params)
|
||||
|
||||
# Delegate view commands
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_info(params)
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_2d_view(params)
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_extents(params)
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
from .layers import BoardLayerCommands
|
||||
from .outline import BoardOutlineCommands
|
||||
|
||||
# Import specialized modules
|
||||
from .size import BoardSizeCommands
|
||||
from .view import BoardViewCommands
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardCommands:
|
||||
"""Handles board-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
# Initialize specialized command classes
|
||||
self.size_commands = BoardSizeCommands(board)
|
||||
self.layer_commands = BoardLayerCommands(board)
|
||||
self.outline_commands = BoardOutlineCommands(board)
|
||||
self.view_commands = BoardViewCommands(board)
|
||||
|
||||
# Delegate board size commands
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board"""
|
||||
self.size_commands.board = self.board
|
||||
return self.size_commands.set_board_size(params)
|
||||
|
||||
# Delegate layer commands
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.add_layer(params)
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.set_active_layer(params)
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.get_layer_list(params)
|
||||
|
||||
# Delegate board outline commands
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_board_outline(params)
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_mounting_hole(params)
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_text(params)
|
||||
|
||||
# Delegate view commands
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_info(params)
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_2d_view(params)
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_extents(params)
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
"""
|
||||
Board layer command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardLayerCommands:
|
||||
"""Handles board layer operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
name = params.get("name")
|
||||
layer_type = params.get("type")
|
||||
position = params.get("position")
|
||||
number = params.get("number")
|
||||
|
||||
if not name or not layer_type or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "name, type, and position are required",
|
||||
}
|
||||
|
||||
# Get layer stack
|
||||
layer_stack = self.board.GetLayerStack()
|
||||
|
||||
# Determine layer ID based on position and number
|
||||
layer_id = None
|
||||
if position == "inner":
|
||||
if number is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing layer number",
|
||||
"errorDetails": "number is required for inner layers",
|
||||
}
|
||||
layer_id = pcbnew.In1_Cu + (number - 1)
|
||||
elif position == "top":
|
||||
layer_id = pcbnew.F_Cu
|
||||
elif position == "bottom":
|
||||
layer_id = pcbnew.B_Cu
|
||||
|
||||
if layer_id is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer position",
|
||||
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
|
||||
}
|
||||
|
||||
# Set layer properties
|
||||
layer_stack.SetLayerName(layer_id, name)
|
||||
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
|
||||
|
||||
# Enable the layer
|
||||
self.board.SetLayerEnabled(layer_id, True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added layer: {name}",
|
||||
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding layer: {str(e)}")
|
||||
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layer = params.get("layer")
|
||||
if not layer:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No layer specified",
|
||||
"errorDetails": "layer parameter is required",
|
||||
}
|
||||
|
||||
# Find layer ID by name
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Layer not found",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Set active layer
|
||||
self.board.SetActiveLayer(layer_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Set active layer to: {layer}",
|
||||
"layer": {"name": layer, "id": layer_id},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting active layer: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set active layer",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "layers": layers}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting layer list: {str(e)}")
|
||||
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
|
||||
|
||||
def _get_layer_type(self, type_name: str) -> int:
|
||||
"""Convert layer type name to KiCAD layer type constant"""
|
||||
type_map = {
|
||||
"copper": pcbnew.LT_SIGNAL,
|
||||
"technical": pcbnew.LT_SIGNAL,
|
||||
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
|
||||
"signal": pcbnew.LT_SIGNAL,
|
||||
}
|
||||
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
"""
|
||||
Board layer command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardLayerCommands:
|
||||
"""Handles board layer operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
name = params.get("name")
|
||||
layer_type = params.get("type")
|
||||
position = params.get("position")
|
||||
number = params.get("number")
|
||||
|
||||
if not name or not layer_type or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "name, type, and position are required",
|
||||
}
|
||||
|
||||
# Get layer stack
|
||||
layer_stack = self.board.GetLayerStack()
|
||||
|
||||
# Determine layer ID based on position and number
|
||||
layer_id = None
|
||||
if position == "inner":
|
||||
if number is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing layer number",
|
||||
"errorDetails": "number is required for inner layers",
|
||||
}
|
||||
layer_id = pcbnew.In1_Cu + (number - 1)
|
||||
elif position == "top":
|
||||
layer_id = pcbnew.F_Cu
|
||||
elif position == "bottom":
|
||||
layer_id = pcbnew.B_Cu
|
||||
|
||||
if layer_id is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer position",
|
||||
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
|
||||
}
|
||||
|
||||
# Set layer properties
|
||||
layer_stack.SetLayerName(layer_id, name)
|
||||
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
|
||||
|
||||
# Enable the layer
|
||||
self.board.SetLayerEnabled(layer_id, True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added layer: {name}",
|
||||
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding layer: {str(e)}")
|
||||
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layer = params.get("layer")
|
||||
if not layer:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No layer specified",
|
||||
"errorDetails": "layer parameter is required",
|
||||
}
|
||||
|
||||
# Find layer ID by name
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Layer not found",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Set active layer
|
||||
self.board.SetActiveLayer(layer_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Set active layer to: {layer}",
|
||||
"layer": {"name": layer, "id": layer_id},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting active layer: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set active layer",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "layers": layers}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting layer list: {str(e)}")
|
||||
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
|
||||
|
||||
def _get_layer_type(self, type_name: str) -> int:
|
||||
"""Convert layer type name to KiCAD layer type constant"""
|
||||
type_map = {
|
||||
"copper": pcbnew.LT_SIGNAL,
|
||||
"technical": pcbnew.LT_SIGNAL,
|
||||
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
|
||||
"signal": pcbnew.LT_SIGNAL,
|
||||
}
|
||||
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
|
||||
@@ -1,484 +1,484 @@
|
||||
"""
|
||||
Board outline command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardOutlineCommands:
|
||||
"""Handles board outline operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Claude sends dimensions nested inside a "params" key:
|
||||
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
|
||||
# Unwrap the inner dict if present so we read dimensions from the right level.
|
||||
inner = params.get("params", params)
|
||||
|
||||
shape = params.get("shape", "rectangle")
|
||||
width = inner.get("width")
|
||||
height = inner.get("height")
|
||||
radius = inner.get("radius")
|
||||
# Accept both "cornerRadius" and "radius" regardless of shape name.
|
||||
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
|
||||
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
|
||||
if shape == "rectangle" and corner_radius > 0:
|
||||
shape = "rounded_rectangle"
|
||||
points = inner.get("points", [])
|
||||
unit = inner.get("unit", "mm")
|
||||
|
||||
# Position: accept top-left corner (x/y) or center (centerX/centerY).
|
||||
# Default: top-left at (0,0) so the board occupies positive coordinate space
|
||||
# and is consistent with component placement coordinates.
|
||||
x = inner.get("x")
|
||||
y = inner.get("y")
|
||||
if x is not None or y is not None:
|
||||
ox = x if x is not None else 0.0
|
||||
oy = y if y is not None else 0.0
|
||||
center_x = ox + (width or 0) / 2.0
|
||||
center_y = oy + (height or 0) / 2.0
|
||||
else:
|
||||
raw_cx = inner.get("centerX")
|
||||
raw_cy = inner.get("centerY")
|
||||
if raw_cx is not None or raw_cy is not None:
|
||||
center_x = raw_cx if raw_cx is not None else 0.0
|
||||
center_y = raw_cy if raw_cy is not None else 0.0
|
||||
else:
|
||||
# No position given → place top-left at (0,0)
|
||||
center_x = (width or 0) / 2.0
|
||||
center_y = (height or 0) / 2.0
|
||||
|
||||
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid shape",
|
||||
"errorDetails": f"Shape '{shape}' not supported",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
|
||||
|
||||
# Create drawing for edge cuts
|
||||
edge_layer = self.board.GetLayerID("Edge.Cuts")
|
||||
|
||||
if shape == "rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
|
||||
# Create rectangle
|
||||
top_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
top_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
|
||||
# Add lines for rectangle
|
||||
self._add_edge_line(top_left, top_right, edge_layer)
|
||||
self._add_edge_line(top_right, bottom_right, edge_layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, edge_layer)
|
||||
self._add_edge_line(bottom_left, top_left, edge_layer)
|
||||
|
||||
elif shape == "rounded_rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rounded rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
corner_radius_nm = int(corner_radius * scale)
|
||||
|
||||
# Create rounded rectangle
|
||||
self._add_rounded_rect(
|
||||
center_x_nm,
|
||||
center_y_nm,
|
||||
width_nm,
|
||||
height_nm,
|
||||
corner_radius_nm,
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
elif shape == "circle":
|
||||
if radius is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing radius",
|
||||
"errorDetails": "Radius is required for circle",
|
||||
}
|
||||
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
radius_nm = int(radius * scale)
|
||||
|
||||
# Create circle
|
||||
circle = pcbnew.PCB_SHAPE(self.board)
|
||||
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
|
||||
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
|
||||
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
|
||||
circle.SetLayer(edge_layer)
|
||||
circle.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(circle)
|
||||
|
||||
elif shape == "polygon":
|
||||
if not points or len(points) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing points",
|
||||
"errorDetails": "At least 3 points are required for polygon",
|
||||
}
|
||||
|
||||
# Convert points to nm
|
||||
polygon_points = []
|
||||
for point in points:
|
||||
x_nm = int(point["x"] * scale)
|
||||
y_nm = int(point["y"] * scale)
|
||||
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add lines for polygon
|
||||
for i in range(len(polygon_points)):
|
||||
self._add_edge_line(
|
||||
polygon_points[i],
|
||||
polygon_points[(i + 1) % len(polygon_points)],
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added board outline: {shape}",
|
||||
"outline": {
|
||||
"shape": shape,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y, "unit": unit},
|
||||
"radius": radius,
|
||||
"cornerRadius": corner_radius,
|
||||
"points": points,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding board outline: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add board outline",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
position = params.get("position")
|
||||
diameter = params.get("diameter")
|
||||
pad_diameter = params.get("padDiameter")
|
||||
plated = params.get("plated", False)
|
||||
|
||||
if not position or not diameter:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "position and diameter are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
diameter_nm = int(diameter * scale)
|
||||
pad_diameter_nm = (
|
||||
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
|
||||
) # 1mm larger by default
|
||||
|
||||
# Create footprint for mounting hole with unique reference
|
||||
existing_mh = [
|
||||
fp.GetReference()
|
||||
for fp in self.board.GetFootprints()
|
||||
if fp.GetReference().startswith("MH")
|
||||
]
|
||||
next_num = 1
|
||||
while f"MH{next_num}" in existing_mh:
|
||||
next_num += 1
|
||||
|
||||
module = pcbnew.FOOTPRINT(self.board)
|
||||
module.SetReference(f"MH{next_num}")
|
||||
module.SetValue(f"MountingHole_{diameter}mm")
|
||||
|
||||
# Create the pad for the hole
|
||||
pad = pcbnew.PAD(module)
|
||||
pad.SetNumber(1)
|
||||
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
|
||||
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
|
||||
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
|
||||
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
|
||||
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
|
||||
module.Add(pad)
|
||||
|
||||
# Position the mounting hole
|
||||
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add to board
|
||||
self.board.Add(module)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added mounting hole",
|
||||
"mountingHole": {
|
||||
"position": position,
|
||||
"diameter": diameter,
|
||||
"padDiameter": pad_diameter or diameter + 1,
|
||||
"plated": plated,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding mounting hole: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add mounting hole",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
text = params.get("text")
|
||||
position = params.get("position")
|
||||
layer = params.get("layer", "F.SilkS")
|
||||
size = params.get("size", 1.0)
|
||||
thickness = params.get("thickness", 0.15)
|
||||
rotation = params.get("rotation", 0)
|
||||
mirror = params.get("mirror", False)
|
||||
|
||||
if not text or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "text and position are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
size_nm = int(size * scale)
|
||||
thickness_nm = int(thickness * scale)
|
||||
|
||||
# Get layer ID
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Create text
|
||||
pcb_text = pcbnew.PCB_TEXT(self.board)
|
||||
pcb_text.SetText(text)
|
||||
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
pcb_text.SetLayer(layer_id)
|
||||
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
|
||||
pcb_text.SetTextThickness(thickness_nm)
|
||||
|
||||
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
|
||||
try:
|
||||
# Try KiCAD 9.0+ API (EDA_ANGLE)
|
||||
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
||||
pcb_text.SetTextAngle(angle)
|
||||
except (AttributeError, TypeError):
|
||||
# Fall back to older API (decidegrees as integer)
|
||||
pcb_text.SetTextAngle(int(rotation * 10))
|
||||
|
||||
pcb_text.SetMirrored(mirror)
|
||||
|
||||
# Add to board
|
||||
self.board.Add(pcb_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added text annotation",
|
||||
"text": {
|
||||
"text": text,
|
||||
"position": position,
|
||||
"layer": layer,
|
||||
"size": size,
|
||||
"thickness": thickness,
|
||||
"rotation": rotation,
|
||||
"mirror": mirror,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding text: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add text",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
|
||||
"""Add a line to the edge cuts layer"""
|
||||
line = pcbnew.PCB_SHAPE(self.board)
|
||||
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
||||
line.SetStart(start)
|
||||
line.SetEnd(end)
|
||||
line.SetLayer(layer)
|
||||
line.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(line)
|
||||
|
||||
def _add_rounded_rect(
|
||||
self,
|
||||
center_x_nm: int,
|
||||
center_y_nm: int,
|
||||
width_nm: int,
|
||||
height_nm: int,
|
||||
radius_nm: int,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add a rounded rectangle to the edge cuts layer"""
|
||||
if radius_nm <= 0:
|
||||
# If no radius, create regular rectangle
|
||||
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
|
||||
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
|
||||
|
||||
self._add_edge_line(top_left, top_right, layer)
|
||||
self._add_edge_line(top_right, bottom_right, layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, layer)
|
||||
self._add_edge_line(bottom_left, top_left, layer)
|
||||
return
|
||||
|
||||
# Calculate corner centers
|
||||
half_width = width_nm // 2
|
||||
half_height = height_nm // 2
|
||||
|
||||
# Ensure radius is not larger than half the smallest dimension
|
||||
max_radius = min(half_width, half_height)
|
||||
if radius_nm > max_radius:
|
||||
radius_nm = max_radius
|
||||
|
||||
# Calculate corner centers
|
||||
top_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
top_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
bottom_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
bottom_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
|
||||
# Add arcs for corners
|
||||
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
|
||||
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
|
||||
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
|
||||
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
|
||||
|
||||
# Add lines for straight edges
|
||||
# Top edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
|
||||
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Right edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
|
||||
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
|
||||
layer,
|
||||
)
|
||||
# Bottom edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
|
||||
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Left edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
|
||||
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
|
||||
layer,
|
||||
)
|
||||
|
||||
def _add_corner_arc(
|
||||
self,
|
||||
center: pcbnew.VECTOR2I,
|
||||
radius: int,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add an arc for a rounded corner"""
|
||||
# Create arc for corner
|
||||
arc = pcbnew.PCB_SHAPE(self.board)
|
||||
arc.SetShape(pcbnew.SHAPE_T_ARC)
|
||||
arc.SetCenter(center)
|
||||
|
||||
# Calculate start and end points
|
||||
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
|
||||
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
|
||||
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
|
||||
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
|
||||
|
||||
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
|
||||
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
|
||||
arc.SetLayer(layer)
|
||||
arc.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(arc)
|
||||
"""
|
||||
Board outline command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardOutlineCommands:
|
||||
"""Handles board outline operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Claude sends dimensions nested inside a "params" key:
|
||||
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
|
||||
# Unwrap the inner dict if present so we read dimensions from the right level.
|
||||
inner = params.get("params", params)
|
||||
|
||||
shape = params.get("shape", "rectangle")
|
||||
width = inner.get("width")
|
||||
height = inner.get("height")
|
||||
radius = inner.get("radius")
|
||||
# Accept both "cornerRadius" and "radius" regardless of shape name.
|
||||
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
|
||||
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
|
||||
if shape == "rectangle" and corner_radius > 0:
|
||||
shape = "rounded_rectangle"
|
||||
points = inner.get("points", [])
|
||||
unit = inner.get("unit", "mm")
|
||||
|
||||
# Position: accept top-left corner (x/y) or center (centerX/centerY).
|
||||
# Default: top-left at (0,0) so the board occupies positive coordinate space
|
||||
# and is consistent with component placement coordinates.
|
||||
x = inner.get("x")
|
||||
y = inner.get("y")
|
||||
if x is not None or y is not None:
|
||||
ox = x if x is not None else 0.0
|
||||
oy = y if y is not None else 0.0
|
||||
center_x = ox + (width or 0) / 2.0
|
||||
center_y = oy + (height or 0) / 2.0
|
||||
else:
|
||||
raw_cx = inner.get("centerX")
|
||||
raw_cy = inner.get("centerY")
|
||||
if raw_cx is not None or raw_cy is not None:
|
||||
center_x = raw_cx if raw_cx is not None else 0.0
|
||||
center_y = raw_cy if raw_cy is not None else 0.0
|
||||
else:
|
||||
# No position given → place top-left at (0,0)
|
||||
center_x = (width or 0) / 2.0
|
||||
center_y = (height or 0) / 2.0
|
||||
|
||||
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid shape",
|
||||
"errorDetails": f"Shape '{shape}' not supported",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
|
||||
|
||||
# Create drawing for edge cuts
|
||||
edge_layer = self.board.GetLayerID("Edge.Cuts")
|
||||
|
||||
if shape == "rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
|
||||
# Create rectangle
|
||||
top_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
top_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
|
||||
# Add lines for rectangle
|
||||
self._add_edge_line(top_left, top_right, edge_layer)
|
||||
self._add_edge_line(top_right, bottom_right, edge_layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, edge_layer)
|
||||
self._add_edge_line(bottom_left, top_left, edge_layer)
|
||||
|
||||
elif shape == "rounded_rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rounded rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
corner_radius_nm = int(corner_radius * scale)
|
||||
|
||||
# Create rounded rectangle
|
||||
self._add_rounded_rect(
|
||||
center_x_nm,
|
||||
center_y_nm,
|
||||
width_nm,
|
||||
height_nm,
|
||||
corner_radius_nm,
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
elif shape == "circle":
|
||||
if radius is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing radius",
|
||||
"errorDetails": "Radius is required for circle",
|
||||
}
|
||||
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
radius_nm = int(radius * scale)
|
||||
|
||||
# Create circle
|
||||
circle = pcbnew.PCB_SHAPE(self.board)
|
||||
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
|
||||
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
|
||||
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
|
||||
circle.SetLayer(edge_layer)
|
||||
circle.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(circle)
|
||||
|
||||
elif shape == "polygon":
|
||||
if not points or len(points) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing points",
|
||||
"errorDetails": "At least 3 points are required for polygon",
|
||||
}
|
||||
|
||||
# Convert points to nm
|
||||
polygon_points = []
|
||||
for point in points:
|
||||
x_nm = int(point["x"] * scale)
|
||||
y_nm = int(point["y"] * scale)
|
||||
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add lines for polygon
|
||||
for i in range(len(polygon_points)):
|
||||
self._add_edge_line(
|
||||
polygon_points[i],
|
||||
polygon_points[(i + 1) % len(polygon_points)],
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added board outline: {shape}",
|
||||
"outline": {
|
||||
"shape": shape,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y, "unit": unit},
|
||||
"radius": radius,
|
||||
"cornerRadius": corner_radius,
|
||||
"points": points,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding board outline: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add board outline",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
position = params.get("position")
|
||||
diameter = params.get("diameter")
|
||||
pad_diameter = params.get("padDiameter")
|
||||
plated = params.get("plated", False)
|
||||
|
||||
if not position or not diameter:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "position and diameter are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
diameter_nm = int(diameter * scale)
|
||||
pad_diameter_nm = (
|
||||
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
|
||||
) # 1mm larger by default
|
||||
|
||||
# Create footprint for mounting hole with unique reference
|
||||
existing_mh = [
|
||||
fp.GetReference()
|
||||
for fp in self.board.GetFootprints()
|
||||
if fp.GetReference().startswith("MH")
|
||||
]
|
||||
next_num = 1
|
||||
while f"MH{next_num}" in existing_mh:
|
||||
next_num += 1
|
||||
|
||||
module = pcbnew.FOOTPRINT(self.board)
|
||||
module.SetReference(f"MH{next_num}")
|
||||
module.SetValue(f"MountingHole_{diameter}mm")
|
||||
|
||||
# Create the pad for the hole
|
||||
pad = pcbnew.PAD(module)
|
||||
pad.SetNumber(1)
|
||||
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
|
||||
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
|
||||
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
|
||||
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
|
||||
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
|
||||
module.Add(pad)
|
||||
|
||||
# Position the mounting hole
|
||||
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add to board
|
||||
self.board.Add(module)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added mounting hole",
|
||||
"mountingHole": {
|
||||
"position": position,
|
||||
"diameter": diameter,
|
||||
"padDiameter": pad_diameter or diameter + 1,
|
||||
"plated": plated,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding mounting hole: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add mounting hole",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
text = params.get("text")
|
||||
position = params.get("position")
|
||||
layer = params.get("layer", "F.SilkS")
|
||||
size = params.get("size", 1.0)
|
||||
thickness = params.get("thickness", 0.15)
|
||||
rotation = params.get("rotation", 0)
|
||||
mirror = params.get("mirror", False)
|
||||
|
||||
if not text or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "text and position are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
size_nm = int(size * scale)
|
||||
thickness_nm = int(thickness * scale)
|
||||
|
||||
# Get layer ID
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Create text
|
||||
pcb_text = pcbnew.PCB_TEXT(self.board)
|
||||
pcb_text.SetText(text)
|
||||
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
pcb_text.SetLayer(layer_id)
|
||||
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
|
||||
pcb_text.SetTextThickness(thickness_nm)
|
||||
|
||||
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
|
||||
try:
|
||||
# Try KiCAD 9.0+ API (EDA_ANGLE)
|
||||
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
||||
pcb_text.SetTextAngle(angle)
|
||||
except (AttributeError, TypeError):
|
||||
# Fall back to older API (decidegrees as integer)
|
||||
pcb_text.SetTextAngle(int(rotation * 10))
|
||||
|
||||
pcb_text.SetMirrored(mirror)
|
||||
|
||||
# Add to board
|
||||
self.board.Add(pcb_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added text annotation",
|
||||
"text": {
|
||||
"text": text,
|
||||
"position": position,
|
||||
"layer": layer,
|
||||
"size": size,
|
||||
"thickness": thickness,
|
||||
"rotation": rotation,
|
||||
"mirror": mirror,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding text: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add text",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
|
||||
"""Add a line to the edge cuts layer"""
|
||||
line = pcbnew.PCB_SHAPE(self.board)
|
||||
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
||||
line.SetStart(start)
|
||||
line.SetEnd(end)
|
||||
line.SetLayer(layer)
|
||||
line.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(line)
|
||||
|
||||
def _add_rounded_rect(
|
||||
self,
|
||||
center_x_nm: int,
|
||||
center_y_nm: int,
|
||||
width_nm: int,
|
||||
height_nm: int,
|
||||
radius_nm: int,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add a rounded rectangle to the edge cuts layer"""
|
||||
if radius_nm <= 0:
|
||||
# If no radius, create regular rectangle
|
||||
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
|
||||
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
|
||||
|
||||
self._add_edge_line(top_left, top_right, layer)
|
||||
self._add_edge_line(top_right, bottom_right, layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, layer)
|
||||
self._add_edge_line(bottom_left, top_left, layer)
|
||||
return
|
||||
|
||||
# Calculate corner centers
|
||||
half_width = width_nm // 2
|
||||
half_height = height_nm // 2
|
||||
|
||||
# Ensure radius is not larger than half the smallest dimension
|
||||
max_radius = min(half_width, half_height)
|
||||
if radius_nm > max_radius:
|
||||
radius_nm = max_radius
|
||||
|
||||
# Calculate corner centers
|
||||
top_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
top_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
bottom_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
bottom_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
|
||||
# Add arcs for corners
|
||||
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
|
||||
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
|
||||
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
|
||||
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
|
||||
|
||||
# Add lines for straight edges
|
||||
# Top edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
|
||||
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Right edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
|
||||
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
|
||||
layer,
|
||||
)
|
||||
# Bottom edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
|
||||
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Left edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
|
||||
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
|
||||
layer,
|
||||
)
|
||||
|
||||
def _add_corner_arc(
|
||||
self,
|
||||
center: pcbnew.VECTOR2I,
|
||||
radius: int,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add an arc for a rounded corner"""
|
||||
# Create arc for corner
|
||||
arc = pcbnew.PCB_SHAPE(self.board)
|
||||
arc.SetShape(pcbnew.SHAPE_T_ARC)
|
||||
arc.SetCenter(center)
|
||||
|
||||
# Calculate start and end points
|
||||
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
|
||||
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
|
||||
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
|
||||
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
|
||||
|
||||
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
|
||||
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
|
||||
arc.SetLayer(layer)
|
||||
arc.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(arc)
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
"""
|
||||
Board size command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardSizeCommands:
|
||||
"""Handles board size operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board by creating edge cuts outline"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
unit = params.get("unit", "mm")
|
||||
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required",
|
||||
}
|
||||
|
||||
# Create board outline using BoardOutlineCommands
|
||||
# This properly creates edge cuts on Edge.Cuts layer
|
||||
from commands.board.outline import BoardOutlineCommands
|
||||
|
||||
outline_commands = BoardOutlineCommands(self.board)
|
||||
|
||||
# Create rectangular outline centered at origin
|
||||
result = outline_commands.add_board_outline(
|
||||
{
|
||||
"shape": "rectangle",
|
||||
"centerX": width / 2, # Center X
|
||||
"centerY": height / 2, # Center Y
|
||||
"width": width,
|
||||
"height": height,
|
||||
"unit": unit,
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created board outline: {width}x{height} {unit}",
|
||||
"size": {"width": width, "height": height, "unit": unit},
|
||||
}
|
||||
else:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting board size: {str(e)}")
|
||||
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}
|
||||
"""
|
||||
Board size command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardSizeCommands:
|
||||
"""Handles board size operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board by creating edge cuts outline"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
unit = params.get("unit", "mm")
|
||||
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required",
|
||||
}
|
||||
|
||||
# Create board outline using BoardOutlineCommands
|
||||
# This properly creates edge cuts on Edge.Cuts layer
|
||||
from commands.board.outline import BoardOutlineCommands
|
||||
|
||||
outline_commands = BoardOutlineCommands(self.board)
|
||||
|
||||
# Create rectangular outline centered at origin
|
||||
result = outline_commands.add_board_outline(
|
||||
{
|
||||
"shape": "rectangle",
|
||||
"centerX": width / 2, # Center X
|
||||
"centerY": height / 2, # Center Y
|
||||
"width": width,
|
||||
"height": height,
|
||||
"unit": unit,
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created board outline: {width}x{height} {unit}",
|
||||
"size": {"width": width, "height": height, "unit": unit},
|
||||
}
|
||||
else:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting board size: {str(e)}")
|
||||
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
"""
|
||||
Board view command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardViewCommands:
|
||||
"""Handles board viewing operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get board dimensions
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
width_nm = board_box.GetWidth()
|
||||
height_nm = board_box.GetHeight()
|
||||
|
||||
# Convert to mm
|
||||
width_mm = width_nm / 1000000
|
||||
height_mm = height_nm / 1000000
|
||||
|
||||
# Get layer information
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"board": {
|
||||
"filename": self.board.GetFileName(),
|
||||
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
|
||||
"layers": layers,
|
||||
"title": self.board.GetTitleBlock().GetTitle(),
|
||||
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get parameters
|
||||
width = params.get("width", 800)
|
||||
height = params.get("height", 600)
|
||||
format = params.get("format", "png")
|
||||
layers = params.get("layers", [])
|
||||
|
||||
# Create plot controller
|
||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
||||
|
||||
# Set up plot options
|
||||
plot_opts = plotter.GetPlotOptions()
|
||||
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
||||
plot_opts.SetScale(1)
|
||||
plot_opts.SetMirror(False)
|
||||
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
|
||||
plot_opts.SetPlotFrameRef(False)
|
||||
plot_opts.SetPlotValue(True)
|
||||
plot_opts.SetPlotReference(True)
|
||||
|
||||
# Plot to SVG first (for vector output)
|
||||
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
|
||||
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
||||
|
||||
# Plot specified layers or all enabled layers
|
||||
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
|
||||
if layers:
|
||||
for layer_name in layers:
|
||||
layer_id = self.board.GetLayerID(layer_name)
|
||||
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
else:
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
|
||||
# Get the actual filename that was created (includes project name prefix)
|
||||
temp_svg = plotter.GetPlotFileName()
|
||||
|
||||
plotter.ClosePlot()
|
||||
|
||||
# Convert SVG to requested format
|
||||
if format == "svg":
|
||||
with open(temp_svg, "r") as f:
|
||||
svg_data = f.read()
|
||||
os.remove(temp_svg)
|
||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||
else:
|
||||
# Use PIL to convert SVG to PNG/JPG
|
||||
from cairosvg import svg2png
|
||||
|
||||
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
||||
os.remove(temp_svg)
|
||||
|
||||
if format == "jpg":
|
||||
# Convert PNG to JPG
|
||||
img = Image.open(io.BytesIO(png_data))
|
||||
jpg_buffer = io.BytesIO()
|
||||
img.convert("RGB").save(jpg_buffer, format="JPEG")
|
||||
jpg_data = jpg_buffer.getvalue()
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
|
||||
"format": "jpg",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board 2D view: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board 2D view",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get unit preference (default to mm)
|
||||
unit = params.get("unit", "mm")
|
||||
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
||||
|
||||
# Get board bounding box
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
|
||||
# Extract bounds in nanometers, then convert
|
||||
left = board_box.GetLeft() / scale
|
||||
top = board_box.GetTop() / scale
|
||||
right = board_box.GetRight() / scale
|
||||
bottom = board_box.GetBottom() / scale
|
||||
width = board_box.GetWidth() / scale
|
||||
height = board_box.GetHeight() / scale
|
||||
|
||||
# Get center point
|
||||
center_x = board_box.GetCenter().x / scale
|
||||
center_y = board_box.GetCenter().y / scale
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"extents": {
|
||||
"left": left,
|
||||
"top": top,
|
||||
"right": right,
|
||||
"bottom": bottom,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y},
|
||||
"unit": unit,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board extents: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board extents",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
"""
|
||||
Board view command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardViewCommands:
|
||||
"""Handles board viewing operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get board dimensions
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
width_nm = board_box.GetWidth()
|
||||
height_nm = board_box.GetHeight()
|
||||
|
||||
# Convert to mm
|
||||
width_mm = width_nm / 1000000
|
||||
height_mm = height_nm / 1000000
|
||||
|
||||
# Get layer information
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"board": {
|
||||
"filename": self.board.GetFileName(),
|
||||
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
|
||||
"layers": layers,
|
||||
"title": self.board.GetTitleBlock().GetTitle(),
|
||||
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get parameters
|
||||
width = params.get("width", 800)
|
||||
height = params.get("height", 600)
|
||||
format = params.get("format", "png")
|
||||
layers = params.get("layers", [])
|
||||
|
||||
# Create plot controller
|
||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
||||
|
||||
# Set up plot options
|
||||
plot_opts = plotter.GetPlotOptions()
|
||||
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
||||
plot_opts.SetScale(1)
|
||||
plot_opts.SetMirror(False)
|
||||
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
|
||||
plot_opts.SetPlotFrameRef(False)
|
||||
plot_opts.SetPlotValue(True)
|
||||
plot_opts.SetPlotReference(True)
|
||||
|
||||
# Plot to SVG first (for vector output)
|
||||
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
|
||||
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
||||
|
||||
# Plot specified layers or all enabled layers
|
||||
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
|
||||
if layers:
|
||||
for layer_name in layers:
|
||||
layer_id = self.board.GetLayerID(layer_name)
|
||||
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
else:
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
|
||||
# Get the actual filename that was created (includes project name prefix)
|
||||
temp_svg = plotter.GetPlotFileName()
|
||||
|
||||
plotter.ClosePlot()
|
||||
|
||||
# Convert SVG to requested format
|
||||
if format == "svg":
|
||||
with open(temp_svg, "r") as f:
|
||||
svg_data = f.read()
|
||||
os.remove(temp_svg)
|
||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||
else:
|
||||
# Use PIL to convert SVG to PNG/JPG
|
||||
from cairosvg import svg2png
|
||||
|
||||
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
||||
os.remove(temp_svg)
|
||||
|
||||
if format == "jpg":
|
||||
# Convert PNG to JPG
|
||||
img = Image.open(io.BytesIO(png_data))
|
||||
jpg_buffer = io.BytesIO()
|
||||
img.convert("RGB").save(jpg_buffer, format="JPEG")
|
||||
jpg_data = jpg_buffer.getvalue()
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
|
||||
"format": "jpg",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board 2D view: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board 2D view",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get unit preference (default to mm)
|
||||
unit = params.get("unit", "mm")
|
||||
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
||||
|
||||
# Get board bounding box
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
|
||||
# Extract bounds in nanometers, then convert
|
||||
left = board_box.GetLeft() / scale
|
||||
top = board_box.GetTop() / scale
|
||||
right = board_box.GetRight() / scale
|
||||
bottom = board_box.GetBottom() / scale
|
||||
width = board_box.GetWidth() / scale
|
||||
height = board_box.GetHeight() / scale
|
||||
|
||||
# Get center point
|
||||
center_x = board_box.GetCenter().x / scale
|
||||
center_y = board_box.GetCenter().y / scale
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"extents": {
|
||||
"left": left,
|
||||
"top": top,
|
||||
"right": right,
|
||||
"bottom": bottom,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y},
|
||||
"unit": unit,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board extents: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board extents",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,410 +1,410 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import dynamic symbol loader
|
||||
try:
|
||||
from commands.dynamic_symbol_loader import DynamicSymbolLoader
|
||||
|
||||
DYNAMIC_LOADING_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
|
||||
DYNAMIC_LOADING_AVAILABLE = False
|
||||
|
||||
|
||||
class ComponentManager:
|
||||
"""Manage components in a schematic"""
|
||||
|
||||
# Initialize dynamic loader (class variable, shared across instances)
|
||||
_dynamic_loader = None
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_loader(cls) -> Any:
|
||||
"""Get or create dynamic symbol loader instance"""
|
||||
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
|
||||
cls._dynamic_loader = DynamicSymbolLoader()
|
||||
return cls._dynamic_loader
|
||||
|
||||
# Template symbol references mapping component type to template reference
|
||||
TEMPLATE_MAP = {
|
||||
# Passives
|
||||
"R": "_TEMPLATE_R",
|
||||
"C": "_TEMPLATE_C",
|
||||
"L": "_TEMPLATE_L",
|
||||
"Y": "_TEMPLATE_Y",
|
||||
"Crystal": "_TEMPLATE_Y",
|
||||
# Semiconductors
|
||||
"D": "_TEMPLATE_D",
|
||||
"LED": "_TEMPLATE_LED",
|
||||
"Q": "_TEMPLATE_Q_NPN",
|
||||
"Q_NPN": "_TEMPLATE_Q_NPN",
|
||||
"Q_NMOS": "_TEMPLATE_Q_NMOS",
|
||||
"MOSFET": "_TEMPLATE_Q_NMOS",
|
||||
# ICs
|
||||
"U": "_TEMPLATE_U_OPAMP",
|
||||
"OpAmp": "_TEMPLATE_U_OPAMP",
|
||||
"IC": "_TEMPLATE_U_OPAMP",
|
||||
"U_REG": "_TEMPLATE_U_REG",
|
||||
"Regulator": "_TEMPLATE_U_REG",
|
||||
# Connectors
|
||||
"J": "_TEMPLATE_J2",
|
||||
"J2": "_TEMPLATE_J2",
|
||||
"J4": "_TEMPLATE_J4",
|
||||
"Conn_2": "_TEMPLATE_J2",
|
||||
"Conn_4": "_TEMPLATE_J4",
|
||||
# Misc
|
||||
"SW": "_TEMPLATE_SW",
|
||||
"Button": "_TEMPLATE_SW",
|
||||
"Switch": "_TEMPLATE_SW",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_template(
|
||||
cls,
|
||||
schematic: Schematic,
|
||||
comp_type: str,
|
||||
library: Optional[str] = None,
|
||||
schematic_path: Optional[Path] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Get template reference for a component type, creating it dynamically if needed
|
||||
|
||||
Args:
|
||||
schematic: Schematic object
|
||||
comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx')
|
||||
library: Optional library name (defaults to 'Device' for common types)
|
||||
schematic_path: Optional path to schematic file (required for dynamic loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
|
||||
"""
|
||||
|
||||
# Helper function to check if template exists in schematic
|
||||
def template_exists(schematic: Any, template_ref: str) -> bool:
|
||||
"""Check if template exists by iterating symbols (handles special characters)"""
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 1. Check static template map first
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
template_ref = cls.TEMPLATE_MAP[comp_type]
|
||||
# Verify template exists in schematic
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Using static template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 2. Check if dynamically loaded template already exists
|
||||
# Build potential template reference names
|
||||
potential_refs = []
|
||||
if library:
|
||||
potential_refs.append(f"_TEMPLATE_{library}_{comp_type}")
|
||||
potential_refs.append(f"_TEMPLATE_{comp_type}")
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
potential_refs.append(cls.TEMPLATE_MAP[comp_type])
|
||||
|
||||
# Check each potential reference
|
||||
for template_ref in potential_refs:
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Found existing template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 3. Try dynamic loading
|
||||
if not DYNAMIC_LOADING_AVAILABLE:
|
||||
logger.warning(
|
||||
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
|
||||
)
|
||||
# Fall back to basic resistor template
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
loader = cls.get_dynamic_loader()
|
||||
if not loader:
|
||||
logger.warning("Dynamic loader unavailable, using fallback template")
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
# Check if schematic path is available
|
||||
if schematic_path is None:
|
||||
logger.warning("Dynamic loading requires schematic file path but none was provided")
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
# Determine library name
|
||||
if library is None:
|
||||
# Default library for common component types
|
||||
library = "Device" # Most passives and basic components are in Device library
|
||||
|
||||
try:
|
||||
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
|
||||
|
||||
# Use dynamic symbol loader to inject symbol and create template
|
||||
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
|
||||
|
||||
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
|
||||
# Signal that schematic needs reload to see new template
|
||||
return (template_ref, True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dynamic loading failed: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
# Fall back to static template if available
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
@staticmethod
|
||||
def add_component(
|
||||
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Add a component to the schematic by cloning from template
|
||||
|
||||
Args:
|
||||
schematic: Schematic object to add component to
|
||||
component_def: Component definition dictionary
|
||||
schematic_path: Optional path to schematic file (enables dynamic symbol loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic
|
||||
"""
|
||||
try:
|
||||
from commands.schematic import SchematicManager
|
||||
|
||||
logger.info(
|
||||
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
|
||||
)
|
||||
logger.debug(f"Full component_def: {component_def}")
|
||||
|
||||
# Get component type and determine template
|
||||
comp_type = component_def.get("type", "R")
|
||||
library = component_def.get("library", None) # Optional library specification
|
||||
|
||||
# Get template reference (static or dynamic)
|
||||
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
||||
schematic, comp_type, library, schematic_path
|
||||
)
|
||||
|
||||
# If dynamic loading occurred, reload schematic to see new template
|
||||
if needs_reload and schematic_path:
|
||||
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
|
||||
schematic = SchematicManager.load_schematic(str(schematic_path))
|
||||
|
||||
# Find template symbol by reference (handles special characters like +)
|
||||
template_symbol = None
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
template_symbol = symbol
|
||||
break
|
||||
|
||||
if not template_symbol:
|
||||
logger.error(
|
||||
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
|
||||
)
|
||||
|
||||
# Clone the template symbol
|
||||
new_symbol = template_symbol.clone()
|
||||
logger.debug(f"Cloned template symbol {template_ref}")
|
||||
|
||||
# Set reference
|
||||
reference = component_def.get("reference", "R?")
|
||||
new_symbol.property.Reference.value = reference
|
||||
logger.debug(f"Set reference to {reference}")
|
||||
|
||||
# Set value
|
||||
if "value" in component_def:
|
||||
new_symbol.property.Value.value = component_def["value"]
|
||||
logger.debug(f"Set value to {component_def['value']}")
|
||||
|
||||
# Set footprint
|
||||
if "footprint" in component_def:
|
||||
new_symbol.property.Footprint.value = component_def["footprint"]
|
||||
logger.debug(f"Set footprint to {component_def['footprint']}")
|
||||
|
||||
# Set datasheet
|
||||
if "datasheet" in component_def:
|
||||
new_symbol.property.Datasheet.value = component_def["datasheet"]
|
||||
|
||||
# Set position
|
||||
x = component_def.get("x", 0)
|
||||
y = component_def.get("y", 0)
|
||||
rotation = component_def.get("rotation", 0)
|
||||
new_symbol.at.value = [x, y, rotation]
|
||||
logger.debug(f"Set position to ({x}, {y}, {rotation})")
|
||||
|
||||
# Set BOM and board flags
|
||||
new_symbol.in_bom.value = component_def.get("in_bom", True)
|
||||
new_symbol.on_board.value = component_def.get("on_board", True)
|
||||
new_symbol.dnp.value = component_def.get("dnp", False)
|
||||
|
||||
# Generate new UUID
|
||||
new_symbol.uuid.value = str(uuid.uuid4())
|
||||
|
||||
# Append to schematic
|
||||
schematic.symbol.append(new_symbol)
|
||||
logger.info(f"Successfully added component {reference} to schematic")
|
||||
|
||||
return new_symbol
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding component: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def remove_component(schematic: Schematic, component_ref: str) -> bool:
|
||||
"""Remove a component from the schematic by reference designator"""
|
||||
try:
|
||||
# kicad-skip doesn't have a direct remove_symbol method by reference.
|
||||
# We need to find the symbol and then remove it from the symbols list.
|
||||
symbol_to_remove = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_remove = symbol
|
||||
break
|
||||
|
||||
if symbol_to_remove:
|
||||
schematic.symbol._elements.remove(symbol_to_remove)
|
||||
logger.info(f"Removed component {component_ref} from schematic.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
|
||||
"""Update component properties by reference designator"""
|
||||
try:
|
||||
symbol_to_update = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_update = symbol
|
||||
break
|
||||
|
||||
if symbol_to_update:
|
||||
for key, value in new_properties.items():
|
||||
if key in symbol_to_update.property:
|
||||
symbol_to_update.property[key].value = value
|
||||
else:
|
||||
symbol_to_update.property.append(key, value)
|
||||
logger.info(f"Updated properties for component {component_ref}.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_component(schematic: Schematic, component_ref: str) -> Any:
|
||||
"""Get a component by reference designator"""
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
logger.debug(f"Found component with reference {component_ref}.")
|
||||
return symbol
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def search_components(schematic: Schematic, query: str) -> List[Any]:
|
||||
"""Search for components matching criteria (basic implementation)"""
|
||||
# This is a basic search, could be expanded to use regex or more complex logic
|
||||
matching_components = []
|
||||
query_lower = query.lower()
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
query_lower in symbol.reference.lower()
|
||||
or query_lower in symbol.name.lower()
|
||||
or (
|
||||
hasattr(symbol.property, "Value")
|
||||
and query_lower in symbol.property.Value.value.lower()
|
||||
)
|
||||
):
|
||||
matching_components.append(symbol)
|
||||
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
return matching_components
|
||||
|
||||
@staticmethod
|
||||
def get_all_components(schematic: Schematic) -> List[Any]:
|
||||
"""Get all components in schematic"""
|
||||
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
return list(schematic.symbol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
from schematic import ( # Assuming schematic.py is in the same directory
|
||||
SchematicManager,
|
||||
)
|
||||
|
||||
# Create a new schematic
|
||||
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
|
||||
|
||||
# Add components
|
||||
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
|
||||
comp2_def = {
|
||||
"type": "C",
|
||||
"reference": "C1",
|
||||
"value": "0.1uF",
|
||||
"x": 200,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
}
|
||||
comp3_def = {
|
||||
"type": "LED",
|
||||
"reference": "D1",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
"properties": {"Color": "Red"},
|
||||
}
|
||||
|
||||
comp1 = ComponentManager.add_component(test_sch, comp1_def)
|
||||
comp2 = ComponentManager.add_component(test_sch, comp2_def)
|
||||
comp3 = ComponentManager.add_component(test_sch, comp3_def)
|
||||
|
||||
# Get a component
|
||||
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
||||
if retrieved_comp:
|
||||
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
|
||||
|
||||
# Update a component
|
||||
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
|
||||
|
||||
# Search components
|
||||
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
|
||||
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
||||
|
||||
# Get all components
|
||||
all_comps = ComponentManager.get_all_components(test_sch)
|
||||
print(f"All components: {[c.reference for c in all_comps]}")
|
||||
|
||||
# Remove a component
|
||||
ComponentManager.remove_component(test_sch, "D1")
|
||||
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
||||
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
|
||||
|
||||
# Save the schematic (optional)
|
||||
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
||||
|
||||
# Clean up (if saved)
|
||||
# if os.path.exists("component_test.kicad_sch"):
|
||||
# os.remove("component_test.kicad_sch")
|
||||
# print("Cleaned up component_test.kicad_sch")
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import dynamic symbol loader
|
||||
try:
|
||||
from commands.dynamic_symbol_loader import DynamicSymbolLoader
|
||||
|
||||
DYNAMIC_LOADING_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
|
||||
DYNAMIC_LOADING_AVAILABLE = False
|
||||
|
||||
|
||||
class ComponentManager:
|
||||
"""Manage components in a schematic"""
|
||||
|
||||
# Initialize dynamic loader (class variable, shared across instances)
|
||||
_dynamic_loader = None
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_loader(cls) -> Any:
|
||||
"""Get or create dynamic symbol loader instance"""
|
||||
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
|
||||
cls._dynamic_loader = DynamicSymbolLoader()
|
||||
return cls._dynamic_loader
|
||||
|
||||
# Template symbol references mapping component type to template reference
|
||||
TEMPLATE_MAP = {
|
||||
# Passives
|
||||
"R": "_TEMPLATE_R",
|
||||
"C": "_TEMPLATE_C",
|
||||
"L": "_TEMPLATE_L",
|
||||
"Y": "_TEMPLATE_Y",
|
||||
"Crystal": "_TEMPLATE_Y",
|
||||
# Semiconductors
|
||||
"D": "_TEMPLATE_D",
|
||||
"LED": "_TEMPLATE_LED",
|
||||
"Q": "_TEMPLATE_Q_NPN",
|
||||
"Q_NPN": "_TEMPLATE_Q_NPN",
|
||||
"Q_NMOS": "_TEMPLATE_Q_NMOS",
|
||||
"MOSFET": "_TEMPLATE_Q_NMOS",
|
||||
# ICs
|
||||
"U": "_TEMPLATE_U_OPAMP",
|
||||
"OpAmp": "_TEMPLATE_U_OPAMP",
|
||||
"IC": "_TEMPLATE_U_OPAMP",
|
||||
"U_REG": "_TEMPLATE_U_REG",
|
||||
"Regulator": "_TEMPLATE_U_REG",
|
||||
# Connectors
|
||||
"J": "_TEMPLATE_J2",
|
||||
"J2": "_TEMPLATE_J2",
|
||||
"J4": "_TEMPLATE_J4",
|
||||
"Conn_2": "_TEMPLATE_J2",
|
||||
"Conn_4": "_TEMPLATE_J4",
|
||||
# Misc
|
||||
"SW": "_TEMPLATE_SW",
|
||||
"Button": "_TEMPLATE_SW",
|
||||
"Switch": "_TEMPLATE_SW",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_template(
|
||||
cls,
|
||||
schematic: Schematic,
|
||||
comp_type: str,
|
||||
library: Optional[str] = None,
|
||||
schematic_path: Optional[Path] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Get template reference for a component type, creating it dynamically if needed
|
||||
|
||||
Args:
|
||||
schematic: Schematic object
|
||||
comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx')
|
||||
library: Optional library name (defaults to 'Device' for common types)
|
||||
schematic_path: Optional path to schematic file (required for dynamic loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
|
||||
"""
|
||||
|
||||
# Helper function to check if template exists in schematic
|
||||
def template_exists(schematic: Any, template_ref: str) -> bool:
|
||||
"""Check if template exists by iterating symbols (handles special characters)"""
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 1. Check static template map first
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
template_ref = cls.TEMPLATE_MAP[comp_type]
|
||||
# Verify template exists in schematic
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Using static template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 2. Check if dynamically loaded template already exists
|
||||
# Build potential template reference names
|
||||
potential_refs = []
|
||||
if library:
|
||||
potential_refs.append(f"_TEMPLATE_{library}_{comp_type}")
|
||||
potential_refs.append(f"_TEMPLATE_{comp_type}")
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
potential_refs.append(cls.TEMPLATE_MAP[comp_type])
|
||||
|
||||
# Check each potential reference
|
||||
for template_ref in potential_refs:
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Found existing template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 3. Try dynamic loading
|
||||
if not DYNAMIC_LOADING_AVAILABLE:
|
||||
logger.warning(
|
||||
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
|
||||
)
|
||||
# Fall back to basic resistor template
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
loader = cls.get_dynamic_loader()
|
||||
if not loader:
|
||||
logger.warning("Dynamic loader unavailable, using fallback template")
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
# Check if schematic path is available
|
||||
if schematic_path is None:
|
||||
logger.warning("Dynamic loading requires schematic file path but none was provided")
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
# Determine library name
|
||||
if library is None:
|
||||
# Default library for common component types
|
||||
library = "Device" # Most passives and basic components are in Device library
|
||||
|
||||
try:
|
||||
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
|
||||
|
||||
# Use dynamic symbol loader to inject symbol and create template
|
||||
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
|
||||
|
||||
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
|
||||
# Signal that schematic needs reload to see new template
|
||||
return (template_ref, True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dynamic loading failed: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
# Fall back to static template if available
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
@staticmethod
|
||||
def add_component(
|
||||
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Add a component to the schematic by cloning from template
|
||||
|
||||
Args:
|
||||
schematic: Schematic object to add component to
|
||||
component_def: Component definition dictionary
|
||||
schematic_path: Optional path to schematic file (enables dynamic symbol loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic
|
||||
"""
|
||||
try:
|
||||
from commands.schematic import SchematicManager
|
||||
|
||||
logger.info(
|
||||
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
|
||||
)
|
||||
logger.debug(f"Full component_def: {component_def}")
|
||||
|
||||
# Get component type and determine template
|
||||
comp_type = component_def.get("type", "R")
|
||||
library = component_def.get("library", None) # Optional library specification
|
||||
|
||||
# Get template reference (static or dynamic)
|
||||
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
||||
schematic, comp_type, library, schematic_path
|
||||
)
|
||||
|
||||
# If dynamic loading occurred, reload schematic to see new template
|
||||
if needs_reload and schematic_path:
|
||||
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
|
||||
schematic = SchematicManager.load_schematic(str(schematic_path))
|
||||
|
||||
# Find template symbol by reference (handles special characters like +)
|
||||
template_symbol = None
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
template_symbol = symbol
|
||||
break
|
||||
|
||||
if not template_symbol:
|
||||
logger.error(
|
||||
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
|
||||
)
|
||||
|
||||
# Clone the template symbol
|
||||
new_symbol = template_symbol.clone()
|
||||
logger.debug(f"Cloned template symbol {template_ref}")
|
||||
|
||||
# Set reference
|
||||
reference = component_def.get("reference", "R?")
|
||||
new_symbol.property.Reference.value = reference
|
||||
logger.debug(f"Set reference to {reference}")
|
||||
|
||||
# Set value
|
||||
if "value" in component_def:
|
||||
new_symbol.property.Value.value = component_def["value"]
|
||||
logger.debug(f"Set value to {component_def['value']}")
|
||||
|
||||
# Set footprint
|
||||
if "footprint" in component_def:
|
||||
new_symbol.property.Footprint.value = component_def["footprint"]
|
||||
logger.debug(f"Set footprint to {component_def['footprint']}")
|
||||
|
||||
# Set datasheet
|
||||
if "datasheet" in component_def:
|
||||
new_symbol.property.Datasheet.value = component_def["datasheet"]
|
||||
|
||||
# Set position
|
||||
x = component_def.get("x", 0)
|
||||
y = component_def.get("y", 0)
|
||||
rotation = component_def.get("rotation", 0)
|
||||
new_symbol.at.value = [x, y, rotation]
|
||||
logger.debug(f"Set position to ({x}, {y}, {rotation})")
|
||||
|
||||
# Set BOM and board flags
|
||||
new_symbol.in_bom.value = component_def.get("in_bom", True)
|
||||
new_symbol.on_board.value = component_def.get("on_board", True)
|
||||
new_symbol.dnp.value = component_def.get("dnp", False)
|
||||
|
||||
# Generate new UUID
|
||||
new_symbol.uuid.value = str(uuid.uuid4())
|
||||
|
||||
# Append to schematic
|
||||
schematic.symbol.append(new_symbol)
|
||||
logger.info(f"Successfully added component {reference} to schematic")
|
||||
|
||||
return new_symbol
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding component: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def remove_component(schematic: Schematic, component_ref: str) -> bool:
|
||||
"""Remove a component from the schematic by reference designator"""
|
||||
try:
|
||||
# kicad-skip doesn't have a direct remove_symbol method by reference.
|
||||
# We need to find the symbol and then remove it from the symbols list.
|
||||
symbol_to_remove = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_remove = symbol
|
||||
break
|
||||
|
||||
if symbol_to_remove:
|
||||
schematic.symbol._elements.remove(symbol_to_remove)
|
||||
logger.info(f"Removed component {component_ref} from schematic.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
|
||||
"""Update component properties by reference designator"""
|
||||
try:
|
||||
symbol_to_update = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_update = symbol
|
||||
break
|
||||
|
||||
if symbol_to_update:
|
||||
for key, value in new_properties.items():
|
||||
if key in symbol_to_update.property:
|
||||
symbol_to_update.property[key].value = value
|
||||
else:
|
||||
symbol_to_update.property.append(key, value)
|
||||
logger.info(f"Updated properties for component {component_ref}.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_component(schematic: Schematic, component_ref: str) -> Any:
|
||||
"""Get a component by reference designator"""
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
logger.debug(f"Found component with reference {component_ref}.")
|
||||
return symbol
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def search_components(schematic: Schematic, query: str) -> List[Any]:
|
||||
"""Search for components matching criteria (basic implementation)"""
|
||||
# This is a basic search, could be expanded to use regex or more complex logic
|
||||
matching_components = []
|
||||
query_lower = query.lower()
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
query_lower in symbol.reference.lower()
|
||||
or query_lower in symbol.name.lower()
|
||||
or (
|
||||
hasattr(symbol.property, "Value")
|
||||
and query_lower in symbol.property.Value.value.lower()
|
||||
)
|
||||
):
|
||||
matching_components.append(symbol)
|
||||
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
return matching_components
|
||||
|
||||
@staticmethod
|
||||
def get_all_components(schematic: Schematic) -> List[Any]:
|
||||
"""Get all components in schematic"""
|
||||
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
return list(schematic.symbol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
from schematic import ( # Assuming schematic.py is in the same directory
|
||||
SchematicManager,
|
||||
)
|
||||
|
||||
# Create a new schematic
|
||||
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
|
||||
|
||||
# Add components
|
||||
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
|
||||
comp2_def = {
|
||||
"type": "C",
|
||||
"reference": "C1",
|
||||
"value": "0.1uF",
|
||||
"x": 200,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
}
|
||||
comp3_def = {
|
||||
"type": "LED",
|
||||
"reference": "D1",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
"properties": {"Color": "Red"},
|
||||
}
|
||||
|
||||
comp1 = ComponentManager.add_component(test_sch, comp1_def)
|
||||
comp2 = ComponentManager.add_component(test_sch, comp2_def)
|
||||
comp3 = ComponentManager.add_component(test_sch, comp3_def)
|
||||
|
||||
# Get a component
|
||||
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
||||
if retrieved_comp:
|
||||
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
|
||||
|
||||
# Update a component
|
||||
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
|
||||
|
||||
# Search components
|
||||
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
|
||||
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
||||
|
||||
# Get all components
|
||||
all_comps = ComponentManager.get_all_components(test_sch)
|
||||
print(f"All components: {[c.reference for c in all_comps]}")
|
||||
|
||||
# Remove a component
|
||||
ComponentManager.remove_component(test_sch, "D1")
|
||||
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
||||
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
|
||||
|
||||
# Save the schematic (optional)
|
||||
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
||||
|
||||
# Clean up (if saved)
|
||||
# if os.path.exists("component_test.kicad_sch"):
|
||||
# os.remove("component_test.kicad_sch")
|
||||
# print("Cleaned up component_test.kicad_sch")
|
||||
|
||||
@@ -1,459 +1,459 @@
|
||||
"""
|
||||
Design rules command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class DesignRuleCommands:
|
||||
"""Handles design rule checking and configuration"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set design rules for the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
|
||||
# Convert mm to nanometers for KiCAD internal units
|
||||
scale = 1000000 # mm to nm
|
||||
|
||||
# Set clearance
|
||||
if "clearance" in params:
|
||||
design_settings.m_MinClearance = int(params["clearance"] * scale)
|
||||
|
||||
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
|
||||
# Track if we set any custom track/via values
|
||||
custom_values_set = False
|
||||
|
||||
if "trackWidth" in params:
|
||||
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# Via settings
|
||||
if "viaDiameter" in params:
|
||||
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
|
||||
custom_values_set = True
|
||||
if "viaDrill" in params:
|
||||
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# KiCAD 9.0: Activate custom track/via values so they become the current values
|
||||
if custom_values_set:
|
||||
design_settings.UseCustomTrackViaSize(True)
|
||||
|
||||
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
||||
if "microViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
||||
if "microViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
||||
|
||||
# Set minimum values
|
||||
if "minTrackWidth" in params:
|
||||
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
||||
if "minViaDiameter" in params:
|
||||
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
|
||||
if "minViaDrill" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
||||
|
||||
if "minMicroViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
||||
if "minMicroViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
||||
if "minHoleDiameter" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: Added hole clearance settings
|
||||
if "holeClearance" in params:
|
||||
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
|
||||
if "holeToHoleMin" in params:
|
||||
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
|
||||
|
||||
# Build response with KiCAD 9.0 compatible properties
|
||||
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
|
||||
response_rules = {
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Updated design rules",
|
||||
"rules": response_rules,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get current design rules - KiCAD 9.0 compatible"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
scale = 1000000 # nm to mm
|
||||
|
||||
# Build rules dict with KiCAD 9.0 compatible properties
|
||||
rules = {
|
||||
# Core clearance and track settings
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
# Via settings (current values from methods)
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
# Via minimum values
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
# Micro via settings
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
# Other constraints
|
||||
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
|
||||
"silkClearance": design_settings.m_SilkClearance / scale,
|
||||
}
|
||||
|
||||
return {"success": True, "rules": rules}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run Design Rule Check using kicad-cli"""
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
report_path = params.get("reportPath")
|
||||
|
||||
# Get the board file path
|
||||
board_file = self.board.GetFileName()
|
||||
if not board_file or not os.path.exists(board_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Board file not found",
|
||||
"errorDetails": "Cannot run DRC without a saved board file",
|
||||
}
|
||||
|
||||
# Find kicad-cli executable
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
|
||||
}
|
||||
|
||||
# Create temporary JSON output file
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||
json_output = tmp.name
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
json_output,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
|
||||
logger.info(f"Running DRC command: {' '.join(cmd)}")
|
||||
|
||||
# Run DRC
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"DRC command failed: {result.stderr}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command failed",
|
||||
"errorDetails": result.stderr,
|
||||
}
|
||||
|
||||
# Read JSON output
|
||||
with open(json_output, "r", encoding="utf-8") as f:
|
||||
drc_data = json.load(f)
|
||||
|
||||
# Parse violations from kicad-cli output
|
||||
violations = []
|
||||
violation_counts: dict[str, int] = {}
|
||||
severity_counts = {"error": 0, "warning": 0, "info": 0}
|
||||
|
||||
for violation in drc_data.get("violations", []):
|
||||
vtype = violation.get("type", "unknown")
|
||||
vseverity = violation.get("severity", "error")
|
||||
|
||||
# Extract location from first item's pos (kicad-cli JSON format)
|
||||
items = violation.get("items", [])
|
||||
loc_x, loc_y = 0, 0
|
||||
if items and "pos" in items[0]:
|
||||
loc_x = items[0]["pos"].get("x", 0)
|
||||
loc_y = items[0]["pos"].get("y", 0)
|
||||
|
||||
violations.append(
|
||||
{
|
||||
"type": vtype,
|
||||
"severity": vseverity,
|
||||
"message": violation.get("description", ""),
|
||||
"location": {
|
||||
"x": loc_x,
|
||||
"y": loc_y,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Count violations by type
|
||||
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
|
||||
|
||||
# Count by severity
|
||||
if vseverity in severity_counts:
|
||||
severity_counts[vseverity] += 1
|
||||
|
||||
# Determine where to save the violations file
|
||||
board_dir = os.path.dirname(board_file)
|
||||
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
||||
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
||||
|
||||
# Always save violations to JSON file (for large result sets)
|
||||
with open(violations_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"board": board_file,
|
||||
"timestamp": drc_data.get("date", "unknown"),
|
||||
"total_violations": len(violations),
|
||||
"violation_counts": violation_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"violations": violations,
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Save text report if requested
|
||||
if report_path:
|
||||
report_path = os.path.abspath(os.path.expanduser(report_path))
|
||||
cmd_report = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"report",
|
||||
"--output",
|
||||
report_path,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
subprocess.run(cmd_report, capture_output=True, timeout=600)
|
||||
|
||||
# Return summary only (not full violations list)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Found {len(violations)} DRC violations",
|
||||
"summary": {
|
||||
"total": len(violations),
|
||||
"by_severity": severity_counts,
|
||||
"by_type": violation_counts,
|
||||
},
|
||||
"violationsFile": violations_file,
|
||||
"reportPath": report_path if report_path else None,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temp JSON file
|
||||
if os.path.exists(json_output):
|
||||
os.unlink(json_output)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("DRC command timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command timed out",
|
||||
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error running DRC: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to run DRC",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _find_kicad_cli(self) -> Optional[str]:
|
||||
"""Find kicad-cli executable"""
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
# Try system PATH first
|
||||
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
|
||||
cli_path = shutil.which(cli_name)
|
||||
if cli_path:
|
||||
return cli_path
|
||||
|
||||
# Try common installation paths (version-specific)
|
||||
if platform.system() == "Windows":
|
||||
common_paths = [
|
||||
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
elif platform.system() == "Darwin": # macOS
|
||||
common_paths = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else: # Linux
|
||||
common_paths = [
|
||||
"/usr/bin/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get list of DRC violations
|
||||
|
||||
Note: This command internally uses run_drc() which calls kicad-cli.
|
||||
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
|
||||
This implementation provides backward compatibility by parsing kicad-cli output.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
severity = params.get("severity", "all")
|
||||
|
||||
# Run DRC using kicad-cli (this saves violations to JSON file)
|
||||
drc_result = self.run_drc({})
|
||||
|
||||
if not drc_result.get("success"):
|
||||
return drc_result # Return the error from run_drc
|
||||
|
||||
# Read violations from the saved JSON file
|
||||
violations_file = drc_result.get("violationsFile")
|
||||
if not violations_file or not os.path.exists(violations_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Violations file not found",
|
||||
"errorDetails": "run_drc did not create violations file",
|
||||
}
|
||||
|
||||
# Load violations from file
|
||||
with open(violations_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
all_violations = data.get("violations", [])
|
||||
|
||||
# Filter by severity if specified
|
||||
if severity != "all":
|
||||
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
|
||||
else:
|
||||
filtered_violations = all_violations
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"violations": filtered_violations,
|
||||
"violationsFile": violations_file, # Include file path for reference
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DRC violations: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get DRC violations",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
"""
|
||||
Design rules command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class DesignRuleCommands:
|
||||
"""Handles design rule checking and configuration"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set design rules for the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
|
||||
# Convert mm to nanometers for KiCAD internal units
|
||||
scale = 1000000 # mm to nm
|
||||
|
||||
# Set clearance
|
||||
if "clearance" in params:
|
||||
design_settings.m_MinClearance = int(params["clearance"] * scale)
|
||||
|
||||
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
|
||||
# Track if we set any custom track/via values
|
||||
custom_values_set = False
|
||||
|
||||
if "trackWidth" in params:
|
||||
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# Via settings
|
||||
if "viaDiameter" in params:
|
||||
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
|
||||
custom_values_set = True
|
||||
if "viaDrill" in params:
|
||||
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# KiCAD 9.0: Activate custom track/via values so they become the current values
|
||||
if custom_values_set:
|
||||
design_settings.UseCustomTrackViaSize(True)
|
||||
|
||||
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
||||
if "microViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
||||
if "microViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
||||
|
||||
# Set minimum values
|
||||
if "minTrackWidth" in params:
|
||||
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
||||
if "minViaDiameter" in params:
|
||||
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
|
||||
if "minViaDrill" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
||||
|
||||
if "minMicroViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
||||
if "minMicroViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
||||
if "minHoleDiameter" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: Added hole clearance settings
|
||||
if "holeClearance" in params:
|
||||
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
|
||||
if "holeToHoleMin" in params:
|
||||
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
|
||||
|
||||
# Build response with KiCAD 9.0 compatible properties
|
||||
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
|
||||
response_rules = {
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Updated design rules",
|
||||
"rules": response_rules,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get current design rules - KiCAD 9.0 compatible"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
scale = 1000000 # nm to mm
|
||||
|
||||
# Build rules dict with KiCAD 9.0 compatible properties
|
||||
rules = {
|
||||
# Core clearance and track settings
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
# Via settings (current values from methods)
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
# Via minimum values
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
# Micro via settings
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
# Other constraints
|
||||
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
|
||||
"silkClearance": design_settings.m_SilkClearance / scale,
|
||||
}
|
||||
|
||||
return {"success": True, "rules": rules}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run Design Rule Check using kicad-cli"""
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
report_path = params.get("reportPath")
|
||||
|
||||
# Get the board file path
|
||||
board_file = self.board.GetFileName()
|
||||
if not board_file or not os.path.exists(board_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Board file not found",
|
||||
"errorDetails": "Cannot run DRC without a saved board file",
|
||||
}
|
||||
|
||||
# Find kicad-cli executable
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
|
||||
}
|
||||
|
||||
# Create temporary JSON output file
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||
json_output = tmp.name
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
json_output,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
|
||||
logger.info(f"Running DRC command: {' '.join(cmd)}")
|
||||
|
||||
# Run DRC
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"DRC command failed: {result.stderr}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command failed",
|
||||
"errorDetails": result.stderr,
|
||||
}
|
||||
|
||||
# Read JSON output
|
||||
with open(json_output, "r", encoding="utf-8") as f:
|
||||
drc_data = json.load(f)
|
||||
|
||||
# Parse violations from kicad-cli output
|
||||
violations = []
|
||||
violation_counts: dict[str, int] = {}
|
||||
severity_counts = {"error": 0, "warning": 0, "info": 0}
|
||||
|
||||
for violation in drc_data.get("violations", []):
|
||||
vtype = violation.get("type", "unknown")
|
||||
vseverity = violation.get("severity", "error")
|
||||
|
||||
# Extract location from first item's pos (kicad-cli JSON format)
|
||||
items = violation.get("items", [])
|
||||
loc_x, loc_y = 0, 0
|
||||
if items and "pos" in items[0]:
|
||||
loc_x = items[0]["pos"].get("x", 0)
|
||||
loc_y = items[0]["pos"].get("y", 0)
|
||||
|
||||
violations.append(
|
||||
{
|
||||
"type": vtype,
|
||||
"severity": vseverity,
|
||||
"message": violation.get("description", ""),
|
||||
"location": {
|
||||
"x": loc_x,
|
||||
"y": loc_y,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Count violations by type
|
||||
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
|
||||
|
||||
# Count by severity
|
||||
if vseverity in severity_counts:
|
||||
severity_counts[vseverity] += 1
|
||||
|
||||
# Determine where to save the violations file
|
||||
board_dir = os.path.dirname(board_file)
|
||||
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
||||
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
||||
|
||||
# Always save violations to JSON file (for large result sets)
|
||||
with open(violations_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"board": board_file,
|
||||
"timestamp": drc_data.get("date", "unknown"),
|
||||
"total_violations": len(violations),
|
||||
"violation_counts": violation_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"violations": violations,
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Save text report if requested
|
||||
if report_path:
|
||||
report_path = os.path.abspath(os.path.expanduser(report_path))
|
||||
cmd_report = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"report",
|
||||
"--output",
|
||||
report_path,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
subprocess.run(cmd_report, capture_output=True, timeout=600)
|
||||
|
||||
# Return summary only (not full violations list)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Found {len(violations)} DRC violations",
|
||||
"summary": {
|
||||
"total": len(violations),
|
||||
"by_severity": severity_counts,
|
||||
"by_type": violation_counts,
|
||||
},
|
||||
"violationsFile": violations_file,
|
||||
"reportPath": report_path if report_path else None,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temp JSON file
|
||||
if os.path.exists(json_output):
|
||||
os.unlink(json_output)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("DRC command timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command timed out",
|
||||
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error running DRC: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to run DRC",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _find_kicad_cli(self) -> Optional[str]:
|
||||
"""Find kicad-cli executable"""
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
# Try system PATH first
|
||||
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
|
||||
cli_path = shutil.which(cli_name)
|
||||
if cli_path:
|
||||
return cli_path
|
||||
|
||||
# Try common installation paths (version-specific)
|
||||
if platform.system() == "Windows":
|
||||
common_paths = [
|
||||
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
elif platform.system() == "Darwin": # macOS
|
||||
common_paths = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else: # Linux
|
||||
common_paths = [
|
||||
"/usr/bin/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get list of DRC violations
|
||||
|
||||
Note: This command internally uses run_drc() which calls kicad-cli.
|
||||
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
|
||||
This implementation provides backward compatibility by parsing kicad-cli output.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
severity = params.get("severity", "all")
|
||||
|
||||
# Run DRC using kicad-cli (this saves violations to JSON file)
|
||||
drc_result = self.run_drc({})
|
||||
|
||||
if not drc_result.get("success"):
|
||||
return drc_result # Return the error from run_drc
|
||||
|
||||
# Read violations from the saved JSON file
|
||||
violations_file = drc_result.get("violationsFile")
|
||||
if not violations_file or not os.path.exists(violations_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Violations file not found",
|
||||
"errorDetails": "run_drc did not create violations file",
|
||||
}
|
||||
|
||||
# Load violations from file
|
||||
with open(violations_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
all_violations = data.get("violations", [])
|
||||
|
||||
# Filter by severity if specified
|
||||
if severity != "all":
|
||||
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
|
||||
else:
|
||||
filtered_violations = all_violations
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"violations": filtered_violations,
|
||||
"violationsFile": violations_file, # Include file path for reference
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DRC violations: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get DRC violations",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,303 +1,303 @@
|
||||
"""
|
||||
JLCPCB API client for fetching parts data
|
||||
|
||||
Handles authentication and downloading the JLCPCB parts library
|
||||
for integration with KiCAD component selection.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCPCBClient:
|
||||
"""
|
||||
Client for JLCPCB API
|
||||
|
||||
Handles HMAC-SHA256 signature-based authentication and fetching
|
||||
the complete parts library from JLCPCB's external API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcpcb.com/external"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
access_key: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize JLCPCB API client
|
||||
|
||||
Args:
|
||||
app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID 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)
|
||||
"""
|
||||
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
|
||||
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
|
||||
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
|
||||
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
logger.warning(
|
||||
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_nonce() -> str:
|
||||
"""Generate a 32-character random nonce"""
|
||||
chars = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(32))
|
||||
|
||||
def _build_signature_string(
|
||||
self, method: str, path: str, timestamp: int, nonce: str, body: str
|
||||
) -> str:
|
||||
"""
|
||||
Build the signature string according to JLCPCB spec
|
||||
|
||||
Format:
|
||||
<HTTP Method>\n
|
||||
<Request Path>\n
|
||||
<Timestamp>\n
|
||||
<Nonce>\n
|
||||
<Request Body>\n
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
timestamp: Unix timestamp in seconds
|
||||
nonce: 32-character random string
|
||||
body: Request body (empty string for GET)
|
||||
|
||||
Returns:
|
||||
Signature string
|
||||
"""
|
||||
return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
|
||||
def _sign(self, signature_string: str) -> str:
|
||||
"""
|
||||
Sign the signature string with HMAC-SHA256
|
||||
|
||||
Args:
|
||||
signature_string: The string to sign
|
||||
|
||||
Returns:
|
||||
Base64-encoded signature
|
||||
"""
|
||||
signature_bytes = hmac.new(
|
||||
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
return base64.b64encode(signature_bytes).decode("utf-8")
|
||||
|
||||
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
|
||||
"""
|
||||
Generate the Authorization header for JLCPCB API requests
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
body: Request body JSON string (empty for GET)
|
||||
|
||||
Returns:
|
||||
Authorization header value
|
||||
"""
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
raise Exception(
|
||||
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
nonce = self._generate_nonce()
|
||||
timestamp = int(time.time())
|
||||
|
||||
signature_string = self._build_signature_string(method, path, timestamp, nonce, body)
|
||||
signature = self._sign(signature_string)
|
||||
|
||||
logger.debug(f"Signature string:\n{repr(signature_string)}")
|
||||
logger.debug(f"Signature: {signature}")
|
||||
logger.debug(
|
||||
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
)
|
||||
|
||||
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
|
||||
def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Fetch one page of parts from JLCPCB API
|
||||
|
||||
Args:
|
||||
last_key: Pagination key from previous response (None for first page)
|
||||
|
||||
Returns:
|
||||
Response dict with parts data and pagination info
|
||||
"""
|
||||
path = "/component/getComponentInfos"
|
||||
|
||||
payload = {}
|
||||
if last_key:
|
||||
payload["lastKey"] = last_key
|
||||
|
||||
# Convert payload to JSON string for signing
|
||||
# For POST requests, we always send JSON, even if empty dict
|
||||
body_str = json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
# Generate authorization header
|
||||
auth_header = self._get_auth_header("POST", path, body_str)
|
||||
|
||||
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
|
||||
)
|
||||
|
||||
logger.debug(f"Response status: {response.status_code}")
|
||||
logger.debug(f"Response headers: {response.headers}")
|
||||
logger.debug(f"Response text: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise Exception(
|
||||
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
|
||||
)
|
||||
|
||||
return data["data"]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch parts page: {e}")
|
||||
raise Exception(f"JLCPCB API request failed: {e}")
|
||||
|
||||
def download_full_database(
|
||||
self, callback: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download entire parts library from JLCPCB
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(current_page, total_parts, status_msg)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
last_key = None
|
||||
page = 0
|
||||
|
||||
logger.info("Starting full JLCPCB parts database download...")
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
|
||||
try:
|
||||
data = self.fetch_parts_page(last_key)
|
||||
|
||||
parts = data.get("componentInfos", [])
|
||||
all_parts.extend(parts)
|
||||
|
||||
last_key = data.get("lastKey")
|
||||
|
||||
if callback:
|
||||
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Check if there are more pages
|
||||
if not last_key or len(parts) == 0:
|
||||
break
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at page {page}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information for a specific LCSC part number
|
||||
|
||||
Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't
|
||||
have a dedicated single-part endpoint. In practice, you should use
|
||||
the local database after initial download.
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC part number (e.g., "C25804")
|
||||
|
||||
Returns:
|
||||
Part info dict or None if not found
|
||||
"""
|
||||
# For now, this would require searching through pages
|
||||
# In practice, you'd use the local database
|
||||
logger.warning("get_part_by_lcsc should use local database, not API")
|
||||
return None
|
||||
|
||||
|
||||
def test_jlcpcb_connection(
|
||||
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Test JLCPCB API connection
|
||||
|
||||
Args:
|
||||
app_id: Optional App ID (uses env var if not provided)
|
||||
access_key: Optional Access Key (uses env var if not provided)
|
||||
secret_key: Optional Secret Key (uses env var if not provided)
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCPCBClient(app_id, access_key, secret_key)
|
||||
# Test by fetching first page
|
||||
data = client.fetch_parts_page()
|
||||
logger.info("JLCPCB API connection test successful")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCPCB API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCPCB client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCPCB API connection...")
|
||||
if test_jlcpcb_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCPCBClient()
|
||||
print("\nFetching first page of parts...")
|
||||
data = client.fetch_parts_page()
|
||||
parts = data.get("componentInfos", [])
|
||||
print(f"✓ Retrieved {len(parts)} parts in first page")
|
||||
|
||||
if parts:
|
||||
print(f"\nExample part:")
|
||||
part = parts[0]
|
||||
print(f" LCSC: {part.get('componentCode')}")
|
||||
print(f" MFR Part: {part.get('componentModelEn')}")
|
||||
print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}")
|
||||
print(f" Package: {part.get('componentSpecificationEn')}")
|
||||
print(f" Stock: {part.get('stockCount')}")
|
||||
else:
|
||||
print("✗ Connection failed. Check your API credentials.")
|
||||
"""
|
||||
JLCPCB API client for fetching parts data
|
||||
|
||||
Handles authentication and downloading the JLCPCB parts library
|
||||
for integration with KiCAD component selection.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCPCBClient:
|
||||
"""
|
||||
Client for JLCPCB API
|
||||
|
||||
Handles HMAC-SHA256 signature-based authentication and fetching
|
||||
the complete parts library from JLCPCB's external API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcpcb.com/external"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
access_key: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize JLCPCB API client
|
||||
|
||||
Args:
|
||||
app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID 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)
|
||||
"""
|
||||
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
|
||||
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
|
||||
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
|
||||
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
logger.warning(
|
||||
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_nonce() -> str:
|
||||
"""Generate a 32-character random nonce"""
|
||||
chars = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(32))
|
||||
|
||||
def _build_signature_string(
|
||||
self, method: str, path: str, timestamp: int, nonce: str, body: str
|
||||
) -> str:
|
||||
"""
|
||||
Build the signature string according to JLCPCB spec
|
||||
|
||||
Format:
|
||||
<HTTP Method>\n
|
||||
<Request Path>\n
|
||||
<Timestamp>\n
|
||||
<Nonce>\n
|
||||
<Request Body>\n
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
timestamp: Unix timestamp in seconds
|
||||
nonce: 32-character random string
|
||||
body: Request body (empty string for GET)
|
||||
|
||||
Returns:
|
||||
Signature string
|
||||
"""
|
||||
return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
|
||||
def _sign(self, signature_string: str) -> str:
|
||||
"""
|
||||
Sign the signature string with HMAC-SHA256
|
||||
|
||||
Args:
|
||||
signature_string: The string to sign
|
||||
|
||||
Returns:
|
||||
Base64-encoded signature
|
||||
"""
|
||||
signature_bytes = hmac.new(
|
||||
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
return base64.b64encode(signature_bytes).decode("utf-8")
|
||||
|
||||
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
|
||||
"""
|
||||
Generate the Authorization header for JLCPCB API requests
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
body: Request body JSON string (empty for GET)
|
||||
|
||||
Returns:
|
||||
Authorization header value
|
||||
"""
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
raise Exception(
|
||||
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
nonce = self._generate_nonce()
|
||||
timestamp = int(time.time())
|
||||
|
||||
signature_string = self._build_signature_string(method, path, timestamp, nonce, body)
|
||||
signature = self._sign(signature_string)
|
||||
|
||||
logger.debug(f"Signature string:\n{repr(signature_string)}")
|
||||
logger.debug(f"Signature: {signature}")
|
||||
logger.debug(
|
||||
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
)
|
||||
|
||||
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
|
||||
def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Fetch one page of parts from JLCPCB API
|
||||
|
||||
Args:
|
||||
last_key: Pagination key from previous response (None for first page)
|
||||
|
||||
Returns:
|
||||
Response dict with parts data and pagination info
|
||||
"""
|
||||
path = "/component/getComponentInfos"
|
||||
|
||||
payload = {}
|
||||
if last_key:
|
||||
payload["lastKey"] = last_key
|
||||
|
||||
# Convert payload to JSON string for signing
|
||||
# For POST requests, we always send JSON, even if empty dict
|
||||
body_str = json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
# Generate authorization header
|
||||
auth_header = self._get_auth_header("POST", path, body_str)
|
||||
|
||||
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
|
||||
)
|
||||
|
||||
logger.debug(f"Response status: {response.status_code}")
|
||||
logger.debug(f"Response headers: {response.headers}")
|
||||
logger.debug(f"Response text: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise Exception(
|
||||
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
|
||||
)
|
||||
|
||||
return data["data"]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch parts page: {e}")
|
||||
raise Exception(f"JLCPCB API request failed: {e}")
|
||||
|
||||
def download_full_database(
|
||||
self, callback: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download entire parts library from JLCPCB
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(current_page, total_parts, status_msg)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
last_key = None
|
||||
page = 0
|
||||
|
||||
logger.info("Starting full JLCPCB parts database download...")
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
|
||||
try:
|
||||
data = self.fetch_parts_page(last_key)
|
||||
|
||||
parts = data.get("componentInfos", [])
|
||||
all_parts.extend(parts)
|
||||
|
||||
last_key = data.get("lastKey")
|
||||
|
||||
if callback:
|
||||
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Check if there are more pages
|
||||
if not last_key or len(parts) == 0:
|
||||
break
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at page {page}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information for a specific LCSC part number
|
||||
|
||||
Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't
|
||||
have a dedicated single-part endpoint. In practice, you should use
|
||||
the local database after initial download.
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC part number (e.g., "C25804")
|
||||
|
||||
Returns:
|
||||
Part info dict or None if not found
|
||||
"""
|
||||
# For now, this would require searching through pages
|
||||
# In practice, you'd use the local database
|
||||
logger.warning("get_part_by_lcsc should use local database, not API")
|
||||
return None
|
||||
|
||||
|
||||
def test_jlcpcb_connection(
|
||||
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Test JLCPCB API connection
|
||||
|
||||
Args:
|
||||
app_id: Optional App ID (uses env var if not provided)
|
||||
access_key: Optional Access Key (uses env var if not provided)
|
||||
secret_key: Optional Secret Key (uses env var if not provided)
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCPCBClient(app_id, access_key, secret_key)
|
||||
# Test by fetching first page
|
||||
data = client.fetch_parts_page()
|
||||
logger.info("JLCPCB API connection test successful")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCPCB API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCPCB client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCPCB API connection...")
|
||||
if test_jlcpcb_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCPCBClient()
|
||||
print("\nFetching first page of parts...")
|
||||
data = client.fetch_parts_page()
|
||||
parts = data.get("componentInfos", [])
|
||||
print(f"✓ Retrieved {len(parts)} parts in first page")
|
||||
|
||||
if parts:
|
||||
print(f"\nExample part:")
|
||||
part = parts[0]
|
||||
print(f" LCSC: {part.get('componentCode')}")
|
||||
print(f" MFR Part: {part.get('componentModelEn')}")
|
||||
print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}")
|
||||
print(f" Package: {part.get('componentSpecificationEn')}")
|
||||
print(f" Stock: {part.get('stockCount')}")
|
||||
else:
|
||||
print("✗ Connection failed. Check your API credentials.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +1,240 @@
|
||||
"""
|
||||
JLCSearch API client (public, no authentication required)
|
||||
|
||||
Alternative to official JLCPCB API using the community-maintained
|
||||
jlcsearch service at https://jlcsearch.tscircuit.com/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCSearchClient:
|
||||
"""
|
||||
Client for JLCSearch public API (tscircuit)
|
||||
|
||||
Provides access to JLCPCB parts database without authentication
|
||||
via the community-maintained jlcsearch service.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcsearch.tscircuit.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize JLCSearch API client"""
|
||||
pass
|
||||
|
||||
def search_components(
|
||||
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search components in JLCSearch database
|
||||
|
||||
Args:
|
||||
category: Component category (e.g., "resistors", "capacitors", "components")
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
**filters: Additional filters (e.g., package="0603", resistance=1000)
|
||||
|
||||
Returns:
|
||||
List of component dicts
|
||||
"""
|
||||
url = f"{self.BASE_URL}/{category}/list.json"
|
||||
|
||||
params = {"limit": limit, "offset": offset, **filters}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# The response has the category name as key
|
||||
# e.g., {"resistors": [...]} or {"components": [...]}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to search JLCSearch: {e}")
|
||||
raise Exception(f"JLCSearch API request failed: {e}")
|
||||
|
||||
def search_resistors(
|
||||
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search for resistors
|
||||
|
||||
Args:
|
||||
resistance: Resistance value in ohms
|
||||
package: Package type (e.g., "0603", "0805")
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of resistor dicts with fields:
|
||||
- lcsc: LCSC number (integer)
|
||||
- mfr: Manufacturer part number
|
||||
- package: Package size
|
||||
- is_basic: True if basic library part
|
||||
- resistance: Resistance in ohms
|
||||
- tolerance_fraction: Tolerance (0.01 = 1%)
|
||||
- power_watts: Power rating in mW
|
||||
- stock: Available stock
|
||||
- price1: Price per unit
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if resistance is not None:
|
||||
filters["resistance"] = resistance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
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]:
|
||||
"""
|
||||
Search for capacitors
|
||||
|
||||
Args:
|
||||
capacitance: Capacitance value in farads
|
||||
package: Package type
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of capacitor dicts
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if capacitance is not None:
|
||||
filters["capacitance"] = capacitance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
return self.search_components("capacitors", limit=limit, **filters)
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get part details by LCSC number
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC number (integer, without 'C' prefix)
|
||||
|
||||
Returns:
|
||||
Part dict or None if not found
|
||||
"""
|
||||
# Search across all components filtering by LCSC
|
||||
# Note: jlcsearch doesn't have a dedicated single-part endpoint
|
||||
# so we search and filter
|
||||
try:
|
||||
results = self.search_components("components", limit=1, lcsc=lcsc_number)
|
||||
return results[0] if results else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get part C{lcsc_number}: {e}")
|
||||
return None
|
||||
|
||||
def download_all_components(
|
||||
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download all components from jlcsearch database
|
||||
|
||||
Note: tscircuit API has a hard-coded 100 result limit per request.
|
||||
Full catalog download requires ~25,000 paginated requests (~40-60 minutes).
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(parts_count, status_msg)
|
||||
batch_size: Number of parts per batch (max 100 due to API limit)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
offset = 0
|
||||
|
||||
logger.info("Starting full jlcsearch parts database download...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
batch = self.search_components("components", limit=batch_size, offset=offset)
|
||||
|
||||
# Stop if no results returned (end of catalog)
|
||||
if not batch or len(batch) == 0:
|
||||
break
|
||||
|
||||
all_parts.extend(batch)
|
||||
offset += len(batch)
|
||||
|
||||
if callback:
|
||||
callback(len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Continue pagination - API returns exactly 100 results per page until exhausted
|
||||
# Only stop when we get 0 results (handled above)
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at offset {offset}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
|
||||
def test_jlcsearch_connection() -> bool:
|
||||
"""
|
||||
Test JLCSearch API connection
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCSearchClient()
|
||||
# Test by searching for 1k resistors
|
||||
results = client.search_resistors(resistance=1000, limit=5)
|
||||
logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCSearch API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCSearch client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCSearch API connection...")
|
||||
if test_jlcsearch_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCSearchClient()
|
||||
|
||||
print("\nSearching for 1k 0603 resistors...")
|
||||
resistors = client.search_resistors(resistance=1000, package="0603", limit=5)
|
||||
print(f"✓ Found {len(resistors)} resistors")
|
||||
|
||||
if resistors:
|
||||
print(f"\nExample resistor:")
|
||||
r = resistors[0]
|
||||
print(f" LCSC: C{r.get('lcsc')}")
|
||||
print(f" MFR: {r.get('mfr')}")
|
||||
print(f" Package: {r.get('package')}")
|
||||
print(f" Resistance: {r.get('resistance')}Ω")
|
||||
print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%")
|
||||
print(f" Power: {r.get('power_watts')}mW")
|
||||
print(f" Stock: {r.get('stock')}")
|
||||
print(f" Price: ${r.get('price1')}")
|
||||
print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}")
|
||||
else:
|
||||
print("✗ Connection failed")
|
||||
"""
|
||||
JLCSearch API client (public, no authentication required)
|
||||
|
||||
Alternative to official JLCPCB API using the community-maintained
|
||||
jlcsearch service at https://jlcsearch.tscircuit.com/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCSearchClient:
|
||||
"""
|
||||
Client for JLCSearch public API (tscircuit)
|
||||
|
||||
Provides access to JLCPCB parts database without authentication
|
||||
via the community-maintained jlcsearch service.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcsearch.tscircuit.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize JLCSearch API client"""
|
||||
pass
|
||||
|
||||
def search_components(
|
||||
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search components in JLCSearch database
|
||||
|
||||
Args:
|
||||
category: Component category (e.g., "resistors", "capacitors", "components")
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
**filters: Additional filters (e.g., package="0603", resistance=1000)
|
||||
|
||||
Returns:
|
||||
List of component dicts
|
||||
"""
|
||||
url = f"{self.BASE_URL}/{category}/list.json"
|
||||
|
||||
params = {"limit": limit, "offset": offset, **filters}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# The response has the category name as key
|
||||
# e.g., {"resistors": [...]} or {"components": [...]}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to search JLCSearch: {e}")
|
||||
raise Exception(f"JLCSearch API request failed: {e}")
|
||||
|
||||
def search_resistors(
|
||||
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search for resistors
|
||||
|
||||
Args:
|
||||
resistance: Resistance value in ohms
|
||||
package: Package type (e.g., "0603", "0805")
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of resistor dicts with fields:
|
||||
- lcsc: LCSC number (integer)
|
||||
- mfr: Manufacturer part number
|
||||
- package: Package size
|
||||
- is_basic: True if basic library part
|
||||
- resistance: Resistance in ohms
|
||||
- tolerance_fraction: Tolerance (0.01 = 1%)
|
||||
- power_watts: Power rating in mW
|
||||
- stock: Available stock
|
||||
- price1: Price per unit
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if resistance is not None:
|
||||
filters["resistance"] = resistance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
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]:
|
||||
"""
|
||||
Search for capacitors
|
||||
|
||||
Args:
|
||||
capacitance: Capacitance value in farads
|
||||
package: Package type
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of capacitor dicts
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if capacitance is not None:
|
||||
filters["capacitance"] = capacitance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
return self.search_components("capacitors", limit=limit, **filters)
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get part details by LCSC number
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC number (integer, without 'C' prefix)
|
||||
|
||||
Returns:
|
||||
Part dict or None if not found
|
||||
"""
|
||||
# Search across all components filtering by LCSC
|
||||
# Note: jlcsearch doesn't have a dedicated single-part endpoint
|
||||
# so we search and filter
|
||||
try:
|
||||
results = self.search_components("components", limit=1, lcsc=lcsc_number)
|
||||
return results[0] if results else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get part C{lcsc_number}: {e}")
|
||||
return None
|
||||
|
||||
def download_all_components(
|
||||
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download all components from jlcsearch database
|
||||
|
||||
Note: tscircuit API has a hard-coded 100 result limit per request.
|
||||
Full catalog download requires ~25,000 paginated requests (~40-60 minutes).
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(parts_count, status_msg)
|
||||
batch_size: Number of parts per batch (max 100 due to API limit)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
offset = 0
|
||||
|
||||
logger.info("Starting full jlcsearch parts database download...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
batch = self.search_components("components", limit=batch_size, offset=offset)
|
||||
|
||||
# Stop if no results returned (end of catalog)
|
||||
if not batch or len(batch) == 0:
|
||||
break
|
||||
|
||||
all_parts.extend(batch)
|
||||
offset += len(batch)
|
||||
|
||||
if callback:
|
||||
callback(len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Continue pagination - API returns exactly 100 results per page until exhausted
|
||||
# Only stop when we get 0 results (handled above)
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at offset {offset}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
|
||||
def test_jlcsearch_connection() -> bool:
|
||||
"""
|
||||
Test JLCSearch API connection
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCSearchClient()
|
||||
# Test by searching for 1k resistors
|
||||
results = client.search_resistors(resistance=1000, limit=5)
|
||||
logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCSearch API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCSearch client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCSearch API connection...")
|
||||
if test_jlcsearch_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCSearchClient()
|
||||
|
||||
print("\nSearching for 1k 0603 resistors...")
|
||||
resistors = client.search_resistors(resistance=1000, package="0603", limit=5)
|
||||
print(f"✓ Found {len(resistors)} resistors")
|
||||
|
||||
if resistors:
|
||||
print(f"\nExample resistor:")
|
||||
r = resistors[0]
|
||||
print(f" LCSC: C{r.get('lcsc')}")
|
||||
print(f" MFR: {r.get('mfr')}")
|
||||
print(f" Package: {r.get('package')}")
|
||||
print(f" Resistance: {r.get('resistance')}Ω")
|
||||
print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%")
|
||||
print(f" Power: {r.get('power_watts')}mW")
|
||||
print(f" Stock: {r.get('stock')}")
|
||||
print(f" Price: ${r.get('price1')}")
|
||||
print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}")
|
||||
else:
|
||||
print("✗ Connection failed")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +1,130 @@
|
||||
import glob
|
||||
import logging
|
||||
|
||||
# Symbol class might not be directly importable in the current version
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
"""Manage symbol libraries"""
|
||||
|
||||
@staticmethod
|
||||
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
||||
"""List all available symbol libraries"""
|
||||
if search_paths is None:
|
||||
# Default library paths based on common KiCAD installations
|
||||
# This would need to be configured for the specific environment
|
||||
search_paths = [
|
||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
|
||||
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
|
||||
os.path.expanduser(
|
||||
"~/Documents/KiCad/*/symbols/*.kicad_sym"
|
||||
), # User libraries pattern
|
||||
]
|
||||
|
||||
libraries = []
|
||||
for path_pattern in search_paths:
|
||||
try:
|
||||
# Use glob to find all matching files
|
||||
matching_libs = glob.glob(path_pattern, recursive=True)
|
||||
libraries.extend(matching_libs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
|
||||
|
||||
# Extract library names from paths
|
||||
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
|
||||
logger.info(
|
||||
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
|
||||
)
|
||||
|
||||
# Return both full paths and library names
|
||||
return {"paths": libraries, "names": library_names}
|
||||
|
||||
@staticmethod
|
||||
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
|
||||
"""Get detailed information about a symbol"""
|
||||
try:
|
||||
logger.warning(
|
||||
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
|
||||
"""Search for symbols matching criteria"""
|
||||
try:
|
||||
# This would typically involve:
|
||||
# 1. Getting a list of all libraries using list_available_libraries
|
||||
# 2. For each library, getting a list of all symbols
|
||||
# 3. Filtering symbols based on the query
|
||||
|
||||
# For now, this is a placeholder implementation
|
||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||
|
||||
results = []
|
||||
logger.warning(
|
||||
f"Searched for symbols matching '{query}'. This requires advanced implementation."
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for symbols matching '{query}': {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_default_symbol_for_component_type(
|
||||
component_type: str, search_paths: Optional[List[str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get a recommended default symbol for a given component type"""
|
||||
# This method provides a simplified way to get a symbol for common component types
|
||||
# It's useful when the user doesn't specify a particular library/symbol
|
||||
|
||||
# Define common mappings from component type to library/symbol
|
||||
common_mappings = {
|
||||
"resistor": {"library": "Device", "symbol": "R"},
|
||||
"capacitor": {"library": "Device", "symbol": "C"},
|
||||
"inductor": {"library": "Device", "symbol": "L"},
|
||||
"diode": {"library": "Device", "symbol": "D"},
|
||||
"led": {"library": "Device", "symbol": "LED"},
|
||||
"transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"},
|
||||
"transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"},
|
||||
"opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"},
|
||||
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
|
||||
# Add more common components as needed
|
||||
}
|
||||
|
||||
# Normalize input to lowercase
|
||||
component_type_lower = component_type.lower()
|
||||
|
||||
# Try direct match first
|
||||
if component_type_lower in common_mappings:
|
||||
return common_mappings[component_type_lower]
|
||||
|
||||
# Try partial matches
|
||||
for key, value in common_mappings.items():
|
||||
if component_type_lower in key or key in component_type_lower:
|
||||
return value
|
||||
|
||||
# Default fallback
|
||||
return {"library": "Device", "symbol": "R"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
# List available libraries
|
||||
libraries = LibraryManager.list_available_libraries()
|
||||
# Get default symbol for a component type
|
||||
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
|
||||
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
|
||||
|
||||
# Try a partial match
|
||||
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
|
||||
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")
|
||||
import glob
|
||||
import logging
|
||||
|
||||
# Symbol class might not be directly importable in the current version
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
"""Manage symbol libraries"""
|
||||
|
||||
@staticmethod
|
||||
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
||||
"""List all available symbol libraries"""
|
||||
if search_paths is None:
|
||||
# Default library paths based on common KiCAD installations
|
||||
# This would need to be configured for the specific environment
|
||||
search_paths = [
|
||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
|
||||
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
|
||||
os.path.expanduser(
|
||||
"~/Documents/KiCad/*/symbols/*.kicad_sym"
|
||||
), # User libraries pattern
|
||||
]
|
||||
|
||||
libraries = []
|
||||
for path_pattern in search_paths:
|
||||
try:
|
||||
# Use glob to find all matching files
|
||||
matching_libs = glob.glob(path_pattern, recursive=True)
|
||||
libraries.extend(matching_libs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
|
||||
|
||||
# Extract library names from paths
|
||||
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
|
||||
logger.info(
|
||||
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
|
||||
)
|
||||
|
||||
# Return both full paths and library names
|
||||
return {"paths": libraries, "names": library_names}
|
||||
|
||||
@staticmethod
|
||||
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
|
||||
"""Get detailed information about a symbol"""
|
||||
try:
|
||||
logger.warning(
|
||||
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
|
||||
"""Search for symbols matching criteria"""
|
||||
try:
|
||||
# This would typically involve:
|
||||
# 1. Getting a list of all libraries using list_available_libraries
|
||||
# 2. For each library, getting a list of all symbols
|
||||
# 3. Filtering symbols based on the query
|
||||
|
||||
# For now, this is a placeholder implementation
|
||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||
|
||||
results = []
|
||||
logger.warning(
|
||||
f"Searched for symbols matching '{query}'. This requires advanced implementation."
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for symbols matching '{query}': {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_default_symbol_for_component_type(
|
||||
component_type: str, search_paths: Optional[List[str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get a recommended default symbol for a given component type"""
|
||||
# This method provides a simplified way to get a symbol for common component types
|
||||
# It's useful when the user doesn't specify a particular library/symbol
|
||||
|
||||
# Define common mappings from component type to library/symbol
|
||||
common_mappings = {
|
||||
"resistor": {"library": "Device", "symbol": "R"},
|
||||
"capacitor": {"library": "Device", "symbol": "C"},
|
||||
"inductor": {"library": "Device", "symbol": "L"},
|
||||
"diode": {"library": "Device", "symbol": "D"},
|
||||
"led": {"library": "Device", "symbol": "LED"},
|
||||
"transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"},
|
||||
"transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"},
|
||||
"opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"},
|
||||
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
|
||||
# Add more common components as needed
|
||||
}
|
||||
|
||||
# Normalize input to lowercase
|
||||
component_type_lower = component_type.lower()
|
||||
|
||||
# Try direct match first
|
||||
if component_type_lower in common_mappings:
|
||||
return common_mappings[component_type_lower]
|
||||
|
||||
# Try partial matches
|
||||
for key, value in common_mappings.items():
|
||||
if component_type_lower in key or key in component_type_lower:
|
||||
return value
|
||||
|
||||
# Default fallback
|
||||
return {"library": "Device", "symbol": "R"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
# List available libraries
|
||||
libraries = LibraryManager.list_available_libraries()
|
||||
# Get default symbol for a component type
|
||||
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
|
||||
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
|
||||
|
||||
# Try a partial match
|
||||
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
|
||||
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,255 +1,255 @@
|
||||
"""
|
||||
Project-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew # type: ignore
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class ProjectCommands:
|
||||
"""Handles project-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new KiCAD project"""
|
||||
try:
|
||||
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
|
||||
project_name = params.get("name") or params.get("projectName", "New_Project")
|
||||
path = params.get("path", os.getcwd())
|
||||
template = params.get("template")
|
||||
|
||||
# Generate the full project path
|
||||
project_path = os.path.join(path, project_name)
|
||||
if not project_path.endswith(".kicad_pro"):
|
||||
project_path += ".kicad_pro"
|
||||
|
||||
# Create project directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(project_path), exist_ok=True)
|
||||
|
||||
# Create a new board
|
||||
board = pcbnew.BOARD()
|
||||
|
||||
# Set project properties
|
||||
board.GetTitleBlock().SetTitle(project_name)
|
||||
|
||||
# Set current date with proper parameter
|
||||
from datetime import datetime
|
||||
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
board.GetTitleBlock().SetDate(current_date)
|
||||
|
||||
# If template is specified, try to load it
|
||||
if template:
|
||||
template_path = os.path.expanduser(template)
|
||||
if os.path.exists(template_path):
|
||||
template_board = pcbnew.LoadBoard(template_path)
|
||||
# Copy settings from template
|
||||
board.SetDesignSettings(template_board.GetDesignSettings())
|
||||
board.SetLayerStack(template_board.GetLayerStack())
|
||||
|
||||
# Save the board
|
||||
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
|
||||
board.SetFileName(board_path)
|
||||
pcbnew.SaveBoard(board_path, board)
|
||||
|
||||
# Create schematic from template (use expanded template with symbol definitions)
|
||||
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
|
||||
template_sch_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"templates",
|
||||
"template_with_symbols_expanded.kicad_sch",
|
||||
)
|
||||
|
||||
if os.path.exists(template_sch_path):
|
||||
# Copy template schematic
|
||||
shutil.copy(template_sch_path, schematic_path)
|
||||
|
||||
# Regenerate UUID to ensure uniqueness for each created project
|
||||
import re
|
||||
import uuid as uuid_module
|
||||
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
new_uuid = str(uuid_module.uuid4())
|
||||
content = re.sub(
|
||||
r"\(uuid [0-9a-fA-F-]+\)",
|
||||
f"(uuid {new_uuid})",
|
||||
content,
|
||||
count=1, # Only replace first (schematic) UUID
|
||||
)
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"Created schematic from template: {schematic_path}")
|
||||
else:
|
||||
# Fallback: create minimal schematic
|
||||
logger.warning(
|
||||
f"Template not found at {template_sch_path}, creating minimal schematic"
|
||||
)
|
||||
import uuid as uuid_module
|
||||
|
||||
schematic_uuid = str(uuid_module.uuid4())
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
|
||||
f.write(f" (uuid {schematic_uuid})\n\n")
|
||||
f.write(' (paper "A4")\n\n')
|
||||
f.write(" (lib_symbols\n )\n\n")
|
||||
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
|
||||
f.write(")\n")
|
||||
|
||||
# Create project file with schematic reference
|
||||
with open(project_path, "w") as f:
|
||||
f.write("{\n")
|
||||
f.write(' "board": {\n')
|
||||
f.write(f' "filename": "{os.path.basename(board_path)}"\n')
|
||||
f.write(" },\n")
|
||||
f.write(' "sheets": [\n')
|
||||
f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n')
|
||||
f.write(" ]\n")
|
||||
f.write("}\n")
|
||||
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created project: {project_name}",
|
||||
"project": {
|
||||
"name": project_name,
|
||||
"path": project_path,
|
||||
"boardPath": board_path,
|
||||
"schematicPath": schematic_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to create project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Open an existing KiCAD project"""
|
||||
try:
|
||||
filename = params.get("filename")
|
||||
if not filename:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No filename provided",
|
||||
"errorDetails": "The filename parameter is required",
|
||||
}
|
||||
|
||||
# Expand user path and make absolute
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
|
||||
# If it's a project file, get the board file
|
||||
if filename.endswith(".kicad_pro"):
|
||||
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
|
||||
else:
|
||||
board_path = filename
|
||||
|
||||
# Load the board
|
||||
board = pcbnew.LoadBoard(board_path)
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Opened project: {os.path.basename(board_path)}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(board_path))[0],
|
||||
"path": filename,
|
||||
"boardPath": board_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to open project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Save the current KiCAD project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
filename = params.get("filename")
|
||||
if filename:
|
||||
# Save to new location
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
self.board.SetFileName(filename)
|
||||
|
||||
# Save the board
|
||||
pcbnew.SaveBoard(self.board.GetFileName(), self.board)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Saved project to: {self.board.GetFileName()}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
|
||||
"path": self.board.GetFileName(),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to save project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
title_block = self.board.GetTitleBlock()
|
||||
filename = self.board.GetFileName()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(filename))[0],
|
||||
"path": filename,
|
||||
"title": title_block.GetTitle(),
|
||||
"date": title_block.GetDate(),
|
||||
"revision": title_block.GetRevision(),
|
||||
"company": title_block.GetCompany(),
|
||||
"comment1": title_block.GetComment(0),
|
||||
"comment2": title_block.GetComment(1),
|
||||
"comment3": title_block.GetComment(2),
|
||||
"comment4": title_block.GetComment(3),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
"""
|
||||
Project-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew # type: ignore
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class ProjectCommands:
|
||||
"""Handles project-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new KiCAD project"""
|
||||
try:
|
||||
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
|
||||
project_name = params.get("name") or params.get("projectName", "New_Project")
|
||||
path = params.get("path", os.getcwd())
|
||||
template = params.get("template")
|
||||
|
||||
# Generate the full project path
|
||||
project_path = os.path.join(path, project_name)
|
||||
if not project_path.endswith(".kicad_pro"):
|
||||
project_path += ".kicad_pro"
|
||||
|
||||
# Create project directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(project_path), exist_ok=True)
|
||||
|
||||
# Create a new board
|
||||
board = pcbnew.BOARD()
|
||||
|
||||
# Set project properties
|
||||
board.GetTitleBlock().SetTitle(project_name)
|
||||
|
||||
# Set current date with proper parameter
|
||||
from datetime import datetime
|
||||
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
board.GetTitleBlock().SetDate(current_date)
|
||||
|
||||
# If template is specified, try to load it
|
||||
if template:
|
||||
template_path = os.path.expanduser(template)
|
||||
if os.path.exists(template_path):
|
||||
template_board = pcbnew.LoadBoard(template_path)
|
||||
# Copy settings from template
|
||||
board.SetDesignSettings(template_board.GetDesignSettings())
|
||||
board.SetLayerStack(template_board.GetLayerStack())
|
||||
|
||||
# Save the board
|
||||
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
|
||||
board.SetFileName(board_path)
|
||||
pcbnew.SaveBoard(board_path, board)
|
||||
|
||||
# Create schematic from template (use expanded template with symbol definitions)
|
||||
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
|
||||
template_sch_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"templates",
|
||||
"template_with_symbols_expanded.kicad_sch",
|
||||
)
|
||||
|
||||
if os.path.exists(template_sch_path):
|
||||
# Copy template schematic
|
||||
shutil.copy(template_sch_path, schematic_path)
|
||||
|
||||
# Regenerate UUID to ensure uniqueness for each created project
|
||||
import re
|
||||
import uuid as uuid_module
|
||||
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
new_uuid = str(uuid_module.uuid4())
|
||||
content = re.sub(
|
||||
r"\(uuid [0-9a-fA-F-]+\)",
|
||||
f"(uuid {new_uuid})",
|
||||
content,
|
||||
count=1, # Only replace first (schematic) UUID
|
||||
)
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"Created schematic from template: {schematic_path}")
|
||||
else:
|
||||
# Fallback: create minimal schematic
|
||||
logger.warning(
|
||||
f"Template not found at {template_sch_path}, creating minimal schematic"
|
||||
)
|
||||
import uuid as uuid_module
|
||||
|
||||
schematic_uuid = str(uuid_module.uuid4())
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
|
||||
f.write(f" (uuid {schematic_uuid})\n\n")
|
||||
f.write(' (paper "A4")\n\n')
|
||||
f.write(" (lib_symbols\n )\n\n")
|
||||
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
|
||||
f.write(")\n")
|
||||
|
||||
# Create project file with schematic reference
|
||||
with open(project_path, "w") as f:
|
||||
f.write("{\n")
|
||||
f.write(' "board": {\n')
|
||||
f.write(f' "filename": "{os.path.basename(board_path)}"\n')
|
||||
f.write(" },\n")
|
||||
f.write(' "sheets": [\n')
|
||||
f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n')
|
||||
f.write(" ]\n")
|
||||
f.write("}\n")
|
||||
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created project: {project_name}",
|
||||
"project": {
|
||||
"name": project_name,
|
||||
"path": project_path,
|
||||
"boardPath": board_path,
|
||||
"schematicPath": schematic_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to create project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Open an existing KiCAD project"""
|
||||
try:
|
||||
filename = params.get("filename")
|
||||
if not filename:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No filename provided",
|
||||
"errorDetails": "The filename parameter is required",
|
||||
}
|
||||
|
||||
# Expand user path and make absolute
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
|
||||
# If it's a project file, get the board file
|
||||
if filename.endswith(".kicad_pro"):
|
||||
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
|
||||
else:
|
||||
board_path = filename
|
||||
|
||||
# Load the board
|
||||
board = pcbnew.LoadBoard(board_path)
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Opened project: {os.path.basename(board_path)}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(board_path))[0],
|
||||
"path": filename,
|
||||
"boardPath": board_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to open project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Save the current KiCAD project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
filename = params.get("filename")
|
||||
if filename:
|
||||
# Save to new location
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
self.board.SetFileName(filename)
|
||||
|
||||
# Save the board
|
||||
pcbnew.SaveBoard(self.board.GetFileName(), self.board)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Saved project to: {self.board.GetFileName()}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
|
||||
"path": self.board.GetFileName(),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to save project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
title_block = self.board.GetTitleBlock()
|
||||
filename = self.board.GetFileName()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(filename))[0],
|
||||
"path": filename,
|
||||
"title": title_block.GetTitle(),
|
||||
"date": title_block.GetDate(),
|
||||
"revision": title_block.GetRevision(),
|
||||
"company": title_block.GetCompany(),
|
||||
"comment1": title_block.GetComment(0),
|
||||
"comment2": title_block.GetComment(1),
|
||||
"comment3": title_block.GetComment(2),
|
||||
"comment4": title_block.GetComment(3),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,494 +1,494 @@
|
||||
"""
|
||||
Wire Connectivity Analysis for KiCad Schematics
|
||||
|
||||
Traces wire networks from a point and finds connected component pins.
|
||||
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
|
||||
coordinate matching, mirroring KiCad's own connectivity algorithm.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
|
||||
|
||||
|
||||
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
|
||||
"""Convert mm coordinates to KiCad internal units (integer)."""
|
||||
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
|
||||
|
||||
|
||||
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
|
||||
"""Extract wire endpoints from a schematic object as IU tuples."""
|
||||
all_wires = []
|
||||
for wire in schematic.wire:
|
||||
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
|
||||
pts = []
|
||||
for point in wire.pts.xy:
|
||||
if hasattr(point, "value"):
|
||||
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
|
||||
if len(pts) >= 2:
|
||||
all_wires.append(pts)
|
||||
return all_wires
|
||||
|
||||
|
||||
def _build_adjacency(
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
|
||||
"""Build wire adjacency using exact IU coordinate matching.
|
||||
|
||||
Wires that share an endpoint are adjacent — this naturally handles
|
||||
junctions since all wires meeting at the same point get connected.
|
||||
|
||||
Returns a tuple of:
|
||||
- adjacency: list of sets, one per wire, containing adjacent wire indices
|
||||
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
|
||||
that have an endpoint at that exact coordinate (used for seed queries)
|
||||
"""
|
||||
# Map each IU endpoint to all wire indices that touch it
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
|
||||
for i, pts in enumerate(all_wires):
|
||||
for pt in pts:
|
||||
iu_to_wires.setdefault(pt, set()).add(i)
|
||||
|
||||
# Wires that share an IU endpoint are adjacent
|
||||
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
|
||||
for wire_set in iu_to_wires.values():
|
||||
wire_list = list(wire_set)
|
||||
for a in wire_list:
|
||||
for b in wire_list:
|
||||
if a != b:
|
||||
adjacency[a].add(b)
|
||||
|
||||
return adjacency, iu_to_wires
|
||||
|
||||
|
||||
def _parse_virtual_connections(
|
||||
schematic: Any, schematic_path: Any
|
||||
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
||||
"""Return virtual connectivity from net labels and power symbols.
|
||||
|
||||
Returns a tuple of:
|
||||
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
|
||||
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
|
||||
"""
|
||||
point_to_label: Dict[Tuple[int, int], str] = {}
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
|
||||
|
||||
if hasattr(schematic, "label"):
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
pt = _to_iu(float(coords[0]), float(coords[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing net label: {e}")
|
||||
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if not ref.startswith("#PWR"):
|
||||
continue
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
if not hasattr(symbol.property, "Value"):
|
||||
continue
|
||||
name = symbol.property.Value.value
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins or "1" not in all_pins:
|
||||
continue
|
||||
pin_data = all_pins["1"]
|
||||
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing power symbol: {e}")
|
||||
|
||||
return point_to_label, label_to_points
|
||||
|
||||
|
||||
def _find_connected_wires(
|
||||
x_mm: float,
|
||||
y_mm: float,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
|
||||
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
|
||||
) -> Tuple:
|
||||
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
|
||||
|
||||
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
|
||||
# Find seed wires: exact IU match on the query endpoint
|
||||
seed_set = iu_to_wires.get(query_iu)
|
||||
if not seed_set:
|
||||
return (None, None)
|
||||
seed_indices: Set[int] = set(seed_set)
|
||||
|
||||
# BFS flood-fill using pre-compiled adjacency
|
||||
visited: Set[int] = set(seed_indices)
|
||||
queue = list(seed_indices)
|
||||
net_points: Set[Tuple[int, int]] = set()
|
||||
for i in seed_indices:
|
||||
net_points.update(all_wires[i])
|
||||
|
||||
seen_labels: Set[str] = set()
|
||||
while queue:
|
||||
wire_idx = queue.pop()
|
||||
for neighbor_idx in adjacency[wire_idx]:
|
||||
if neighbor_idx not in visited:
|
||||
visited.add(neighbor_idx)
|
||||
queue.append(neighbor_idx)
|
||||
net_points.update(all_wires[neighbor_idx])
|
||||
|
||||
if point_to_label and label_to_points:
|
||||
for pt in all_wires[wire_idx]:
|
||||
label_name = point_to_label.get(pt)
|
||||
if label_name and label_name not in seen_labels:
|
||||
seen_labels.add(label_name)
|
||||
for other_pt in label_to_points.get(label_name, []):
|
||||
if other_pt == pt:
|
||||
continue
|
||||
for idx in iu_to_wires.get(other_pt, set()):
|
||||
if idx not in visited:
|
||||
visited.add(idx)
|
||||
queue.append(idx)
|
||||
net_points.update(all_wires[idx])
|
||||
|
||||
return (visited, net_points)
|
||||
|
||||
|
||||
def _find_pins_on_net(
|
||||
net_points: Set[Tuple[int, int]],
|
||||
schematic_path: Any,
|
||||
schematic: Any,
|
||||
) -> List[Dict]:
|
||||
"""Find component pins that land on net points using exact IU matching.
|
||||
|
||||
Returns a list of {"component": ref, "pin": pin_num} dicts.
|
||||
"""
|
||||
|
||||
def _on_net(px_mm: float, py_mm: float) -> bool:
|
||||
return _to_iu(px_mm, py_mm) in net_points
|
||||
|
||||
locator = PinLocator()
|
||||
pins = []
|
||||
seen: Set[Tuple] = set()
|
||||
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
if _on_net(pin_data[0], pin_data[1]):
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
pins.append({"component": ref, "pin": pin_num})
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return pins
|
||||
|
||||
|
||||
def get_wire_connections(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Optional[Dict]:
|
||||
"""Find the net name and all component pins reachable from a point via connected wires.
|
||||
|
||||
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
|
||||
Interior (mid-segment) points are not matched —
|
||||
use wire endpoint coordinates obtained from the schematic data.
|
||||
|
||||
Net labels and power symbols are traversed: wires on the same named net are
|
||||
treated as connected even when they are not geometrically adjacent.
|
||||
|
||||
Returns dict with keys:
|
||||
- "net": str or None (net label/power name, None if unnamed)
|
||||
- "pins": list of {"component": str, "pin": str}
|
||||
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
|
||||
- "query_point": {"x": float, "y": float}
|
||||
Or None if no wire endpoint found within tolerance of the query point.
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
query_point = {"x": x_mm, "y": y_mm}
|
||||
if not all_wires:
|
||||
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
|
||||
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if visited is None:
|
||||
return None
|
||||
|
||||
# Resolve net name: first label anchor that falls on this net's IU points
|
||||
net: Optional[str] = None
|
||||
for pt in net_points:
|
||||
label = point_to_label.get(pt)
|
||||
if label is not None:
|
||||
net = label
|
||||
break
|
||||
|
||||
wires_out = [
|
||||
{
|
||||
"start": {
|
||||
"x": all_wires[i][0][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][0][1] / _IU_PER_MM,
|
||||
},
|
||||
"end": {
|
||||
"x": all_wires[i][-1][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][-1][1] / _IU_PER_MM,
|
||||
},
|
||||
}
|
||||
for i in visited
|
||||
]
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
|
||||
|
||||
pins = _find_pins_on_net(net_points, schematic_path, schematic)
|
||||
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
|
||||
|
||||
|
||||
def count_pins_on_net(
|
||||
schematic: Any,
|
||||
schematic_path: str,
|
||||
net_name: str,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Dict[Tuple[int, int], str],
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]],
|
||||
) -> int:
|
||||
"""Count the number of component pins connected to the named net.
|
||||
|
||||
A pin is counted if its IU coordinate falls on the wire-network reachable
|
||||
from any label anchor for *net_name*, or directly on a label anchor of that
|
||||
net (pin directly touching a label with no intervening wire).
|
||||
|
||||
Returns the count of distinct (component, pin_num) pairs on this net.
|
||||
"""
|
||||
label_positions = label_to_points.get(net_name, [])
|
||||
if not label_positions:
|
||||
return 0
|
||||
|
||||
# Collect the union of all net-points across all label positions for this net
|
||||
all_net_points: Set[Tuple[int, int]] = set()
|
||||
for lx, ly in label_positions:
|
||||
# Include the label anchor itself so pins directly at the label count
|
||||
all_net_points.add((lx, ly))
|
||||
# Trace from this label position into the wire graph
|
||||
x_mm = lx / _IU_PER_MM
|
||||
y_mm = ly / _IU_PER_MM
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if net_points:
|
||||
all_net_points |= net_points
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return 0
|
||||
|
||||
locator = PinLocator()
|
||||
seen: Set[Tuple[str, str]] = set()
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
if pin_iu in all_net_points:
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return len(seen)
|
||||
|
||||
|
||||
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
|
||||
"""Return net labels that are not connected to any component pin.
|
||||
|
||||
A label is "floating" when no component pin's IU coordinate falls on the
|
||||
wire-network reachable from the label's anchor position. These labels are
|
||||
likely placed off-grid or incorrectly positioned and will cause ERC errors.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
- "name": str — the net label text
|
||||
- "x": float — label X position in mm
|
||||
- "y": float — label Y position in mm
|
||||
- "type": str — "label" or "global_label"
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
else:
|
||||
adjacency = []
|
||||
iu_to_wires = {}
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Build a set of all pin IU positions for fast lookup
|
||||
pin_iu_set: Set[Tuple[int, int]] = set()
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_data in all_pins.values():
|
||||
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading pins for floating-label check: {e}")
|
||||
|
||||
floating: List[Dict[str, Any]] = []
|
||||
|
||||
if not hasattr(schematic, "label"):
|
||||
return floating
|
||||
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
lx_mm = float(coords[0])
|
||||
ly_mm = float(coords[1])
|
||||
label_iu = _to_iu(lx_mm, ly_mm)
|
||||
|
||||
# Check if the label anchor itself is a pin position
|
||||
if label_iu in pin_iu_set:
|
||||
continue
|
||||
|
||||
# Trace the wire-network from this label and check for pins
|
||||
if all_wires:
|
||||
_, net_points = _find_connected_wires(
|
||||
lx_mm,
|
||||
ly_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
else:
|
||||
net_points = None
|
||||
|
||||
if net_points is not None and net_points & pin_iu_set:
|
||||
continue # at least one pin on this net
|
||||
|
||||
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking label for floating status: {e}")
|
||||
|
||||
return floating
|
||||
|
||||
|
||||
def get_net_at_point(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the net name at the given coordinate, or null if none found.
|
||||
|
||||
Checks net label positions first (exact IU match within tolerance), then
|
||||
wire endpoints. Returns a dict with keys:
|
||||
- "net_name": str or None
|
||||
- "position": {"x": float, "y": float}
|
||||
- "source": "net_label" | "wire_endpoint" | None
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
position = {"x": x_mm, "y": y_mm}
|
||||
|
||||
# Build label map from schematic
|
||||
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Check if query point is exactly on a net label / power symbol position
|
||||
label_name = point_to_label.get(query_iu)
|
||||
if label_name is not None:
|
||||
return {"net_name": label_name, "position": position, "source": "net_label"}
|
||||
|
||||
# Check if query point is on a wire endpoint
|
||||
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
if query_iu in iu_to_wires:
|
||||
# Found a wire endpoint — trace the net to get the name
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=None,
|
||||
)
|
||||
if visited is not None:
|
||||
net: Optional[str] = None
|
||||
if net_points:
|
||||
for pt in net_points:
|
||||
net = point_to_label.get(pt)
|
||||
if net is not None:
|
||||
break
|
||||
return {"net_name": net, "position": position, "source": "wire_endpoint"}
|
||||
|
||||
return {"net_name": None, "position": position, "source": None}
|
||||
"""
|
||||
Wire Connectivity Analysis for KiCad Schematics
|
||||
|
||||
Traces wire networks from a point and finds connected component pins.
|
||||
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
|
||||
coordinate matching, mirroring KiCad's own connectivity algorithm.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
|
||||
|
||||
|
||||
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
|
||||
"""Convert mm coordinates to KiCad internal units (integer)."""
|
||||
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
|
||||
|
||||
|
||||
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
|
||||
"""Extract wire endpoints from a schematic object as IU tuples."""
|
||||
all_wires = []
|
||||
for wire in schematic.wire:
|
||||
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
|
||||
pts = []
|
||||
for point in wire.pts.xy:
|
||||
if hasattr(point, "value"):
|
||||
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
|
||||
if len(pts) >= 2:
|
||||
all_wires.append(pts)
|
||||
return all_wires
|
||||
|
||||
|
||||
def _build_adjacency(
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
|
||||
"""Build wire adjacency using exact IU coordinate matching.
|
||||
|
||||
Wires that share an endpoint are adjacent — this naturally handles
|
||||
junctions since all wires meeting at the same point get connected.
|
||||
|
||||
Returns a tuple of:
|
||||
- adjacency: list of sets, one per wire, containing adjacent wire indices
|
||||
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
|
||||
that have an endpoint at that exact coordinate (used for seed queries)
|
||||
"""
|
||||
# Map each IU endpoint to all wire indices that touch it
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
|
||||
for i, pts in enumerate(all_wires):
|
||||
for pt in pts:
|
||||
iu_to_wires.setdefault(pt, set()).add(i)
|
||||
|
||||
# Wires that share an IU endpoint are adjacent
|
||||
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
|
||||
for wire_set in iu_to_wires.values():
|
||||
wire_list = list(wire_set)
|
||||
for a in wire_list:
|
||||
for b in wire_list:
|
||||
if a != b:
|
||||
adjacency[a].add(b)
|
||||
|
||||
return adjacency, iu_to_wires
|
||||
|
||||
|
||||
def _parse_virtual_connections(
|
||||
schematic: Any, schematic_path: Any
|
||||
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
||||
"""Return virtual connectivity from net labels and power symbols.
|
||||
|
||||
Returns a tuple of:
|
||||
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
|
||||
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
|
||||
"""
|
||||
point_to_label: Dict[Tuple[int, int], str] = {}
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
|
||||
|
||||
if hasattr(schematic, "label"):
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
pt = _to_iu(float(coords[0]), float(coords[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing net label: {e}")
|
||||
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if not ref.startswith("#PWR"):
|
||||
continue
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
if not hasattr(symbol.property, "Value"):
|
||||
continue
|
||||
name = symbol.property.Value.value
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins or "1" not in all_pins:
|
||||
continue
|
||||
pin_data = all_pins["1"]
|
||||
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing power symbol: {e}")
|
||||
|
||||
return point_to_label, label_to_points
|
||||
|
||||
|
||||
def _find_connected_wires(
|
||||
x_mm: float,
|
||||
y_mm: float,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
|
||||
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
|
||||
) -> Tuple:
|
||||
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
|
||||
|
||||
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
|
||||
# Find seed wires: exact IU match on the query endpoint
|
||||
seed_set = iu_to_wires.get(query_iu)
|
||||
if not seed_set:
|
||||
return (None, None)
|
||||
seed_indices: Set[int] = set(seed_set)
|
||||
|
||||
# BFS flood-fill using pre-compiled adjacency
|
||||
visited: Set[int] = set(seed_indices)
|
||||
queue = list(seed_indices)
|
||||
net_points: Set[Tuple[int, int]] = set()
|
||||
for i in seed_indices:
|
||||
net_points.update(all_wires[i])
|
||||
|
||||
seen_labels: Set[str] = set()
|
||||
while queue:
|
||||
wire_idx = queue.pop()
|
||||
for neighbor_idx in adjacency[wire_idx]:
|
||||
if neighbor_idx not in visited:
|
||||
visited.add(neighbor_idx)
|
||||
queue.append(neighbor_idx)
|
||||
net_points.update(all_wires[neighbor_idx])
|
||||
|
||||
if point_to_label and label_to_points:
|
||||
for pt in all_wires[wire_idx]:
|
||||
label_name = point_to_label.get(pt)
|
||||
if label_name and label_name not in seen_labels:
|
||||
seen_labels.add(label_name)
|
||||
for other_pt in label_to_points.get(label_name, []):
|
||||
if other_pt == pt:
|
||||
continue
|
||||
for idx in iu_to_wires.get(other_pt, set()):
|
||||
if idx not in visited:
|
||||
visited.add(idx)
|
||||
queue.append(idx)
|
||||
net_points.update(all_wires[idx])
|
||||
|
||||
return (visited, net_points)
|
||||
|
||||
|
||||
def _find_pins_on_net(
|
||||
net_points: Set[Tuple[int, int]],
|
||||
schematic_path: Any,
|
||||
schematic: Any,
|
||||
) -> List[Dict]:
|
||||
"""Find component pins that land on net points using exact IU matching.
|
||||
|
||||
Returns a list of {"component": ref, "pin": pin_num} dicts.
|
||||
"""
|
||||
|
||||
def _on_net(px_mm: float, py_mm: float) -> bool:
|
||||
return _to_iu(px_mm, py_mm) in net_points
|
||||
|
||||
locator = PinLocator()
|
||||
pins = []
|
||||
seen: Set[Tuple] = set()
|
||||
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
if _on_net(pin_data[0], pin_data[1]):
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
pins.append({"component": ref, "pin": pin_num})
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return pins
|
||||
|
||||
|
||||
def get_wire_connections(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Optional[Dict]:
|
||||
"""Find the net name and all component pins reachable from a point via connected wires.
|
||||
|
||||
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
|
||||
Interior (mid-segment) points are not matched —
|
||||
use wire endpoint coordinates obtained from the schematic data.
|
||||
|
||||
Net labels and power symbols are traversed: wires on the same named net are
|
||||
treated as connected even when they are not geometrically adjacent.
|
||||
|
||||
Returns dict with keys:
|
||||
- "net": str or None (net label/power name, None if unnamed)
|
||||
- "pins": list of {"component": str, "pin": str}
|
||||
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
|
||||
- "query_point": {"x": float, "y": float}
|
||||
Or None if no wire endpoint found within tolerance of the query point.
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
query_point = {"x": x_mm, "y": y_mm}
|
||||
if not all_wires:
|
||||
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
|
||||
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if visited is None:
|
||||
return None
|
||||
|
||||
# Resolve net name: first label anchor that falls on this net's IU points
|
||||
net: Optional[str] = None
|
||||
for pt in net_points:
|
||||
label = point_to_label.get(pt)
|
||||
if label is not None:
|
||||
net = label
|
||||
break
|
||||
|
||||
wires_out = [
|
||||
{
|
||||
"start": {
|
||||
"x": all_wires[i][0][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][0][1] / _IU_PER_MM,
|
||||
},
|
||||
"end": {
|
||||
"x": all_wires[i][-1][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][-1][1] / _IU_PER_MM,
|
||||
},
|
||||
}
|
||||
for i in visited
|
||||
]
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
|
||||
|
||||
pins = _find_pins_on_net(net_points, schematic_path, schematic)
|
||||
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
|
||||
|
||||
|
||||
def count_pins_on_net(
|
||||
schematic: Any,
|
||||
schematic_path: str,
|
||||
net_name: str,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Dict[Tuple[int, int], str],
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]],
|
||||
) -> int:
|
||||
"""Count the number of component pins connected to the named net.
|
||||
|
||||
A pin is counted if its IU coordinate falls on the wire-network reachable
|
||||
from any label anchor for *net_name*, or directly on a label anchor of that
|
||||
net (pin directly touching a label with no intervening wire).
|
||||
|
||||
Returns the count of distinct (component, pin_num) pairs on this net.
|
||||
"""
|
||||
label_positions = label_to_points.get(net_name, [])
|
||||
if not label_positions:
|
||||
return 0
|
||||
|
||||
# Collect the union of all net-points across all label positions for this net
|
||||
all_net_points: Set[Tuple[int, int]] = set()
|
||||
for lx, ly in label_positions:
|
||||
# Include the label anchor itself so pins directly at the label count
|
||||
all_net_points.add((lx, ly))
|
||||
# Trace from this label position into the wire graph
|
||||
x_mm = lx / _IU_PER_MM
|
||||
y_mm = ly / _IU_PER_MM
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if net_points:
|
||||
all_net_points |= net_points
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return 0
|
||||
|
||||
locator = PinLocator()
|
||||
seen: Set[Tuple[str, str]] = set()
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
if pin_iu in all_net_points:
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return len(seen)
|
||||
|
||||
|
||||
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
|
||||
"""Return net labels that are not connected to any component pin.
|
||||
|
||||
A label is "floating" when no component pin's IU coordinate falls on the
|
||||
wire-network reachable from the label's anchor position. These labels are
|
||||
likely placed off-grid or incorrectly positioned and will cause ERC errors.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
- "name": str — the net label text
|
||||
- "x": float — label X position in mm
|
||||
- "y": float — label Y position in mm
|
||||
- "type": str — "label" or "global_label"
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
else:
|
||||
adjacency = []
|
||||
iu_to_wires = {}
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Build a set of all pin IU positions for fast lookup
|
||||
pin_iu_set: Set[Tuple[int, int]] = set()
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_data in all_pins.values():
|
||||
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading pins for floating-label check: {e}")
|
||||
|
||||
floating: List[Dict[str, Any]] = []
|
||||
|
||||
if not hasattr(schematic, "label"):
|
||||
return floating
|
||||
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
lx_mm = float(coords[0])
|
||||
ly_mm = float(coords[1])
|
||||
label_iu = _to_iu(lx_mm, ly_mm)
|
||||
|
||||
# Check if the label anchor itself is a pin position
|
||||
if label_iu in pin_iu_set:
|
||||
continue
|
||||
|
||||
# Trace the wire-network from this label and check for pins
|
||||
if all_wires:
|
||||
_, net_points = _find_connected_wires(
|
||||
lx_mm,
|
||||
ly_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
else:
|
||||
net_points = None
|
||||
|
||||
if net_points is not None and net_points & pin_iu_set:
|
||||
continue # at least one pin on this net
|
||||
|
||||
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking label for floating status: {e}")
|
||||
|
||||
return floating
|
||||
|
||||
|
||||
def get_net_at_point(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the net name at the given coordinate, or null if none found.
|
||||
|
||||
Checks net label positions first (exact IU match within tolerance), then
|
||||
wire endpoints. Returns a dict with keys:
|
||||
- "net_name": str or None
|
||||
- "position": {"x": float, "y": float}
|
||||
- "source": "net_label" | "wire_endpoint" | None
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
position = {"x": x_mm, "y": y_mm}
|
||||
|
||||
# Build label map from schematic
|
||||
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Check if query point is exactly on a net label / power symbol position
|
||||
label_name = point_to_label.get(query_iu)
|
||||
if label_name is not None:
|
||||
return {"net_name": label_name, "position": position, "source": "net_label"}
|
||||
|
||||
# Check if query point is on a wire endpoint
|
||||
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
if query_iu in iu_to_wires:
|
||||
# Found a wire endpoint — trace the net to get the name
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=None,
|
||||
)
|
||||
if visited is not None:
|
||||
net: Optional[str] = None
|
||||
if net_points:
|
||||
for pt in net_points:
|
||||
net = point_to_label.get(pt)
|
||||
if net is not None:
|
||||
break
|
||||
return {"net_name": net, "position": position, "source": "wire_endpoint"}
|
||||
|
||||
return {"net_name": None, "position": position, "source": None}
|
||||
|
||||
@@ -1,439 +1,439 @@
|
||||
"""
|
||||
WireDragger — drag connected wires when a schematic component is moved.
|
||||
|
||||
All methods operate on in-memory sexpdata lists (no disk I/O).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# Module-level Symbol constants
|
||||
_K = {
|
||||
name: Symbol(name)
|
||||
for name in [
|
||||
"symbol",
|
||||
"at",
|
||||
"lib_id",
|
||||
"mirror",
|
||||
"lib_symbols",
|
||||
"pts",
|
||||
"xy",
|
||||
"wire",
|
||||
"junction",
|
||||
"property",
|
||||
"stroke",
|
||||
"width",
|
||||
"type",
|
||||
"uuid",
|
||||
]
|
||||
}
|
||||
|
||||
EPS = 1e-4 # mm — coordinate match tolerance
|
||||
|
||||
|
||||
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
|
||||
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
|
||||
if angle_deg == 0:
|
||||
return x, y
|
||||
rad = math.radians(angle_deg)
|
||||
c, s = math.cos(rad), math.sin(rad)
|
||||
return x * c - y * s, x * s + y * c
|
||||
|
||||
|
||||
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
|
||||
return abs(ax - bx) < eps and abs(ay - by) < eps
|
||||
|
||||
|
||||
class WireDragger:
|
||||
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
|
||||
|
||||
@staticmethod
|
||||
def find_symbol(sch_data: list, reference: str) -> Any:
|
||||
"""
|
||||
Find a placed symbol by reference designator.
|
||||
|
||||
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
|
||||
or None if the reference is not found.
|
||||
|
||||
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
|
||||
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
at_k = _K["at"]
|
||||
lib_id_k = _K["lib_id"]
|
||||
mirror_k = _K["mirror"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
|
||||
# Check Reference property
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val != reference:
|
||||
continue
|
||||
|
||||
old_x = old_y = rotation = 0.0
|
||||
lib_id = ""
|
||||
mirror_x = mirror_y = False
|
||||
|
||||
for sub in item[1:]:
|
||||
if not isinstance(sub, list) or not sub:
|
||||
continue
|
||||
tag = sub[0]
|
||||
if tag == at_k:
|
||||
if len(sub) >= 3:
|
||||
old_x = float(sub[1])
|
||||
old_y = float(sub[2])
|
||||
if len(sub) >= 4:
|
||||
rotation = float(sub[3])
|
||||
elif tag == lib_id_k and len(sub) >= 2:
|
||||
lib_id = str(sub[1]).strip('"')
|
||||
elif tag == mirror_k and len(sub) >= 2:
|
||||
mv = str(sub[1])
|
||||
if mv == "x":
|
||||
mirror_x = True
|
||||
elif mv == "y":
|
||||
mirror_y = True
|
||||
|
||||
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
|
||||
"""
|
||||
Get pin definitions from lib_symbols for the given lib_id.
|
||||
|
||||
Returns the same dict format as PinLocator.parse_symbol_definition:
|
||||
{pin_num: {"x": ..., "y": ..., ...}}.
|
||||
"""
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
lib_sym_k = _K["lib_symbols"]
|
||||
symbol_k = _K["symbol"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
|
||||
continue
|
||||
for sym_def in item[1:]:
|
||||
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
|
||||
continue
|
||||
if len(sym_def) < 2:
|
||||
continue
|
||||
name = str(sym_def[1]).strip('"')
|
||||
if name == lib_id:
|
||||
return PinLocator.parse_symbol_definition(sym_def)
|
||||
break # only one lib_symbols section
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def pin_world_xy(
|
||||
px: float,
|
||||
py: float,
|
||||
sym_x: float,
|
||||
sym_y: float,
|
||||
rotation: float,
|
||||
mirror_x: bool,
|
||||
mirror_y: bool,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Compute the world coordinate of a pin given the symbol transform.
|
||||
|
||||
KiCAD applies mirror first (in local space), then rotation, then translation.
|
||||
mirror_x negates the local X axis; mirror_y negates the local Y axis.
|
||||
"""
|
||||
lx, ly = px, py
|
||||
if mirror_x:
|
||||
lx = -lx
|
||||
if mirror_y:
|
||||
ly = -ly
|
||||
rx, ry = _rotate(lx, ly, rotation)
|
||||
return sym_x + rx, sym_y + ry
|
||||
|
||||
@staticmethod
|
||||
def compute_pin_positions(
|
||||
sch_data: list,
|
||||
reference: str,
|
||||
new_x: float,
|
||||
new_y: float,
|
||||
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Compute world pin positions before and after a component move.
|
||||
|
||||
Returns {pin_num: (old_world_xy, new_world_xy)}.
|
||||
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return {}
|
||||
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
|
||||
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
result: Dict[str, Tuple] = {}
|
||||
for pin_num, pin in pins.items():
|
||||
px, py = pin["x"], pin["y"]
|
||||
old_wx, old_wy = WireDragger.pin_world_xy(
|
||||
px, py, old_x, old_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
new_wx, new_wy = WireDragger.pin_world_xy(
|
||||
px, py, new_x, new_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
result[pin_num] = (
|
||||
(round(old_wx, 6), round(old_wy, 6)),
|
||||
(round(new_wx, 6), round(new_wy, 6)),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def drag_wires(
|
||||
sch_data: list,
|
||||
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
|
||||
eps: float = EPS,
|
||||
) -> Dict:
|
||||
"""
|
||||
Move wire endpoints and junctions from old positions to new positions.
|
||||
Removes zero-length wires that result from the move.
|
||||
Modifies sch_data in place.
|
||||
|
||||
old_to_new: {(old_x, old_y): (new_x, new_y)}
|
||||
|
||||
Returns {'endpoints_moved': N, 'wires_removed': M}.
|
||||
"""
|
||||
wire_k = _K["wire"]
|
||||
pts_k = _K["pts"]
|
||||
xy_k = _K["xy"]
|
||||
junction_k = _K["junction"]
|
||||
at_k = _K["at"]
|
||||
|
||||
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
|
||||
for (ox, oy), (nx, ny) in old_to_new.items():
|
||||
if _coords_match(x, y, ox, oy, eps):
|
||||
return nx, ny
|
||||
return None
|
||||
|
||||
endpoints_moved = 0
|
||||
zero_length_indices = []
|
||||
|
||||
# First pass: update wire endpoints
|
||||
for idx, item in enumerate(sch_data):
|
||||
if not (isinstance(item, list) and item and item[0] == wire_k):
|
||||
continue
|
||||
|
||||
pts_sub = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == pts_k:
|
||||
pts_sub = sub
|
||||
break
|
||||
if pts_sub is None:
|
||||
continue
|
||||
|
||||
xy_items = [
|
||||
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
|
||||
]
|
||||
for xy_item in xy_items:
|
||||
nc = find_new(float(xy_item[1]), float(xy_item[2]))
|
||||
if nc is not None:
|
||||
xy_item[1] = nc[0]
|
||||
xy_item[2] = nc[1]
|
||||
endpoints_moved += 1
|
||||
|
||||
# Check if this wire is now zero-length
|
||||
if len(xy_items) >= 2:
|
||||
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
|
||||
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
|
||||
if _coords_match(x1, y1, x2, y2, eps):
|
||||
zero_length_indices.append(idx)
|
||||
|
||||
# Remove zero-length wires (backwards to preserve indices)
|
||||
for idx in reversed(zero_length_indices):
|
||||
del sch_data[idx]
|
||||
|
||||
# Second pass: update junctions
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == junction_k):
|
||||
continue
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
nc = find_new(float(sub[1]), float(sub[2]))
|
||||
if nc is not None:
|
||||
sub[1] = nc[0]
|
||||
sub[2] = nc[1]
|
||||
break
|
||||
|
||||
return {
|
||||
"endpoints_moved": endpoints_moved,
|
||||
"wires_removed": len(zero_length_indices),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
|
||||
"""
|
||||
Update the (at x y rot) of the named symbol in sch_data.
|
||||
Returns True if the symbol was found and updated.
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return False
|
||||
item = found[0]
|
||||
at_k = _K["at"]
|
||||
prop_k = _K["property"]
|
||||
|
||||
# Find current position and compute delta
|
||||
old_x = old_y = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
old_x, old_y = sub[1], sub[2]
|
||||
sub[1] = new_x
|
||||
sub[2] = new_y
|
||||
break
|
||||
if old_x is None or old_y is None:
|
||||
return False
|
||||
|
||||
dx = new_x - old_x
|
||||
dy = new_y - old_y
|
||||
|
||||
# Shift all property label positions by the same delta
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == prop_k:
|
||||
for psub in sub[1:]:
|
||||
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
|
||||
psub[1] += dx
|
||||
psub[2] += dy
|
||||
break
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
|
||||
"""Build a wire s-expression list in KiCAD schematic format."""
|
||||
wire_uuid = str(uuid.uuid4())
|
||||
return [
|
||||
_K["wire"],
|
||||
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
|
||||
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
|
||||
[_K["uuid"], wire_uuid],
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_all_stationary_pin_positions(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
) -> Dict[Tuple[float, float], str]:
|
||||
"""
|
||||
Return a map of {world_xy: reference} for every pin of every symbol
|
||||
in sch_data *except* moved_reference.
|
||||
|
||||
This is used to detect pins of stationary components that coincide
|
||||
with pins of the moved component (touching-pin connections).
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
result: Dict[Tuple[float, float], str] = {}
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
# Determine reference
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val is None or ref_val == moved_reference:
|
||||
continue
|
||||
# Skip template / power symbols whose references start with special chars
|
||||
# but we still want to handle them — no filtering needed here.
|
||||
|
||||
# Find lib_id and position for this symbol
|
||||
found = WireDragger.find_symbol(sch_data, ref_val)
|
||||
if found is None:
|
||||
continue
|
||||
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
for pin_num, pin in pins.items():
|
||||
wx, wy = WireDragger.pin_world_xy(
|
||||
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
|
||||
)
|
||||
key = (round(wx, 6), round(wy, 6))
|
||||
result[key] = ref_val
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def synthesize_touching_pin_wires(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
|
||||
eps: float = EPS,
|
||||
) -> int:
|
||||
"""
|
||||
Detect touching-pin connections and synthesize wire segments to bridge gaps
|
||||
created by moving a component.
|
||||
|
||||
For each pin of *moved_reference* whose old world position coincides with
|
||||
a pin of a stationary component:
|
||||
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
|
||||
- If the pin now lands on another stationary pin's position, skip (they touch again).
|
||||
- If old_xy == new_xy, do nothing (no gap was created).
|
||||
|
||||
Modifies sch_data in place.
|
||||
Returns the number of wire segments synthesized.
|
||||
"""
|
||||
if not pin_positions:
|
||||
return 0
|
||||
|
||||
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
|
||||
if not stationary_pins:
|
||||
return 0
|
||||
|
||||
synthesized = 0
|
||||
|
||||
for pin_num, (old_xy, new_xy) in pin_positions.items():
|
||||
# Check if a stationary pin touches this pin's old position
|
||||
touching = any(
|
||||
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if not touching:
|
||||
continue
|
||||
|
||||
# The pin has moved — check if it actually separated
|
||||
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
|
||||
# Pin didn't actually move; no gap
|
||||
continue
|
||||
|
||||
# Check if the pin's new position happens to touch another stationary pin
|
||||
# (component moved into a different touching position — no wire needed)
|
||||
rejoining = any(
|
||||
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if rejoining:
|
||||
logger.debug(
|
||||
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
|
||||
f"and rejoins another stationary pin; no wire synthesized"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Synthesizing wire for touching-pin connection: "
|
||||
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
|
||||
)
|
||||
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
|
||||
# Insert before the last item (sheet_instances) to keep file tidy,
|
||||
# but appending is also valid — just append.
|
||||
sch_data.append(wire)
|
||||
synthesized += 1
|
||||
|
||||
return synthesized
|
||||
"""
|
||||
WireDragger — drag connected wires when a schematic component is moved.
|
||||
|
||||
All methods operate on in-memory sexpdata lists (no disk I/O).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# Module-level Symbol constants
|
||||
_K = {
|
||||
name: Symbol(name)
|
||||
for name in [
|
||||
"symbol",
|
||||
"at",
|
||||
"lib_id",
|
||||
"mirror",
|
||||
"lib_symbols",
|
||||
"pts",
|
||||
"xy",
|
||||
"wire",
|
||||
"junction",
|
||||
"property",
|
||||
"stroke",
|
||||
"width",
|
||||
"type",
|
||||
"uuid",
|
||||
]
|
||||
}
|
||||
|
||||
EPS = 1e-4 # mm — coordinate match tolerance
|
||||
|
||||
|
||||
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
|
||||
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
|
||||
if angle_deg == 0:
|
||||
return x, y
|
||||
rad = math.radians(angle_deg)
|
||||
c, s = math.cos(rad), math.sin(rad)
|
||||
return x * c - y * s, x * s + y * c
|
||||
|
||||
|
||||
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
|
||||
return abs(ax - bx) < eps and abs(ay - by) < eps
|
||||
|
||||
|
||||
class WireDragger:
|
||||
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
|
||||
|
||||
@staticmethod
|
||||
def find_symbol(sch_data: list, reference: str) -> Any:
|
||||
"""
|
||||
Find a placed symbol by reference designator.
|
||||
|
||||
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
|
||||
or None if the reference is not found.
|
||||
|
||||
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
|
||||
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
at_k = _K["at"]
|
||||
lib_id_k = _K["lib_id"]
|
||||
mirror_k = _K["mirror"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
|
||||
# Check Reference property
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val != reference:
|
||||
continue
|
||||
|
||||
old_x = old_y = rotation = 0.0
|
||||
lib_id = ""
|
||||
mirror_x = mirror_y = False
|
||||
|
||||
for sub in item[1:]:
|
||||
if not isinstance(sub, list) or not sub:
|
||||
continue
|
||||
tag = sub[0]
|
||||
if tag == at_k:
|
||||
if len(sub) >= 3:
|
||||
old_x = float(sub[1])
|
||||
old_y = float(sub[2])
|
||||
if len(sub) >= 4:
|
||||
rotation = float(sub[3])
|
||||
elif tag == lib_id_k and len(sub) >= 2:
|
||||
lib_id = str(sub[1]).strip('"')
|
||||
elif tag == mirror_k and len(sub) >= 2:
|
||||
mv = str(sub[1])
|
||||
if mv == "x":
|
||||
mirror_x = True
|
||||
elif mv == "y":
|
||||
mirror_y = True
|
||||
|
||||
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
|
||||
"""
|
||||
Get pin definitions from lib_symbols for the given lib_id.
|
||||
|
||||
Returns the same dict format as PinLocator.parse_symbol_definition:
|
||||
{pin_num: {"x": ..., "y": ..., ...}}.
|
||||
"""
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
lib_sym_k = _K["lib_symbols"]
|
||||
symbol_k = _K["symbol"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
|
||||
continue
|
||||
for sym_def in item[1:]:
|
||||
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
|
||||
continue
|
||||
if len(sym_def) < 2:
|
||||
continue
|
||||
name = str(sym_def[1]).strip('"')
|
||||
if name == lib_id:
|
||||
return PinLocator.parse_symbol_definition(sym_def)
|
||||
break # only one lib_symbols section
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def pin_world_xy(
|
||||
px: float,
|
||||
py: float,
|
||||
sym_x: float,
|
||||
sym_y: float,
|
||||
rotation: float,
|
||||
mirror_x: bool,
|
||||
mirror_y: bool,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Compute the world coordinate of a pin given the symbol transform.
|
||||
|
||||
KiCAD applies mirror first (in local space), then rotation, then translation.
|
||||
mirror_x negates the local X axis; mirror_y negates the local Y axis.
|
||||
"""
|
||||
lx, ly = px, py
|
||||
if mirror_x:
|
||||
lx = -lx
|
||||
if mirror_y:
|
||||
ly = -ly
|
||||
rx, ry = _rotate(lx, ly, rotation)
|
||||
return sym_x + rx, sym_y + ry
|
||||
|
||||
@staticmethod
|
||||
def compute_pin_positions(
|
||||
sch_data: list,
|
||||
reference: str,
|
||||
new_x: float,
|
||||
new_y: float,
|
||||
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Compute world pin positions before and after a component move.
|
||||
|
||||
Returns {pin_num: (old_world_xy, new_world_xy)}.
|
||||
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return {}
|
||||
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
|
||||
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
result: Dict[str, Tuple] = {}
|
||||
for pin_num, pin in pins.items():
|
||||
px, py = pin["x"], pin["y"]
|
||||
old_wx, old_wy = WireDragger.pin_world_xy(
|
||||
px, py, old_x, old_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
new_wx, new_wy = WireDragger.pin_world_xy(
|
||||
px, py, new_x, new_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
result[pin_num] = (
|
||||
(round(old_wx, 6), round(old_wy, 6)),
|
||||
(round(new_wx, 6), round(new_wy, 6)),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def drag_wires(
|
||||
sch_data: list,
|
||||
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
|
||||
eps: float = EPS,
|
||||
) -> Dict:
|
||||
"""
|
||||
Move wire endpoints and junctions from old positions to new positions.
|
||||
Removes zero-length wires that result from the move.
|
||||
Modifies sch_data in place.
|
||||
|
||||
old_to_new: {(old_x, old_y): (new_x, new_y)}
|
||||
|
||||
Returns {'endpoints_moved': N, 'wires_removed': M}.
|
||||
"""
|
||||
wire_k = _K["wire"]
|
||||
pts_k = _K["pts"]
|
||||
xy_k = _K["xy"]
|
||||
junction_k = _K["junction"]
|
||||
at_k = _K["at"]
|
||||
|
||||
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
|
||||
for (ox, oy), (nx, ny) in old_to_new.items():
|
||||
if _coords_match(x, y, ox, oy, eps):
|
||||
return nx, ny
|
||||
return None
|
||||
|
||||
endpoints_moved = 0
|
||||
zero_length_indices = []
|
||||
|
||||
# First pass: update wire endpoints
|
||||
for idx, item in enumerate(sch_data):
|
||||
if not (isinstance(item, list) and item and item[0] == wire_k):
|
||||
continue
|
||||
|
||||
pts_sub = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == pts_k:
|
||||
pts_sub = sub
|
||||
break
|
||||
if pts_sub is None:
|
||||
continue
|
||||
|
||||
xy_items = [
|
||||
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
|
||||
]
|
||||
for xy_item in xy_items:
|
||||
nc = find_new(float(xy_item[1]), float(xy_item[2]))
|
||||
if nc is not None:
|
||||
xy_item[1] = nc[0]
|
||||
xy_item[2] = nc[1]
|
||||
endpoints_moved += 1
|
||||
|
||||
# Check if this wire is now zero-length
|
||||
if len(xy_items) >= 2:
|
||||
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
|
||||
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
|
||||
if _coords_match(x1, y1, x2, y2, eps):
|
||||
zero_length_indices.append(idx)
|
||||
|
||||
# Remove zero-length wires (backwards to preserve indices)
|
||||
for idx in reversed(zero_length_indices):
|
||||
del sch_data[idx]
|
||||
|
||||
# Second pass: update junctions
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == junction_k):
|
||||
continue
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
nc = find_new(float(sub[1]), float(sub[2]))
|
||||
if nc is not None:
|
||||
sub[1] = nc[0]
|
||||
sub[2] = nc[1]
|
||||
break
|
||||
|
||||
return {
|
||||
"endpoints_moved": endpoints_moved,
|
||||
"wires_removed": len(zero_length_indices),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
|
||||
"""
|
||||
Update the (at x y rot) of the named symbol in sch_data.
|
||||
Returns True if the symbol was found and updated.
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return False
|
||||
item = found[0]
|
||||
at_k = _K["at"]
|
||||
prop_k = _K["property"]
|
||||
|
||||
# Find current position and compute delta
|
||||
old_x = old_y = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
old_x, old_y = sub[1], sub[2]
|
||||
sub[1] = new_x
|
||||
sub[2] = new_y
|
||||
break
|
||||
if old_x is None or old_y is None:
|
||||
return False
|
||||
|
||||
dx = new_x - old_x
|
||||
dy = new_y - old_y
|
||||
|
||||
# Shift all property label positions by the same delta
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == prop_k:
|
||||
for psub in sub[1:]:
|
||||
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
|
||||
psub[1] += dx
|
||||
psub[2] += dy
|
||||
break
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
|
||||
"""Build a wire s-expression list in KiCAD schematic format."""
|
||||
wire_uuid = str(uuid.uuid4())
|
||||
return [
|
||||
_K["wire"],
|
||||
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
|
||||
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
|
||||
[_K["uuid"], wire_uuid],
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_all_stationary_pin_positions(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
) -> Dict[Tuple[float, float], str]:
|
||||
"""
|
||||
Return a map of {world_xy: reference} for every pin of every symbol
|
||||
in sch_data *except* moved_reference.
|
||||
|
||||
This is used to detect pins of stationary components that coincide
|
||||
with pins of the moved component (touching-pin connections).
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
result: Dict[Tuple[float, float], str] = {}
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
# Determine reference
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val is None or ref_val == moved_reference:
|
||||
continue
|
||||
# Skip template / power symbols whose references start with special chars
|
||||
# but we still want to handle them — no filtering needed here.
|
||||
|
||||
# Find lib_id and position for this symbol
|
||||
found = WireDragger.find_symbol(sch_data, ref_val)
|
||||
if found is None:
|
||||
continue
|
||||
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
for pin_num, pin in pins.items():
|
||||
wx, wy = WireDragger.pin_world_xy(
|
||||
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
|
||||
)
|
||||
key = (round(wx, 6), round(wy, 6))
|
||||
result[key] = ref_val
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def synthesize_touching_pin_wires(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
|
||||
eps: float = EPS,
|
||||
) -> int:
|
||||
"""
|
||||
Detect touching-pin connections and synthesize wire segments to bridge gaps
|
||||
created by moving a component.
|
||||
|
||||
For each pin of *moved_reference* whose old world position coincides with
|
||||
a pin of a stationary component:
|
||||
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
|
||||
- If the pin now lands on another stationary pin's position, skip (they touch again).
|
||||
- If old_xy == new_xy, do nothing (no gap was created).
|
||||
|
||||
Modifies sch_data in place.
|
||||
Returns the number of wire segments synthesized.
|
||||
"""
|
||||
if not pin_positions:
|
||||
return 0
|
||||
|
||||
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
|
||||
if not stationary_pins:
|
||||
return 0
|
||||
|
||||
synthesized = 0
|
||||
|
||||
for pin_num, (old_xy, new_xy) in pin_positions.items():
|
||||
# Check if a stationary pin touches this pin's old position
|
||||
touching = any(
|
||||
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if not touching:
|
||||
continue
|
||||
|
||||
# The pin has moved — check if it actually separated
|
||||
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
|
||||
# Pin didn't actually move; no gap
|
||||
continue
|
||||
|
||||
# Check if the pin's new position happens to touch another stationary pin
|
||||
# (component moved into a different touching position — no wire needed)
|
||||
rejoining = any(
|
||||
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if rejoining:
|
||||
logger.debug(
|
||||
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
|
||||
f"and rejoins another stationary pin; no wire synthesized"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Synthesizing wire for touching-pin connection: "
|
||||
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
|
||||
)
|
||||
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
|
||||
# Insert before the last item (sheet_instances) to keep file tidy,
|
||||
# but appending is also valid — just append.
|
||||
sch_data.append(wire)
|
||||
synthesized += 1
|
||||
|
||||
return synthesized
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user