style: apply Black formatting to all Python files

Add [tool.black] config to pyproject.toml and Black hook to
.pre-commit-config.yaml (rev 26.3.1), then auto-format all Python
source and test files with line-length=100, target-version=py310.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-29 13:01:08 +01:00
parent eee5bfb9ed
commit 75cead0860
53 changed files with 1847 additions and 2394 deletions

View File

@@ -10,10 +10,10 @@ from .design_rules import DesignRuleCommands
from .export import ExportCommands
__all__ = [
'ProjectCommands',
'BoardCommands',
'ComponentCommands',
'RoutingCommands',
'DesignRuleCommands',
'ExportCommands'
"ProjectCommands",
"BoardCommands",
"ComponentCommands",
"RoutingCommands",
"DesignRuleCommands",
"ExportCommands",
]

View File

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

View File

@@ -12,7 +12,8 @@ from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
from .view import BoardViewCommands
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""
@@ -75,8 +76,8 @@ class BoardCommands:
"""Get a 2D image of the PCB"""
self.view_commands.board = self.board
return self.view_commands.get_board_2d_view(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)

View File

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

View File

@@ -224,9 +224,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
diameter_nm = int(diameter * scale)
@@ -252,9 +250,7 @@ class BoardOutlineCommands:
pad = pcbnew.PAD(module)
pad.SetNumber(1)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(
pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH
)
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
@@ -311,9 +307,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
size_nm = int(size * scale)
@@ -372,9 +366,7 @@ class BoardOutlineCommands:
"errorDetails": str(e),
}
def _add_edge_line(
self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int
) -> None:
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
"""Add a line to the edge cuts layer"""
line = pcbnew.PCB_SHAPE(self.board)
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
@@ -396,18 +388,12 @@ class BoardOutlineCommands:
"""Add a rounded rectangle to the edge cuts layer"""
if radius_nm <= 0:
# If no radius, create regular rectangle
top_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
)
top_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
)
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)

View File

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

View File

@@ -10,7 +10,8 @@ from PIL import Image
import io
import base64
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardViewCommands:
"""Handles board viewing operations"""
@@ -26,7 +27,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get board dimensions
@@ -42,26 +43,24 @@ class BoardViewCommands:
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append({
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id
})
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {
"width": width_mm,
"height": height_mm,
"unit": "mm"
},
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"title": self.board.GetTitleBlock().GetTitle()
"title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}
},
}
except Exception as e:
@@ -69,7 +68,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -79,7 +78,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -126,17 +125,14 @@ class BoardViewCommands:
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, 'r') as f:
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_svg)
return {
"success": True,
"imageData": svg_data,
"format": "svg"
}
return {"success": True, "imageData": svg_data, "format": "svg"}
else:
# Use PIL to convert SVG to PNG/JPG
from cairosvg import svg2png
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg)
@@ -144,18 +140,18 @@ class BoardViewCommands:
# Convert PNG to JPG
img = Image.open(io.BytesIO(png_data))
jpg_buffer = io.BytesIO()
img.convert('RGB').save(jpg_buffer, format='JPEG')
img.convert("RGB").save(jpg_buffer, format="JPEG")
jpg_data = jpg_buffer.getvalue()
return {
"success": True,
"imageData": base64.b64encode(jpg_data).decode('utf-8'),
"format": "jpg"
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
"format": "jpg",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode('utf-8'),
"format": "png"
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
@@ -163,7 +159,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
@@ -172,61 +168,58 @@ class BoardViewCommands:
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {
"x": center_x,
"y": center_y
},
"unit": unit
}
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e)
}
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e),
}

View File

@@ -10,12 +10,15 @@ from typing import Dict, Any, Optional, List, Tuple
import base64
from commands.library import LibraryManager
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class ComponentCommands:
"""Handles component-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None):
def __init__(
self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None
):
"""Initialize with optional board instance and library manager"""
self.board = board
self.library_manager = library_manager or LibraryManager()
@@ -27,7 +30,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -43,7 +46,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "componentId and position are required"
"errorDetails": "componentId and position are required",
}
# Find footprint using library manager
@@ -55,13 +58,14 @@ class ComponentCommands:
suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n\nDid you mean one of these?\n" + \
"\n".join([f" - {s['full_name']}" for s in suggestions])
suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join(
[f" - {s['full_name']}" for s in suggestions]
)
return {
"success": False,
"message": "Footprint not found",
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}"
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}",
}
library_path, footprint_name = footprint_result
@@ -78,7 +82,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Internal error",
"errorDetails": "Could not determine library nickname"
"errorDetails": "Could not determine library nickname",
}
# Load the footprint
@@ -87,7 +91,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to load footprint",
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}"
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}",
}
# Set position
@@ -145,14 +149,10 @@ class ComponentCommands:
"component": {
"reference": module.GetReference(),
"value": module.GetValue(),
"position": {
"x": position["x"],
"y": position["y"],
"unit": position["unit"]
},
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
"rotation": rotation,
"layer": layer
}
"layer": layer,
},
}
except Exception as e:
@@ -160,7 +160,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to place component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -170,7 +170,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -182,7 +182,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and position are required"
"errorDetails": "reference and position are required",
}
# Find the component
@@ -191,7 +191,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Set new position
@@ -218,23 +218,17 @@ class ComponentCommands:
"message": f"Moved component: {reference}",
"component": {
"reference": reference,
"position": {
"x": position["x"],
"y": position["y"],
"unit": position["unit"]
},
"rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer())
}
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
"rotation": (
rotation if rotation is not None else module.GetOrientation().AsDegrees()
),
"layer": self.board.GetLayerName(module.GetLayer()),
},
}
except Exception as e:
logger.error(f"Error moving component: {str(e)}")
return {
"success": False,
"message": "Failed to move component",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to move component", "errorDetails": str(e)}
def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Rotate an existing component"""
@@ -243,7 +237,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -253,7 +247,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and angle are required"
"errorDetails": "reference and angle are required",
}
# Find the component
@@ -262,7 +256,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Set rotation
@@ -272,10 +266,7 @@ class ComponentCommands:
return {
"success": True,
"message": f"Rotated component: {reference}",
"component": {
"reference": reference,
"rotation": angle
}
"component": {"reference": reference, "rotation": angle},
}
except Exception as e:
@@ -283,7 +274,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to rotate component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -293,7 +284,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -301,7 +292,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -310,23 +301,20 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Remove from board
self.board.Remove(module)
return {
"success": True,
"message": f"Deleted component: {reference}"
}
return {"success": True, "message": f"Deleted component: {reference}"}
except Exception as e:
logger.error(f"Error deleting component: {str(e)}")
return {
"success": False,
"message": "Failed to delete component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -336,7 +324,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -348,7 +336,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -357,7 +345,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Update properties
@@ -385,17 +373,13 @@ class ComponentCommands:
"component": {
"reference": new_reference or reference,
"value": value or module.GetValue(),
"footprint": footprint or module.GetFPIDAsString()
}
"footprint": footprint or module.GetFPIDAsString(),
},
}
except Exception as e:
logger.error(f"Error editing component: {str(e)}")
return {
"success": False,
"message": "Failed to edit component",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed properties of a component"""
@@ -404,7 +388,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -412,7 +396,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -421,7 +405,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Get position in mm
@@ -435,19 +419,15 @@ class ComponentCommands:
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {
"x": x_mm,
"y": y_mm,
"unit": "mm"
},
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()),
"attributes": {
"smd": module.GetAttributes() & pcbnew.FP_SMD,
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY
}
}
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
},
},
}
except Exception as e:
@@ -455,7 +435,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to get component properties",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -465,7 +445,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
components = []
@@ -474,30 +454,25 @@ class ComponentCommands:
x_mm = pos.x / 1000000
y_mm = pos.y / 1000000
components.append({
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {
"x": x_mm,
"y": y_mm,
"unit": "mm"
},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer())
})
components.append(
{
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()),
}
)
return {
"success": True,
"components": components
}
return {"success": True, "components": components}
except Exception as e:
logger.error(f"Error getting component list: {str(e)}")
return {
"success": False,
"message": "Failed to get component list",
"errorDetails": str(e)
"errorDetails": str(e),
}
def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -507,7 +482,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get search parameters
@@ -519,7 +494,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing search criteria",
"errorDetails": "At least one of reference, value, or footprint pattern is required"
"errorDetails": "At least one of reference, value, or footprint pattern is required",
}
matches = []
@@ -539,31 +514,25 @@ class ComponentCommands:
if match:
pos = module.GetPosition()
matches.append({
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer())
})
matches.append(
{
"reference": module.GetReference(),
"value": module.GetValue(),
"footprint": module.GetFPIDAsString(),
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()),
}
)
return {
"success": True,
"matchCount": len(matches),
"components": matches
}
return {"success": True, "matchCount": len(matches), "components": matches}
except Exception as e:
logger.error(f"Error finding components: {str(e)}")
return {
"success": False,
"message": "Failed to find components",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -573,7 +542,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -581,7 +550,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
# Find the component
@@ -590,7 +559,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
pads = []
@@ -606,7 +575,7 @@ class ComponentCommands:
pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid",
pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect",
pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect",
pcbnew.PAD_SHAPE_CUSTOM: "custom"
pcbnew.PAD_SHAPE_CUSTOM: "custom",
}
shape = shape_map.get(pad.GetShape(), "unknown")
@@ -615,29 +584,25 @@ class ComponentCommands:
pcbnew.PAD_ATTRIB_PTH: "through_hole",
pcbnew.PAD_ATTRIB_SMD: "smd",
pcbnew.PAD_ATTRIB_CONN: "connector",
pcbnew.PAD_ATTRIB_NPTH: "npth"
pcbnew.PAD_ATTRIB_NPTH: "npth",
}
pad_type = type_map.get(pad.GetAttribute(), "unknown")
pads.append({
"name": pad.GetName(),
"number": pad.GetNumber(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"net": pad.GetNetname(),
"netCode": pad.GetNetCode(),
"shape": shape,
"type": pad_type,
"size": {
"x": size.x / 1000000,
"y": size.y / 1000000,
"unit": "mm"
},
"drillSize": pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
})
pads.append(
{
"name": pad.GetName(),
"number": pad.GetNumber(),
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"net": pad.GetNetname(),
"netCode": pad.GetNetCode(),
"shape": shape,
"type": pad_type,
"size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
"drillSize": (
pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
),
}
)
# Get component position for reference
comp_pos = module.GetPosition()
@@ -648,10 +613,10 @@ class ComponentCommands:
"componentPosition": {
"x": comp_pos.x / 1000000,
"y": comp_pos.y / 1000000,
"unit": "mm"
"unit": "mm",
},
"padCount": len(pads),
"pads": pads
"pads": pads,
}
except Exception as e:
@@ -659,7 +624,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to get component pads",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -669,7 +634,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -679,13 +644,13 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing reference",
"errorDetails": "reference parameter is required"
"errorDetails": "reference parameter is required",
}
if not pad_name:
return {
"success": False,
"message": "Missing pad identifier",
"errorDetails": "padName or padNumber parameter is required"
"errorDetails": "padName or padNumber parameter is required",
}
# Find the component
@@ -694,7 +659,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Find the specific pad
@@ -705,7 +670,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Pad not found",
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}"
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}",
}
pos = pad.GetPosition()
@@ -715,18 +680,10 @@ class ComponentCommands:
"success": True,
"reference": reference,
"padName": pad.GetNumber(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"net": pad.GetNetname(),
"netCode": pad.GetNetCode(),
"size": {
"x": size.x / 1000000,
"y": size.y / 1000000,
"unit": "mm"
}
"size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
}
except Exception as e:
@@ -734,7 +691,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to get pad position",
"errorDetails": str(e)
"errorDetails": str(e),
}
def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -744,7 +701,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
component_id = params.get("componentId")
@@ -757,7 +714,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "componentId and count are required"
"errorDetails": "componentId and count are required",
}
if pattern == "grid":
@@ -773,14 +730,14 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing grid parameters",
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required"
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required",
}
if rows * columns != count:
return {
"success": False,
"message": "Invalid grid parameters",
"errorDetails": "rows * columns must equal count"
"errorDetails": "rows * columns must equal count",
}
placed_components = self._place_grid_array(
@@ -793,7 +750,7 @@ class ComponentCommands:
reference_prefix,
value,
rotation,
layer
layer,
)
elif pattern == "circular":
@@ -808,7 +765,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing circular parameters",
"errorDetails": "For circular pattern, center, radius, and angleStep are required"
"errorDetails": "For circular pattern, center, radius, and angleStep are required",
}
placed_components = self._place_circular_array(
@@ -821,20 +778,20 @@ class ComponentCommands:
reference_prefix,
value,
rotation_offset,
layer
layer,
)
else:
return {
"success": False,
"message": "Invalid pattern",
"errorDetails": "Pattern must be 'grid' or 'circular'"
"errorDetails": "Pattern must be 'grid' or 'circular'",
}
return {
"success": True,
"message": f"Placed {count} components in {pattern} pattern",
"components": placed_components
"components": placed_components,
}
except Exception as e:
@@ -842,7 +799,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to place component array",
"errorDetails": str(e)
"errorDetails": str(e),
}
def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -852,7 +809,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
references = params.get("references", [])
@@ -864,7 +821,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing references",
"errorDetails": "At least two component references are required"
"errorDetails": "At least two component references are required",
}
# Find all referenced components
@@ -875,7 +832,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {ref}"
"errorDetails": f"Could not find component: {ref}",
}
components.append(module)
@@ -890,36 +847,34 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing edge parameter",
"errorDetails": "Edge parameter is required for edge alignment"
"errorDetails": "Edge parameter is required for edge alignment",
}
self._align_components_to_edge(components, edge)
else:
return {
"success": False,
"message": "Invalid alignment option",
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'"
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'",
}
# Prepare result data
aligned_components = []
for module in components:
pos = module.GetPosition()
aligned_components.append({
"reference": module.GetReference(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"rotation": module.GetOrientation().AsDegrees()
})
aligned_components.append(
{
"reference": module.GetReference(),
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"rotation": module.GetOrientation().AsDegrees(),
}
)
return {
"success": True,
"message": f"Aligned {len(components)} components",
"alignment": alignment,
"distribution": distribution,
"components": aligned_components
"components": aligned_components,
}
except Exception as e:
@@ -927,7 +882,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to align components",
"errorDetails": str(e)
"errorDetails": str(e),
}
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -937,7 +892,7 @@ class ComponentCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
reference = params.get("reference")
@@ -949,7 +904,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "reference and newReference are required"
"errorDetails": "reference and newReference are required",
}
# Find the source component
@@ -958,7 +913,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Component not found",
"errorDetails": f"Could not find component: {reference}"
"errorDetails": f"Could not find component: {reference}",
}
# Check if new reference already exists
@@ -966,7 +921,7 @@ class ComponentCommands:
return {
"success": False,
"message": "Reference already exists",
"errorDetails": f"A component with reference {new_reference} already exists"
"errorDetails": f"A component with reference {new_reference} already exists",
}
# Create new footprint with the same properties
@@ -1014,14 +969,10 @@ class ComponentCommands:
"reference": new_reference,
"value": new_module.GetValue(),
"footprint": new_module.GetFPIDAsString(),
"position": {
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"rotation": new_module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(new_module.GetLayer())
}
"layer": self.board.GetLayerName(new_module.GetLayer()),
},
}
except Exception as e:
@@ -1029,12 +980,22 @@ class ComponentCommands:
return {
"success": False,
"message": "Failed to duplicate component",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _place_grid_array(self, component_id: str, start_position: Dict[str, Any],
rows: int, columns: int, spacing_x: float, spacing_y: float,
reference_prefix: str, value: str, rotation: float, layer: str) -> List[Dict[str, Any]]:
def _place_grid_array(
self,
component_id: str,
start_position: Dict[str, Any],
rows: int,
columns: int,
spacing_x: float,
spacing_y: float,
reference_prefix: str,
value: str,
rotation: float,
layer: str,
) -> List[Dict[str, Any]]:
"""Place components in a grid pattern and return the list of placed components"""
placed = []
@@ -1058,24 +1019,35 @@ class ComponentCommands:
component_reference = f"{reference_prefix}{index}"
# Place component
result = self.place_component({
"componentId": component_id,
"position": {"x": x, "y": y, "unit": unit},
"reference": component_reference,
"value": value,
"rotation": rotation,
"layer": layer
})
result = self.place_component(
{
"componentId": component_id,
"position": {"x": x, "y": y, "unit": unit},
"reference": component_reference,
"value": value,
"rotation": rotation,
"layer": layer,
}
)
if result["success"]:
placed.append(result["component"])
return placed
def _place_circular_array(self, component_id: str, center: Dict[str, Any],
radius: float, count: int, angle_start: float,
angle_step: float, reference_prefix: str,
value: str, rotation_offset: float, layer: str) -> List[Dict[str, Any]]:
def _place_circular_array(
self,
component_id: str,
center: Dict[str, Any],
radius: float,
count: int,
angle_start: float,
angle_step: float,
reference_prefix: str,
value: str,
rotation_offset: float,
layer: str,
) -> List[Dict[str, Any]]:
"""Place components in a circular pattern and return the list of placed components"""
placed = []
@@ -1098,22 +1070,25 @@ class ComponentCommands:
component_rotation = angle + rotation_offset
# Place component
result = self.place_component({
"componentId": component_id,
"position": {"x": x, "y": y, "unit": unit},
"reference": component_reference,
"value": value,
"rotation": component_rotation,
"layer": layer
})
result = self.place_component(
{
"componentId": component_id,
"position": {"x": x, "y": y, "unit": unit},
"reference": component_reference,
"value": value,
"rotation": component_rotation,
"layer": layer,
}
)
if result["success"]:
placed.append(result["component"])
return placed
def _align_components_horizontally(self, components: List[pcbnew.FOOTPRINT],
distribution: str, spacing: Optional[float]) -> None:
def _align_components_horizontally(
self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
) -> None:
"""Align components horizontally and optionally distribute them"""
if not components:
return
@@ -1157,8 +1132,9 @@ class ComponentCommands:
x_current += spacing_nm
components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y))
def _align_components_vertically(self, components: List[pcbnew.FOOTPRINT],
distribution: str, spacing: Optional[float]) -> None:
def _align_components_vertically(
self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
) -> None:
"""Align components vertically and optionally distribute them"""
if not components:
return

View File

@@ -13,9 +13,7 @@ try:
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning(
"Dynamic symbol loader not available - falling back to template-only mode"
)
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
@@ -135,32 +133,22 @@ class ComponentManager:
# Check if schematic path is available
if schematic_path is None:
logger.warning(
"Dynamic loading requires schematic file path but none was provided"
)
logger.warning("Dynamic loading requires schematic file path but none was provided")
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
# Determine library name
if library is None:
# Default library for common component types
library = (
"Device" # Most passives and basic components are in Device library
)
library = "Device" # Most passives and basic components are in Device library
try:
logger.info(
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
# Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically(
schematic_path, library, comp_type
)
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
logger.info(
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
@@ -198,9 +186,7 @@ class ComponentManager:
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get(
"library", None
) # Optional library specification
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(
@@ -209,9 +195,7 @@ class ComponentManager:
# If dynamic loading occurred, reload schematic to see new template
if needs_reload and schematic_path:
logger.info(
f"Reloading schematic after dynamic loading: {schematic_path}"
)
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
@@ -303,9 +287,7 @@ class ComponentManager:
return False
@staticmethod
def update_component(
schematic: Schematic, component_ref: str, new_properties: dict
):
def update_component(schematic: Schematic, component_ref: str, new_properties: dict):
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -401,19 +383,13 @@ if __name__ == "__main__":
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
matching_comps = ComponentManager.search_components(
test_sch, "100"
) # Search by position
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components
@@ -423,9 +399,7 @@ if __name__ == "__main__":
# Remove a component
ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print(
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")

View File

@@ -48,9 +48,7 @@ class ConnectionManager:
logger.error("Schematic does not have label collection")
return None
label = schematic.label.append(
text=net_name, at={"x": position[0], "y": position[1]}
)
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
logger.info(f"Added net label '{net_name}' at {position}")
return label
except Exception as e:
@@ -58,9 +56,7 @@ class ConnectionManager:
return None
@staticmethod
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str):
"""
Connect a component pin to a named net using a wire stub and label
@@ -93,9 +89,7 @@ class ConnectionManager:
# Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
try:
pin_angle_deg = (
locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
)
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
except Exception:
pin_angle_deg = 0
import math as _math
@@ -172,9 +166,7 @@ class ConnectionManager:
connected = []
failed = []
for pin_num in sorted(
src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0
):
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
try:
net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}"
@@ -200,15 +192,11 @@ class ConnectionManager:
failed.append(f"{target_ref}/{pin_num} (pin not found)")
continue
connected.append(
f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]"
)
connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]")
except Exception as e:
failed.append(f"{source_ref}/{pin_num}: {e}")
logger.info(
f"connect_passthrough: {len(connected)} connected, {len(failed)} failed"
)
logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed")
return {"connected": connected, "failed": failed}
@staticmethod
@@ -256,9 +244,7 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'")
return connections
logger.debug(
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
# 2. Find all wires connected to these label positions
if not hasattr(schematic, "wire"):
@@ -272,9 +258,7 @@ class ConnectionManager:
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
wire_points.append([float(point.value[0]), float(point.value[1])])
# Check if any wire point touches a label
wire_connected = False
@@ -334,18 +318,14 @@ class ConnectionManager:
# Check each pin
for pin_num, pin_data in pins.items():
# Get pin location
pin_loc = locator.get_pin_location(
schematic_path, ref, pin_num
)
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
if not pin_loc:
continue
# Check if pin coincides with any wire point
for wire_pt in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)):
connections.append(
{"component": ref, "pin": pin_num}
)
connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points
except Exception as e:
@@ -364,9 +344,7 @@ class ConnectionManager:
# Check if symbol is near any wire point (within 10mm)
for wire_pt in connected_wire_points:
dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
) ** 0.5
dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component
@@ -419,9 +397,7 @@ class ConnectionManager:
component_info = {
"reference": symbol.property.Reference.value,
"value": (
symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
),
"footprint": (
symbol.property.Footprint.value
@@ -444,9 +420,7 @@ class ConnectionManager:
schematic, net_name, schematic_path
)
if connections:
netlist["nets"].append(
{"name": net_name, "connections": connections}
)
netlist["nets"].append({"name": net_name, "connections": connections})
logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"

View File

@@ -81,9 +81,7 @@ class DatasheetManager:
return lib_sym_start, lib_sym_end
@staticmethod
def _process_symbol_block(
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]:
"""
Extract LCSC and Datasheet info from a placed symbol block.
@@ -114,9 +112,7 @@ class DatasheetManager:
"datasheet_value": datasheet_current,
}
def enrich_schematic(
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict:
"""
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
@@ -223,9 +219,7 @@ class DatasheetManager:
no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1
logger.debug(
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}")
else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run:
@@ -256,9 +250,7 @@ class DatasheetManager:
if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
logger.info(
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written")
return {
"success": True,

View File

@@ -58,13 +58,9 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["microViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["microViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values
if "minTrackWidth" in params:
@@ -77,19 +73,13 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["minMicroViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["minMicroViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params:
design_settings.m_MinThroughDrill = int(
params["minHoleDiameter"] * scale
)
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
# KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params:
@@ -216,9 +206,7 @@ class DesignRuleCommands:
}
# Create temporary JSON output file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
@@ -297,9 +285,7 @@ class DesignRuleCommands:
# Determine where to save the violations file
board_dir = os.path.dirname(board_file)
board_name = os.path.splitext(os.path.basename(board_file))[0]
violations_file = os.path.join(
board_dir, f"{board_name}_drc_violations.json"
)
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
# Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f:
@@ -453,9 +439,7 @@ class DesignRuleCommands:
# Filter by severity if specified
if severity != "all":
filtered_violations = [
v for v in all_violations if v.get("severity") == severity
]
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
else:
filtered_violations = all_violations

View File

@@ -46,7 +46,12 @@ class DynamicSymbolLoader:
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
]
for env_var in ["KICAD10_SYMBOL_DIR", "KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]:
for env_var in [
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD_SYMBOL_DIR",
]:
if env_var in os.environ:
possible_paths.insert(0, Path(os.environ[env_var]))
@@ -83,7 +88,9 @@ class DynamicSymbolLoader:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
lib_pattern = (
r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
)
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
if nickname != library_name:
@@ -208,9 +215,7 @@ class DynamicSymbolLoader:
return items
def _inline_extends_symbol(
self, lib_content: str, symbol_name: str, child_block: str
) -> str:
def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
"""
Fully inline a child symbol that uses (extends "ParentName") by merging
the parent's pins / graphics into the child definition.
@@ -255,22 +260,16 @@ class DynamicSymbolLoader:
for item in self._iter_top_level_items(parent_block):
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
sub_match = re.search(
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
)
sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
if prop_match:
pname = prop_match.group(1)
parent_prop_names.add(pname)
body_lines.append(
child_props[pname] if pname in child_props else item
)
body_lines.append(child_props[pname] if pname in child_props else item)
elif sub_match:
# Rename ParentName_0_1 → ChildName_0_1
body_lines.append(
item.replace(f'"{parent_name}_', f'"{symbol_name}_')
)
elif re.match(r'[\s\t]*\(extends ', item):
body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
elif re.match(r"[\s\t]*\(extends ", item):
pass # drop extends clause
else:
body_lines.append(item) # pin_names, in_bom, on_board …
@@ -280,16 +279,12 @@ class DynamicSymbolLoader:
if pname not in parent_prop_names:
body_lines.append(pblock)
first_line = parent_block.split("\n")[0].replace(
f'"{parent_name}"', f'"{symbol_name}"'
)
first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
last_line = parent_block.split("\n")[-1]
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
def extract_symbol_from_library(
self, library_name: str, symbol_name: str
) -> Optional[str]:
def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
"""
Extract a symbol definition from a KiCad .kicad_sym library file.
Returns the raw text block, ready to be injected into a schematic.
@@ -311,9 +306,7 @@ class DynamicSymbolLoader:
block = self._extract_symbol_block(lib_content, symbol_name)
if block is None:
logger.warning(
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
)
logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
return None
# If the symbol uses (extends "ParentName"), inline the parent content
@@ -322,9 +315,7 @@ class DynamicSymbolLoader:
# load a schematic whose lib_symbols section contains it.
if re.search(r'\(extends "([^"]+)"\)', block):
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
logger.info(
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
)
logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
block = self._inline_extends_symbol(lib_content, symbol_name, block)
# Prefix top-level symbol name with library
@@ -362,9 +353,7 @@ class DynamicSymbolLoader:
# Extract symbol from library
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
if not symbol_block:
raise ValueError(
f"Symbol '{symbol_name}' not found in library '{library_name}'"
)
raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
indented_lines = []
@@ -399,11 +388,7 @@ class DynamicSymbolLoader:
f.write(content)
# Handle both Path objects and strings
sch_name = (
schematic_path.name
if hasattr(schematic_path, "name")
else str(schematic_path)
)
sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
logger.info(f"Injected symbol {full_name} into {sch_name}")
return True
@@ -457,9 +442,7 @@ class DynamicSymbolLoader:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info(
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
)
logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
return True
def load_symbol_dynamically(

View File

@@ -105,22 +105,16 @@ class ExportCommands:
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
# Get list of generated drill files
for file in os.listdir(output_dir):
if file.endswith((".drl", ".cnc")):
drill_files.append(file)
else:
logger.warning(
f"Drill file generation failed: {result.stderr}"
)
logger.warning(f"Drill file generation failed: {result.stderr}")
except Exception as drill_error:
logger.warning(
f"Could not generate drill files: {str(drill_error)}"
)
logger.warning(f"Could not generate drill files: {str(drill_error)}")
else:
logger.warning("kicad-cli not available for drill file generation")
@@ -236,9 +230,7 @@ class ExportCommands:
# Get the actual output filename that was created
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
actual_filename = f"{board_name}-{base_name}.pdf"
actual_output_path = os.path.join(
os.path.dirname(output_path), actual_filename
)
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
return {
"success": True,
@@ -395,9 +387,7 @@ class ExportCommands:
if not include_components:
cmd.append("--no-components")
if include_copper:
cmd.extend(
["--include-tracks", "--include-pads", "--include-zones"]
)
cmd.extend(["--include-tracks", "--include-pads", "--include-zones"])
if include_silkscreen:
cmd.append("--include-silkscreen")
if include_solder_mask:
@@ -696,6 +686,7 @@ class ExportCommands:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
from pathlib import Path
logs_dir = Path(project_dir) / "logs"
logs_dir.mkdir(exist_ok=True)
dest = str(logs_dir / f"mcp_log_{timestamp}.txt")

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import requests
from typing import Optional, Dict, List, Callable
import time
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
@@ -28,11 +28,7 @@ class JLCSearchClient:
pass
def search_components(
self,
category: str = "components",
limit: int = 100,
offset: int = 0,
**filters
self, category: str = "components", limit: int = 100, offset: int = 0, **filters
) -> List[Dict]:
"""
Search components in JLCSearch database
@@ -48,11 +44,7 @@ class JLCSearchClient:
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {
"limit": limit,
"offset": offset,
**filters
}
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
@@ -71,7 +63,9 @@ class JLCSearchClient:
logger.error(f"Failed to search JLCSearch: {e}")
raise Exception(f"JLCSearch API request failed: {e}")
def search_resistors(self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_resistors(
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for resistors
@@ -100,7 +94,9 @@ class JLCSearchClient:
return self.search_components("resistors", limit=limit, **filters)
def search_capacitors(self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_capacitors(
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for capacitors
@@ -141,9 +137,7 @@ class JLCSearchClient:
return None
def download_all_components(
self,
callback: Optional[Callable[[int, str], None]] = None,
batch_size: int = 100
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
@@ -165,11 +159,7 @@ class JLCSearchClient:
while True:
try:
batch = self.search_components(
"components",
limit=batch_size,
offset=offset
)
batch = self.search_components("components", limit=batch_size, offset=offset)
# Stop if no results returned (end of catalog)
if not batch or len(batch) == 0:
@@ -219,7 +209,7 @@ def test_jlcsearch_connection() -> bool:
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)

View File

@@ -116,9 +116,7 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else:
logger.warning(
f" Could not resolve URI for library {nickname}: {uri}"
)
logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -221,12 +219,7 @@ class LibraryManager:
/ "9.0"
/ "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home()
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
]
for config_path in kicad_common_paths:
@@ -352,9 +345,7 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists():
logger.info(
f"Found footprint {footprint_name} in library {library_nickname}"
)
logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -461,9 +452,7 @@ class LibraryCommands:
# Filter by library if specified
if library_filter:
results = [
r
for r in results
if r.get("library", "").lower() == library_filter.lower()
r for r in results if r.get("library", "").lower() == library_filter.lower()
]
results = results[:limit]
@@ -527,7 +516,6 @@ class LibraryCommands:
info: Dict = {
"library": library_nickname,
"name": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
@@ -536,19 +524,24 @@ class LibraryCommands:
try:
from parsers.kicad_mod_parser import parse_kicad_mod
from pathlib import Path as _Path
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
parsed = parse_kicad_mod(mod_file)
if parsed:
# Merge parser output into info; keep our resolved library context
info.update(parsed)
info["name"] = footprint_name # entry name wins over in-file name
info["name"] = footprint_name # entry name wins over in-file name
info["library"] = library_nickname
info["full_name"] = f"{library_nickname}:{footprint_name}"
info["library_path"] = library_path
else:
logger.warning(f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info")
logger.warning(
f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info"
)
except Exception as parse_err:
logger.warning(f"get_footprint_info: parser error ({parse_err}), using minimal info")
logger.warning(
f"get_footprint_info: parser error ({parse_err}), using minimal info"
)
return {"success": True, "info": info}

View File

@@ -1,8 +1,10 @@
from skip import Schematic
# Symbol class might not be directly importable in the current version
import os
import glob
class LibraryManager:
"""Manage symbol libraries"""
@@ -14,9 +16,11 @@ class LibraryManager:
# This would need to be configured for the specific environment
search_paths = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
os.path.expanduser("~/Documents/KiCad/*/symbols/*.kicad_sym") # User libraries pattern
os.path.expanduser(
"~/Documents/KiCad/*/symbols/*.kicad_sym"
), # User libraries pattern
]
libraries = []
@@ -30,7 +34,9 @@ class LibraryManager:
# Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
print(f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}")
print(
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
)
# Return both full paths and library names
return {"paths": libraries, "names": library_names}
@@ -47,7 +53,9 @@ class LibraryManager:
# A potential approach would be to load the library file using KiCAD's Python API
# or by parsing the library file format.
# KiCAD symbol libraries are .kicad_sym files which are S-expression format
print(f"Attempted to list symbols in library {library_path}. This requires advanced implementation.")
print(
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
)
return []
except Exception as e:
print(f"Error listing symbols in library {library_path}: {e}")
@@ -59,7 +67,9 @@ class LibraryManager:
try:
# Similar to list_library_symbols, this might require a more direct approach
# using KiCAD's Python API or by parsing the symbol library.
print(f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation.")
print(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {}
except Exception as e:
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
@@ -78,7 +88,9 @@ class LibraryManager:
libraries = LibraryManager.list_available_libraries(search_paths)
results = []
print(f"Searched for symbols matching '{query}'. This requires advanced implementation.")
print(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results
except Exception as e:
print(f"Error searching for symbols matching '{query}': {e}")
@@ -119,7 +131,8 @@ class LibraryManager:
# Default fallback
return {"library": "Device", "symbol": "R"}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()

View File

@@ -12,26 +12,27 @@ from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
@dataclass
class SymbolInfo:
"""Information about a symbol in a library"""
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
value: str = "" # Value property
description: str = "" # Description property
footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
value: str = "" # Value property
description: str = "" # Description property
footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
manufacturer: str = "" # Manufacturer property
mpn: str = "" # Part/MPN property
category: str = "" # Category property
datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended
mpn: str = "" # Part/MPN property
category: str = "" # Category property
datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended
class SymbolLibraryManager:
@@ -107,7 +108,7 @@ class SymbolLibraryManager:
)
"""
try:
with open(table_path, 'r', encoding='utf-8') as f:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
# Simple regex-based parser for lib entries
@@ -155,25 +156,25 @@ class SymbolLibraryManager:
# Common KiCAD environment variables
env_vars = {
'KICAD10_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD10_3RD_PARTY': self._find_3rd_party_dir(),
'KICAD9_3RD_PARTY': self._find_3rd_party_dir(),
'KICAD8_3RD_PARTY': self._find_3rd_party_dir(),
'KISYSSYM': self._find_kicad_symbol_dir(),
"KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
"KISYSSYM": self._find_kicad_symbol_dir(),
}
# Project directory
if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path)
env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables
for var, value in env_vars.items():
if value:
resolved = resolved.replace(f'${{{var}}}', value)
resolved = resolved.replace(f'${var}', value)
resolved = resolved.replace(f"${{{var}}}", value)
resolved = resolved.replace(f"${var}", value)
# Expand ~ to home directory
resolved = os.path.expanduser(resolved)
@@ -199,10 +200,10 @@ class SymbolLibraryManager:
]
# Check environment variable
if 'KICAD9_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR'])
if 'KICAD8_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR'])
if "KICAD9_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
if "KICAD8_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
for path in possible_paths:
if os.path.isdir(path):
@@ -219,12 +220,12 @@ class SymbolLibraryManager:
]
# Check environment variable
if 'KICAD10_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD10_3RD_PARTY'])
if 'KICAD9_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY'])
if 'KICAD8_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY'])
if "KICAD10_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
if "KICAD9_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
if "KICAD8_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
for path in possible_paths:
if os.path.isdir(path):
@@ -246,7 +247,7 @@ class SymbolLibraryManager:
symbols = []
try:
with open(library_path, 'r', encoding='utf-8') as f:
with open(library_path, "r", encoding="utf-8") as f:
content = f.read()
# Find all top-level symbol definitions
@@ -261,7 +262,7 @@ class SymbolLibraryManager:
symbol_name = match.group(1)
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
if re.search(r'_\d+_\d+$', symbol_name):
if re.search(r"_\d+_\d+$", symbol_name):
continue
# Find the start position of this symbol
@@ -280,17 +281,17 @@ class SymbolLibraryManager:
name=symbol_name,
library=library_name,
full_ref=f"{library_name}:{symbol_name}",
value=properties.get('Value', ''),
description=properties.get('Description', ''),
footprint=properties.get('Footprint', ''),
lcsc_id=properties.get('LCSC', ''),
manufacturer=properties.get('Manufacturer', ''),
mpn=properties.get('Part', properties.get('MPN', '')),
category=properties.get('Category', ''),
datasheet=properties.get('Datasheet', ''),
stock=properties.get('Stock', ''),
price=properties.get('Price', ''),
lib_class=properties.get('Class', ''),
value=properties.get("Value", ""),
description=properties.get("Description", ""),
footprint=properties.get("Footprint", ""),
lcsc_id=properties.get("LCSC", ""),
manufacturer=properties.get("Manufacturer", ""),
mpn=properties.get("Part", properties.get("MPN", "")),
category=properties.get("Category", ""),
datasheet=properties.get("Datasheet", ""),
stock=properties.get("Stock", ""),
price=properties.get("Price", ""),
lib_class=properties.get("Class", ""),
)
symbols.append(symbol_info)
@@ -351,7 +352,9 @@ class SymbolLibraryManager:
return symbols
def search_symbols(self, query: str, limit: int = 20, library_filter: Optional[str] = None) -> List[SymbolInfo]:
def search_symbols(
self, query: str, limit: int = 20, library_filter: Optional[str] = None
) -> List[SymbolInfo]:
"""
Search for symbols matching a query
@@ -370,7 +373,9 @@ class SymbolLibraryManager:
libraries_to_search = self.libraries.keys()
if library_filter:
filter_lower = library_filter.lower()
libraries_to_search = [lib for lib in libraries_to_search if filter_lower in lib.lower()]
libraries_to_search = [
lib for lib in libraries_to_search if filter_lower in lib.lower()
]
for library_nickname in libraries_to_search:
symbols = self.list_symbols(library_nickname)
@@ -495,17 +500,13 @@ class SymbolLibraryCommands:
"""List all available symbol libraries"""
try:
libraries = self.library_manager.list_libraries()
return {
"success": True,
"libraries": libraries,
"count": len(libraries)
}
return {"success": True, "libraries": libraries, "count": len(libraries)}
except Exception as e:
logger.error(f"Error listing symbol libraries: {e}")
return {
"success": False,
"message": "Failed to list symbol libraries",
"errorDetails": str(e)
"errorDetails": str(e),
}
def search_symbols(self, params: Dict) -> Dict:
@@ -513,10 +514,7 @@ class SymbolLibraryCommands:
try:
query = params.get("query", "")
if not query:
return {
"success": False,
"message": "Missing query parameter"
}
return {"success": False, "message": "Missing query parameter"}
limit = params.get("limit", 20)
library_filter = params.get("library")
@@ -527,25 +525,18 @@ class SymbolLibraryCommands:
"success": True,
"symbols": [asdict(s) for s in results],
"count": len(results),
"query": query
"query": query,
}
except Exception as e:
logger.error(f"Error searching symbols: {e}")
return {
"success": False,
"message": "Failed to search symbols",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
def list_library_symbols(self, params: Dict) -> Dict:
"""List all symbols in a specific library"""
try:
library = params.get("library")
if not library:
return {
"success": False,
"message": "Missing library parameter"
}
return {"success": False, "message": "Missing library parameter"}
# Check if library exists in sym-lib-table
if library not in self.library_manager.libraries:
@@ -554,10 +545,10 @@ class SymbolLibraryCommands:
"success": False,
"message": f"Library '{library}' not found in sym-lib-table",
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
"available_libraries_count": len(available_libs),
"suggestion": "Use 'list_symbol_libraries' to see all available libraries"
"suggestion": "Use 'list_symbol_libraries' to see all available libraries",
}
symbols = self.library_manager.list_symbols(library)
@@ -566,14 +557,14 @@ class SymbolLibraryCommands:
"success": True,
"library": library,
"symbols": [asdict(s) for s in symbols],
"count": len(symbols)
"count": len(symbols),
}
except Exception as e:
logger.error(f"Error listing library symbols: {e}")
return {
"success": False,
"message": "Failed to list library symbols",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_symbol_info(self, params: Dict) -> Dict:
@@ -581,34 +572,25 @@ class SymbolLibraryCommands:
try:
symbol_spec = params.get("symbol")
if not symbol_spec:
return {
"success": False,
"message": "Missing symbol parameter"
}
return {"success": False, "message": "Missing symbol parameter"}
result = self.library_manager.find_symbol(symbol_spec)
if result:
return {
"success": True,
"symbol_info": asdict(result)
}
return {"success": True, "symbol_info": asdict(result)}
else:
return {
"success": False,
"message": f"Symbol not found: {symbol_spec}"
}
return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
except Exception as e:
logger.error(f"Error getting symbol info: {e}")
return {
"success": False,
"message": "Failed to get symbol info",
"errorDetails": str(e)
"errorDetails": str(e),
}
if __name__ == '__main__':
if __name__ == "__main__":
# Test the symbol library manager
import json

View File

@@ -117,11 +117,7 @@ class PinLocator:
# Find lib_symbols section
lib_symbols = None
for item in sch_data:
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols = item
break
@@ -131,11 +127,7 @@ class PinLocator:
# Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if (
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
@@ -220,20 +212,14 @@ class PinLocator:
symbol_at = target_symbol.at.value
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
return None
pins = self.get_symbol_pins(schematic_path, lib_id)
if pin_number not in pins:
matched_num = next(
(
num
for num, data in pins.items()
if data.get("name") == pin_number
),
(num for num, data in pins.items() if data.get("name") == pin_number),
None,
)
if matched_num:
@@ -290,9 +276,7 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return None
@@ -311,11 +295,7 @@ class PinLocator:
if pin_number not in pins:
# Try matching by pin name (e.g. "VCC1", "SDA", "GND")
matched_num = next(
(
num
for num, data in pins.items()
if data.get("name") == pin_number
),
(num for num, data in pins.items() if data.get("name") == pin_number),
None,
)
if matched_num:
@@ -336,26 +316,18 @@ class PinLocator:
pin_rel_x = pin_data["x"]
pin_rel_y = pin_data["y"]
logger.debug(
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
# Apply symbol rotation to pin position
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(
pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
# Calculate absolute position
abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y
logger.info(
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
return [abs_x, abs_y]
except Exception as e:
@@ -397,9 +369,7 @@ class PinLocator:
return {}
# Get lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return {}
@@ -412,9 +382,7 @@ class PinLocator:
# Calculate location for each pin
result = {}
for pin_num in pins.keys():
location = self.get_pin_location(
schematic_path, symbol_reference, pin_num
)
location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
if location:
result[pin_num] = location
@@ -444,9 +412,7 @@ if __name__ == "__main__":
# Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = (
Path(__file__).parent.parent
/ "templates"
/ "template_with_symbols_expanded.kicad_sch"
Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch"
)
shutil.copy(template_path, test_path)

View File

@@ -22,9 +22,7 @@ class ProjectCommands:
"""Create a new KiCAD project"""
try:
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
project_name = params.get("name") or params.get(
"projectName", "New_Project"
)
project_name = params.get("name") or params.get("projectName", "New_Project")
path = params.get("path", os.getcwd())
template = params.get("template")
@@ -101,9 +99,7 @@ class ProjectCommands:
schematic_uuid = str(uuid_module.uuid4())
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -207,9 +203,7 @@ class ProjectCommands:
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(
os.path.basename(self.board.GetFileName())
)[0],
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}

View File

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

View File

@@ -31,31 +31,28 @@ class SchematicManager:
# Regenerate UUID to ensure uniqueness for each created schematic
import re
with open(output_path, 'r', encoding='utf-8') as f:
with open(output_path, "r", encoding="utf-8") as f:
content = f.read()
new_uuid = str(uuid.uuid4())
content = re.sub(
r'\(uuid [0-9a-fA-F-]+\)',
f'(uuid {new_uuid})',
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
content,
count=1 # Only replace first (schematic) UUID
count=1, # Only replace first (schematic) UUID
)
with open(output_path, 'w', encoding='utf-8', newline='\n') as f:
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {output_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_path}, creating minimal schematic"
)
logger.warning(f"Template not found at {template_path}, creating minimal schematic")
# Generate unique UUID for this schematic
schematic_uuid = str(uuid.uuid4())
# Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")

View File

@@ -169,28 +169,16 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if (
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]:
if (
isinstance(sub, list)
and len(sub) >= 3
and sub[0] == Symbol("center")
):
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
cx, cy = float(sub[1]), float(sub[2])
elif (
isinstance(sub, list)
and len(sub) >= 2
and sub[0] == Symbol("radius")
):
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
r = float(sub[1])
if r > 0:
points.extend(
@@ -212,11 +200,7 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if (
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
else:
@@ -243,11 +227,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
"""
lib_symbols_section = None
for item in sexp_data:
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols_section = item
break
@@ -465,9 +445,7 @@ def _compute_symbol_bbox_direct(
# ---------------------------------------------------------------------------
def find_overlapping_elements(
schematic_path: Path, tolerance: float = 0.5
) -> Dict[str, Any]:
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
"""
Detect spatially overlapping symbols, wires, and labels.
@@ -490,9 +468,7 @@ def find_overlapping_elements(
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
non_template_symbols = [
s
for s in symbols
if not s["reference"].startswith("_TEMPLATE") and s["reference"]
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
]
# Pre-compute bounding boxes for all non-template symbols
@@ -503,9 +479,7 @@ def find_overlapping_elements(
graphics_points = lib_data.get("graphics_points", [])
bbox = None
if pin_defs:
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)):
@@ -706,9 +680,7 @@ def get_elements_in_region(
if (
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
or _line_segment_intersects_aabb(
s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y
)
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
):
region_wires.append(
{
@@ -877,15 +849,11 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
ux, uy = dx / length, dy / length
if start_at_pin:
nsx, nsy = sx + ux * nudge, sy + uy * nudge
if not _line_segment_intersects_aabb(
nsx, nsy, ex, ey, bx1, by1, bx2, by2
):
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
else:
nex, ney = ex - ux * nudge, ey - uy * nudge
if not _line_segment_intersects_aabb(
sx, sy, nex, ney, bx1, by1, bx2, by2
):
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
sym = sd["sym"]

View File

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

View File

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

View File

@@ -95,9 +95,7 @@ def _parse_virtual_connections(schematic, schematic_path):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(
symbol.property, "Reference"
):
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if not ref.startswith("#PWR"):
@@ -194,9 +192,7 @@ def _find_pins_on_net(
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(
symbol.property, "Reference"
):
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
@@ -241,9 +237,7 @@ def get_wire_connections(
adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(
schematic, schematic_path
)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
visited, net_points = _find_connected_wires(
x_mm,

View File

@@ -76,11 +76,7 @@ class WireManager:
# Find insertion point (before sheet_instances)
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -146,20 +142,14 @@ class WireManager:
# KiCAD wire elements only support exactly 2 pts each.
# Split N waypoints into N-1 individual wire segments.
wire_sexps = [
WireManager._make_wire_sexp(
points[i], points[i + 1], stroke_width, stroke_type
)
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
for i in range(len(points) - 1)
]
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -235,11 +225,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -274,11 +260,7 @@ class WireManager:
Parse a wire S-expression item in a single pass.
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
"""
if not (
isinstance(wire_item, list)
and len(wire_item) >= 2
and wire_item[0] == _SYM_WIRE
):
if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
return None
start = end = None
stroke_width: float = 0
@@ -379,9 +361,7 @@ class WireManager:
return splits
@staticmethod
def add_junction(
schematic_path: Path, position: List[float], diameter: float = 0
) -> bool:
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
"""
Add a junction (connection dot) to the schematic.
@@ -423,11 +403,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -484,11 +460,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -544,9 +516,7 @@ class WireManager:
ex, ey = end_point
for i, item in enumerate(sch_data):
if not (
isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE):
continue
# Extract pts from the wire s-expression
@@ -626,9 +596,7 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
for i, item in enumerate(sch_data):
if not (
isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL):
continue
# Second element is the label text
@@ -649,8 +617,7 @@ class WireManager:
continue
lx, ly = float(at_entry[1]), float(at_entry[2])
if not (
abs(lx - position[0]) < tolerance
and abs(ly - position[1]) < tolerance
abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance
):
continue