Merge branch 'main' of https://github.com/mixelpixx/KiCAD-MCP-Server
This commit is contained in:
@@ -75,3 +75,8 @@ class BoardCommands:
|
|||||||
"""Get a 2D image of the PCB"""
|
"""Get a 2D image of the PCB"""
|
||||||
self.view_commands.board = self.board
|
self.view_commands.board = self.board
|
||||||
return self.view_commands.get_board_2d_view(params)
|
return self.view_commands.get_board_2d_view(params)
|
||||||
|
|
||||||
|
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Get the bounding box extents of the board"""
|
||||||
|
self.view_commands.board = self.board
|
||||||
|
return self.view_commands.get_board_extents(params)
|
||||||
|
|||||||
@@ -151,8 +151,9 @@ class BoardLayerCommands:
|
|||||||
layers.append({
|
layers.append({
|
||||||
"name": self.board.GetLayerName(layer_id),
|
"name": self.board.GetLayerName(layer_id),
|
||||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||||
"id": layer_id,
|
"id": layer_id
|
||||||
"isActive": layer_id == self.board.GetActiveLayer()
|
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||||
|
# Active layer is a UI concept not applicable to headless scripting
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -173,7 +174,7 @@ class BoardLayerCommands:
|
|||||||
type_map = {
|
type_map = {
|
||||||
"copper": pcbnew.LT_SIGNAL,
|
"copper": pcbnew.LT_SIGNAL,
|
||||||
"technical": pcbnew.LT_SIGNAL,
|
"technical": pcbnew.LT_SIGNAL,
|
||||||
"user": pcbnew.LT_USER,
|
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
|
||||||
"signal": pcbnew.LT_SIGNAL
|
"signal": pcbnew.LT_SIGNAL
|
||||||
}
|
}
|
||||||
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
||||||
@@ -184,7 +185,7 @@ class BoardLayerCommands:
|
|||||||
pcbnew.LT_SIGNAL: "signal",
|
pcbnew.LT_SIGNAL: "signal",
|
||||||
pcbnew.LT_POWER: "power",
|
pcbnew.LT_POWER: "power",
|
||||||
pcbnew.LT_MIXED: "mixed",
|
pcbnew.LT_MIXED: "mixed",
|
||||||
pcbnew.LT_JUMPER: "jumper",
|
pcbnew.LT_JUMPER: "jumper"
|
||||||
pcbnew.LT_USER: "user"
|
|
||||||
}
|
}
|
||||||
|
# Note: LT_USER was removed in KiCAD 9.0
|
||||||
return type_map.get(type_id, "unknown")
|
return type_map.get(type_id, "unknown")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class BoardSizeCommands:
|
|||||||
self.board = board
|
self.board = board
|
||||||
|
|
||||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Set the size of the PCB board"""
|
"""Set the size of the PCB board by creating edge cuts outline"""
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
if not self.board:
|
||||||
return {
|
return {
|
||||||
@@ -36,35 +36,33 @@ class BoardSizeCommands:
|
|||||||
"errorDetails": "Both width and height are required"
|
"errorDetails": "Both width and height are required"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convert to internal units (nanometers)
|
# Create board outline using BoardOutlineCommands
|
||||||
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
|
# This properly creates edge cuts on Edge.Cuts layer
|
||||||
width_nm = int(width * scale)
|
from commands.board.outline import BoardOutlineCommands
|
||||||
height_nm = int(height * scale)
|
outline_commands = BoardOutlineCommands(self.board)
|
||||||
|
|
||||||
# Set board size using KiCAD 9.0 API
|
# Create rectangular outline centered at origin
|
||||||
# Note: In KiCAD 9.0, SetSize takes two separate parameters instead of VECTOR2I
|
result = outline_commands.add_board_outline({
|
||||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
"shape": "rectangle",
|
||||||
try:
|
"centerX": width / 2, # Center X
|
||||||
# Try KiCAD 9.0+ API (two parameters)
|
"centerY": height / 2, # Center Y
|
||||||
board_box.SetSize(width_nm, height_nm)
|
"width": width,
|
||||||
except TypeError:
|
"height": height,
|
||||||
# Fall back to older API (VECTOR2I)
|
"unit": unit
|
||||||
board_box.SetSize(pcbnew.VECTOR2I(width_nm, height_nm))
|
})
|
||||||
|
|
||||||
# Note: SetBoardEdgesBoundingBox might not exist in all versions
|
if result.get("success"):
|
||||||
# The board bounding box is typically derived from actual edge cuts
|
return {
|
||||||
# For now, we'll just note the size was calculated
|
"success": True,
|
||||||
logger.info(f"Board size set to {width}x{height} {unit}")
|
"message": f"Created board outline: {width}x{height} {unit}",
|
||||||
|
"size": {
|
||||||
return {
|
"width": width,
|
||||||
"success": True,
|
"height": height,
|
||||||
"message": f"Set board size to {width}x{height} {unit}",
|
"unit": unit
|
||||||
"size": {
|
}
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"unit": unit
|
|
||||||
}
|
}
|
||||||
}
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error setting board size: {str(e)}")
|
logger.error(f"Error setting board size: {str(e)}")
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ class BoardViewCommands:
|
|||||||
"unit": "mm"
|
"unit": "mm"
|
||||||
},
|
},
|
||||||
"layers": layers,
|
"layers": layers,
|
||||||
"title": self.board.GetTitleBlock().GetTitle(),
|
"title": self.board.GetTitleBlock().GetTitle()
|
||||||
"activeLayer": self.board.GetActiveLayer()
|
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||||
|
# Active layer is a UI concept not applicable to headless scripting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,26 +96,32 @@ class BoardViewCommands:
|
|||||||
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
||||||
plot_opts.SetScale(1)
|
plot_opts.SetScale(1)
|
||||||
plot_opts.SetMirror(False)
|
plot_opts.SetMirror(False)
|
||||||
plot_opts.SetExcludeEdgeLayer(False)
|
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
|
||||||
plot_opts.SetPlotFrameRef(False)
|
plot_opts.SetPlotFrameRef(False)
|
||||||
plot_opts.SetPlotValue(True)
|
plot_opts.SetPlotValue(True)
|
||||||
plot_opts.SetPlotReference(True)
|
plot_opts.SetPlotReference(True)
|
||||||
|
|
||||||
# Plot to SVG first (for vector output)
|
# Plot to SVG first (for vector output)
|
||||||
temp_svg = os.path.join(os.path.dirname(self.board.GetFileName()), "temp_view.svg")
|
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
|
||||||
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
||||||
|
|
||||||
# Plot specified layers or all enabled layers
|
# Plot specified layers or all enabled layers
|
||||||
|
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
|
||||||
if layers:
|
if layers:
|
||||||
for layer_name in layers:
|
for layer_name in layers:
|
||||||
layer_id = self.board.GetLayerID(layer_name)
|
layer_id = self.board.GetLayerID(layer_name)
|
||||||
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
else:
|
else:
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
if self.board.IsLayerEnabled(layer_id):
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
|
|
||||||
|
# Get the actual filename that was created (includes project name prefix)
|
||||||
|
temp_svg = plotter.GetPlotFileName()
|
||||||
|
|
||||||
plotter.ClosePlot()
|
plotter.ClosePlot()
|
||||||
|
|
||||||
# Convert SVG to requested format
|
# Convert SVG to requested format
|
||||||
@@ -165,7 +172,61 @@ class BoardViewCommands:
|
|||||||
pcbnew.LT_SIGNAL: "signal",
|
pcbnew.LT_SIGNAL: "signal",
|
||||||
pcbnew.LT_POWER: "power",
|
pcbnew.LT_POWER: "power",
|
||||||
pcbnew.LT_MIXED: "mixed",
|
pcbnew.LT_MIXED: "mixed",
|
||||||
pcbnew.LT_JUMPER: "jumper",
|
pcbnew.LT_JUMPER: "jumper"
|
||||||
pcbnew.LT_USER: "user"
|
|
||||||
}
|
}
|
||||||
|
# Note: LT_USER was removed in KiCAD 9.0
|
||||||
return type_map.get(type_id, "unknown")
|
return type_map.get(type_id, "unknown")
|
||||||
|
|
||||||
|
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Get the bounding box extents of the board"""
|
||||||
|
try:
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get unit preference (default to mm)
|
||||||
|
unit = params.get("unit", "mm")
|
||||||
|
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
||||||
|
|
||||||
|
# Get board bounding box
|
||||||
|
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||||
|
|
||||||
|
# Extract bounds in nanometers, then convert
|
||||||
|
left = board_box.GetLeft() / scale
|
||||||
|
top = board_box.GetTop() / scale
|
||||||
|
right = board_box.GetRight() / scale
|
||||||
|
bottom = board_box.GetBottom() / scale
|
||||||
|
width = board_box.GetWidth() / scale
|
||||||
|
height = board_box.GetHeight() / scale
|
||||||
|
|
||||||
|
# Get center point
|
||||||
|
center_x = board_box.GetCenter().x / scale
|
||||||
|
center_y = board_box.GetCenter().y / scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"extents": {
|
||||||
|
"left": left,
|
||||||
|
"top": top,
|
||||||
|
"right": right,
|
||||||
|
"bottom": bottom,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"center": {
|
||||||
|
"x": center_x,
|
||||||
|
"y": center_y
|
||||||
|
},
|
||||||
|
"unit": unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting board extents: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to get board extents",
|
||||||
|
"errorDetails": str(e)
|
||||||
|
}
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ class ComponentCommands:
|
|||||||
"attributes": {
|
"attributes": {
|
||||||
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
||||||
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
||||||
"virtual": module.GetAttributes() & pcbnew.FP_VIRTUAL
|
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,65 +33,82 @@ class DesignRuleCommands:
|
|||||||
|
|
||||||
# Set clearance
|
# Set clearance
|
||||||
if "clearance" in params:
|
if "clearance" in params:
|
||||||
design_settings.SetMinClearance(int(params["clearance"] * scale))
|
design_settings.m_MinClearance = int(params["clearance"] * scale)
|
||||||
|
|
||||||
|
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
|
||||||
|
# Track if we set any custom track/via values
|
||||||
|
custom_values_set = False
|
||||||
|
|
||||||
# Set track width
|
|
||||||
if "trackWidth" in params:
|
if "trackWidth" in params:
|
||||||
design_settings.SetCurrentTrackWidth(int(params["trackWidth"] * scale))
|
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
|
||||||
|
custom_values_set = True
|
||||||
|
|
||||||
# Set via settings
|
# Via settings
|
||||||
if "viaDiameter" in params:
|
if "viaDiameter" in params:
|
||||||
design_settings.SetCurrentViaSize(int(params["viaDiameter"] * scale))
|
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
|
||||||
|
custom_values_set = True
|
||||||
if "viaDrill" in params:
|
if "viaDrill" in params:
|
||||||
design_settings.SetCurrentViaDrill(int(params["viaDrill"] * scale))
|
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
|
||||||
|
custom_values_set = True
|
||||||
|
|
||||||
# Set micro via settings
|
# KiCAD 9.0: Activate custom track/via values so they become the current values
|
||||||
|
if custom_values_set:
|
||||||
|
design_settings.UseCustomTrackViaSize(True)
|
||||||
|
|
||||||
|
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
||||||
if "microViaDiameter" in params:
|
if "microViaDiameter" in params:
|
||||||
design_settings.SetCurrentMicroViaSize(int(params["microViaDiameter"] * scale))
|
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
||||||
if "microViaDrill" in params:
|
if "microViaDrill" in params:
|
||||||
design_settings.SetCurrentMicroViaDrill(int(params["microViaDrill"] * scale))
|
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
||||||
|
|
||||||
# Set minimum values
|
# Set minimum values
|
||||||
if "minTrackWidth" in params:
|
if "minTrackWidth" in params:
|
||||||
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
||||||
if "minViaDiameter" in params:
|
if "minViaDiameter" in params:
|
||||||
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
||||||
|
|
||||||
|
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
|
||||||
if "minViaDrill" in params:
|
if "minViaDrill" in params:
|
||||||
design_settings.m_ViasMinDrill = int(params["minViaDrill"] * scale)
|
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
||||||
|
|
||||||
if "minMicroViaDiameter" in params:
|
if "minMicroViaDiameter" in params:
|
||||||
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
||||||
if "minMicroViaDrill" in params:
|
if "minMicroViaDrill" in params:
|
||||||
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
||||||
|
|
||||||
# Set hole diameter
|
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
||||||
if "minHoleDiameter" in params:
|
if "minHoleDiameter" in params:
|
||||||
design_settings.m_MinHoleDiameter = int(params["minHoleDiameter"] * scale)
|
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
||||||
|
|
||||||
# Set courtyard settings
|
# KiCAD 9.0: Added hole clearance settings
|
||||||
if "requireCourtyard" in params:
|
if "holeClearance" in params:
|
||||||
design_settings.m_RequireCourtyards = params["requireCourtyard"]
|
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
|
||||||
if "courtyardClearance" in params:
|
if "holeToHoleMin" in params:
|
||||||
design_settings.m_CourtyardMinClearance = int(params["courtyardClearance"] * scale)
|
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
|
||||||
|
|
||||||
|
# Build response with KiCAD 9.0 compatible properties
|
||||||
|
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
|
||||||
|
response_rules = {
|
||||||
|
"clearance": design_settings.m_MinClearance / scale,
|
||||||
|
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||||
|
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||||
|
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||||
|
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||||
|
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||||
|
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||||
|
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||||
|
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||||
|
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||||
|
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||||
|
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||||
|
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||||
|
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Updated design rules",
|
"message": "Updated design rules",
|
||||||
"rules": {
|
"rules": response_rules
|
||||||
"clearance": design_settings.GetMinClearance() / scale,
|
|
||||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
|
||||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
|
||||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
|
||||||
"microViaDiameter": design_settings.GetCurrentMicroViaSize() / scale,
|
|
||||||
"microViaDrill": design_settings.GetCurrentMicroViaDrill() / scale,
|
|
||||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
|
||||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
|
||||||
"minViaDrill": design_settings.m_ViasMinDrill / scale,
|
|
||||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
|
||||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
|
||||||
"minHoleDiameter": design_settings.m_MinHoleDiameter / scale,
|
|
||||||
"requireCourtyard": design_settings.m_RequireCourtyards,
|
|
||||||
"courtyardClearance": design_settings.m_CourtyardMinClearance / scale
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -103,7 +120,7 @@ class DesignRuleCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get current design rules"""
|
"""Get current design rules - KiCAD 9.0 compatible"""
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
if not self.board:
|
||||||
return {
|
return {
|
||||||
@@ -115,24 +132,40 @@ class DesignRuleCommands:
|
|||||||
design_settings = self.board.GetDesignSettings()
|
design_settings = self.board.GetDesignSettings()
|
||||||
scale = 1000000 # nm to mm
|
scale = 1000000 # nm to mm
|
||||||
|
|
||||||
|
# Build rules dict with KiCAD 9.0 compatible properties
|
||||||
|
rules = {
|
||||||
|
# Core clearance and track settings
|
||||||
|
"clearance": design_settings.m_MinClearance / scale,
|
||||||
|
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||||
|
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||||
|
|
||||||
|
# Via settings (current values from methods)
|
||||||
|
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||||
|
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||||
|
|
||||||
|
# Via minimum values
|
||||||
|
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||||
|
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||||
|
|
||||||
|
# Micro via settings
|
||||||
|
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||||
|
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||||
|
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||||
|
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||||
|
|
||||||
|
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
|
||||||
|
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||||
|
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||||
|
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||||
|
|
||||||
|
# Other constraints
|
||||||
|
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
|
||||||
|
"silkClearance": design_settings.m_SilkClearance / scale,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"rules": {
|
"rules": rules
|
||||||
"clearance": design_settings.GetMinClearance() / scale,
|
|
||||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
|
||||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
|
||||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
|
||||||
"microViaDiameter": design_settings.GetCurrentMicroViaSize() / scale,
|
|
||||||
"microViaDrill": design_settings.GetCurrentMicroViaDrill() / scale,
|
|
||||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
|
||||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
|
||||||
"minViaDrill": design_settings.m_ViasMinDrill / scale,
|
|
||||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
|
||||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
|
||||||
"minHoleDiameter": design_settings.m_MinHoleDiameter / scale,
|
|
||||||
"requireCourtyard": design_settings.m_RequireCourtyards,
|
|
||||||
"courtyardClearance": design_settings.m_CourtyardMinClearance / scale
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -144,7 +177,13 @@ class DesignRuleCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Run Design Rule Check"""
|
"""Run Design Rule Check using kicad-cli"""
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
if not self.board:
|
||||||
return {
|
return {
|
||||||
@@ -155,38 +194,144 @@ class DesignRuleCommands:
|
|||||||
|
|
||||||
report_path = params.get("reportPath")
|
report_path = params.get("reportPath")
|
||||||
|
|
||||||
# Create DRC runner
|
# Get the board file path
|
||||||
drc = pcbnew.DRC(self.board)
|
board_file = self.board.GetFileName()
|
||||||
|
if not board_file or not os.path.exists(board_file):
|
||||||
# Run DRC
|
return {
|
||||||
drc.Run()
|
"success": False,
|
||||||
|
"message": "Board file not found",
|
||||||
|
"errorDetails": "Cannot run DRC without a saved board file"
|
||||||
|
}
|
||||||
|
|
||||||
# Get violations
|
# Find kicad-cli executable
|
||||||
violations = []
|
kicad_cli = self._find_kicad_cli()
|
||||||
for marker in drc.GetMarkers():
|
if not kicad_cli:
|
||||||
violations.append({
|
return {
|
||||||
"type": marker.GetErrorCode(),
|
"success": False,
|
||||||
"severity": "error",
|
"message": "kicad-cli not found",
|
||||||
"message": marker.GetDescription(),
|
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH."
|
||||||
"location": {
|
}
|
||||||
"x": marker.GetPos().x / 1000000,
|
|
||||||
"y": marker.GetPos().y / 1000000,
|
# Create temporary JSON output file
|
||||||
"unit": "mm"
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
||||||
|
json_output = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build command
|
||||||
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
'pcb',
|
||||||
|
'drc',
|
||||||
|
'--format', 'json',
|
||||||
|
'--output', json_output,
|
||||||
|
'--units', 'mm',
|
||||||
|
board_file
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Running DRC command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
# Run DRC
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600 # 10 minute timeout for large boards (21MB PCB needs time)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"DRC command failed: {result.stderr}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "DRC command failed",
|
||||||
|
"errorDetails": result.stderr
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
# Save report if path provided
|
# Read JSON output
|
||||||
if report_path:
|
with open(json_output, 'r', encoding='utf-8') as f:
|
||||||
report_path = os.path.abspath(os.path.expanduser(report_path))
|
drc_data = json.load(f)
|
||||||
drc.WriteReport(report_path)
|
|
||||||
|
|
||||||
|
# Parse violations from kicad-cli output
|
||||||
|
violations = []
|
||||||
|
violation_counts = {}
|
||||||
|
severity_counts = {"error": 0, "warning": 0, "info": 0}
|
||||||
|
|
||||||
|
for violation in drc_data.get('violations', []):
|
||||||
|
vtype = violation.get("type", "unknown")
|
||||||
|
vseverity = violation.get("severity", "error")
|
||||||
|
|
||||||
|
violations.append({
|
||||||
|
"type": vtype,
|
||||||
|
"severity": vseverity,
|
||||||
|
"message": violation.get("description", ""),
|
||||||
|
"location": {
|
||||||
|
"x": violation.get("x", 0),
|
||||||
|
"y": violation.get("y", 0),
|
||||||
|
"unit": "mm"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Count violations by type
|
||||||
|
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
|
||||||
|
|
||||||
|
# Count by severity
|
||||||
|
if vseverity in severity_counts:
|
||||||
|
severity_counts[vseverity] += 1
|
||||||
|
|
||||||
|
# Determine where to save the violations file
|
||||||
|
board_dir = os.path.dirname(board_file)
|
||||||
|
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
||||||
|
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
||||||
|
|
||||||
|
# Always save violations to JSON file (for large result sets)
|
||||||
|
with open(violations_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({
|
||||||
|
"board": board_file,
|
||||||
|
"timestamp": drc_data.get("date", "unknown"),
|
||||||
|
"total_violations": len(violations),
|
||||||
|
"violation_counts": violation_counts,
|
||||||
|
"severity_counts": severity_counts,
|
||||||
|
"violations": violations
|
||||||
|
}, f, indent=2)
|
||||||
|
|
||||||
|
# Save text report if requested
|
||||||
|
if report_path:
|
||||||
|
report_path = os.path.abspath(os.path.expanduser(report_path))
|
||||||
|
cmd_report = [
|
||||||
|
kicad_cli,
|
||||||
|
'pcb',
|
||||||
|
'drc',
|
||||||
|
'--format', 'report',
|
||||||
|
'--output', report_path,
|
||||||
|
'--units', 'mm',
|
||||||
|
board_file
|
||||||
|
]
|
||||||
|
subprocess.run(cmd_report, capture_output=True, timeout=600)
|
||||||
|
|
||||||
|
# Return summary only (not full violations list)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Found {len(violations)} DRC violations",
|
||||||
|
"summary": {
|
||||||
|
"total": len(violations),
|
||||||
|
"by_severity": severity_counts,
|
||||||
|
"by_type": violation_counts
|
||||||
|
},
|
||||||
|
"violationsFile": violations_file,
|
||||||
|
"reportPath": report_path if report_path else None
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp JSON file
|
||||||
|
if os.path.exists(json_output):
|
||||||
|
os.unlink(json_output)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("DRC command timed out")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": False,
|
||||||
"message": f"Found {len(violations)} DRC violations",
|
"message": "DRC command timed out",
|
||||||
"violations": violations,
|
"errorDetails": "Command took longer than 600 seconds (10 minutes)"
|
||||||
"reportPath": report_path if report_path else None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error running DRC: {str(e)}")
|
logger.error(f"Error running DRC: {str(e)}")
|
||||||
return {
|
return {
|
||||||
@@ -195,6 +340,50 @@ class DesignRuleCommands:
|
|||||||
"errorDetails": str(e)
|
"errorDetails": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _find_kicad_cli(self) -> Optional[str]:
|
||||||
|
"""Find kicad-cli executable"""
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Try system PATH first
|
||||||
|
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
|
||||||
|
cli_path = shutil.which(cli_name)
|
||||||
|
if cli_path:
|
||||||
|
return cli_path
|
||||||
|
|
||||||
|
# Try common installation paths (version-specific)
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
common_paths = [
|
||||||
|
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
|
||||||
|
]
|
||||||
|
for path in common_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
elif platform.system() == "Darwin": # macOS
|
||||||
|
common_paths = [
|
||||||
|
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||||
|
"/usr/local/bin/kicad-cli",
|
||||||
|
]
|
||||||
|
for path in common_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
else: # Linux
|
||||||
|
common_paths = [
|
||||||
|
"/usr/bin/kicad-cli",
|
||||||
|
"/usr/local/bin/kicad-cli",
|
||||||
|
]
|
||||||
|
for path in common_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
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"""
|
"""Get list of DRC violations"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""
|
"""
|
||||||
Export command implementations for KiCAD interface
|
Export command implementations for KiCAD interface
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -63,31 +63,51 @@ class ExportCommands:
|
|||||||
for layer_name in layers:
|
for layer_name in layers:
|
||||||
layer_id = self.board.GetLayerID(layer_name)
|
layer_id = self.board.GetLayerID(layer_name)
|
||||||
if layer_id >= 0:
|
if layer_id >= 0:
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
plotted_layers.append(layer_name)
|
plotted_layers.append(layer_name)
|
||||||
else:
|
else:
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
if self.board.IsLayerEnabled(layer_id):
|
||||||
layer_name = self.board.GetLayerName(layer_id)
|
layer_name = self.board.GetLayerName(layer_id)
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
plotted_layers.append(layer_name)
|
plotted_layers.append(layer_name)
|
||||||
|
|
||||||
# Generate drill files if requested
|
# Generate drill files if requested
|
||||||
drill_files = []
|
drill_files = []
|
||||||
if generate_drill_files:
|
if generate_drill_files:
|
||||||
drill_writer = pcbnew.EXCELLON_WRITER(self.board)
|
# KiCAD 9.0: Use kicad-cli for more reliable drill file generation
|
||||||
drill_writer.SetFormat(True)
|
# The Python API's EXCELLON_WRITER.SetOptions() signature changed
|
||||||
drill_writer.SetMapFileFormat(pcbnew.PLOT_FORMAT_GERBER)
|
board_file = self.board.GetFileName()
|
||||||
|
kicad_cli = self._find_kicad_cli()
|
||||||
merge_npth = False # Keep plated/non-plated holes separate
|
|
||||||
drill_writer.SetOptions(merge_npth)
|
if kicad_cli and board_file and os.path.exists(board_file):
|
||||||
|
import subprocess
|
||||||
drill_writer.CreateDrillandMapFilesSet(output_dir, True, generate_map_file)
|
# Generate drill files using kicad-cli
|
||||||
|
cmd = [
|
||||||
# Get list of generated drill files
|
kicad_cli,
|
||||||
for file in os.listdir(output_dir):
|
'pcb', 'export', 'drill',
|
||||||
if file.endswith(".drl") or file.endswith(".cnc"):
|
'--output', output_dir,
|
||||||
drill_files.append(file)
|
'--format', 'excellon',
|
||||||
|
'--drill-origin', 'absolute',
|
||||||
|
'--excellon-separate-th', # Separate plated/non-plated
|
||||||
|
board_file
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Get list of generated drill files
|
||||||
|
for file in os.listdir(output_dir):
|
||||||
|
if file.endswith((".drl", ".cnc")):
|
||||||
|
drill_files.append(file)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Drill file generation failed: {result.stderr}")
|
||||||
|
except Exception as drill_error:
|
||||||
|
logger.warning(f"Could not generate drill files: {str(drill_error)}")
|
||||||
|
else:
|
||||||
|
logger.warning("kicad-cli not available for drill file generation")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -137,7 +157,7 @@ class ExportCommands:
|
|||||||
|
|
||||||
# Create plot controller
|
# Create plot controller
|
||||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
||||||
|
|
||||||
# Set up plot options
|
# Set up plot options
|
||||||
plot_opts = plotter.GetPlotOptions()
|
plot_opts = plotter.GetPlotOptions()
|
||||||
plot_opts.SetOutputDirectory(os.path.dirname(output_path))
|
plot_opts.SetOutputDirectory(os.path.dirname(output_path))
|
||||||
@@ -145,22 +165,28 @@ class ExportCommands:
|
|||||||
plot_opts.SetPlotFrameRef(frame_reference)
|
plot_opts.SetPlotFrameRef(frame_reference)
|
||||||
plot_opts.SetPlotValue(True)
|
plot_opts.SetPlotValue(True)
|
||||||
plot_opts.SetPlotReference(True)
|
plot_opts.SetPlotReference(True)
|
||||||
plot_opts.SetMonochrome(black_and_white)
|
plot_opts.SetBlackAndWhite(black_and_white)
|
||||||
|
|
||||||
# Set page size
|
# KiCAD 9.0 page size handling:
|
||||||
page_sizes = {
|
# - SetPageSettings() was removed in KiCAD 9.0
|
||||||
"A4": (297, 210),
|
# - SetA4Output(bool) forces A4 page size when True
|
||||||
"A3": (420, 297),
|
# - For other sizes, KiCAD auto-scales to fit the board
|
||||||
"A2": (594, 420),
|
# - SetAutoScale(True) enables automatic scaling to fit page
|
||||||
"A1": (841, 594),
|
if page_size == "A4":
|
||||||
"A0": (1189, 841),
|
plot_opts.SetA4Output(True)
|
||||||
"Letter": (279.4, 215.9),
|
else:
|
||||||
"Legal": (355.6, 215.9),
|
# For non-A4 sizes, disable A4 forcing and use auto-scale
|
||||||
"Tabloid": (431.8, 279.4)
|
plot_opts.SetA4Output(False)
|
||||||
}
|
plot_opts.SetAutoScale(True)
|
||||||
if page_size in page_sizes:
|
# Note: KiCAD 9.0 doesn't support explicit page size selection
|
||||||
height, width = page_sizes[page_size]
|
# for formats other than A4. The PDF will auto-scale to fit.
|
||||||
plot_opts.SetPageSettings((width, height))
|
logger.warning(f"Page size '{page_size}' requested, but KiCAD 9.0 only supports A4 explicitly. Using auto-scale instead.")
|
||||||
|
|
||||||
|
# Open plot for writing
|
||||||
|
# Note: For PDF, all layers are combined into a single file
|
||||||
|
# KiCAD prepends the board filename to the plot file name
|
||||||
|
base_name = os.path.basename(output_path).replace('.pdf', '')
|
||||||
|
plotter.OpenPlotfile(base_name, pcbnew.PLOT_FORMAT_PDF, '')
|
||||||
|
|
||||||
# Plot specified layers or all enabled layers
|
# Plot specified layers or all enabled layers
|
||||||
plotted_layers = []
|
plotted_layers = []
|
||||||
@@ -168,22 +194,34 @@ class ExportCommands:
|
|||||||
for layer_name in layers:
|
for layer_name in layers:
|
||||||
layer_id = self.board.GetLayerID(layer_name)
|
layer_id = self.board.GetLayerID(layer_name)
|
||||||
if layer_id >= 0:
|
if layer_id >= 0:
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
plotted_layers.append(layer_name)
|
plotted_layers.append(layer_name)
|
||||||
else:
|
else:
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
if self.board.IsLayerEnabled(layer_id):
|
||||||
layer_name = self.board.GetLayerName(layer_id)
|
layer_name = self.board.GetLayerName(layer_id)
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
plotted_layers.append(layer_name)
|
plotted_layers.append(layer_name)
|
||||||
|
|
||||||
|
# Close the plot file to finalize the PDF
|
||||||
|
plotter.ClosePlot()
|
||||||
|
|
||||||
|
# KiCAD automatically prepends the board name to the output file
|
||||||
|
# Get the actual output filename that was created
|
||||||
|
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
|
||||||
|
actual_filename = f"{board_name}-{base_name}.pdf"
|
||||||
|
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Exported PDF file",
|
"message": "Exported PDF file",
|
||||||
"file": {
|
"file": {
|
||||||
"path": output_path,
|
"path": actual_output_path,
|
||||||
|
"requestedPath": output_path,
|
||||||
"layers": plotted_layers,
|
"layers": plotted_layers,
|
||||||
"pageSize": page_size
|
"pageSize": page_size if page_size == "A4" else "auto-scaled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +268,7 @@ class ExportCommands:
|
|||||||
plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG)
|
plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG)
|
||||||
plot_opts.SetPlotValue(include_components)
|
plot_opts.SetPlotValue(include_components)
|
||||||
plot_opts.SetPlotReference(include_components)
|
plot_opts.SetPlotReference(include_components)
|
||||||
plot_opts.SetMonochrome(black_and_white)
|
plot_opts.SetBlackAndWhite(black_and_white)
|
||||||
|
|
||||||
# Plot specified layers or all enabled layers
|
# Plot specified layers or all enabled layers
|
||||||
plotted_layers = []
|
plotted_layers = []
|
||||||
@@ -238,13 +276,15 @@ class ExportCommands:
|
|||||||
for layer_name in layers:
|
for layer_name in layers:
|
||||||
layer_id = self.board.GetLayerID(layer_name)
|
layer_id = self.board.GetLayerID(layer_name)
|
||||||
if layer_id >= 0:
|
if layer_id >= 0:
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
plotted_layers.append(layer_name)
|
plotted_layers.append(layer_name)
|
||||||
else:
|
else:
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
if self.board.IsLayerEnabled(layer_id):
|
||||||
layer_name = self.board.GetLayerName(layer_id)
|
layer_name = self.board.GetLayerName(layer_id)
|
||||||
plotter.PlotLayer(layer_id)
|
plotter.SetLayer(layer_id)
|
||||||
|
plotter.PlotLayer()
|
||||||
plotted_layers.append(layer_name)
|
plotted_layers.append(layer_name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -265,7 +305,11 @@ class ExportCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Export 3D model files"""
|
"""Export 3D model files using kicad-cli (KiCAD 9.0 compatible)"""
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
if not self.board:
|
||||||
return {
|
return {
|
||||||
@@ -288,46 +332,108 @@ class ExportCommands:
|
|||||||
"errorDetails": "outputPath parameter is required"
|
"errorDetails": "outputPath parameter is required"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get board file path
|
||||||
|
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": "Board must be saved before exporting 3D models"
|
||||||
|
}
|
||||||
|
|
||||||
# Create output directory if it doesn't exist
|
# Create output directory if it doesn't exist
|
||||||
output_path = os.path.abspath(os.path.expanduser(output_path))
|
output_path = os.path.abspath(os.path.expanduser(output_path))
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
# Get 3D viewer
|
# Find kicad-cli executable
|
||||||
viewer = self.board.Get3DViewer()
|
kicad_cli = self._find_kicad_cli()
|
||||||
if not viewer:
|
if not kicad_cli:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "3D viewer not available",
|
"message": "kicad-cli not found",
|
||||||
"errorDetails": "Could not initialize 3D viewer"
|
"errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set export options
|
# Build command based on format
|
||||||
viewer.SetCopperLayersOn(include_copper)
|
format_upper = format.upper()
|
||||||
viewer.SetSolderMaskLayersOn(include_solder_mask)
|
|
||||||
viewer.SetSilkScreenLayersOn(include_silkscreen)
|
if format_upper == "STEP":
|
||||||
viewer.Set3DModelsOn(include_components)
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
'pcb', 'export', 'step',
|
||||||
|
'--output', output_path,
|
||||||
|
'--force' # Overwrite existing file
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add options based on parameters
|
||||||
|
if not include_components:
|
||||||
|
cmd.append('--no-components')
|
||||||
|
if include_copper:
|
||||||
|
cmd.extend(['--include-tracks', '--include-pads', '--include-zones'])
|
||||||
|
if include_silkscreen:
|
||||||
|
cmd.append('--include-silkscreen')
|
||||||
|
if include_solder_mask:
|
||||||
|
cmd.append('--include-soldermask')
|
||||||
|
|
||||||
|
cmd.append(board_file)
|
||||||
|
|
||||||
|
elif format_upper == "VRML":
|
||||||
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
'pcb', 'export', 'vrml',
|
||||||
|
'--output', output_path,
|
||||||
|
'--units', 'mm', # Use mm for consistency
|
||||||
|
'--force'
|
||||||
|
]
|
||||||
|
|
||||||
|
if not include_components:
|
||||||
|
# Note: VRML export doesn't have a direct --no-components flag
|
||||||
|
# The models will be included by default, but can be controlled via 3D settings
|
||||||
|
pass
|
||||||
|
|
||||||
|
cmd.append(board_file)
|
||||||
|
|
||||||
# Export based on format
|
|
||||||
if format == "STEP":
|
|
||||||
viewer.ExportSTEPFile(output_path)
|
|
||||||
elif format == "VRML":
|
|
||||||
viewer.ExportVRMLFile(output_path)
|
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Unsupported format",
|
"message": "Unsupported format",
|
||||||
"errorDetails": f"Format {format} is not supported"
|
"errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute kicad-cli command
|
||||||
|
logger.info(f"Running 3D export command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300 # 5 minute timeout for 3D export
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"3D export command failed: {result.stderr}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "3D export command failed",
|
||||||
|
"errorDetails": result.stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Exported {format} file",
|
"message": f"Exported {format_upper} file",
|
||||||
"file": {
|
"file": {
|
||||||
"path": output_path,
|
"path": output_path,
|
||||||
"format": format
|
"format": format_upper
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("3D export command timed out")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "3D export timed out",
|
||||||
|
"errorDetails": "Export took longer than 5 minutes"
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error exporting 3D model: {str(e)}")
|
logger.error(f"Error exporting 3D model: {str(e)}")
|
||||||
return {
|
return {
|
||||||
@@ -368,7 +474,7 @@ class ExportCommands:
|
|||||||
component = {
|
component = {
|
||||||
"reference": module.GetReference(),
|
"reference": module.GetReference(),
|
||||||
"value": module.GetValue(),
|
"value": module.GetValue(),
|
||||||
"footprint": module.GetFootprintName(),
|
"footprint": str(module.GetFPID()),
|
||||||
"layer": self.board.GetLayerName(module.GetLayer())
|
"layer": self.board.GetLayerName(module.GetLayer())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,3 +579,44 @@ class ExportCommands:
|
|||||||
import json
|
import json
|
||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
json.dump({"components": components}, f, indent=2)
|
json.dump({"components": components}, f, indent=2)
|
||||||
|
|
||||||
|
def _find_kicad_cli(self) -> Optional[str]:
|
||||||
|
"""Find kicad-cli executable in system PATH or common locations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to kicad-cli executable, or None if not found
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import platform
|
||||||
|
|
||||||
|
# Try system PATH first
|
||||||
|
cli_path = shutil.which("kicad-cli")
|
||||||
|
if cli_path:
|
||||||
|
return cli_path
|
||||||
|
|
||||||
|
# Try platform-specific default locations
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Windows":
|
||||||
|
possible_paths = [
|
||||||
|
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||||
|
]
|
||||||
|
elif system == "Darwin": # macOS
|
||||||
|
possible_paths = [
|
||||||
|
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||||
|
"/usr/local/bin/kicad-cli",
|
||||||
|
]
|
||||||
|
else: # Linux
|
||||||
|
possible_paths = [
|
||||||
|
"/usr/bin/kicad-cli",
|
||||||
|
"/usr/local/bin/kicad-cli",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ function Find-KiCAD {
|
|||||||
$possiblePaths = @(
|
$possiblePaths = @(
|
||||||
"C:\Program Files\KiCad",
|
"C:\Program Files\KiCad",
|
||||||
"C:\Program Files (x86)\KiCad"
|
"C:\Program Files (x86)\KiCad"
|
||||||
|
"$env:USERPROFILE\AppData\Local\Programs\KiCad"
|
||||||
)
|
)
|
||||||
|
|
||||||
$versions = @("9.0", "9.1", "10.0", "8.0")
|
$versions = @("9.0", "9.1", "10.0", "8.0")
|
||||||
@@ -114,6 +115,7 @@ if ($kicad) {
|
|||||||
Write-Info "Python Path: $($kicad.PythonLib)"
|
Write-Info "Python Path: $($kicad.PythonLib)"
|
||||||
} else {
|
} else {
|
||||||
Write-Error-Custom "KiCAD not found in standard locations"
|
Write-Error-Custom "KiCAD not found in standard locations"
|
||||||
|
Write-Warning-Custom "Checked: C:\Program Files, C:\Program Files (x86), and $env:USERPROFILE\AppData\Local\Programs"
|
||||||
Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/"
|
Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/"
|
||||||
$script:Results.Errors += "KiCAD not found"
|
$script:Results.Errors += "KiCAD not found"
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/index.ts
13
src/index.ts
@@ -107,13 +107,12 @@ async function shutdownServer(server: KiCADMcpServer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the main function if this file is executed directly
|
// Run the main function - always run when imported as module entry point
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
// The import.meta.url check was failing on Windows due to path separators
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
logger.error(`Unhandled error in main: ${error}`);
|
console.error(`Unhandled error in main: ${error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// For testing and programmatic usage
|
// For testing and programmatic usage
|
||||||
export { KiCADMcpServer };
|
export { KiCADMcpServer };
|
||||||
|
|||||||
@@ -86,21 +86,10 @@ class Logger {
|
|||||||
private log(level: LogLevel, message: string): void {
|
private log(level: LogLevel, message: string): void {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||||
|
|
||||||
// Log to console
|
// Log to console.error (stderr) only - stdout is reserved for MCP protocol
|
||||||
switch (level) {
|
// All log levels go to stderr to avoid corrupting STDIO MCP transport
|
||||||
case 'error':
|
console.error(formattedMessage);
|
||||||
console.error(formattedMessage);
|
|
||||||
break;
|
|
||||||
case 'warn':
|
|
||||||
console.warn(formattedMessage);
|
|
||||||
break;
|
|
||||||
case 'info':
|
|
||||||
case 'debug':
|
|
||||||
default:
|
|
||||||
console.log(formattedMessage);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to file
|
// Log to file
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, exec, ChildProcess } from 'child_process';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -176,7 +176,6 @@ export class KiCADMcpServer {
|
|||||||
logger.info('Validating pcbnew module access...');
|
logger.info('Validating pcbnew module access...');
|
||||||
|
|
||||||
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
|
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
|
||||||
const { exec } = require('child_process');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => {
|
const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => {
|
||||||
@@ -325,7 +324,7 @@ export class KiCADMcpServer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Call the KiCAD scripting interface to execute commands
|
* Call the KiCAD scripting interface to execute commands
|
||||||
*
|
*
|
||||||
* @param command The command to execute
|
* @param command The command to execute
|
||||||
* @param params The parameters for the command
|
* @param params The parameters for the command
|
||||||
* @returns The result of the command execution
|
* @returns The result of the command execution
|
||||||
@@ -338,14 +337,23 @@ export class KiCADMcpServer {
|
|||||||
reject(new Error("Python process for KiCAD scripting is not running"));
|
reject(new Error("Python process for KiCAD scripting is not running"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add request to queue
|
// Determine timeout based on command type
|
||||||
|
// DRC and export operations need longer timeouts for large boards
|
||||||
|
let commandTimeout = 30000; // Default 30 seconds
|
||||||
|
const longRunningCommands = ['run_drc', 'export_gerber', 'export_pdf', 'export_3d'];
|
||||||
|
if (longRunningCommands.includes(command)) {
|
||||||
|
commandTimeout = 600000; // 10 minutes for long operations
|
||||||
|
logger.info(`Using extended timeout (${commandTimeout/1000}s) for command: ${command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request to queue with timeout info
|
||||||
this.requestQueue.push({
|
this.requestQueue.push({
|
||||||
request: { command, params },
|
request: { command, params, timeout: commandTimeout },
|
||||||
resolve,
|
resolve,
|
||||||
reject
|
reject
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process the queue if not already processing
|
// Process the queue if not already processing
|
||||||
if (!this.processingRequest) {
|
if (!this.processingRequest) {
|
||||||
this.processNextRequest();
|
this.processNextRequest();
|
||||||
@@ -376,40 +384,46 @@ export class KiCADMcpServer {
|
|||||||
|
|
||||||
// Set up response handling
|
// Set up response handling
|
||||||
let responseData = '';
|
let responseData = '';
|
||||||
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
// Clear any previous listeners
|
// Clear any previous listeners
|
||||||
if (this.pythonProcess?.stdout) {
|
if (this.pythonProcess?.stdout) {
|
||||||
this.pythonProcess.stdout.removeAllListeners('data');
|
this.pythonProcess.stdout.removeAllListeners('data');
|
||||||
this.pythonProcess.stdout.removeAllListeners('end');
|
this.pythonProcess.stdout.removeAllListeners('end');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up new listeners
|
// Set up new listeners
|
||||||
if (this.pythonProcess?.stdout) {
|
if (this.pythonProcess?.stdout) {
|
||||||
this.pythonProcess.stdout.on('data', (data: Buffer) => {
|
this.pythonProcess.stdout.on('data', (data: Buffer) => {
|
||||||
const chunk = data.toString();
|
const chunk = data.toString();
|
||||||
logger.debug(`Received data chunk: ${chunk.length} bytes`);
|
logger.debug(`Received data chunk: ${chunk.length} bytes`);
|
||||||
responseData += chunk;
|
responseData += chunk;
|
||||||
|
|
||||||
// Check if we have a complete response
|
// Check if we have a complete response
|
||||||
try {
|
try {
|
||||||
// Try to parse the response as JSON
|
// Try to parse the response as JSON
|
||||||
const result = JSON.parse(responseData);
|
const result = JSON.parse(responseData);
|
||||||
|
|
||||||
// If we get here, we have a valid JSON response
|
// If we get here, we have a valid JSON response
|
||||||
logger.debug(`Completed KiCAD command: ${request.command} with result: ${result.success ? 'success' : 'failure'}`);
|
logger.debug(`Completed KiCAD command: ${request.command} with result: ${result.success ? 'success' : 'failure'}`);
|
||||||
|
|
||||||
|
// Clear the timeout since we got a response
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset processing flag
|
// Reset processing flag
|
||||||
this.processingRequest = false;
|
this.processingRequest = false;
|
||||||
|
|
||||||
// Process next request if any
|
// Process next request if any
|
||||||
setTimeout(() => this.processNextRequest(), 0);
|
setTimeout(() => this.processNextRequest(), 0);
|
||||||
|
|
||||||
// Clear listeners
|
// Clear listeners
|
||||||
if (this.pythonProcess?.stdout) {
|
if (this.pythonProcess?.stdout) {
|
||||||
this.pythonProcess.stdout.removeAllListeners('data');
|
this.pythonProcess.stdout.removeAllListeners('data');
|
||||||
this.pythonProcess.stdout.removeAllListeners('end');
|
this.pythonProcess.stdout.removeAllListeners('end');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the promise with the result
|
// Resolve the promise with the result
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -417,26 +431,27 @@ export class KiCADMcpServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set a timeout
|
// Set a timeout (use command-specific timeout or default)
|
||||||
const timeout = setTimeout(() => {
|
const timeoutDuration = request.timeout || 30000;
|
||||||
logger.error(`Command timeout: ${request.command}`);
|
timeoutHandle = setTimeout(() => {
|
||||||
|
logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`);
|
||||||
|
|
||||||
// Clear listeners
|
// Clear listeners
|
||||||
if (this.pythonProcess?.stdout) {
|
if (this.pythonProcess?.stdout) {
|
||||||
this.pythonProcess.stdout.removeAllListeners('data');
|
this.pythonProcess.stdout.removeAllListeners('data');
|
||||||
this.pythonProcess.stdout.removeAllListeners('end');
|
this.pythonProcess.stdout.removeAllListeners('end');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset processing flag
|
// Reset processing flag
|
||||||
this.processingRequest = false;
|
this.processingRequest = false;
|
||||||
|
|
||||||
// Process next request
|
// Process next request
|
||||||
setTimeout(() => this.processNextRequest(), 0);
|
setTimeout(() => this.processNextRequest(), 0);
|
||||||
|
|
||||||
// Reject the promise
|
// Reject the promise
|
||||||
reject(new Error(`Command timeout: ${request.command}`));
|
reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`));
|
||||||
}, 30000); // 30 seconds timeout
|
}, timeoutDuration);
|
||||||
|
|
||||||
// Write the request to the Python process
|
// Write the request to the Python process
|
||||||
logger.debug(`Sending request: ${requestStr}`);
|
logger.debug(`Sending request: ${requestStr}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user