* feat(units): add mil unit support across all position/coordinate commands KiCad natively supports mils, so the MCP server should too. Added "mil" as a valid unit option in tool schemas and updated all unit-to-nanometer scale conversions across component, routing, outline, view, and IPC handler code paths. 1 mil = 25400 nm (0.0254 mm). Also fixes a pre-existing mypy overload error in pin_locator.py (str cast on dict.get key) that was blocking pre-commit on any Python file change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(units): add mil to TypeScript tool schemas The Python-side mil support was added but the actual input validation happens in the TypeScript/Zod schemas. Updated all z.enum(["mm", "inch"]) to include "mil" across board, component, routing, design-rules, and export tool definitions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tools): replace CP-1252 mojibake with correct Unicode in board.ts Replace U+00C3 U+00D7 (×) with U+00D7 (×) in add_logo size output string. Character was mangled when file was saved as CP-1252 instead of UTF-8. * fix: restore em-dash and fix pre-commit mypy in component/routing component.py: replace CP-1252 mojibake (â€") with correct Unicode em-dash (—) in the 'Add to board first' comment. Addresses maintainer review on PR #162. routing.py: annotate ex/ey as float at first assignment site in _point_to_segment_distance_nm so mypy pre-commit hook passes cleanly on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1556 lines
61 KiB
Python
1556 lines
61 KiB
Python
"""
|
|
Component-related command implementations for KiCAD interface
|
|
"""
|
|
|
|
import base64
|
|
import logging
|
|
import math
|
|
import os
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import pcbnew
|
|
from commands.library import LibraryManager
|
|
|
|
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
|
|
):
|
|
"""Initialize with optional board instance and library manager"""
|
|
self.board = board
|
|
self.library_manager = library_manager or LibraryManager()
|
|
|
|
def place_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Place a component on the PCB"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
# Get parameters
|
|
component_id = params.get("componentId")
|
|
position = params.get("position")
|
|
reference = params.get("reference")
|
|
value = params.get("value")
|
|
footprint = params.get("footprint")
|
|
rotation = params.get("rotation", 0)
|
|
layer = params.get("layer", "F.Cu")
|
|
|
|
if not component_id or not position:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing parameters",
|
|
"errorDetails": "componentId and position are required",
|
|
}
|
|
|
|
# Find footprint using library manager
|
|
# component_id can be "Library:Footprint" or just "Footprint"
|
|
footprint_result = self.library_manager.find_footprint(component_id)
|
|
|
|
if not footprint_result:
|
|
# Try to suggest similar footprints
|
|
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]
|
|
)
|
|
|
|
return {
|
|
"success": False,
|
|
"message": "Footprint not found",
|
|
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}",
|
|
}
|
|
|
|
library_path, footprint_name = footprint_result
|
|
|
|
# Load footprint from library
|
|
# Extract library nickname from path
|
|
library_nickname = None
|
|
for nick, path in self.library_manager.libraries.items():
|
|
if path == library_path:
|
|
library_nickname = nick
|
|
break
|
|
|
|
if not library_nickname:
|
|
return {
|
|
"success": False,
|
|
"message": "Internal error",
|
|
"errorDetails": "Could not determine library nickname",
|
|
}
|
|
|
|
# Load the footprint
|
|
module = pcbnew.FootprintLoad(library_path, footprint_name)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to load footprint",
|
|
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}",
|
|
}
|
|
|
|
# Set position
|
|
scale = (
|
|
1000000
|
|
if position["unit"] == "mm"
|
|
else (25400 if position["unit"] == "mil" else 25400000)
|
|
) # mm, mil, or inch to nm
|
|
x_nm = int(position["x"] * scale)
|
|
y_nm = int(position["y"] * scale)
|
|
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
|
|
|
# Set reference if provided
|
|
if reference:
|
|
module.SetReference(reference)
|
|
|
|
# Set value if provided
|
|
if value:
|
|
module.SetValue(value)
|
|
|
|
# Set footprint if provided (use existing library_nickname and footprint_name)
|
|
# For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName
|
|
if footprint:
|
|
# Parse footprint string if it's in "Library:Footprint" format
|
|
if ":" in footprint:
|
|
lib_name, fp_name = footprint.split(":", 1)
|
|
else:
|
|
# Use the library_nickname we already have from loading
|
|
lib_name = library_nickname
|
|
fp_name = footprint
|
|
fpid = pcbnew.LIB_ID(lib_name, fp_name)
|
|
module.SetFPID(fpid)
|
|
else:
|
|
# Use the footprint we just loaded
|
|
fpid = pcbnew.LIB_ID(library_nickname, footprint_name)
|
|
module.SetFPID(fpid)
|
|
|
|
# Set rotation (KiCAD 9.0 uses EDA_ANGLE)
|
|
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
|
module.SetOrientation(angle)
|
|
|
|
# Set layer for F.Cu (or non-B.Cu) before adding to board
|
|
if layer != "B.Cu":
|
|
layer_id = self.board.GetLayerID(layer)
|
|
if layer_id >= 0:
|
|
module.SetLayer(layer_id)
|
|
|
|
# Add to board first — Flip() requires board context in KiCAD 9
|
|
self.board.Add(module)
|
|
|
|
# Flip to B.Cu after add (board context needed, otherwise hangs 30s)
|
|
if layer == "B.Cu":
|
|
if not module.IsFlipped():
|
|
module.Flip(module.GetPosition(), False)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Placed component: {component_id}",
|
|
"component": {
|
|
"reference": module.GetReference(),
|
|
"value": module.GetValue(),
|
|
"position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
|
|
"rotation": rotation,
|
|
"layer": layer,
|
|
},
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error placing component: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to place component",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Move an existing component to a new position"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
position = params.get("position")
|
|
rotation = params.get("rotation")
|
|
layer = params.get("layer")
|
|
|
|
if not reference or not position:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing parameters",
|
|
"errorDetails": "reference and position are required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Set new position
|
|
scale = (
|
|
1000000
|
|
if position["unit"] == "mm"
|
|
else (25400 if position["unit"] == "mil" else 25400000)
|
|
) # mm, mil, or inch to nm
|
|
x_nm = int(position["x"] * scale)
|
|
y_nm = int(position["y"] * scale)
|
|
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
|
|
|
# Set new rotation if provided
|
|
if rotation is not None:
|
|
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
|
module.SetOrientation(angle)
|
|
|
|
# Flip to target layer if specified
|
|
if layer:
|
|
current_layer = self.board.GetLayerName(module.GetLayer())
|
|
if layer == "B.Cu" and current_layer != "B.Cu":
|
|
module.Flip(module.GetPosition(), False)
|
|
elif layer == "F.Cu" and current_layer != "F.Cu":
|
|
module.Flip(module.GetPosition(), False)
|
|
|
|
return {
|
|
"success": True,
|
|
"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()),
|
|
},
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error moving component: {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"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
angle = params.get("angle")
|
|
|
|
if not reference or angle is None:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing parameters",
|
|
"errorDetails": "reference and angle are required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Set rotation
|
|
rotation_angle = pcbnew.EDA_ANGLE(angle, pcbnew.DEGREES_T)
|
|
module.SetOrientation(rotation_angle)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Rotated component: {reference}",
|
|
"component": {"reference": reference, "rotation": angle},
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error rotating component: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to rotate component",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Delete a component from the PCB"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
if not reference:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing reference",
|
|
"errorDetails": "reference parameter is required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Remove from board
|
|
self.board.Remove(module)
|
|
|
|
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),
|
|
}
|
|
|
|
def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Edit the properties of an existing component"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
new_reference = params.get("newReference")
|
|
value = params.get("value")
|
|
footprint = params.get("footprint")
|
|
|
|
if not reference:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing reference",
|
|
"errorDetails": "reference parameter is required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Update properties
|
|
if new_reference:
|
|
module.SetReference(new_reference)
|
|
if value:
|
|
module.SetValue(value)
|
|
if footprint:
|
|
# For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName
|
|
# Parse footprint string (format: "Library:Footprint")
|
|
if ":" in footprint:
|
|
lib_name, fp_name = footprint.split(":", 1)
|
|
fpid = pcbnew.LIB_ID(lib_name, fp_name)
|
|
module.SetFPID(fpid)
|
|
else:
|
|
# If no library specified, keep existing library
|
|
current_fpid = module.GetFPID()
|
|
lib_name = current_fpid.GetLibNickname().GetUTF8()
|
|
fpid = pcbnew.LIB_ID(lib_name, footprint)
|
|
module.SetFPID(fpid)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Updated component: {reference}",
|
|
"component": {
|
|
"reference": new_reference or reference,
|
|
"value": value or module.GetValue(),
|
|
"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)}
|
|
|
|
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Get detailed properties of a component"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
if not reference:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing reference",
|
|
"errorDetails": "reference parameter is required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Get position in mm
|
|
pos = module.GetPosition()
|
|
x_mm = pos.x / 1000000
|
|
y_mm = pos.y / 1000000
|
|
|
|
# Get bounding box
|
|
bbox = module.GetBoundingBox()
|
|
bbox_data = {
|
|
"min_x": bbox.GetLeft() / 1000000,
|
|
"min_y": bbox.GetTop() / 1000000,
|
|
"max_x": bbox.GetRight() / 1000000,
|
|
"max_y": bbox.GetBottom() / 1000000,
|
|
"width": (bbox.GetRight() - bbox.GetLeft()) / 1000000,
|
|
"height": (bbox.GetBottom() - bbox.GetTop()) / 1000000,
|
|
"unit": "mm",
|
|
}
|
|
|
|
# Try to get courtyard bounds (preferred for placement clearance)
|
|
courtyard_data = None
|
|
try:
|
|
for layer_id in [pcbnew.F_CrtYd, pcbnew.B_CrtYd]:
|
|
courtyard = module.GetCourtyard(layer_id)
|
|
if courtyard and courtyard.OutlineCount() > 0:
|
|
cbox = courtyard.BBox()
|
|
courtyard_data = {
|
|
"min_x": cbox.GetLeft() / 1000000,
|
|
"min_y": cbox.GetTop() / 1000000,
|
|
"max_x": cbox.GetRight() / 1000000,
|
|
"max_y": cbox.GetBottom() / 1000000,
|
|
"width": (cbox.GetRight() - cbox.GetLeft()) / 1000000,
|
|
"height": (cbox.GetBottom() - cbox.GetTop()) / 1000000,
|
|
"unit": "mm",
|
|
}
|
|
break
|
|
except Exception:
|
|
pass # Courtyard may not exist or API may differ
|
|
|
|
return {
|
|
"success": True,
|
|
"component": {
|
|
"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()),
|
|
"attributes": {
|
|
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
|
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
|
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
|
|
},
|
|
"boundingBox": bbox_data,
|
|
"courtyard": courtyard_data,
|
|
},
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting component properties: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get component properties",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Get a list of all components on the board"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
components = []
|
|
for module in self.board.GetFootprints():
|
|
pos = module.GetPosition()
|
|
x_mm = pos.x / 1000000
|
|
y_mm = pos.y / 1000000
|
|
|
|
bbox = module.GetBoundingBox()
|
|
bbox_data = {
|
|
"min_x": bbox.GetLeft() / 1000000,
|
|
"min_y": bbox.GetTop() / 1000000,
|
|
"max_x": bbox.GetRight() / 1000000,
|
|
"max_y": bbox.GetBottom() / 1000000,
|
|
"width": (bbox.GetRight() - bbox.GetLeft()) / 1000000,
|
|
"height": (bbox.GetBottom() - bbox.GetTop()) / 1000000,
|
|
"unit": "mm",
|
|
}
|
|
|
|
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()),
|
|
"boundingBox": bbox_data,
|
|
}
|
|
)
|
|
|
|
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),
|
|
}
|
|
|
|
def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Find components matching search criteria (reference, value, or footprint pattern)"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
# Get search parameters
|
|
reference_pattern = params.get("reference", "").lower()
|
|
value_pattern = params.get("value", "").lower()
|
|
footprint_pattern = params.get("footprint", "").lower()
|
|
|
|
if not reference_pattern and not value_pattern and not footprint_pattern:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing search criteria",
|
|
"errorDetails": "At least one of reference, value, or footprint pattern is required",
|
|
}
|
|
|
|
matches = []
|
|
for module in self.board.GetFootprints():
|
|
ref = module.GetReference().lower()
|
|
val = module.GetValue().lower()
|
|
fp = module.GetFPIDAsString().lower()
|
|
|
|
# Check if component matches all provided patterns
|
|
match = True
|
|
if reference_pattern and reference_pattern not in ref:
|
|
match = False
|
|
if value_pattern and value_pattern not in val:
|
|
match = False
|
|
if footprint_pattern and footprint_pattern not in fp:
|
|
match = False
|
|
|
|
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()),
|
|
}
|
|
)
|
|
|
|
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),
|
|
}
|
|
|
|
def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Get all pads for a component with their positions and net connections"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
if not reference:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing reference",
|
|
"errorDetails": "reference parameter is required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
pads = []
|
|
for pad in module.Pads():
|
|
pos = pad.GetPosition()
|
|
size = pad.GetSize()
|
|
|
|
# Get pad shape as string
|
|
shape_map = {
|
|
pcbnew.PAD_SHAPE_CIRCLE: "circle",
|
|
pcbnew.PAD_SHAPE_RECT: "rect",
|
|
pcbnew.PAD_SHAPE_OVAL: "oval",
|
|
pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid",
|
|
pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect",
|
|
pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect",
|
|
pcbnew.PAD_SHAPE_CUSTOM: "custom",
|
|
}
|
|
shape = shape_map.get(pad.GetShape(), "unknown")
|
|
|
|
# Get pad type
|
|
type_map = {
|
|
pcbnew.PAD_ATTRIB_PTH: "through_hole",
|
|
pcbnew.PAD_ATTRIB_SMD: "smd",
|
|
pcbnew.PAD_ATTRIB_CONN: "connector",
|
|
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
|
|
),
|
|
}
|
|
)
|
|
|
|
# Get component position for reference
|
|
comp_pos = module.GetPosition()
|
|
|
|
return {
|
|
"success": True,
|
|
"reference": reference,
|
|
"componentPosition": {
|
|
"x": comp_pos.x / 1000000,
|
|
"y": comp_pos.y / 1000000,
|
|
"unit": "mm",
|
|
},
|
|
"padCount": len(pads),
|
|
"pads": pads,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting component pads: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get component pads",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Get the position of a specific pad on a component"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
pad_name = params.get("padName") or params.get("padNumber")
|
|
|
|
if not reference:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing reference",
|
|
"errorDetails": "reference parameter is required",
|
|
}
|
|
if not pad_name:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing pad identifier",
|
|
"errorDetails": "padName or padNumber parameter is required",
|
|
}
|
|
|
|
# Find the component
|
|
module = self.board.FindFootprintByReference(reference)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Find the specific pad
|
|
pad = module.FindPadByNumber(str(pad_name))
|
|
if not pad:
|
|
# List available pads in error message
|
|
available_pads = [p.GetNumber() for p in module.Pads()]
|
|
return {
|
|
"success": False,
|
|
"message": "Pad not found",
|
|
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}",
|
|
}
|
|
|
|
pos = pad.GetPosition()
|
|
size = pad.GetSize()
|
|
|
|
return {
|
|
"success": True,
|
|
"reference": reference,
|
|
"padName": pad.GetNumber(),
|
|
"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"},
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting pad position: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get pad position",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Place an array of components in a grid or circular pattern"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
component_id = params.get("componentId")
|
|
pattern = params.get("pattern", "grid") # grid or circular
|
|
count = params.get("count")
|
|
reference_prefix = params.get("referencePrefix", "U")
|
|
value = params.get("value")
|
|
|
|
if not component_id or not count:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing parameters",
|
|
"errorDetails": "componentId and count are required",
|
|
}
|
|
|
|
if pattern == "grid":
|
|
start_position = params.get("startPosition")
|
|
rows = params.get("rows")
|
|
columns = params.get("columns")
|
|
spacing_x = params.get("spacingX")
|
|
spacing_y = params.get("spacingY")
|
|
rotation = params.get("rotation", 0)
|
|
layer = params.get("layer", "F.Cu")
|
|
|
|
if not start_position or not rows or not columns or not spacing_x or not spacing_y:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing grid parameters",
|
|
"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",
|
|
}
|
|
|
|
placed_components = self._place_grid_array(
|
|
component_id,
|
|
start_position,
|
|
rows,
|
|
columns,
|
|
spacing_x,
|
|
spacing_y,
|
|
reference_prefix,
|
|
value,
|
|
rotation,
|
|
layer,
|
|
)
|
|
|
|
elif pattern == "circular":
|
|
center = params.get("center")
|
|
radius = params.get("radius")
|
|
angle_start = params.get("angleStart", 0)
|
|
angle_step = params.get("angleStep")
|
|
rotation_offset = params.get("rotationOffset", 0)
|
|
layer = params.get("layer", "F.Cu")
|
|
|
|
if not center or not radius or not angle_step:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing circular parameters",
|
|
"errorDetails": "For circular pattern, center, radius, and angleStep are required",
|
|
}
|
|
|
|
placed_components = self._place_circular_array(
|
|
component_id,
|
|
center,
|
|
radius,
|
|
count,
|
|
angle_start,
|
|
angle_step,
|
|
reference_prefix,
|
|
value,
|
|
rotation_offset,
|
|
layer,
|
|
)
|
|
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"message": "Invalid pattern",
|
|
"errorDetails": "Pattern must be 'grid' or 'circular'",
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Placed {count} components in {pattern} pattern",
|
|
"components": placed_components,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error placing component array: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to place component array",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Align multiple components along a line or distribute them evenly"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
references = params.get("references", [])
|
|
alignment = params.get("alignment", "horizontal") # horizontal, vertical, or edge
|
|
distribution = params.get("distribution", "none") # none, equal, or spacing
|
|
spacing = params.get("spacing")
|
|
|
|
if not references or len(references) < 2:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing references",
|
|
"errorDetails": "At least two component references are required",
|
|
}
|
|
|
|
# Find all referenced components
|
|
components = []
|
|
for ref in references:
|
|
module = self.board.FindFootprintByReference(ref)
|
|
if not module:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {ref}",
|
|
}
|
|
components.append(module)
|
|
|
|
# Perform alignment based on selected option
|
|
if alignment == "horizontal":
|
|
self._align_components_horizontally(components, distribution, spacing)
|
|
elif alignment == "vertical":
|
|
self._align_components_vertically(components, distribution, spacing)
|
|
elif alignment == "edge":
|
|
edge = params.get("edge")
|
|
if not edge:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing edge parameter",
|
|
"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'",
|
|
}
|
|
|
|
# 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(),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Aligned {len(components)} components",
|
|
"alignment": alignment,
|
|
"distribution": distribution,
|
|
"components": aligned_components,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error aligning components: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to align components",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Duplicate an existing component"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
reference = params.get("reference")
|
|
new_reference = params.get("newReference")
|
|
position = params.get("position")
|
|
rotation = params.get("rotation")
|
|
|
|
if not reference or not new_reference:
|
|
return {
|
|
"success": False,
|
|
"message": "Missing parameters",
|
|
"errorDetails": "reference and newReference are required",
|
|
}
|
|
|
|
# Find the source component
|
|
source = self.board.FindFootprintByReference(reference)
|
|
if not source:
|
|
return {
|
|
"success": False,
|
|
"message": "Component not found",
|
|
"errorDetails": f"Could not find component: {reference}",
|
|
}
|
|
|
|
# Check if new reference already exists
|
|
if self.board.FindFootprintByReference(new_reference):
|
|
return {
|
|
"success": False,
|
|
"message": "Reference already exists",
|
|
"errorDetails": f"A component with reference {new_reference} already exists",
|
|
}
|
|
|
|
# Create new footprint with the same properties
|
|
new_module = pcbnew.FOOTPRINT(self.board)
|
|
# For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName
|
|
new_module.SetFPID(source.GetFPID())
|
|
new_module.SetValue(source.GetValue())
|
|
new_module.SetReference(new_reference)
|
|
new_module.SetLayer(source.GetLayer())
|
|
|
|
# Copy pads and other items
|
|
for pad in source.Pads():
|
|
new_pad = pcbnew.PAD(new_module)
|
|
new_pad.Copy(pad)
|
|
new_module.Add(new_pad)
|
|
|
|
# Set position if provided, otherwise use offset from original
|
|
if position:
|
|
scale = (
|
|
1000000
|
|
if position.get("unit", "mm") == "mm"
|
|
else (25400 if position.get("unit", "mm") == "mil" else 25400000)
|
|
) # mm, mil, or inch to nm
|
|
x_nm = int(position["x"] * scale)
|
|
y_nm = int(position["y"] * scale)
|
|
new_module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
|
else:
|
|
# Offset by 5mm
|
|
source_pos = source.GetPosition()
|
|
new_module.SetPosition(pcbnew.VECTOR2I(source_pos.x + 5000000, source_pos.y))
|
|
|
|
# Set rotation if provided, otherwise use same as original
|
|
if rotation is not None:
|
|
rotation_angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
|
new_module.SetOrientation(rotation_angle)
|
|
else:
|
|
new_module.SetOrientation(source.GetOrientation())
|
|
|
|
# Add to board
|
|
self.board.Add(new_module)
|
|
|
|
# Get final position in mm
|
|
pos = new_module.GetPosition()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Duplicated component {reference} to {new_reference}",
|
|
"component": {
|
|
"reference": new_reference,
|
|
"value": new_module.GetValue(),
|
|
"footprint": new_module.GetFPIDAsString(),
|
|
"position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
|
|
"rotation": new_module.GetOrientation().AsDegrees(),
|
|
"layer": self.board.GetLayerName(new_module.GetLayer()),
|
|
},
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error duplicating component: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to duplicate component",
|
|
"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]]:
|
|
"""Place components in a grid pattern and return the list of placed components"""
|
|
placed = []
|
|
|
|
# Convert spacing to nm
|
|
unit = start_position.get("unit", "mm")
|
|
scale = (
|
|
1000000 if unit == "mm" else (25400 if unit == "mil" else 25400000)
|
|
) # mm, mil, or inch to nm
|
|
spacing_x_nm = int(spacing_x * scale)
|
|
spacing_y_nm = int(spacing_y * scale)
|
|
|
|
# Get layer ID
|
|
layer_id = self.board.GetLayerID(layer)
|
|
|
|
for row in range(rows):
|
|
for col in range(columns):
|
|
# Calculate position
|
|
x = start_position["x"] + (col * spacing_x)
|
|
y = start_position["y"] + (row * spacing_y)
|
|
|
|
# Generate reference
|
|
index = row * columns + col + 1
|
|
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,
|
|
}
|
|
)
|
|
|
|
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]]:
|
|
"""Place components in a circular pattern and return the list of placed components"""
|
|
placed = []
|
|
|
|
# Get unit
|
|
unit = center.get("unit", "mm")
|
|
|
|
for i in range(count):
|
|
# Calculate angle for this component
|
|
angle = angle_start + (i * angle_step)
|
|
angle_rad = math.radians(angle)
|
|
|
|
# Calculate position
|
|
x = center["x"] + (radius * math.cos(angle_rad))
|
|
y = center["y"] + (radius * math.sin(angle_rad))
|
|
|
|
# Generate reference
|
|
component_reference = f"{reference_prefix}{i+1}"
|
|
|
|
# Calculate rotation (pointing outward from center)
|
|
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,
|
|
}
|
|
)
|
|
|
|
if result["success"]:
|
|
placed.append(result["component"])
|
|
|
|
return placed
|
|
|
|
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
|
|
|
|
# Find the average Y coordinate
|
|
y_sum = sum(module.GetPosition().y for module in components)
|
|
y_avg = y_sum // len(components)
|
|
|
|
# Sort components by X position
|
|
components.sort(key=lambda m: m.GetPosition().x)
|
|
|
|
# Set Y coordinate for all components
|
|
for module in components:
|
|
pos = module.GetPosition()
|
|
module.SetPosition(pcbnew.VECTOR2I(pos.x, y_avg))
|
|
|
|
# Handle distribution if requested
|
|
if distribution == "equal" and len(components) > 1:
|
|
# Get leftmost and rightmost X coordinates
|
|
x_min = components[0].GetPosition().x
|
|
x_max = components[-1].GetPosition().x
|
|
|
|
# Calculate equal spacing
|
|
total_space = x_max - x_min
|
|
spacing_nm = total_space // (len(components) - 1)
|
|
|
|
# Set X positions with equal spacing
|
|
for i in range(1, len(components) - 1):
|
|
pos = components[i].GetPosition()
|
|
new_x = x_min + (i * spacing_nm)
|
|
components[i].SetPosition(pcbnew.VECTOR2I(new_x, pos.y))
|
|
|
|
elif distribution == "spacing" and spacing is not None:
|
|
# Convert spacing to nanometers
|
|
spacing_nm = int(spacing * 1000000) # assuming mm
|
|
|
|
# Set X positions with the specified spacing
|
|
x_current = components[0].GetPosition().x
|
|
for i in range(1, len(components)):
|
|
pos = components[i].GetPosition()
|
|
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:
|
|
"""Align components vertically and optionally distribute them"""
|
|
if not components:
|
|
return
|
|
|
|
# Find the average X coordinate
|
|
x_sum = sum(module.GetPosition().x for module in components)
|
|
x_avg = x_sum // len(components)
|
|
|
|
# Sort components by Y position
|
|
components.sort(key=lambda m: m.GetPosition().y)
|
|
|
|
# Set X coordinate for all components
|
|
for module in components:
|
|
pos = module.GetPosition()
|
|
module.SetPosition(pcbnew.VECTOR2I(x_avg, pos.y))
|
|
|
|
# Handle distribution if requested
|
|
if distribution == "equal" and len(components) > 1:
|
|
# Get topmost and bottommost Y coordinates
|
|
y_min = components[0].GetPosition().y
|
|
y_max = components[-1].GetPosition().y
|
|
|
|
# Calculate equal spacing
|
|
total_space = y_max - y_min
|
|
spacing_nm = total_space // (len(components) - 1)
|
|
|
|
# Set Y positions with equal spacing
|
|
for i in range(1, len(components) - 1):
|
|
pos = components[i].GetPosition()
|
|
new_y = y_min + (i * spacing_nm)
|
|
components[i].SetPosition(pcbnew.VECTOR2I(pos.x, new_y))
|
|
|
|
elif distribution == "spacing" and spacing is not None:
|
|
# Convert spacing to nanometers
|
|
spacing_nm = int(spacing * 1000000) # assuming mm
|
|
|
|
# Set Y positions with the specified spacing
|
|
y_current = components[0].GetPosition().y
|
|
for i in range(1, len(components)):
|
|
pos = components[i].GetPosition()
|
|
y_current += spacing_nm
|
|
components[i].SetPosition(pcbnew.VECTOR2I(pos.x, y_current))
|
|
|
|
def _align_components_to_edge(self, components: List[pcbnew.FOOTPRINT], edge: str) -> None:
|
|
"""Align components to the specified edge of the board"""
|
|
if not components:
|
|
return
|
|
|
|
# Get board bounds
|
|
board_box = self.board.GetBoardEdgesBoundingBox()
|
|
left = board_box.GetLeft()
|
|
right = board_box.GetRight()
|
|
top = board_box.GetTop()
|
|
bottom = board_box.GetBottom()
|
|
|
|
# Align based on specified edge
|
|
if edge == "left":
|
|
for module in components:
|
|
pos = module.GetPosition()
|
|
module.SetPosition(pcbnew.VECTOR2I(left + 2000000, pos.y)) # 2mm offset from edge
|
|
elif edge == "right":
|
|
for module in components:
|
|
pos = module.GetPosition()
|
|
module.SetPosition(pcbnew.VECTOR2I(right - 2000000, pos.y)) # 2mm offset from edge
|
|
elif edge == "top":
|
|
for module in components:
|
|
pos = module.GetPosition()
|
|
module.SetPosition(pcbnew.VECTOR2I(pos.x, top + 2000000)) # 2mm offset from edge
|
|
elif edge == "bottom":
|
|
for module in components:
|
|
pos = module.GetPosition()
|
|
module.SetPosition(pcbnew.VECTOR2I(pos.x, bottom - 2000000)) # 2mm offset from edge
|
|
else:
|
|
logger.warning(f"Unknown edge alignment: {edge}")
|
|
|
|
# -----------------------------------------------------------------------
|
|
# check_courtyard_overlaps
|
|
#
|
|
# Originally prototyped in morningfire-pcb-automation
|
|
# https://github.com/NiNjA-CodE/morningfire-pcb-automation
|
|
# (scripts/placement/check_overlaps.py — AABB lookup-table version)
|
|
#
|
|
# The version here uses the real courtyard polygons from the loaded
|
|
# board (more accurate than a static lookup), with virtual-placement
|
|
# support so an AI can validate a proposed move before committing it.
|
|
# -----------------------------------------------------------------------
|
|
def check_courtyard_overlaps(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Detect courtyard overlaps between footprints (and board-edge violations).
|
|
|
|
Each footprint has an F.Courtyard / B.Courtyard polygon that defines its
|
|
physical keepout. KiCad's own DRC reports `courtyards_overlap` after the
|
|
fact; this tool lets the caller check ahead of time — either against
|
|
the current placement or against a hypothetical placement
|
|
(``positions``) that hasn't been committed to the board yet.
|
|
|
|
Args:
|
|
positions: Optional dict ``{ref: [x, y]}`` or ``{ref: [x, y, rot]}``
|
|
in mm/degrees. Virtual placements: the listed refs are
|
|
temporarily considered to be at the given (x, y[, rot]). The
|
|
board file is not modified.
|
|
refs: Optional list of reference designators to limit the check
|
|
to. Default: every footprint on the board.
|
|
margin: Extra clearance in mm to enforce around each courtyard
|
|
(default 0). Overlaps below this margin are flagged.
|
|
include_boundary: If True (default), also flag courtyards that
|
|
extend past the board outline.
|
|
board_outline: Optional ``{"x1": ..., "y1": ..., "x2": ..., "y2":
|
|
..., "unit": "mm"|"inch"}`` override; otherwise the board's
|
|
Edge.Cuts bounding box is used.
|
|
|
|
Returns:
|
|
``{"success": True, "overlaps": [...], "boundary_violations": [...],
|
|
"summary": {...}}``
|
|
Each overlap entry has ``{a, b, overlap_x_mm, overlap_y_mm,
|
|
overlap_area_mm2, bbox}``; each boundary entry has
|
|
``{ref, bbox, exceeds: {top, bottom, left, right} in mm}``.
|
|
"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
ref_filter = params.get("refs")
|
|
if ref_filter is not None:
|
|
ref_filter = set(ref_filter)
|
|
|
|
margin_mm = float(params.get("margin", 0.0))
|
|
include_boundary = bool(params.get("include_boundary", True))
|
|
|
|
virtual = {}
|
|
for ref, spec in (params.get("positions") or {}).items():
|
|
if not isinstance(spec, (list, tuple)) or len(spec) not in (2, 3):
|
|
return {
|
|
"success": False,
|
|
"message": "Bad position spec",
|
|
"errorDetails": f"positions['{ref}'] must be [x, y] or [x, y, rot]; "
|
|
f"got {spec!r}",
|
|
}
|
|
virtual[ref] = spec
|
|
|
|
# Resolve board outline once.
|
|
outline_bbox = self._resolve_outline_bbox(params.get("board_outline"))
|
|
|
|
# Gather courtyard bboxes for every footprint we'll consider.
|
|
entries = []
|
|
for fp in self.board.GetFootprints():
|
|
ref = fp.GetReference()
|
|
if ref_filter is not None and ref not in ref_filter:
|
|
continue
|
|
bbox = self._footprint_courtyard_bbox(fp, virtual.get(ref))
|
|
if bbox is None:
|
|
continue
|
|
# Expand by margin
|
|
if margin_mm:
|
|
x1, y1, x2, y2 = bbox
|
|
bbox = (x1 - margin_mm, y1 - margin_mm, x2 + margin_mm, y2 + margin_mm)
|
|
entries.append((ref, bbox))
|
|
|
|
# Pairwise overlap (AABB intersect — matches KiCad DRC's
|
|
# courtyard-overlap detection model).
|
|
overlaps = []
|
|
entries_sorted = sorted(entries, key=lambda e: e[0])
|
|
for i in range(len(entries_sorted)):
|
|
a_ref, a = entries_sorted[i]
|
|
for j in range(i + 1, len(entries_sorted)):
|
|
b_ref, b = entries_sorted[j]
|
|
if a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]:
|
|
ox = min(a[2], b[2]) - max(a[0], b[0])
|
|
oy = min(a[3], b[3]) - max(a[1], b[1])
|
|
overlaps.append(
|
|
{
|
|
"a": a_ref,
|
|
"b": b_ref,
|
|
"overlap_x_mm": round(ox, 3),
|
|
"overlap_y_mm": round(oy, 3),
|
|
"overlap_area_mm2": round(ox * oy, 4),
|
|
"bbox": {
|
|
"x1": round(max(a[0], b[0]), 3),
|
|
"y1": round(max(a[1], b[1]), 3),
|
|
"x2": round(min(a[2], b[2]), 3),
|
|
"y2": round(min(a[3], b[3]), 3),
|
|
"unit": "mm",
|
|
},
|
|
}
|
|
)
|
|
|
|
# Boundary violations
|
|
boundary_violations = []
|
|
if include_boundary and outline_bbox is not None:
|
|
ox1, oy1, ox2, oy2 = outline_bbox
|
|
for ref, bbox in entries_sorted:
|
|
x1, y1, x2, y2 = bbox
|
|
exceeds = {}
|
|
if x1 < ox1 - 1e-6:
|
|
exceeds["left"] = round(ox1 - x1, 3)
|
|
if x2 > ox2 + 1e-6:
|
|
exceeds["right"] = round(x2 - ox2, 3)
|
|
if y1 < oy1 - 1e-6:
|
|
exceeds["top"] = round(oy1 - y1, 3)
|
|
if y2 > oy2 + 1e-6:
|
|
exceeds["bottom"] = round(y2 - oy2, 3)
|
|
if exceeds:
|
|
boundary_violations.append(
|
|
{
|
|
"ref": ref,
|
|
"bbox": {
|
|
"x1": round(x1, 3),
|
|
"y1": round(y1, 3),
|
|
"x2": round(x2, 3),
|
|
"y2": round(y2, 3),
|
|
"unit": "mm",
|
|
},
|
|
"exceeds": exceeds,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"overlaps": overlaps,
|
|
"boundary_violations": boundary_violations,
|
|
"summary": {
|
|
"checked": len(entries_sorted),
|
|
"overlap_count": len(overlaps),
|
|
"boundary_violation_count": len(boundary_violations),
|
|
"margin_mm": margin_mm,
|
|
"virtual_placements": len(virtual),
|
|
"board_outline_mm": (
|
|
None
|
|
if outline_bbox is None
|
|
else {
|
|
"x1": round(outline_bbox[0], 3),
|
|
"y1": round(outline_bbox[1], 3),
|
|
"x2": round(outline_bbox[2], 3),
|
|
"y2": round(outline_bbox[3], 3),
|
|
"unit": "mm",
|
|
}
|
|
),
|
|
},
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"check_courtyard_overlaps failed: {e}", exc_info=True)
|
|
return {
|
|
"success": False,
|
|
"message": "check_courtyard_overlaps failed",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
# --- helpers for check_courtyard_overlaps ----------------------------
|
|
|
|
@staticmethod
|
|
def _nm_to_mm(v):
|
|
return v / 1_000_000.0
|
|
|
|
def _resolve_outline_bbox(self, override):
|
|
"""Return (x1, y1, x2, y2) in mm for the board outline, or None.
|
|
|
|
Priority:
|
|
1. caller-supplied override dict (x1,y1,x2,y2 + unit)
|
|
2. board.GetBoardEdgesBoundingBox()
|
|
"""
|
|
if override:
|
|
scale = 1.0 if override.get("unit", "mm") == "mm" else 25.4
|
|
return (
|
|
override["x1"] * scale,
|
|
override["y1"] * scale,
|
|
override["x2"] * scale,
|
|
override["y2"] * scale,
|
|
)
|
|
try:
|
|
bb = self.board.GetBoardEdgesBoundingBox()
|
|
return (
|
|
self._nm_to_mm(bb.GetLeft()),
|
|
self._nm_to_mm(bb.GetTop()),
|
|
self._nm_to_mm(bb.GetRight()),
|
|
self._nm_to_mm(bb.GetBottom()),
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
def _footprint_courtyard_bbox(self, fp, override_pos):
|
|
"""Return courtyard bbox in mm, optionally relocated to a virtual position.
|
|
|
|
Strategy:
|
|
1. Use F.Courtyard or B.Courtyard polygon if present.
|
|
2. Otherwise fall back to footprint.GetBoundingBox() (includes pads,
|
|
excludes text by default).
|
|
3. If override_pos is given, translate (and optionally rotate) the
|
|
bbox to land at the virtual position — preserving the bbox's
|
|
extents relative to the new anchor.
|
|
"""
|
|
bbox_nm = None
|
|
# Try the courtyard polygons first (front then back)
|
|
for layer in (pcbnew.F_CrtYd, pcbnew.B_CrtYd):
|
|
try:
|
|
ct = fp.GetCourtyard(layer)
|
|
if ct is not None and ct.OutlineCount() > 0:
|
|
box = ct.BBox()
|
|
bbox_nm = (box.GetLeft(), box.GetTop(), box.GetRight(), box.GetBottom())
|
|
break
|
|
except Exception:
|
|
continue
|
|
if bbox_nm is None:
|
|
try:
|
|
box = fp.GetBoundingBox()
|
|
bbox_nm = (box.GetLeft(), box.GetTop(), box.GetRight(), box.GetBottom())
|
|
except Exception:
|
|
return None
|
|
|
|
x1, y1, x2, y2 = (self._nm_to_mm(v) for v in bbox_nm)
|
|
|
|
if override_pos is None:
|
|
return (x1, y1, x2, y2)
|
|
|
|
# Re-anchor at the virtual position. We do this by translating the
|
|
# bbox by (new_pos - current_pos). Rotation override is honoured by
|
|
# rotating the *local* bbox (relative to the current anchor) by the
|
|
# delta between the override rotation and the current rotation, then
|
|
# re-anchoring. This is conservative for non-square parts: the AABB
|
|
# of a rotated bbox is larger than the rotated polygon, but never
|
|
# smaller — so an overlap report is still correct (never false-negative).
|
|
cur = fp.GetPosition()
|
|
cur_x_mm = self._nm_to_mm(cur.x)
|
|
cur_y_mm = self._nm_to_mm(cur.y)
|
|
new_x = float(override_pos[0])
|
|
new_y = float(override_pos[1])
|
|
new_rot = float(override_pos[2]) if len(override_pos) == 3 else None
|
|
|
|
# Local bbox (relative to current anchor)
|
|
lx1, ly1, lx2, ly2 = x1 - cur_x_mm, y1 - cur_y_mm, x2 - cur_x_mm, y2 - cur_y_mm
|
|
|
|
if new_rot is not None:
|
|
cur_rot = fp.GetOrientationDegrees()
|
|
delta = new_rot - cur_rot
|
|
if abs(delta) > 0.01:
|
|
lx1, ly1, lx2, ly2 = self._rotate_aabb(lx1, ly1, lx2, ly2, delta)
|
|
|
|
return (new_x + lx1, new_y + ly1, new_x + lx2, new_y + ly2)
|
|
|
|
@staticmethod
|
|
def _rotate_aabb(x1, y1, x2, y2, angle_deg):
|
|
"""Rotate the four AABB corners around origin and return the new
|
|
axis-aligned bounding box. KiCad uses Y-down screen coords."""
|
|
import math
|
|
|
|
rad = math.radians(angle_deg)
|
|
c, s = math.cos(rad), math.sin(rad)
|
|
# Note: screen Y-down means rotation CCW visually requires the
|
|
# standard math rotation with y negated; but for AABB extents this
|
|
# is symmetric — we end up with the same xmin/ymin/xmax/ymax.
|
|
corners = [(x1, y1), (x2, y1), (x1, y2), (x2, y2)]
|
|
rotated = [(x * c - y * s, x * s + y * c) for x, y in corners]
|
|
xs = [p[0] for p in rotated]
|
|
ys = [p[1] for p in rotated]
|
|
return min(xs), min(ys), max(xs), max(ys)
|