""" 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 25400000 # mm 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 25400000 # mm 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 25400000 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 25400000 # mm 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)