Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.
"""
Board outline command implementations for KiCAD interface
"""
import logging
import math
from typing import Any, Dict, Optional
import pcbnew
from utils.board_items import delete_board_item
logger = logging.getLogger("kicad_interface")
class BoardOutlineCommands:
"""Handles board outline operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a board outline to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Claude sends dimensions nested inside a "params" key:
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
# Unwrap the inner dict if present so we read dimensions from the right level.
inner = params.get("params", params)
shape = params.get("shape", "rectangle")
width = inner.get("width")
height = inner.get("height")
radius = inner.get("radius")
# Accept both "cornerRadius" and "radius" regardless of shape name.
# The AI often sends shape=”rectangle” with radius=2.5 — we treat that as rounded_rectangle.
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
if shape == "rectangle" and corner_radius > 0:
shape = "rounded_rectangle"
points = inner.get("points", [])
unit = inner.get("unit", "mm")
# Position: accept top-left corner (x/y) or center (centerX/centerY).
# Default: top-left at (0,0) so the board occupies positive coordinate space
# and is consistent with component placement coordinates.
x = inner.get("x")
y = inner.get("y")
if x is not None or y is not None:
ox = x if x is not None else 0.0
oy = y if y is not None else 0.0
center_x = ox + (width or 0) / 2.0
center_y = oy + (height or 0) / 2.0
else:
raw_cx = inner.get("centerX")
raw_cy = inner.get("centerY")
if raw_cx is not None or raw_cy is not None:
center_x = raw_cx if raw_cx is not None else 0.0
center_y = raw_cy if raw_cy is not None else 0.0
else:
# No position given → place top-left at (0,0)
center_x = (width or 0) / 2.0
center_y = (height or 0) / 2.0
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
return {
"success": False,
"message": "Invalid shape",
"errorDetails": f"Shape '{shape}' not supported",
}
# Convert to internal units (nanometers)
scale = (
1000000 if unit == "mm" else (25400 if unit == "mil" else 25400000)
) # mm, mil, or inch to nm
# Create drawing for edge cuts
edge_layer = self.board.GetLayerID("Edge.Cuts")
if shape == "rectangle":
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required for rectangle",
}
width_nm = int(width * scale)
height_nm = int(height * scale)
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
# Create rectangle
top_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
)
top_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
)
bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
)
# Add lines for rectangle
self._add_edge_line(top_left, top_right, edge_layer)
self._add_edge_line(top_right, bottom_right, edge_layer)
self._add_edge_line(bottom_right, bottom_left, edge_layer)
self._add_edge_line(bottom_left, top_left, edge_layer)
elif shape == "rounded_rectangle":
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required for rounded rectangle",
}
width_nm = int(width * scale)
height_nm = int(height * scale)
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
corner_radius_nm = int(corner_radius * scale)
# Create rounded rectangle
self._add_rounded_rect(
center_x_nm,
center_y_nm,
width_nm,
height_nm,
corner_radius_nm,
edge_layer,
)
elif shape == "circle":
if radius is None:
return {
"success": False,
"message": "Missing radius",
"errorDetails": "Radius is required for circle",
}
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
radius_nm = int(radius * scale)
# Create circle
circle = pcbnew.PCB_SHAPE(self.board)
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
circle.SetLayer(edge_layer)
circle.SetWidth(0) # Zero width for edge cuts
self.board.Add(circle)
elif shape == "polygon":
if not points or len(points) < 3:
return {
"success": False,
"message": "Missing points",
"errorDetails": "At least 3 points are required for polygon",
}
# Convert points to nm
polygon_points = []
for point in points:
x_nm = int(point["x"] * scale)
y_nm = int(point["y"] * scale)
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
# Add lines for polygon
for i in range(len(polygon_points)):
self._add_edge_line(
polygon_points[i],
polygon_points[(i + 1) % len(polygon_points)],
edge_layer,
)
return {
"success": True,
"message": f"Added board outline: {shape}",
"outline": {
"shape": shape,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y, "unit": unit},
"radius": radius,
"cornerRadius": corner_radius,
"points": points,
},
}
except Exception as e:
logger.error(f"Error adding board outline: {str(e)}")
return {
"success": False,
"message": "Failed to add board outline",
"errorDetails": str(e),
}
def clear_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Delete all Edge.Cuts drawing items from the board."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
edge_layer = self.board.GetLayerID("Edge.Cuts")
removed = 0
for item in list(self.board.GetDrawings()):
try:
if item.GetLayer() == edge_layer:
delete_board_item(self.board, item)
removed += 1
except Exception:
continue
return {
"success": True,
"message": f"Removed {removed} Edge.Cuts item(s)",
"removed": removed,
}
except Exception as e:
logger.error(f"Error clearing board outline: {str(e)}")
return {
"success": False,
"message": "Failed to clear board outline",
"errorDetails": str(e),
}
def replace_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Replace the current Edge.Cuts outline with a new shape."""
cleared = self.clear_board_outline({})
if not cleared.get("success"):
return cleared
added = self.add_board_outline(params)
if added.get("success"):
added["cleared"] = cleared.get("removed", 0)
added["message"] = (
f"Replaced board outline; removed {cleared.get('removed', 0)} old item(s)"
)
return added
def list_graphics(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List board drawing items, optionally filtered by layer."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
graphics = []
for item in self.board.GetDrawings():
payload = self._graphic_payload(item)
if layer and payload.get("layer") != layer:
continue
graphics.append(payload)
return {"success": True, "count": len(graphics), "graphics": graphics}
except Exception as e:
logger.error(f"Error listing graphics: {str(e)}")
return {"success": False, "message": "Failed to list graphics", "errorDetails": str(e)}
def delete_graphic(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Delete a board drawing item by UUID."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
uuid = params.get("uuid")
if not uuid:
return {"success": False, "message": "uuid is required"}
for item in list(self.board.GetDrawings()):
if self._item_uuid(item) == uuid:
delete_board_item(self.board, item)
return {"success": True, "message": f"Deleted graphic {uuid}", "uuid": uuid}
return {
"success": False,
"message": "Graphic not found",
"errorDetails": f"No board graphic has uuid {uuid}",
}
except Exception as e:
logger.error(f"Error deleting graphic: {str(e)}")
return {"success": False, "message": "Failed to delete graphic", "errorDetails": str(e)}
def update_graphic(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Update a board drawing item by UUID."""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
uuid = params.get("uuid")
if not uuid:
return {"success": False, "message": "uuid is required"}
item = None
for candidate in self.board.GetDrawings():
if self._item_uuid(candidate) == uuid:
item = candidate
break
if item is None:
return {
"success": False,
"message": "Graphic not found",
"errorDetails": f"No board graphic has uuid {uuid}",
}
unit = params.get("unit", "mm")
scale = 1_000_000 if unit == "mm" else (25_400 if unit == "mil" else 25_400_000)
if params.get("layer") is not None:
item.SetLayer(self.board.GetLayerID(params["layer"]))
if params.get("width") is not None and hasattr(item, "SetWidth"):
item.SetWidth(int(float(params["width"]) * scale))
if params.get("start") and hasattr(item, "SetStart"):
item.SetStart(self._vec_from_xy(params["start"], scale))
if params.get("end") and hasattr(item, "SetEnd"):
item.SetEnd(self._vec_from_xy(params["end"], scale))
if params.get("center") and hasattr(item, "SetCenter"):
item.SetCenter(self._vec_from_xy(params["center"], scale))
if params.get("position") and hasattr(item, "SetPosition"):
item.SetPosition(self._vec_from_xy(params["position"], scale))
if params.get("text") is not None and hasattr(item, "SetText"):
item.SetText(str(params["text"]))
return {
"success": True,
"message": f"Updated graphic {uuid}",
"graphic": self._graphic_payload(item),
}
except Exception as e:
logger.error(f"Error updating graphic: {str(e)}")
return {"success": False, "message": "Failed to update graphic", "errorDetails": str(e)}
@staticmethod
def _vec_from_xy(data: Dict[str, Any], scale: float):
return pcbnew.VECTOR2I(int(float(data["x"]) * scale), int(float(data["y"]) * scale))
def _item_uuid(self, item) -> str:
for attr in ("m_Uuid", "GetUuid"):
try:
value = getattr(item, attr)
uuid_obj = value() if attr == "GetUuid" and callable(value) else value
if hasattr(uuid_obj, "AsString"):
return uuid_obj.AsString()
return str(uuid_obj)
except Exception:
continue
return ""
def _graphic_payload(self, item) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"uuid": self._item_uuid(item),
"type": type(item).__name__,
}
try:
payload["layer"] = self.board.GetLayerName(item.GetLayer())
except Exception:
payload["layer"] = None
if hasattr(item, "GetShape"):
try:
payload["shape"] = str(item.GetShape())
except Exception:
pass
for key, getter in (
("start", "GetStart"),
("end", "GetEnd"),
("center", "GetCenter"),
("position", "GetPosition"),
):
if hasattr(item, getter):
try:
p = getattr(item, getter)()
payload[key] = {"x": p.x / 1_000_000, "y": p.y / 1_000_000, "unit": "mm"}
except Exception:
pass
if hasattr(item, "GetText"):
try:
payload["text"] = item.GetText()
except Exception:
pass
if hasattr(item, "GetWidth"):
try:
payload["width"] = item.GetWidth() / 1_000_000
except Exception:
pass
if hasattr(item, "GetBoundingBox"):
try:
bb = item.GetBoundingBox()
payload["boundingBox"] = {
"min_x": bb.GetLeft() / 1_000_000,
"min_y": bb.GetTop() / 1_000_000,
"max_x": bb.GetRight() / 1_000_000,
"max_y": bb.GetBottom() / 1_000_000,
"unit": "mm",
}
except Exception:
pass
return payload
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a mounting hole to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
position = params.get("position")
diameter = params.get("diameter")
pad_diameter = params.get("padDiameter")
plated = params.get("plated", False)
footprint_lib_id = params.get("footprintLibId")
if not position or not diameter:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "position and diameter are required",
}
# Convert to internal units (nanometers)
scale = (
1000000
if position.get("unit", "mm") == "mm"
else (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)
diameter_nm = int(diameter * scale)
pad_diameter_nm = (
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
) # 1mm larger by default
# Create footprint for mounting hole with unique reference
existing_mh = [
fp.GetReference()
for fp in self.board.GetFootprints()
if fp.GetReference().startswith("MH")
]
next_num = 1
while f"MH{next_num}" in existing_mh:
next_num += 1
module = pcbnew.FOOTPRINT(self.board)
module.SetReference(f"MH{next_num}")
module.SetValue(f"MountingHole_{diameter}mm")
# Set a real library:name FPID. Without this, the footprint is
# written as `(footprint "" ...)` and KiCad's GUI Move tool refuses
# to select it (no library link → not draggable in the editor).
if not footprint_lib_id:
# Strip trailing zeros so 3.2 → "3.2" not "3.20"
footprint_lib_id = f"MountingHole:MountingHole_{diameter:g}mm"
if ":" in footprint_lib_id:
lib_name, fp_name = footprint_lib_id.split(":", 1)
else:
lib_name = "MountingHole"
fp_name = footprint_lib_id
module.SetFPID(pcbnew.LIB_ID(lib_name, fp_name))
# Create the pad for the hole
pad = pcbnew.PAD(module)
pad.SetNumber(1)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
if not plated:
# NPTH must not include *.Cu in pad layers. The default LSET
# for a circular pad is *.Cu + *.Mask; on a NPTH with
# padDiameter > diameter that produces phantom copper annular
# rings on every Cu layer, which trip clearance DRC against
# neighbouring nets.
mask_only = pcbnew.LSET()
mask_only.AddLayer(pcbnew.F_Mask)
mask_only.AddLayer(pcbnew.B_Mask)
pad.SetLayerSet(mask_only)
module.Add(pad)
# Position the mounting hole
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Add to board
self.board.Add(module)
return {
"success": True,
"message": "Added mounting hole",
"mountingHole": {
"position": position,
"diameter": diameter,
"padDiameter": pad_diameter or diameter + 1,
"plated": plated,
"footprintLibId": f"{lib_name}:{fp_name}",
},
}
except Exception as e:
logger.error(f"Error adding mounting hole: {str(e)}")
return {
"success": False,
"message": "Failed to add mounting hole",
"errorDetails": str(e),
}
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text annotation to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
text = params.get("text")
position = params.get("position")
layer = params.get("layer", "F.SilkS")
size = params.get("size", 1.0)
thickness = params.get("thickness", 0.15)
rotation = params.get("rotation", 0)
mirror = params.get("mirror", False)
if not text or not position:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "text and position are required",
}
# Convert to internal units (nanometers)
scale = (
1000000
if position.get("unit", "mm") == "mm"
else (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)
size_nm = int(size * scale)
thickness_nm = int(thickness * scale)
# Get layer ID
layer_id = self.board.GetLayerID(layer)
if layer_id < 0:
return {
"success": False,
"message": "Invalid layer",
"errorDetails": f"Layer '{layer}' does not exist",
}
# Create text
pcb_text = pcbnew.PCB_TEXT(self.board)
pcb_text.SetText(text)
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
pcb_text.SetLayer(layer_id)
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
pcb_text.SetTextThickness(thickness_nm)
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
try:
# Try KiCAD 9.0+ API (EDA_ANGLE)
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
pcb_text.SetTextAngle(angle)
except (AttributeError, TypeError):
# Fall back to older API (decidegrees as integer)
pcb_text.SetTextAngle(int(rotation * 10))
pcb_text.SetMirrored(mirror)
# Add to board
self.board.Add(pcb_text)
return {
"success": True,
"message": "Added text annotation",
"text": {
"text": text,
"position": position,
"layer": layer,
"size": size,
"thickness": thickness,
"rotation": rotation,
"mirror": mirror,
},
}
except Exception as e:
logger.error(f"Error adding text: {str(e)}")
return {
"success": False,
"message": "Failed to add text",
"errorDetails": str(e),
}
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
"""Add a line to the edge cuts layer"""
line = pcbnew.PCB_SHAPE(self.board)
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
line.SetStart(start)
line.SetEnd(end)
line.SetLayer(layer)
line.SetWidth(0) # Zero width for edge cuts
self.board.Add(line)
def _add_rounded_rect(
self,
center_x_nm: int,
center_y_nm: int,
width_nm: int,
height_nm: int,
radius_nm: int,
layer: int,
) -> None:
"""Add a rounded rectangle to the edge cuts layer"""
if radius_nm <= 0:
# If no radius, create regular rectangle
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)
self._add_edge_line(bottom_right, bottom_left, layer)
self._add_edge_line(bottom_left, top_left, layer)
return
# Calculate corner centers
half_width = width_nm // 2
half_height = height_nm // 2
# Ensure radius is not larger than half the smallest dimension
max_radius = min(half_width, half_height)
if radius_nm > max_radius:
radius_nm = max_radius
# Calculate corner centers
top_left_center = pcbnew.VECTOR2I(
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
)
top_right_center = pcbnew.VECTOR2I(
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
)
bottom_right_center = pcbnew.VECTOR2I(
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
)
bottom_left_center = pcbnew.VECTOR2I(
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
)
# Add arcs for corners
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
# Add lines for straight edges
# Top edge
self._add_edge_line(
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
layer,
)
# Right edge
self._add_edge_line(
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
layer,
)
# Bottom edge
self._add_edge_line(
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
layer,
)
# Left edge
self._add_edge_line(
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
layer,
)
def _add_corner_arc(
self,
center: pcbnew.VECTOR2I,
radius: int,
start_angle: float,
end_angle: float,
layer: int,
) -> None:
"""Add an arc for a rounded corner"""
# Create arc for corner
arc = pcbnew.PCB_SHAPE(self.board)
arc.SetShape(pcbnew.SHAPE_T_ARC)
arc.SetCenter(center)
# Calculate start and end points
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
arc.SetLayer(layer)
arc.SetWidth(0) # Zero width for edge cuts
self.board.Add(arc)