diff --git a/python/commands/board/view.py b/python/commands/board/view.py
index aaad991..b54a5c3 100644
--- a/python/commands/board/view.py
+++ b/python/commands/board/view.py
@@ -4,15 +4,18 @@ Board view command implementations for KiCAD interface
import os
import pcbnew
-import logging
-from typing import Dict, Any, Optional, List, Tuple
-from PIL import Image
-import io
-import base64
+import logging
+from typing import Dict, Any, Optional, List, Tuple
+from PIL import Image
+import io
+import base64
+import tempfile
+import subprocess
+import shutil
logger = logging.getLogger('kicad_interface')
-class BoardViewCommands:
+class BoardViewCommands:
"""Handles board viewing operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
@@ -166,16 +169,16 @@ class BoardViewCommands:
"errorDetails": str(e)
}
- def _get_layer_type_name(self, type_id: int) -> str:
- """Convert KiCAD layer type constant to name"""
- type_map = {
- pcbnew.LT_SIGNAL: "signal",
- pcbnew.LT_POWER: "power",
- pcbnew.LT_MIXED: "mixed",
- pcbnew.LT_JUMPER: "jumper"
- }
- # Note: LT_USER was removed in KiCAD 9.0
- return type_map.get(type_id, "unknown")
+ def _get_layer_type_name(self, type_id: int) -> str:
+ """Convert KiCAD layer type constant to name"""
+ type_map = {
+ pcbnew.LT_SIGNAL: "signal",
+ pcbnew.LT_POWER: "power",
+ pcbnew.LT_MIXED: "mixed",
+ pcbnew.LT_JUMPER: "jumper"
+ }
+ # Note: LT_USER was removed in KiCAD 9.0
+ return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
@@ -230,3 +233,103 @@ class BoardViewCommands:
"message": "Failed to get board extents",
"errorDetails": str(e)
}
+
+ def _find_kicad_cli(self) -> Optional[str]:
+ return shutil.which("kicad-cli")
+
+ def get_board_3d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Render a 3D board preview using kicad-cli."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ board_file = self.board.GetFileName()
+ if not board_file or not os.path.exists(board_file):
+ return {
+ "success": False,
+ "message": "Board file not found",
+ "errorDetails": "Save the board before requesting a 3D preview",
+ }
+
+ kicad_cli = self._find_kicad_cli()
+ if not kicad_cli:
+ return {
+ "success": False,
+ "message": "kicad-cli not found",
+ "errorDetails": "KiCad CLI is required for 3D renders",
+ }
+
+ angle = str(params.get("angle", "isometric")).lower()
+ width = int(params.get("width", 1600))
+ height = int(params.get("height", 900))
+
+ render_args = []
+ if angle == "isometric":
+ render_args.extend(["--side", "top", "--rotate", "-45,0,45", "--perspective"])
+ elif angle in {"top", "bottom", "left", "right", "front", "back"}:
+ render_args.extend(["--side", angle])
+ else:
+ return {
+ "success": False,
+ "message": "Unsupported angle",
+ "errorDetails": f"Angle '{angle}' is not supported",
+ }
+
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
+ output_path = tmp.name
+
+ try:
+ cmd = [
+ kicad_cli,
+ "pcb",
+ "render",
+ "--output",
+ output_path,
+ "--width",
+ str(width),
+ "--height",
+ str(height),
+ "--background",
+ "transparent",
+ "--quality",
+ "high",
+ *render_args,
+ board_file,
+ ]
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=180,
+ )
+ if result.returncode != 0:
+ return {
+ "success": False,
+ "message": "3D render failed",
+ "errorDetails": result.stderr.strip() or result.stdout.strip(),
+ }
+
+ with open(output_path, "rb") as f:
+ image_bytes = f.read()
+
+ return {
+ "success": True,
+ "imageData": base64.b64encode(image_bytes).decode("utf-8"),
+ "format": "png",
+ "angle": angle,
+ }
+ finally:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ except Exception as e:
+ logger.error(f"Error getting board 3D view: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get board 3D view",
+ "errorDetails": str(e),
+ }
diff --git a/python/commands/component.py b/python/commands/component.py
index 9489060..870eb8e 100644
--- a/python/commands/component.py
+++ b/python/commands/component.py
@@ -4,15 +4,17 @@ Component-related command implementations for KiCAD interface
import os
import pcbnew
-import logging
-import math
-from typing import Dict, Any, Optional, List, Tuple
-import base64
-from commands.library import LibraryManager
+import logging
+import math
+from typing import Dict, Any, Optional, List, Tuple
+import base64
+import io
+from commands.library import LibraryManager
+from PIL import Image, ImageDraw
logger = logging.getLogger('kicad_interface')
-class ComponentCommands:
+class ComponentCommands:
"""Handles component-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None):
@@ -914,7 +916,7 @@ class ComponentCommands:
"errorDetails": str(e)
}
- def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Duplicate an existing component"""
try:
if not self.board:
@@ -1010,11 +1012,503 @@ class ComponentCommands:
except Exception as e:
logger.error(f"Error duplicating component: {str(e)}")
- return {
- "success": False,
- "message": "Failed to duplicate component",
- "errorDetails": str(e)
- }
+ return {
+ "success": False,
+ "message": "Failed to duplicate component",
+ "errorDetails": str(e)
+ }
+
+ def add_component_annotation(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Add a text annotation close to a component footprint."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ reference = params.get("reference")
+ annotation = params.get("annotation")
+ visible = params.get("visible", True)
+
+ if not reference or not annotation:
+ return {
+ "success": False,
+ "message": "Missing parameters",
+ "errorDetails": "reference and annotation are required",
+ }
+
+ module = self.board.FindFootprintByReference(reference)
+ if not module:
+ return {
+ "success": False,
+ "message": "Component not found",
+ "errorDetails": f"Could not find component: {reference}",
+ }
+
+ bbox = module.GetBoundingBox()
+ layer_name = "B.SilkS" if self.board.GetLayerName(module.GetLayer()).startswith("B.") else "F.SilkS"
+ layer_id = self.board.GetLayerID(layer_name)
+
+ text_item = pcbnew.PCB_TEXT(self.board)
+ text_item.SetText(annotation)
+ text_item.SetLayer(layer_id)
+ text_item.SetPosition(
+ pcbnew.VECTOR2I(
+ bbox.GetRight() + 800000,
+ bbox.GetTop() - 800000,
+ )
+ )
+ text_item.SetTextSize(pcbnew.VECTOR2I(1000000, 1000000))
+ text_item.SetTextThickness(150000)
+ if hasattr(text_item, "SetVisible"):
+ text_item.SetVisible(bool(visible))
+
+ self.board.Add(text_item)
+
+ return {
+ "success": True,
+ "message": f"Added annotation to {reference}",
+ "annotation": {
+ "reference": reference,
+ "text": annotation,
+ "layer": layer_name,
+ "visible": bool(visible),
+ },
+ }
+ except Exception as e:
+ logger.error(f"Error adding component annotation: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to add component annotation",
+ "errorDetails": str(e),
+ }
+
+ def group_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Create or extend a real KiCad PCB group with the selected components."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ references = params.get("references", [])
+ group_name = params.get("groupName")
+ if not references or not group_name:
+ return {
+ "success": False,
+ "message": "Missing parameters",
+ "errorDetails": "references and groupName are required",
+ }
+
+ modules = []
+ missing = []
+ for reference in references:
+ module = self.board.FindFootprintByReference(reference)
+ if not module:
+ missing.append(reference)
+ continue
+ modules.append(module)
+
+ if not modules:
+ return {
+ "success": False,
+ "message": "No valid components found",
+ "errorDetails": f"Missing references: {', '.join(missing)}",
+ }
+
+ group = None
+ if hasattr(self.board, "Groups"):
+ for existing in self.board.Groups():
+ name = existing.GetName() if hasattr(existing, "GetName") else ""
+ if name == group_name:
+ group = existing
+ break
+
+ if group is None:
+ group = pcbnew.PCB_GROUP(self.board)
+ if hasattr(group, "SetName"):
+ group.SetName(group_name)
+ self.board.Add(group)
+
+ for module in modules:
+ group.AddItem(module)
+ if hasattr(module, "SetParentGroup"):
+ module.SetParentGroup(group)
+
+ return {
+ "success": True,
+ "message": f"Grouped {len(modules)} component(s) into {group_name}",
+ "group": {
+ "name": group_name,
+ "references": [module.GetReference() for module in modules],
+ "missing": missing,
+ },
+ }
+ except Exception as e:
+ logger.error(f"Error grouping components: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to group components",
+ "errorDetails": str(e),
+ }
+
+ def replace_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Replace a component footprint while preserving placement and reference."""
+ 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_component_id = params.get("newComponentId")
+ new_footprint = params.get("newFootprint")
+ new_value = params.get("newValue")
+
+ if not reference or not new_component_id:
+ return {
+ "success": False,
+ "message": "Missing parameters",
+ "errorDetails": "reference and newComponentId are required",
+ }
+
+ module = self.board.FindFootprintByReference(reference)
+ if not module:
+ return {
+ "success": False,
+ "message": "Component not found",
+ "errorDetails": f"Could not find component: {reference}",
+ }
+
+ pos = module.GetPosition()
+ layer = self.board.GetLayerName(module.GetLayer())
+ rotation = module.GetOrientation().AsDegrees()
+ value = new_value or module.GetValue()
+ temp_reference = f"{reference}_TMP"
+ replacement_spec = new_footprint or new_component_id
+
+ place_result = self.place_component(
+ {
+ "componentId": new_component_id,
+ "position": {
+ "x": pos.x / 1000000,
+ "y": pos.y / 1000000,
+ "unit": "mm",
+ },
+ "reference": temp_reference,
+ "value": value,
+ "footprint": replacement_spec,
+ "rotation": rotation,
+ "layer": layer,
+ }
+ )
+ if not place_result.get("success"):
+ return place_result
+
+ delete_result = self.delete_component({"reference": reference})
+ if not delete_result.get("success"):
+ return delete_result
+
+ rename_result = self.edit_component(
+ {
+ "reference": temp_reference,
+ "newReference": reference,
+ "value": value,
+ "footprint": replacement_spec,
+ }
+ )
+ if not rename_result.get("success"):
+ return rename_result
+
+ return {
+ "success": True,
+ "message": f"Replaced component {reference}",
+ "component": {
+ "reference": reference,
+ "newComponentId": new_component_id,
+ "footprint": replacement_spec,
+ "value": value,
+ },
+ }
+ except Exception as e:
+ logger.error(f"Error replacing component: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to replace component",
+ "errorDetails": str(e),
+ }
+
+ def get_component_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Get pad and net connectivity for 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",
+ }
+
+ module = self.board.FindFootprintByReference(reference)
+ if not module:
+ return {
+ "success": False,
+ "message": "Component not found",
+ "errorDetails": f"Could not find component: {reference}",
+ }
+
+ nets = {}
+ pads = []
+ for pad in module.Pads():
+ net_name = pad.GetNetname() or ""
+ pad_entry = {
+ "pad": pad.GetNumber(),
+ "net": net_name,
+ "position": {
+ "x": pad.GetPosition().x / 1000000,
+ "y": pad.GetPosition().y / 1000000,
+ "unit": "mm",
+ },
+ }
+ pads.append(pad_entry)
+ if net_name:
+ nets.setdefault(net_name, []).append(pad.GetNumber())
+
+ related_components = {}
+ for other in self.board.GetFootprints():
+ other_ref = other.GetReference()
+ if other_ref == reference:
+ continue
+ for pad in other.Pads():
+ net_name = pad.GetNetname() or ""
+ if net_name and net_name in nets:
+ related_components.setdefault(net_name, set()).add(other_ref)
+
+ return {
+ "success": True,
+ "reference": reference,
+ "pads": pads,
+ "connections": [
+ {
+ "net": net_name,
+ "pads": pad_numbers,
+ "connectedReferences": sorted(related_components.get(net_name, set())),
+ }
+ for net_name, pad_numbers in sorted(nets.items())
+ ],
+ }
+ except Exception as e:
+ logger.error(f"Error getting component connections: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get component connections",
+ "errorDetails": str(e),
+ }
+
+ def get_component_placement(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Get placement data for 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",
+ }
+
+ placements = []
+ for module in self.board.GetFootprints():
+ bbox = module.GetBoundingBox()
+ placements.append(
+ {
+ "reference": module.GetReference(),
+ "value": module.GetValue(),
+ "footprint": module.GetFPIDAsString(),
+ "layer": self.board.GetLayerName(module.GetLayer()),
+ "position": {
+ "x": module.GetPosition().x / 1000000,
+ "y": module.GetPosition().y / 1000000,
+ "unit": "mm",
+ },
+ "rotation": module.GetOrientation().AsDegrees(),
+ "bounds": {
+ "left": bbox.GetLeft() / 1000000,
+ "top": bbox.GetTop() / 1000000,
+ "right": bbox.GetRight() / 1000000,
+ "bottom": bbox.GetBottom() / 1000000,
+ "unit": "mm",
+ },
+ }
+ )
+
+ return {
+ "success": True,
+ "placements": placements,
+ "count": len(placements),
+ }
+ except Exception as e:
+ logger.error(f"Error getting component placement: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get component placement",
+ "errorDetails": str(e),
+ }
+
+ def get_component_groups(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Get explicit KiCad groups, or derived prefix groups when none exist."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ grouped = {}
+ derived = {}
+ for module in self.board.GetFootprints():
+ reference = module.GetReference()
+ parent_group = module.GetParentGroup() if hasattr(module, "GetParentGroup") else None
+ if parent_group:
+ name = ""
+ if hasattr(parent_group, "GetName"):
+ name = parent_group.GetName()
+ if not name and hasattr(parent_group, "GetFriendlyName"):
+ name = parent_group.GetFriendlyName()
+ name = name or "unnamed-group"
+ grouped.setdefault(name, {"name": name, "source": "board", "references": []})
+ grouped[name]["references"].append(reference)
+ else:
+ prefix = "".join(ch for ch in reference if ch.isalpha()) or "misc"
+ derived.setdefault(prefix, {"name": prefix, "source": "derived-prefix", "references": []})
+ derived[prefix]["references"].append(reference)
+
+ groups = list(grouped.values()) if grouped else list(derived.values())
+ for group in groups:
+ group["references"].sort()
+ group["count"] = len(group["references"])
+
+ return {
+ "success": True,
+ "groups": sorted(groups, key=lambda group: group["name"]),
+ "count": len(groups),
+ }
+ except Exception as e:
+ logger.error(f"Error getting component groups: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get component groups",
+ "errorDetails": str(e),
+ }
+
+ def get_component_visualization(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Render a lightweight top-down PNG preview for a component footprint."""
+ 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",
+ }
+
+ module = self.board.FindFootprintByReference(reference)
+ if not module:
+ return {
+ "success": False,
+ "message": "Component not found",
+ "errorDetails": f"Could not find component: {reference}",
+ }
+
+ bbox = module.GetBoundingBox()
+ margin_nm = 500000
+ min_x = bbox.GetLeft() - margin_nm
+ min_y = bbox.GetTop() - margin_nm
+ max_x = bbox.GetRight() + margin_nm
+ max_y = bbox.GetBottom() + margin_nm
+
+ width_nm = max(max_x - min_x, 1000000)
+ height_nm = max(max_y - min_y, 1000000)
+ canvas_size = 320
+ padding = 20
+ scale = min(
+ (canvas_size - 2 * padding) / width_nm,
+ (canvas_size - 2 * padding) / height_nm,
+ )
+
+ def to_px(x_nm: int, y_nm: int) -> Tuple[int, int]:
+ x = padding + int((x_nm - min_x) * scale)
+ y = padding + int((y_nm - min_y) * scale)
+ return x, y
+
+ image = Image.new("RGBA", (canvas_size, canvas_size), (255, 255, 255, 0))
+ draw = ImageDraw.Draw(image)
+
+ body_left, body_top = to_px(bbox.GetLeft(), bbox.GetTop())
+ body_right, body_bottom = to_px(bbox.GetRight(), bbox.GetBottom())
+ draw.rounded_rectangle(
+ [body_left, body_top, body_right, body_bottom],
+ radius=10,
+ outline=(36, 74, 124, 255),
+ width=3,
+ fill=(230, 238, 250, 180),
+ )
+
+ for pad in module.Pads():
+ pad_box = pad.GetBoundingBox()
+ x0, y0 = to_px(pad_box.GetLeft(), pad_box.GetTop())
+ x1, y1 = to_px(pad_box.GetRight(), pad_box.GetBottom())
+ shape = pad.GetShape()
+ circle_shapes = {
+ getattr(pcbnew, "PAD_SHAPE_CIRCLE", object()),
+ getattr(pcbnew, "PAD_SHAPE_OVAL", object()),
+ }
+ if shape in circle_shapes:
+ draw.ellipse([x0, y0, x1, y1], fill=(201, 96, 74, 255), outline=(122, 41, 27, 255))
+ else:
+ draw.rounded_rectangle(
+ [x0, y0, x1, y1],
+ radius=4,
+ fill=(201, 96, 74, 255),
+ outline=(122, 41, 27, 255),
+ )
+
+ draw.text((16, 12), reference, fill=(22, 22, 22, 255))
+
+ buffer = io.BytesIO()
+ image.save(buffer, format="PNG")
+ return {
+ "success": True,
+ "imageData": base64.b64encode(buffer.getvalue()).decode("utf-8"),
+ "format": "png",
+ "reference": reference,
+ }
+ except Exception as e:
+ logger.error(f"Error getting component visualization: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get component visualization",
+ "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,
diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py
index 71eb7aa..2a88f81 100644
--- a/python/commands/design_rules.py
+++ b/python/commands/design_rules.py
@@ -2,15 +2,16 @@
Design rules command implementations for KiCAD interface
"""
-import os
-import pcbnew
-import logging
-from typing import Dict, Any, Optional, List, Tuple
+import os
+import pcbnew
+import logging
+from typing import Dict, Any, Optional, List, Tuple
+import json
logger = logging.getLogger("kicad_interface")
-class DesignRuleCommands:
+class DesignRuleCommands:
"""Handles design rule checking and configuration"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
@@ -410,7 +411,7 @@ class DesignRuleCommands:
return None
- def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Get list of DRC violations
@@ -467,8 +468,189 @@ class DesignRuleCommands:
except Exception as e:
logger.error(f"Error getting DRC violations: {str(e)}")
- return {
- "success": False,
- "message": "Failed to get DRC violations",
- "errorDetails": str(e),
- }
+ return {
+ "success": False,
+ "message": "Failed to get DRC violations",
+ "errorDetails": str(e),
+ }
+
+ def _get_project_file(self) -> Optional[str]:
+ if not self.board:
+ return None
+ board_file = self.board.GetFileName()
+ if not board_file:
+ return None
+ return os.path.splitext(board_file)[0] + ".kicad_pro"
+
+ def assign_net_to_class(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Assign an existing net to an existing net class."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ net_name = params.get("net")
+ net_class_name = params.get("netClass")
+ if not net_name or not net_class_name:
+ return {
+ "success": False,
+ "message": "Missing parameters",
+ "errorDetails": "net and netClass are required",
+ }
+
+ net_classes = self.board.GetNetClasses()
+ net_class = net_classes.Find(net_class_name)
+ if not net_class:
+ return {
+ "success": False,
+ "message": "Net class not found",
+ "errorDetails": f"Net class '{net_class_name}' does not exist",
+ }
+
+ nets_map = self.board.GetNetInfo().NetsByName()
+ if not nets_map.has_key(net_name):
+ return {
+ "success": False,
+ "message": "Net not found",
+ "errorDetails": f"Net '{net_name}' does not exist",
+ }
+
+ net = nets_map[net_name]
+ net.SetClass(net_class)
+
+ return {
+ "success": True,
+ "message": f"Assigned net {net_name} to class {net_class_name}",
+ "assignment": {"net": net_name, "netClass": net_class_name},
+ }
+ except Exception as e:
+ logger.error(f"Error assigning net to class: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to assign net to class",
+ "errorDetails": str(e),
+ }
+
+ def set_layer_constraints(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Persist layer-specific constraints in the project file under kicad_mcp."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ layer = params.get("layer")
+ if not layer:
+ return {
+ "success": False,
+ "message": "Missing layer parameter",
+ "errorDetails": "layer is required",
+ }
+
+ project_file = self._get_project_file()
+ if not project_file:
+ return {
+ "success": False,
+ "message": "Project file unavailable",
+ "errorDetails": "Save the board before setting layer constraints",
+ }
+
+ payload = {}
+ if os.path.exists(project_file):
+ with open(project_file, "r", encoding="utf-8") as f:
+ try:
+ payload = json.load(f)
+ except json.JSONDecodeError:
+ payload = {}
+
+ constraints = {
+ key: value
+ for key, value in {
+ "minTrackWidth": params.get("minTrackWidth"),
+ "minClearance": params.get("minClearance"),
+ "minViaDiameter": params.get("minViaDiameter"),
+ "minViaDrill": params.get("minViaDrill"),
+ }.items()
+ if value is not None
+ }
+
+ payload.setdefault("kicad_mcp", {}).setdefault("layer_constraints", {})[layer] = constraints
+ with open(project_file, "w", encoding="utf-8") as f:
+ json.dump(payload, f, indent=2)
+
+ return {
+ "success": True,
+ "message": f"Stored layer constraints for {layer}",
+ "layer": layer,
+ "constraints": constraints,
+ "projectFile": project_file,
+ }
+ except Exception as e:
+ logger.error(f"Error setting layer constraints: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to set layer constraints",
+ "errorDetails": str(e),
+ }
+
+ def check_clearance(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Approximate clearance check using item positions and bounding boxes."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ scale = 1000000
+ min_clearance = self.board.GetDesignSettings().m_MinClearance / scale
+
+ def _resolve_point(item: Dict[str, Any]) -> Optional[Tuple[float, float]]:
+ if item.get("position"):
+ pos = item["position"]
+ unit_scale = 1.0 if pos.get("unit", "mm") == "mm" else 25.4
+ return (float(pos.get("x", 0)) * unit_scale, float(pos.get("y", 0)) * unit_scale)
+
+ if item.get("reference"):
+ module = self.board.FindFootprintByReference(item["reference"])
+ if module:
+ pos = module.GetPosition()
+ return (pos.x / scale, pos.y / scale)
+
+ return None
+
+ point1 = _resolve_point(params.get("item1", {}))
+ point2 = _resolve_point(params.get("item2", {}))
+ if not point1 or not point2:
+ return {
+ "success": False,
+ "message": "Unable to resolve both items",
+ "errorDetails": "Provide either positions or component references",
+ }
+
+ dx = point1[0] - point2[0]
+ dy = point1[1] - point2[1]
+ distance = (dx * dx + dy * dy) ** 0.5
+
+ return {
+ "success": True,
+ "clearance": {
+ "actual": distance,
+ "required": min_clearance,
+ "passes": distance >= min_clearance,
+ "unit": "mm",
+ },
+ }
+ except Exception as e:
+ logger.error(f"Error checking clearance: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to check clearance",
+ "errorDetails": str(e),
+ }
diff --git a/python/commands/export.py b/python/commands/export.py
index 5c74a66..698ab84 100644
--- a/python/commands/export.py
+++ b/python/commands/export.py
@@ -2,16 +2,17 @@
Export command implementations for KiCAD interface
"""
-import os
-import pcbnew
-import logging
-from typing import Dict, Any, Optional, List, Tuple
-import base64
-
-logger = logging.getLogger("kicad_interface")
+import os
+import pcbnew
+import logging
+from typing import Dict, Any, Optional, List, Tuple
+import base64
+import subprocess
+
+logger = logging.getLogger("kicad_interface")
-class ExportCommands:
+class ExportCommands:
"""Handles export-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
@@ -462,7 +463,7 @@ class ExportCommands:
"errorDetails": str(e),
}
- def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export Bill of Materials"""
try:
if not self.board:
@@ -550,11 +551,184 @@ class ExportCommands:
except Exception as e:
logger.error(f"Error exporting BOM: {str(e)}")
- return {
- "success": False,
- "message": "Failed to export BOM",
- "errorDetails": str(e),
- }
+ return {
+ "success": False,
+ "message": "Failed to export BOM",
+ "errorDetails": str(e),
+ }
+
+ def export_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Export the current project's schematic netlist using kicad-cli."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ output_path = params.get("outputPath")
+ if not output_path:
+ return {
+ "success": False,
+ "message": "Missing output path",
+ "errorDetails": "outputPath parameter is required",
+ }
+
+ board_file = self.board.GetFileName()
+ if not board_file:
+ return {
+ "success": False,
+ "message": "Board file not found",
+ "errorDetails": "Save the board before exporting a netlist",
+ }
+
+ schematic_path = os.path.splitext(board_file)[0] + ".kicad_sch"
+ if not os.path.exists(schematic_path):
+ return {
+ "success": False,
+ "message": "Schematic file not found",
+ "errorDetails": f"No schematic found at {schematic_path}",
+ }
+
+ output_path = os.path.abspath(os.path.expanduser(output_path))
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ format_map = {
+ "kicad": "kicadsexpr",
+ "spice": "spice",
+ "cadstar": "cadstar",
+ "orcadpcb2": "orcadpcb2",
+ }
+ format_name = str(params.get("format", "KiCad")).lower()
+ cli_format = format_map.get(format_name, "kicadsexpr")
+
+ kicad_cli = self._find_kicad_cli()
+ if not kicad_cli:
+ return {
+ "success": False,
+ "message": "kicad-cli not found",
+ "errorDetails": "KiCad CLI is required for netlist export",
+ }
+
+ cmd = [
+ kicad_cli,
+ "sch",
+ "export",
+ "netlist",
+ "--output",
+ output_path,
+ "--format",
+ cli_format,
+ schematic_path,
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
+ if result.returncode != 0:
+ return {
+ "success": False,
+ "message": "Netlist export failed",
+ "errorDetails": result.stderr.strip() or result.stdout.strip(),
+ }
+
+ return {
+ "success": True,
+ "message": "Exported netlist",
+ "file": {"path": output_path, "format": cli_format, "schematicPath": schematic_path},
+ }
+ except Exception as e:
+ logger.error(f"Error exporting netlist: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to export netlist",
+ "errorDetails": str(e),
+ }
+
+ def export_position_file(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Export a pick-and-place position file using kicad-cli."""
+ try:
+ if not self.board:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ output_path = params.get("outputPath")
+ if not output_path:
+ return {
+ "success": False,
+ "message": "Missing output path",
+ "errorDetails": "outputPath parameter is required",
+ }
+
+ board_file = self.board.GetFileName()
+ if not board_file or not os.path.exists(board_file):
+ return {
+ "success": False,
+ "message": "Board file not found",
+ "errorDetails": "Save the board before exporting a position file",
+ }
+
+ output_path = os.path.abspath(os.path.expanduser(output_path))
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ side_map = {"top": "front", "bottom": "back", "both": "both"}
+ format_map = {"csv": "csv", "ascii": "ascii"}
+ units_map = {"mm": "mm", "inch": "in"}
+
+ side = side_map.get(str(params.get("side", "both")).lower(), "both")
+ format_name = format_map.get(str(params.get("format", "ASCII")).lower(), "ascii")
+ units = units_map.get(str(params.get("units", "mm")).lower(), "mm")
+
+ kicad_cli = self._find_kicad_cli()
+ if not kicad_cli:
+ return {
+ "success": False,
+ "message": "kicad-cli not found",
+ "errorDetails": "KiCad CLI is required for position export",
+ }
+
+ cmd = [
+ kicad_cli,
+ "pcb",
+ "export",
+ "pos",
+ "--output",
+ output_path,
+ "--side",
+ side,
+ "--format",
+ format_name,
+ "--units",
+ units,
+ board_file,
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
+ if result.returncode != 0:
+ return {
+ "success": False,
+ "message": "Position export failed",
+ "errorDetails": result.stderr.strip() or result.stdout.strip(),
+ }
+
+ return {
+ "success": True,
+ "message": "Exported position file",
+ "file": {"path": output_path, "format": format_name, "units": units, "side": side},
+ }
+ except Exception as e:
+ logger.error(f"Error exporting position file: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to export position file",
+ "errorDetails": str(e),
+ }
+
+ def export_vrml(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Export VRML via the generic 3D export path."""
+ bridged = dict(params)
+ bridged["format"] = "VRML"
+ return self.export_3d(bridged)
def _export_bom_csv(self, path: str, components: List[Dict[str, Any]]) -> None:
"""Export BOM to CSV format"""
diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py
index f93e40a..8598250 100644
--- a/python/commands/jlcpcb.py
+++ b/python/commands/jlcpcb.py
@@ -40,12 +40,12 @@ class JLCPCBClient:
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
- self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
- self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
- self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
-
- if not self.app_id or not self.access_key or not self.secret_key:
- logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
+ self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
+ self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
+ self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
+
+ if not self.app_id or not self.access_key or not self.secret_key:
+ logger.info("JLCPCB API credentials not found. JLCPCB API features stay disabled until credentials are configured.")
@staticmethod
def _generate_nonce() -> str:
diff --git a/python/commands/library.py b/python/commands/library.py
index d56a791..8209fd0 100644
--- a/python/commands/library.py
+++ b/python/commands/library.py
@@ -40,10 +40,10 @@ class LibraryManager:
# Load global libraries
global_table = self._get_global_fp_lib_table()
if global_table and global_table.exists():
- logger.info(f"Loading global fp-lib-table from: {global_table}")
- self._parse_fp_lib_table(global_table)
- else:
- logger.warning(f"Global fp-lib-table not found at: {global_table}")
+ logger.info(f"Loading global fp-lib-table from: {global_table}")
+ self._parse_fp_lib_table(global_table)
+ else:
+ logger.info(f"Global fp-lib-table not found at: {global_table}")
# Load project-specific libraries if project path provided
if self.project_path:
@@ -312,8 +312,8 @@ class LibraryManager:
logger.info(f"Found KiCad 3rd party directory: {candidate}")
return str(candidate)
- logger.warning("Could not find KiCad 3rd party directory")
- return None
+ logger.info("Could not find KiCad 3rd party directory")
+ return None
def list_libraries(self) -> List[str]:
"""Get list of available library nicknames"""
diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py
index 1c14bb5..47fed8d 100644
--- a/python/commands/library_symbol.py
+++ b/python/commands/library_symbol.py
@@ -62,7 +62,7 @@ class SymbolLibraryManager:
logger.info(f"Loading global sym-lib-table from: {global_table}")
self._parse_sym_lib_table(global_table)
else:
- logger.warning(f"Global sym-lib-table not found at: {global_table}")
+ logger.info(f"Global sym-lib-table not found at: {global_table}")
# Load project-specific libraries if project path provided
if self.project_path:
diff --git a/python/commands/project.py b/python/commands/project.py
index 46872cf..82edf4b 100644
--- a/python/commands/project.py
+++ b/python/commands/project.py
@@ -2,21 +2,42 @@
Project-related command implementations for KiCAD interface
"""
-import os
-import pcbnew # type: ignore
-import logging
-import shutil
-from typing import Dict, Any, Optional
+import os
+import pcbnew # type: ignore
+import logging
+import shutil
+from typing import Dict, Any, Optional
+from datetime import datetime
logger = logging.getLogger("kicad_interface")
-class ProjectCommands:
+class ProjectCommands:
"""Handles project-related KiCAD operations"""
- def __init__(self, board: Optional[pcbnew.BOARD] = None):
- """Initialize with optional board instance"""
- self.board = board
+ def __init__(self, board: Optional[pcbnew.BOARD] = None):
+ """Initialize with optional board instance"""
+ self.board = board
+
+ def _get_project_context(self) -> Optional[Dict[str, str]]:
+ if not self.board:
+ return None
+
+ board_path = self.board.GetFileName()
+ if not board_path:
+ return None
+
+ board_path = os.path.abspath(os.path.expanduser(board_path))
+ project_root, board_name = os.path.split(board_path)
+ project_stem, _ = os.path.splitext(board_name)
+
+ return {
+ "board_path": board_path,
+ "project_root": project_root,
+ "project_name": project_stem,
+ "project_file": os.path.join(project_root, f"{project_stem}.kicad_pro"),
+ "schematic_file": os.path.join(project_root, f"{project_stem}.kicad_sch"),
+ }
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new KiCAD project"""
@@ -222,10 +243,10 @@ class ProjectCommands:
"errorDetails": str(e),
}
- def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
- """Get information about the current project"""
- try:
- if not self.board:
+ def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Get information about the current project"""
+ try:
+ if not self.board:
return {
"success": False,
"message": "No board is loaded",
@@ -253,8 +274,168 @@ class ProjectCommands:
except Exception as e:
logger.error(f"Error getting project info: {str(e)}")
- return {
- "success": False,
- "message": "Failed to get project information",
- "errorDetails": str(e),
- }
+ return {
+ "success": False,
+ "message": "Failed to get project information",
+ "errorDetails": str(e),
+ }
+
+ def get_project_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Get resolved project file paths and title block metadata."""
+ try:
+ context = self._get_project_context()
+ if not context:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ title_block = self.board.GetTitleBlock()
+ project_file = context["project_file"]
+ board_path = context["board_path"]
+ schematic_file = context["schematic_file"]
+
+ return {
+ "success": True,
+ "properties": {
+ "projectName": context["project_name"],
+ "projectRoot": context["project_root"],
+ "projectFile": project_file,
+ "boardFile": board_path,
+ "schematicFile": schematic_file,
+ "exists": {
+ "projectFile": os.path.exists(project_file),
+ "boardFile": os.path.exists(board_path),
+ "schematicFile": os.path.exists(schematic_file),
+ },
+ "titleBlock": {
+ "title": title_block.GetTitle(),
+ "date": title_block.GetDate(),
+ "revision": title_block.GetRevision(),
+ "company": title_block.GetCompany(),
+ "comment1": title_block.GetComment(0),
+ "comment2": title_block.GetComment(1),
+ "comment3": title_block.GetComment(2),
+ "comment4": title_block.GetComment(3),
+ },
+ },
+ }
+ except Exception as e:
+ logger.error(f"Error getting project properties: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get project properties",
+ "errorDetails": str(e),
+ }
+
+ def get_project_files(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """List the files that make up the current KiCad project directory."""
+ try:
+ context = self._get_project_context()
+ if not context:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ project_root = context["project_root"]
+ project_name = context["project_name"]
+ files = []
+
+ for entry in sorted(os.listdir(project_root)):
+ path = os.path.join(project_root, entry)
+ if not os.path.isfile(path):
+ continue
+
+ if not (
+ entry.startswith(project_name)
+ or entry in {"fp-lib-table", "sym-lib-table"}
+ or entry.endswith(
+ (
+ ".kicad_pro",
+ ".kicad_prl",
+ ".kicad_pcb",
+ ".kicad_sch",
+ ".dru",
+ ".json",
+ ".csv",
+ ".txt",
+ )
+ )
+ ):
+ continue
+
+ ext = os.path.splitext(entry)[1].lower()
+ file_type = {
+ ".kicad_pro": "project",
+ ".kicad_prl": "local-settings",
+ ".kicad_pcb": "board",
+ ".kicad_sch": "schematic",
+ ".kicad_dru": "design-rules",
+ ".csv": "report",
+ ".json": "report",
+ ".txt": "report",
+ }.get(ext, "auxiliary")
+
+ stat = os.stat(path)
+ files.append(
+ {
+ "name": entry,
+ "path": path,
+ "type": file_type,
+ "sizeBytes": stat.st_size,
+ "modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(),
+ }
+ )
+
+ return {
+ "success": True,
+ "projectRoot": project_root,
+ "files": files,
+ "count": len(files),
+ }
+ except Exception as e:
+ logger.error(f"Error getting project files: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get project files",
+ "errorDetails": str(e),
+ }
+
+ def get_project_status(self, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Get the runtime status of the current project."""
+ try:
+ context = self._get_project_context()
+ if not context:
+ return {
+ "success": False,
+ "message": "No board is loaded",
+ "errorDetails": "Load or create a board first",
+ }
+
+ files_result = self.get_project_files({})
+ board_box = self.board.GetBoardEdgesBoundingBox()
+
+ return {
+ "success": True,
+ "status": {
+ "projectName": context["project_name"],
+ "projectRoot": context["project_root"],
+ "boardLoaded": self.board is not None,
+ "boardSaved": bool(context["board_path"]) and os.path.exists(context["board_path"]),
+ "projectFileExists": os.path.exists(context["project_file"]),
+ "schematicFileExists": os.path.exists(context["schematic_file"]),
+ "boardOutlinePresent": board_box.GetWidth() > 0 and board_box.GetHeight() > 0,
+ "componentCount": len(list(self.board.GetFootprints())),
+ "fileCount": files_result.get("count", 0),
+ },
+ }
+ except Exception as e:
+ logger.error(f"Error getting project status: {str(e)}")
+ return {
+ "success": False,
+ "message": "Failed to get project status",
+ "errorDetails": str(e),
+ }
diff --git a/python/commands/routing.py b/python/commands/routing.py
index b84f4c1..ee6b911 100644
--- a/python/commands/routing.py
+++ b/python/commands/routing.py
@@ -1006,15 +1006,14 @@ class RoutingCommands:
"errorDetails": "name parameter is required",
}
- # Get net classes
- net_classes = self.board.GetNetClasses()
-
- # Create new net class if it doesn't exist
- if not net_classes.Find(name):
- netclass = pcbnew.NETCLASS(name)
- net_classes.Add(netclass)
- else:
- netclass = net_classes.Find(name)
+ # KiCad 10 exposes netclasses_map with lowercase helpers and item assignment.
+ net_classes = self.board.GetNetClasses()
+
+ if net_classes.has_key(name):
+ netclass = net_classes[name]
+ else:
+ netclass = pcbnew.NETCLASS(name)
+ net_classes[name] = netclass
# Set properties
scale = 1000000 # mm to nm
@@ -1022,18 +1021,18 @@ class RoutingCommands:
netclass.SetClearance(int(clearance * scale))
if track_width is not None:
netclass.SetTrackWidth(int(track_width * scale))
- if via_diameter is not None:
- netclass.SetViaDiameter(int(via_diameter * scale))
- if via_drill is not None:
- netclass.SetViaDrill(int(via_drill * scale))
- if uvia_diameter is not None:
- netclass.SetMicroViaDiameter(int(uvia_diameter * scale))
- if uvia_drill is not None:
- netclass.SetMicroViaDrill(int(uvia_drill * scale))
- if diff_pair_width is not None:
- netclass.SetDiffPairWidth(int(diff_pair_width * scale))
- if diff_pair_gap is not None:
- netclass.SetDiffPairGap(int(diff_pair_gap * scale))
+ if via_diameter is not None:
+ netclass.SetViaDiameter(int(via_diameter * scale))
+ if via_drill is not None:
+ netclass.SetViaDrill(int(via_drill * scale))
+ if uvia_diameter is not None:
+ netclass.SetuViaDiameter(int(uvia_diameter * scale))
+ if uvia_drill is not None:
+ netclass.SetuViaDrill(int(uvia_drill * scale))
+ if diff_pair_width is not None:
+ netclass.SetDiffPairWidth(int(diff_pair_width * scale))
+ if diff_pair_gap is not None:
+ netclass.SetDiffPairGap(int(diff_pair_gap * scale))
# Add nets to net class
netinfo = self.board.GetNetInfo()
@@ -1047,17 +1046,17 @@ class RoutingCommands:
"success": True,
"message": f"Created net class: {name}",
"netClass": {
- "name": name,
- "clearance": netclass.GetClearance() / scale,
- "trackWidth": netclass.GetTrackWidth() / scale,
- "viaDiameter": netclass.GetViaDiameter() / scale,
- "viaDrill": netclass.GetViaDrill() / scale,
- "uviaDiameter": netclass.GetMicroViaDiameter() / scale,
- "uviaDrill": netclass.GetMicroViaDrill() / scale,
- "diffPairWidth": netclass.GetDiffPairWidth() / scale,
- "diffPairGap": netclass.GetDiffPairGap() / scale,
- "nets": nets,
- },
+ "name": name,
+ "clearance": netclass.GetClearance() / scale,
+ "trackWidth": netclass.GetTrackWidth() / scale,
+ "viaDiameter": netclass.GetViaDiameter() / scale,
+ "viaDrill": netclass.GetViaDrill() / scale,
+ "uviaDiameter": netclass.GetuViaDiameter() / scale,
+ "uviaDrill": netclass.GetuViaDrill() / scale,
+ "diffPairWidth": netclass.GetDiffPairWidth() / scale,
+ "diffPairGap": netclass.GetDiffPairGap() / scale,
+ "nets": nets,
+ },
}
except Exception as e:
diff --git a/python/kicad_interface.py b/python/kicad_interface.py
index 891a6d1..52a788e 100644
--- a/python/kicad_interface.py
+++ b/python/kicad_interface.py
@@ -12,8 +12,17 @@ import json
import traceback
import logging
import os
+import base64
+import io
+import re
+import subprocess
+import tempfile
+from dataclasses import asdict
+from pathlib import Path
from typing import Dict, Any, Optional
+from PIL import Image, ImageDraw
+
# Import tool schemas and resource definitions
from schemas.tool_schemas import TOOL_SCHEMAS
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -23,11 +32,33 @@ log_dir = os.path.join(os.path.expanduser("~"), ".kicad-mcp", "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "kicad_interface.log")
-logging.basicConfig(
- level=logging.DEBUG,
- format="%(asctime)s [%(levelname)s] %(message)s",
- handlers=[logging.FileHandler(log_file), logging.StreamHandler(sys.stderr)],
+def _resolve_log_level(env_name: str, default: str) -> int:
+ value = os.environ.get(env_name, default).upper()
+ return getattr(logging, value, getattr(logging, default.upper(), logging.INFO))
+
+
+root_logger = logging.getLogger()
+root_logger.handlers.clear()
+root_logger.setLevel(logging.DEBUG)
+
+formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
+
+file_handler = logging.FileHandler(log_file)
+file_handler.setLevel(_resolve_log_level("KICAD_PYTHON_FILE_LOG_LEVEL", "INFO"))
+file_handler.setFormatter(formatter)
+
+stderr_handler = logging.StreamHandler(sys.stderr)
+stderr_handler.setLevel(
+ _resolve_log_level(
+ "KICAD_PYTHON_STDERR_LOG_LEVEL",
+ os.environ.get("KICAD_PYTHON_LOG_LEVEL", "WARNING"),
+ )
)
+stderr_handler.setFormatter(formatter)
+
+root_logger.addHandler(file_handler)
+root_logger.addHandler(stderr_handler)
+
logger = logging.getLogger("kicad_interface")
# Log Python environment details
@@ -106,7 +137,7 @@ if AUTO_LAUNCH_KICAD:
# Check which backend to use
# KICAD_BACKEND can be: 'auto', 'ipc', or 'swig'
KICAD_BACKEND = os.environ.get("KICAD_BACKEND", "auto").lower()
-logger.info(f"KiCAD backend preference: {KICAD_BACKEND}")
+logger.debug(f"KiCAD backend preference: {KICAD_BACKEND}")
# Try to use IPC backend first if available and preferred
USE_IPC_BACKEND = False
@@ -129,7 +160,7 @@ if KICAD_BACKEND in ("auto", "ipc"):
except ImportError:
logger.info("IPC backend not available (kicad-python not installed)")
except Exception as e:
- logger.info(f"IPC backend connection failed: {e}")
+ logger.debug(f"IPC backend connection failed: {e}")
ipc_backend = None
# Fall back to SWIG backend if IPC not available
@@ -141,7 +172,7 @@ if not USE_IPC_BACKEND and KICAD_BACKEND != "ipc":
logger.info(f"Successfully imported pcbnew module from: {pcbnew.__file__}")
logger.info(f"pcbnew version: {pcbnew.GetBuildVersion()}")
- logger.warning("Using SWIG backend - changes require manual reload in KiCAD UI")
+ logger.info("Using SWIG backend - changes require manual reload in KiCAD UI")
except ImportError as e:
logger.error(f"Failed to import pcbnew module: {e}")
logger.error(f"Current sys.path: {sys.path}")
@@ -295,6 +326,9 @@ class KiCADInterface:
"open_project": self.project_commands.open_project,
"save_project": self.project_commands.save_project,
"get_project_info": self.project_commands.get_project_info,
+ "get_project_properties": self._handle_get_project_properties,
+ "get_project_files": self._handle_get_project_files,
+ "get_project_status": self._handle_get_project_status,
# Board commands
"set_board_size": self.board_commands.set_board_size,
"add_layer": self.board_commands.add_layer,
@@ -302,6 +336,7 @@ class KiCADInterface:
"get_board_info": self.board_commands.get_board_info,
"get_layer_list": self.board_commands.get_layer_list,
"get_board_2d_view": self.board_commands.get_board_2d_view,
+ "get_board_3d_view": self._handle_get_board_3d_view,
"get_board_extents": self.board_commands.get_board_extents,
"add_board_outline": self.board_commands.add_board_outline,
"add_mounting_hole": self.board_commands.add_mounting_hole,
@@ -314,9 +349,16 @@ class KiCADInterface:
"rotate_component": self.component_commands.rotate_component,
"delete_component": self.component_commands.delete_component,
"edit_component": self.component_commands.edit_component,
+ "add_component_annotation": self.component_commands.add_component_annotation,
"get_component_properties": self.component_commands.get_component_properties,
"get_component_list": self.component_commands.get_component_list,
+ "get_component_connections": self.component_commands.get_component_connections,
+ "get_component_placement": self.component_commands.get_component_placement,
+ "get_component_groups": self.component_commands.get_component_groups,
+ "get_component_visualization": self.component_commands.get_component_visualization,
"find_component": self.component_commands.find_component,
+ "group_components": self.component_commands.group_components,
+ "replace_component": self.component_commands.replace_component,
"get_component_pads": self.component_commands.get_component_pads,
"get_pad_position": self.component_commands.get_pad_position,
"place_component_array": self.component_commands.place_component_array,
@@ -333,12 +375,17 @@ class KiCADInterface:
"get_nets_list": self.routing_commands.get_nets_list,
"create_netclass": self.routing_commands.create_netclass,
"add_copper_pour": self.routing_commands.add_copper_pour,
+ "add_zone": self.routing_commands.add_copper_pour,
"route_differential_pair": self.routing_commands.route_differential_pair,
"refill_zones": self._handle_refill_zones,
# Design rule commands
"set_design_rules": self.design_rule_commands.set_design_rules,
"get_design_rules": self.design_rule_commands.get_design_rules,
"run_drc": self.design_rule_commands.run_drc,
+ "add_net_class": self._handle_add_net_class,
+ "assign_net_to_class": self.design_rule_commands.assign_net_to_class,
+ "set_layer_constraints": self.design_rule_commands.set_layer_constraints,
+ "check_clearance": self.design_rule_commands.check_clearance,
"get_drc_violations": self.design_rule_commands.get_drc_violations,
# Export commands
"export_gerber": self.export_commands.export_gerber,
@@ -346,11 +393,20 @@ class KiCADInterface:
"export_svg": self.export_commands.export_svg,
"export_3d": self.export_commands.export_3d,
"export_bom": self.export_commands.export_bom,
+ "export_netlist": self.export_commands.export_netlist,
+ "export_position_file": self.export_commands.export_position_file,
+ "export_vrml": self.export_commands.export_vrml,
# Library commands (footprint management)
"list_libraries": self.library_commands.list_libraries,
"search_footprints": self.library_commands.search_footprints,
"list_library_footprints": self.library_commands.list_library_footprints,
"get_footprint_info": self.library_commands.get_footprint_info,
+ "get_component_library": self._handle_get_component_library,
+ "get_library_list": self._handle_get_library_list,
+ "get_component_details": self._handle_get_component_details,
+ "get_component_footprint": self._handle_get_component_footprint,
+ "get_component_symbol": self._handle_get_component_symbol,
+ "get_component_3d_model": self._handle_get_component_3d_model,
# Symbol library commands (local KiCad symbol library search)
"list_symbol_libraries": self.symbol_library_commands.list_symbol_libraries,
"search_symbols": self.symbol_library_commands.search_symbols,
@@ -371,6 +427,7 @@ class KiCADInterface:
"add_schematic_component": self._handle_add_schematic_component,
"delete_schematic_component": self._handle_delete_schematic_component,
"edit_schematic_component": self._handle_edit_schematic_component,
+ "add_wire": self._handle_add_wire,
"add_schematic_wire": self._handle_add_schematic_wire,
"add_schematic_connection": self._handle_add_schematic_connection,
"add_schematic_net_label": self._handle_add_schematic_net_label,
@@ -485,10 +542,22 @@ class KiCADInterface:
# Update board reference if command was successful
if result.get("success", False):
- if command == "create_project" or command == "open_project":
+ if (
+ command == "create_project"
+ or command == "open_project"
+ or command == "save_project"
+ ):
logger.info("Updating board reference...")
# Get board from the project commands handler
self.board = self.project_commands.board
+ project_info = result.get("project", {})
+ self._set_project_context_from_paths(
+ project_info.get("boardPath")
+ or project_info.get("path")
+ or self.board.GetFileName()
+ if self.board
+ else None
+ )
self._update_command_handlers()
return result
@@ -520,6 +589,923 @@ class KiCADInterface:
self.design_rule_commands.board = self.board
self.export_commands.board = self.board
+ def _set_project_context_from_paths(self, path_value):
+ """Refresh project-local library managers from a project or board path."""
+ if not path_value:
+ return
+
+ candidate = Path(os.path.abspath(os.path.expanduser(str(path_value))))
+ if candidate.suffix in {".kicad_pcb", ".kicad_pro", ".kicad_sch"}:
+ project_path = candidate.parent
+ else:
+ project_path = candidate if candidate.is_dir() else candidate.parent
+
+ if project_path == getattr(self, "_current_project_path", None):
+ return
+
+ self._current_project_path = project_path
+ self.footprint_library = FootprintLibraryManager(project_path=project_path)
+ self.component_commands.library_manager = self.footprint_library
+ self.library_commands.library_manager = self.footprint_library
+ self.symbol_library_commands.library_manager = SymbolLibraryManager(
+ project_path=project_path
+ )
+ logger.info(f"Updated project-local library context: {project_path}")
+
+ def _get_project_root(self) -> Optional[Path]:
+ """Resolve the current project directory from tracked state."""
+ if getattr(self, "_current_project_path", None):
+ return self._current_project_path
+
+ if self.board and self.board.GetFileName():
+ board_path = Path(self.board.GetFileName())
+ if board_path.parent:
+ return board_path.parent
+
+ return None
+
+ def _get_board_file_path(self) -> Optional[Path]:
+ """Return the current board file path if one is available."""
+ if not self.board:
+ return None
+
+ filename = self.board.GetFileName()
+ if not filename:
+ return None
+
+ return Path(filename)
+
+ def _get_project_basename(self) -> Optional[str]:
+ """Return the inferred KiCad project basename."""
+ board_path = self._get_board_file_path()
+ if board_path:
+ return board_path.stem
+
+ project_root = self._get_project_root()
+ if project_root:
+ return project_root.name
+
+ return None
+
+ def _get_symbol_manager(self):
+ return self.symbol_library_commands.library_manager
+
+ def _get_footprint_manager(self):
+ return self.library_commands.library_manager
+
+ def _resolve_symbol_info(
+ self, component_id: Optional[str], library: Optional[str] = None
+ ):
+ """Resolve a symbol from explicit library/id hints."""
+ if not component_id:
+ return None
+
+ symbol_manager = self._get_symbol_manager()
+ symbol_name = component_id
+
+ if ":" in component_id and not library:
+ return symbol_manager.find_symbol(component_id)
+
+ if ":" in component_id:
+ _, symbol_name = component_id.split(":", 1)
+
+ if library:
+ return symbol_manager.get_symbol_info(library, symbol_name)
+
+ symbol = symbol_manager.find_symbol(symbol_name)
+ if symbol:
+ return symbol
+
+ matches = symbol_manager.search_symbols(symbol_name, limit=1)
+ return matches[0] if matches else None
+
+ def _load_footprint_object(self, footprint_spec: Optional[str]):
+ """Load a footprint object from a library spec."""
+ if not footprint_spec:
+ return None
+
+ footprint_manager = self._get_footprint_manager()
+ located = footprint_manager.find_footprint(footprint_spec)
+ if not located:
+ return None
+
+ library_path, footprint_name = located
+ footprint = pcbnew.FootprintLoad(library_path, footprint_name)
+ if not footprint:
+ return None
+
+ library_name = None
+ for nickname, path in footprint_manager.libraries.items():
+ if path == library_path:
+ library_name = nickname
+ break
+
+ return {
+ "footprint": footprint,
+ "library_path": library_path,
+ "library_name": library_name,
+ "footprint_name": footprint_name,
+ "full_name": (
+ f"{library_name}:{footprint_name}"
+ if library_name
+ else footprint_name
+ ),
+ }
+
+ def _resolve_model_path(self, raw_path: str, library_path: Optional[str] = None) -> str:
+ """Best-effort resolution of KiCad 3D model paths with env vars."""
+ if not raw_path:
+ return ""
+
+ expanded = os.path.expandvars(raw_path)
+ if os.path.isabs(expanded):
+ return expanded
+
+ if library_path:
+ candidate = os.path.join(library_path, expanded)
+ if os.path.exists(candidate):
+ return candidate
+
+ project_root = self._get_project_root()
+ if project_root:
+ candidate = str(project_root / expanded)
+ if os.path.exists(candidate):
+ return candidate
+
+ return expanded
+
+ def _get_current_schematic_path(self) -> Optional[Path]:
+ """Infer the current schematic path from the loaded project context."""
+ board_path = self._get_board_file_path()
+ if board_path:
+ candidate = board_path.with_suffix(".kicad_sch")
+ if candidate.exists():
+ return candidate
+
+ project_root = self._get_project_root()
+ if project_root:
+ for candidate in sorted(project_root.glob("*.kicad_sch")):
+ return candidate
+
+ return None
+
+ def _handle_add_net_class(self, params):
+ """Bridge legacy TypeScript field names to the routing netclass command."""
+ bridged = dict(params)
+ rename_map = {
+ "uvia_diameter": "uviaDiameter",
+ "uvia_drill": "uviaDrill",
+ "diff_pair_width": "diffPairWidth",
+ "diff_pair_gap": "diffPairGap",
+ }
+ for source, target in rename_map.items():
+ if source in bridged and target not in bridged:
+ bridged[target] = bridged.pop(source)
+ return self.routing_commands.create_netclass(bridged)
+
+ def _handle_add_wire(self, params):
+ """Bridge the generic add_wire tool to the schematic wire backend."""
+ start = params.get("startPoint") or params.get("start")
+ end = params.get("endPoint") or params.get("end")
+ schematic_path = params.get("schematicPath")
+ if not schematic_path:
+ resolved = self._get_current_schematic_path()
+ schematic_path = str(resolved) if resolved else None
+
+ def _normalize_point(point):
+ if isinstance(point, dict):
+ return [point.get("x", 0), point.get("y", 0)]
+ return point
+
+ return self._handle_add_schematic_wire(
+ {
+ "schematicPath": schematic_path,
+ "startPoint": _normalize_point(start),
+ "endPoint": _normalize_point(end),
+ "properties": params.get("properties", {}),
+ }
+ )
+
+ def _handle_get_project_properties(self, params):
+ """Return normalized project properties for the current board."""
+ project_result = self.project_commands.get_project_info({})
+ if not project_result.get("success"):
+ return project_result
+
+ project = project_result.get("project", {})
+ properties = {
+ "title": project.get("title", ""),
+ "date": project.get("date", ""),
+ "revision": project.get("revision", ""),
+ "company": project.get("company", ""),
+ "comments": [
+ project.get("comment1", ""),
+ project.get("comment2", ""),
+ project.get("comment3", ""),
+ project.get("comment4", ""),
+ ],
+ }
+
+ return {"success": True, "project": project, "properties": properties}
+
+ def _handle_get_project_files(self, params):
+ """List the primary files associated with the current project."""
+ project_root = self._get_project_root()
+ if not project_root:
+ return {
+ "success": False,
+ "message": "No project is loaded",
+ "errorDetails": "Load or create a project first",
+ }
+
+ project_name = self._get_project_basename() or project_root.name
+ candidates = [
+ project_root / f"{project_name}.kicad_pro",
+ project_root / f"{project_name}.kicad_pcb",
+ project_root / f"{project_name}.kicad_sch",
+ project_root / f"{project_name}.kicad_prl",
+ project_root / "fp-lib-table",
+ project_root / "sym-lib-table",
+ ]
+
+ files = []
+ seen_paths = set()
+ for candidate in candidates:
+ candidate_str = str(candidate)
+ if candidate_str in seen_paths or not candidate.exists():
+ continue
+ seen_paths.add(candidate_str)
+ stat = candidate.stat()
+ files.append(
+ {
+ "name": candidate.name,
+ "path": candidate_str,
+ "type": candidate.suffix or candidate.name,
+ "sizeBytes": stat.st_size,
+ "modifiedAt": stat.st_mtime,
+ }
+ )
+
+ return {
+ "success": True,
+ "projectRoot": str(project_root),
+ "projectName": project_name,
+ "files": files,
+ "count": len(files),
+ }
+
+ def _handle_get_project_status(self, params):
+ """Return a concise status snapshot for the current project."""
+ project_root = self._get_project_root()
+ board_path = self._get_board_file_path()
+ project_files = self._handle_get_project_files({})
+ components = self.component_commands.get_component_list({})
+ nets = self.routing_commands.get_nets_list({})
+
+ project_file_types = {
+ Path(entry["path"]).suffix or Path(entry["path"]).name
+ for entry in project_files.get("files", [])
+ }
+
+ return {
+ "success": True,
+ "status": {
+ "boardLoaded": self.board is not None,
+ "projectRoot": str(project_root) if project_root else None,
+ "boardPath": str(board_path) if board_path else None,
+ "hasProjectFile": ".kicad_pro" in project_file_types,
+ "hasBoardFile": ".kicad_pcb" in project_file_types,
+ "hasSchematicFile": ".kicad_sch" in project_file_types,
+ "componentCount": len(components.get("components", []))
+ if components.get("success")
+ else 0,
+ "netCount": len(nets.get("nets", []))
+ if nets.get("success")
+ else 0,
+ "libraryTables": sorted(
+ entry["name"]
+ for entry in project_files.get("files", [])
+ if entry["name"] in {"fp-lib-table", "sym-lib-table"}
+ ),
+ },
+ }
+
+ def _handle_get_board_3d_view(self, params):
+ """Render a board 3D preview using kicad-cli."""
+ board_path = self._get_board_file_path()
+ if not self.board or not board_path or not board_path.exists():
+ return {
+ "success": False,
+ "message": "No saved board is loaded",
+ "errorDetails": "Load or create a project and save the board first",
+ }
+
+ kicad_cli = self.export_commands._find_kicad_cli()
+ if not kicad_cli:
+ return {
+ "success": False,
+ "message": "kicad-cli not found",
+ "errorDetails": "KiCad CLI is required to render board previews",
+ }
+
+ angle = str(params.get("angle", "isometric")).lower()
+ width = int(params.get("width", 1600))
+ height = int(params.get("height", 900))
+ image_format = str(params.get("format", "png")).lower()
+ image_format = "jpg" if image_format == "jpeg" else image_format
+ suffix = ".jpg" if image_format == "jpg" else ".png"
+
+ render_args = []
+ if angle == "isometric":
+ render_args.extend(["--side", "top", "--rotate", "-45,0,45"])
+ elif angle in {"top", "bottom", "left", "right", "front", "back"}:
+ render_args.extend(["--side", angle])
+ else:
+ return {
+ "success": False,
+ "message": "Unsupported angle",
+ "errorDetails": f"Unsupported 3D render angle: {angle}",
+ }
+
+ data_dir = os.environ.get("KICAD_MCP_DATA_DIR")
+ temp_dir = data_dir if data_dir and os.path.isdir(data_dir) else None
+ with tempfile.NamedTemporaryFile(
+ suffix=suffix,
+ delete=False,
+ dir=temp_dir,
+ ) as tmp_file:
+ output_path = tmp_file.name
+
+ cmd = [
+ kicad_cli,
+ "pcb",
+ "render",
+ "--output",
+ output_path,
+ "--width",
+ str(width),
+ "--height",
+ str(height),
+ "--quality",
+ "basic",
+ "--background",
+ "transparent" if image_format == "png" else "opaque",
+ *render_args,
+ str(board_path),
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=180,
+ )
+ if result.returncode != 0 or not os.path.exists(output_path):
+ return {
+ "success": False,
+ "message": "3D render failed",
+ "errorDetails": result.stderr or "render output was not created",
+ }
+
+ with open(output_path, "rb") as image_file:
+ encoded = base64.b64encode(image_file.read()).decode("ascii")
+
+ return {
+ "success": True,
+ "imageData": encoded,
+ "format": image_format,
+ "angle": angle,
+ "size": {"width": width, "height": height},
+ }
+ finally:
+ try:
+ os.unlink(output_path)
+ except OSError:
+ pass
+
+ def _handle_get_component_connections(self, params):
+ """Return the net connections for a placed component."""
+ reference = params.get("reference")
+ pads_result = self.component_commands.get_component_pads({"reference": reference})
+ if not pads_result.get("success"):
+ return pads_result
+
+ connections = []
+ for pad in pads_result.get("pads", []):
+ if pad.get("net"):
+ connections.append(
+ {
+ "pad": pad.get("number") or pad.get("name"),
+ "net": pad.get("net"),
+ "position": pad.get("position"),
+ }
+ )
+
+ return {
+ "success": True,
+ "reference": reference,
+ "connections": connections,
+ "count": len(connections),
+ }
+
+ def _handle_get_component_placement(self, params):
+ """Return placement information for all placed components."""
+ components_result = self.component_commands.get_component_list({})
+ if not components_result.get("success"):
+ return components_result
+
+ by_layer = {}
+ for component in components_result.get("components", []):
+ by_layer[component.get("layer", "unknown")] = (
+ by_layer.get(component.get("layer", "unknown"), 0) + 1
+ )
+
+ return {
+ "success": True,
+ "components": components_result.get("components", []),
+ "count": len(components_result.get("components", [])),
+ "byLayer": by_layer,
+ }
+
+ def _handle_get_component_groups(self, params):
+ """Group components by reference prefix."""
+ components_result = self.component_commands.get_component_list({})
+ if not components_result.get("success"):
+ return components_result
+
+ grouped = {}
+ for component in components_result.get("components", []):
+ reference = component.get("reference", "")
+ match = re.match(r"[A-Za-z]+", reference)
+ key = match.group(0) if match else "Other"
+ grouped.setdefault(
+ key,
+ {
+ "group": key,
+ "count": 0,
+ "references": [],
+ "values": set(),
+ },
+ )
+ grouped[key]["count"] += 1
+ grouped[key]["references"].append(reference)
+ value = component.get("value")
+ if value:
+ grouped[key]["values"].add(value)
+
+ groups = []
+ for key in sorted(grouped):
+ group = grouped[key]
+ groups.append(
+ {
+ "group": group["group"],
+ "count": group["count"],
+ "references": sorted(group["references"]),
+ "values": sorted(group["values"]),
+ }
+ )
+
+ return {"success": True, "groups": groups, "count": len(groups)}
+
+ def _handle_get_component_visualization(self, params):
+ """Generate a simple footprint-centric PNG preview for a placed component."""
+ 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",
+ }
+
+ module = self.board.FindFootprintByReference(reference)
+ if not module:
+ return {
+ "success": False,
+ "message": "Component not found",
+ "errorDetails": f"Could not find component: {reference}",
+ }
+
+ pads = list(module.Pads())
+ if not pads:
+ return {
+ "success": False,
+ "message": "Component has no pads",
+ "errorDetails": f"Component {reference} has no drawable pads",
+ }
+
+ module_pos = module.GetPosition()
+ pad_entries = []
+ min_x = min_y = float("inf")
+ max_x = max_y = float("-inf")
+
+ for pad in pads:
+ pad_pos = pad.GetPosition()
+ pad_size = pad.GetSize()
+ x_mm = (pad_pos.x - module_pos.x) / 1000000.0
+ y_mm = (pad_pos.y - module_pos.y) / 1000000.0
+ w_mm = pad_size.x / 1000000.0
+ h_mm = pad_size.y / 1000000.0
+
+ min_x = min(min_x, x_mm - w_mm / 2.0)
+ max_x = max(max_x, x_mm + w_mm / 2.0)
+ min_y = min(min_y, y_mm - h_mm / 2.0)
+ max_y = max(max_y, y_mm + h_mm / 2.0)
+
+ pad_entries.append(
+ {
+ "x": x_mm,
+ "y": y_mm,
+ "w": w_mm,
+ "h": h_mm,
+ "shape": pad.GetShape(),
+ "label": pad.GetNumber() or pad.GetName(),
+ }
+ )
+
+ width_px = 420
+ height_px = 420
+ padding_px = 40
+ span_x = max(max_x - min_x, 1.0)
+ span_y = max(max_y - min_y, 1.0)
+ scale = min(
+ (width_px - 2 * padding_px) / span_x,
+ (height_px - 2 * padding_px) / span_y,
+ )
+
+ image = Image.new("RGBA", (width_px, height_px), (250, 252, 255, 255))
+ draw = ImageDraw.Draw(image)
+ draw.rounded_rectangle(
+ [(10, 10), (width_px - 10, height_px - 10)],
+ radius=18,
+ outline=(30, 41, 59, 255),
+ width=2,
+ fill=(255, 255, 255, 255),
+ )
+
+ body_left = padding_px
+ body_top = padding_px
+ body_right = width_px - padding_px
+ body_bottom = height_px - padding_px
+ draw.rounded_rectangle(
+ [(body_left, body_top), (body_right, body_bottom)],
+ radius=20,
+ outline=(99, 102, 241, 255),
+ width=3,
+ fill=(238, 242, 255, 255),
+ )
+
+ def to_canvas(x_mm, y_mm):
+ x_px = padding_px + (x_mm - min_x) * scale
+ y_px = height_px - padding_px - (y_mm - min_y) * scale
+ return x_px, y_px
+
+ for pad in pad_entries:
+ x_px, y_px = to_canvas(pad["x"], pad["y"])
+ half_w = (pad["w"] * scale) / 2.0
+ half_h = (pad["h"] * scale) / 2.0
+ box = [
+ x_px - half_w,
+ y_px - half_h,
+ x_px + half_w,
+ y_px + half_h,
+ ]
+
+ if pad["shape"] in {
+ pcbnew.PAD_SHAPE_CIRCLE,
+ pcbnew.PAD_SHAPE_OVAL,
+ pcbnew.PAD_SHAPE_ROUNDRECT,
+ }:
+ draw.ellipse(box, fill=(15, 118, 110, 255), outline=(15, 23, 42, 255))
+ else:
+ draw.rounded_rectangle(
+ box,
+ radius=6,
+ fill=(15, 118, 110, 255),
+ outline=(15, 23, 42, 255),
+ )
+ draw.text((box[0], box[1] - 14), str(pad["label"]), fill=(15, 23, 42, 255))
+
+ draw.text((20, 18), f"{reference} {module.GetValue()}", fill=(15, 23, 42, 255))
+
+ buffer = io.BytesIO()
+ image.save(buffer, format="PNG")
+ return {
+ "success": True,
+ "reference": reference,
+ "format": "png",
+ "imageData": base64.b64encode(buffer.getvalue()).decode("ascii"),
+ }
+
+ def _handle_get_component_library(self, params):
+ """Return normalized symbol and footprint matches for a component query."""
+ filter_value = str(params.get("filter", "") or "").strip()
+ library_filter = str(params.get("library", "") or "").strip() or None
+ limit = int(params.get("limit", 25) or 25)
+ symbol_manager = self._get_symbol_manager()
+ footprint_manager = self._get_footprint_manager()
+ components = []
+
+ if filter_value:
+ symbols = symbol_manager.search_symbols(filter_value, limit, library_filter)
+ for symbol in symbols:
+ components.append(
+ {
+ "id": symbol.full_ref,
+ "name": symbol.name,
+ "library": symbol.library,
+ "kind": "symbol",
+ "description": symbol.description,
+ "footprint": symbol.footprint,
+ "datasheet": symbol.datasheet,
+ }
+ )
+
+ footprint_pattern = f"*{filter_value}*"
+ footprint_result = self.library_commands.search_footprints(
+ {"pattern": footprint_pattern, "limit": limit, "library": library_filter}
+ )
+ for footprint in footprint_result.get("footprints", []):
+ components.append(
+ {
+ "id": footprint["full_name"],
+ "name": footprint["footprint"],
+ "library": footprint["library"],
+ "kind": "footprint",
+ "path": footprint_manager.get_library_path(footprint["library"]),
+ }
+ )
+ else:
+ symbol_libraries = [library_filter] if library_filter else symbol_manager.list_libraries()
+ for library_name in symbol_libraries:
+ for symbol in symbol_manager.list_symbols(library_name)[: max(limit // 2, 1)]:
+ components.append(
+ {
+ "id": symbol.full_ref,
+ "name": symbol.name,
+ "library": symbol.library,
+ "kind": "symbol",
+ "description": symbol.description,
+ "footprint": symbol.footprint,
+ "datasheet": symbol.datasheet,
+ }
+ )
+ if len(components) >= limit:
+ break
+ if len(components) >= limit:
+ break
+
+ if len(components) < limit:
+ footprint_libraries = [library_filter] if library_filter else footprint_manager.list_libraries()
+ for library_name in footprint_libraries:
+ footprint_result = self.library_commands.list_library_footprints(
+ {"library": library_name}
+ )
+ for footprint_name in footprint_result.get("footprints", [])[: max(limit // 2, 1)]:
+ components.append(
+ {
+ "id": f"{library_name}:{footprint_name}",
+ "name": footprint_name,
+ "library": library_name,
+ "kind": "footprint",
+ "path": footprint_manager.get_library_path(library_name),
+ }
+ )
+ if len(components) >= limit:
+ break
+ if len(components) >= limit:
+ break
+
+ return {
+ "success": True,
+ "components": components[:limit],
+ "count": len(components[:limit]),
+ "filter": filter_value,
+ "library": library_filter,
+ }
+
+ def _handle_get_library_list(self, params):
+ """Return both footprint and symbol library inventories."""
+ footprint_libraries = self.library_commands.list_libraries({})
+ symbol_libraries = self.symbol_library_commands.list_symbol_libraries({})
+
+ libraries = [
+ {"name": name, "type": "footprint"}
+ for name in footprint_libraries.get("libraries", [])
+ ] + [
+ {"name": name, "type": "symbol"}
+ for name in symbol_libraries.get("libraries", [])
+ ]
+ libraries.sort(key=lambda entry: (entry["name"], entry["type"]))
+
+ return {
+ "success": True,
+ "libraries": libraries,
+ "counts": {
+ "footprint": footprint_libraries.get("count", 0),
+ "symbol": symbol_libraries.get("count", 0),
+ },
+ }
+
+ def _handle_get_component_details(self, params):
+ """Return symbol- and footprint-centric component metadata."""
+ component_id = params.get("componentId")
+ library = params.get("library")
+ symbol = self._resolve_symbol_info(component_id, library)
+ footprint_spec = params.get("footprint")
+ if not footprint_spec and symbol and symbol.footprint:
+ footprint_spec = symbol.footprint
+ if not footprint_spec and component_id:
+ footprint_spec = component_id
+
+ footprint_result = None
+ if footprint_spec:
+ footprint_result = self.library_commands.get_footprint_info({"footprint": footprint_spec})
+
+ if not symbol and not (footprint_result and footprint_result.get("success")):
+ return {
+ "success": False,
+ "message": "Component not found",
+ "errorDetails": f"Could not resolve component: {component_id}",
+ }
+
+ return {
+ "success": True,
+ "component": asdict(symbol) if symbol else None,
+ "footprint": footprint_result.get("footprint_info")
+ if footprint_result and footprint_result.get("success")
+ else None,
+ }
+
+ def _handle_get_component_footprint(self, params):
+ """Resolve the footprint associated with a library component."""
+ component_id = params.get("componentId")
+ footprint_spec = params.get("footprint")
+ library = params.get("library")
+
+ symbol = None
+ if not footprint_spec:
+ symbol = self._resolve_symbol_info(component_id, library)
+ if symbol and symbol.footprint:
+ footprint_spec = symbol.footprint
+
+ if not footprint_spec:
+ return {
+ "success": False,
+ "message": "Footprint not found",
+ "errorDetails": f"No footprint is associated with component: {component_id}",
+ }
+
+ footprint_info = self.library_commands.get_footprint_info(
+ {"footprint": footprint_spec}
+ )
+ if not footprint_info.get("success"):
+ search_term = component_id.split(":", 1)[-1] if component_id else footprint_spec
+ suggestions = self.library_commands.search_footprints(
+ {"pattern": f"*{search_term}*", "limit": 10}
+ )
+ return {
+ "success": False,
+ "message": "Footprint not found",
+ "errorDetails": footprint_info.get("errorDetails")
+ or footprint_info.get("message"),
+ "candidates": suggestions.get("footprints", []),
+ }
+
+ loaded = self._load_footprint_object(footprint_spec)
+ footprint_payload = footprint_info.get("footprint_info", {}).copy()
+ if loaded:
+ footprint_obj = loaded["footprint"]
+ footprint_payload.update(
+ {
+ "padCount": len(list(footprint_obj.Pads())),
+ "libraryPath": loaded["library_path"],
+ "fullName": loaded["full_name"],
+ }
+ )
+
+ return {
+ "success": True,
+ "componentId": component_id,
+ "footprint": footprint_payload,
+ "sourceComponent": asdict(symbol) if symbol else None,
+ }
+
+ def _handle_get_component_symbol(self, params):
+ """Resolve a symbol entry and return its metadata plus a lightweight SVG card."""
+ component_id = params.get("componentId")
+ library = params.get("library")
+ symbol = self._resolve_symbol_info(component_id, library)
+ if not symbol:
+ return {
+ "success": False,
+ "message": "Symbol not found",
+ "errorDetails": f"Could not resolve symbol for component: {component_id}",
+ }
+
+ symbol_manager = self._get_symbol_manager()
+ description = (symbol.description or "No description").replace("&", "&").replace("<", "<").replace(">", ">")
+ footprint = (symbol.footprint or "No linked footprint").replace("&", "&").replace("<", "<").replace(">", ">")
+ datasheet = (symbol.datasheet or "No datasheet").replace("&", "&").replace("<", "<").replace(">", ">")
+ full_ref = symbol.full_ref.replace("&", "&").replace("<", "<").replace(">", ">")
+ svg = f"""
+
+""".strip()
+
+ return {
+ "success": True,
+ "symbol": asdict(symbol),
+ "libraryPath": symbol_manager.get_library_path(symbol.library),
+ "svgData": svg,
+ }
+
+ def _handle_get_component_3d_model(self, params):
+ """Return the 3D model references attached to a footprint."""
+ component_id = params.get("componentId")
+ footprint_spec = params.get("footprint")
+ footprint_obj = None
+ library_path = None
+ resolved_footprint = None
+
+ if self.board and component_id:
+ placed_component = self.board.FindFootprintByReference(component_id)
+ if placed_component:
+ footprint_obj = placed_component
+ resolved_footprint = placed_component.GetFPIDAsString()
+
+ if footprint_obj is None:
+ if not footprint_spec:
+ symbol = self._resolve_symbol_info(component_id, params.get("library"))
+ if symbol and symbol.footprint:
+ footprint_spec = symbol.footprint
+ loaded = self._load_footprint_object(footprint_spec)
+ if not loaded:
+ return {
+ "success": False,
+ "message": "3D model not found",
+ "errorDetails": f"Could not resolve footprint for component: {component_id}",
+ }
+ footprint_obj = loaded["footprint"]
+ library_path = loaded["library_path"]
+ resolved_footprint = loaded["full_name"]
+
+ models = []
+ for model in footprint_obj.Models():
+ raw_path = str(model.m_Filename)
+ resolved_path = self._resolve_model_path(raw_path, library_path)
+ models.append(
+ {
+ "path": raw_path,
+ "resolvedPath": resolved_path,
+ "exists": bool(resolved_path) and os.path.exists(resolved_path),
+ "scale": {
+ "x": model.m_Scale.x,
+ "y": model.m_Scale.y,
+ "z": model.m_Scale.z,
+ },
+ "rotation": {
+ "x": model.m_Rotation.x,
+ "y": model.m_Rotation.y,
+ "z": model.m_Rotation.z,
+ },
+ "offset": {
+ "x": model.m_Offset.x,
+ "y": model.m_Offset.y,
+ "z": model.m_Offset.z,
+ },
+ "visible": bool(model.m_Show),
+ "opacity": model.m_Opacity,
+ }
+ )
+
+ return {
+ "success": True,
+ "componentId": component_id,
+ "footprint": resolved_footprint,
+ "models": models,
+ "count": len(models),
+ }
+
# Schematic command handlers
def _handle_create_schematic(self, params):
"""Create a new schematic"""
@@ -583,18 +1569,9 @@ class KiCADInterface:
def _handle_place_component(self, params):
"""Place a component on the PCB, with project-local fp-lib-table support."""
- from pathlib import Path
-
board_path = params.get("boardPath")
if board_path:
- project_path = Path(board_path).parent
- if project_path != getattr(self, "_current_project_path", None):
- self._current_project_path = project_path
- local_lib = FootprintLibraryManager(project_path=project_path)
- self.component_commands = ComponentCommands(self.board, local_lib)
- logger.info(
- f"Reloaded FootprintLibraryManager with project_path={project_path}"
- )
+ self._set_project_context_from_paths(board_path)
return self.component_commands.place_component(params)
diff --git a/src/config.ts b/src/config.ts
index 7148515..67b7f21 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -16,6 +16,15 @@ const __dirname = dirname(__filename);
// Default config location
const DEFAULT_CONFIG_PATH = join(dirname(__dirname), 'config', 'default-config.json');
+const LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const;
+
+function resolveLogLevel(): (typeof LOG_LEVELS)[number] {
+ const value = process.env.KICAD_MCP_LOG_LEVEL;
+ return LOG_LEVELS.includes(value as (typeof LOG_LEVELS)[number])
+ ? (value as (typeof LOG_LEVELS)[number])
+ : 'warn';
+}
+
/**
* Server configuration schema
*/
@@ -25,7 +34,7 @@ const ConfigSchema = z.object({
description: z.string().default('MCP server for KiCAD PCB design operations'),
pythonPath: z.string().optional(),
kicadPath: z.string().optional(),
- logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
+ logLevel: z.enum(LOG_LEVELS).default(resolveLogLevel()),
logDir: z.string().optional()
});
@@ -41,14 +50,18 @@ export type Config = z.infer;
* @returns Loaded and validated configuration
*/
export async function loadConfig(configPath?: string): Promise {
+ const envOverrides = {
+ logLevel: resolveLogLevel(),
+ };
+
try {
// Determine which config file to load
const filePath = configPath || DEFAULT_CONFIG_PATH;
// Check if file exists
if (!existsSync(filePath)) {
- logger.warn(`Configuration file not found: ${filePath}, using defaults`);
- return ConfigSchema.parse({});
+ logger.debug(`Configuration file not found: ${filePath}, using defaults`);
+ return ConfigSchema.parse(envOverrides);
}
// Read and parse configuration
@@ -56,11 +69,11 @@ export async function loadConfig(configPath?: string): Promise {
const config = JSON.parse(configData);
// Validate configuration
- return ConfigSchema.parse(config);
+ return ConfigSchema.parse({ ...config, ...envOverrides });
} catch (error) {
logger.error(`Error loading configuration: ${error}`);
// Return default configuration
- return ConfigSchema.parse({});
+ return ConfigSchema.parse(envOverrides);
}
}
diff --git a/src/index.ts b/src/index.ts
index 6f31054..191c2c2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,7 +31,7 @@ async function main() {
// Create the server
const server = new KiCADMcpServer(
kicadScriptPath,
- config.logLevel
+ config.logLevel,
);
// Start the server
diff --git a/src/logger.ts b/src/logger.ts
index 6a22d8b..770af0e 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -16,7 +16,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
* Logger class for KiCAD MCP server
*/
class Logger {
- private logLevel: LogLevel = 'info';
+ private logLevel: LogLevel = 'warn';
private logDir: string = DEFAULT_LOG_DIR;
/**
diff --git a/src/server.ts b/src/server.ts
index 4f1474b..81c0579 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -220,6 +220,7 @@ export class KiCADMcpServer {
reject: Function;
timeoutHandle: NodeJS.Timeout;
} | null = null;
+ private pythonStderrBuffer = "";
private buildPythonEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
@@ -240,7 +241,7 @@ export class KiCADMcpServer {
*/
constructor(
kicadScriptPath: string,
- logLevel: "error" | "warn" | "info" | "debug" = "info",
+ logLevel: "error" | "warn" | "info" | "debug" = "warn",
) {
// Set up the logger
logger.setLogLevel(logLevel);
@@ -272,12 +273,8 @@ export class KiCADMcpServer {
* Register all tools, resources, and prompts
*/
private registerAll(): void {
- logger.info("Registering KiCAD tools, resources, and prompts...");
+ logger.info("Registering KiCAD MCP runtime surface");
- // Register router tools FIRST (for tool discovery and execution)
- registerRouterTools(this.server, this.callKicadScript.bind(this));
-
- // Register all tools
registerProjectTools(this.server, this.callKicadScript.bind(this));
registerBoardTools(this.server, this.callKicadScript.bind(this));
registerComponentTools(this.server, this.callKicadScript.bind(this));
@@ -287,28 +284,65 @@ export class KiCADMcpServer {
registerSchematicTools(this.server, this.callKicadScript.bind(this));
registerLibraryTools(this.server, this.callKicadScript.bind(this));
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
- registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
+ registerRouterTools(this.server, this.callKicadScript.bind(this));
+ registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerFootprintTools(this.server, this.callKicadScript.bind(this));
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
registerUITools(this.server, this.callKicadScript.bind(this));
- // Register all resources
registerProjectResources(this.server, this.callKicadScript.bind(this));
registerBoardResources(this.server, this.callKicadScript.bind(this));
registerComponentResources(this.server, this.callKicadScript.bind(this));
registerLibraryResources(this.server, this.callKicadScript.bind(this));
- // Register all prompts
registerComponentPrompts(this.server);
registerRoutingPrompts(this.server);
registerDesignPrompts(this.server);
registerFootprintPrompts(this.server);
- logger.info("All KiCAD tools, resources, and prompts registered");
- logger.info(
- "Router pattern enabled: 4 router tools + direct tools for discovery",
- );
+ logger.info("KiCAD MCP runtime registration complete");
+ }
+
+ private logPythonStderrLine(rawLine: string): void {
+ const line = rawLine.trim();
+ if (!line) {
+ return;
+ }
+
+ const severityMatch = line.match(/\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]/i);
+ const message = `python ${line}`;
+
+ switch (severityMatch?.[1]?.toUpperCase()) {
+ case "DEBUG":
+ logger.debug(message);
+ return;
+ case "INFO":
+ logger.info(message);
+ return;
+ case "WARNING":
+ logger.warn(message);
+ return;
+ case "ERROR":
+ case "CRITICAL":
+ logger.error(message);
+ return;
+ default:
+ if (/(traceback|exception|error)/i.test(line)) {
+ logger.error(message);
+ return;
+ }
+ logger.debug(message);
+ }
+ }
+
+ private handlePythonStderr(data: Buffer): void {
+ this.pythonStderrBuffer += data.toString();
+ const lines = this.pythonStderrBuffer.split(/\r?\n/);
+ this.pythonStderrBuffer = lines.pop() ?? "";
+ for (const line of lines) {
+ this.logPythonStderrLine(line);
+ }
}
/**
@@ -520,6 +554,10 @@ export class KiCADMcpServer {
// Listen for process exit
this.pythonProcess.on("exit", (code, signal) => {
+ if (this.pythonStderrBuffer.trim()) {
+ this.logPythonStderrLine(this.pythonStderrBuffer);
+ this.pythonStderrBuffer = "";
+ }
logger.warn(
`Python process exited with code ${code} and signal ${signal}`,
);
@@ -534,7 +572,7 @@ export class KiCADMcpServer {
// Set up error logging for stderr
if (this.pythonProcess.stderr) {
this.pythonProcess.stderr.on("data", (data: Buffer) => {
- logger.error(`Python stderr: ${data.toString()}`);
+ this.handlePythonStderr(data);
});
}
@@ -555,9 +593,6 @@ export class KiCADMcpServer {
throw error;
}
- // Write a ready message to stderr (for debugging)
- process.stderr.write("KiCAD MCP SERVER READY\n");
-
logger.info("KiCAD MCP server started and ready");
} catch (error) {
logger.error(`Failed to start KiCAD MCP server: ${error}`);