diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 374d0b3..08b974e 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -75,3 +75,8 @@ class BoardCommands: """Get a 2D image of the PCB""" self.view_commands.board = self.board 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) diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index 9d4d6c0..1e407ec 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -151,8 +151,9 @@ class BoardLayerCommands: layers.append({ "name": self.board.GetLayerName(layer_id), "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id, - "isActive": layer_id == self.board.GetActiveLayer() + "id": layer_id + # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 + # Active layer is a UI concept not applicable to headless scripting }) return { @@ -173,7 +174,7 @@ class BoardLayerCommands: type_map = { "copper": 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 } return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) @@ -184,7 +185,7 @@ class BoardLayerCommands: pcbnew.LT_SIGNAL: "signal", pcbnew.LT_POWER: "power", pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper", - pcbnew.LT_USER: "user" + pcbnew.LT_JUMPER: "jumper" } + # Note: LT_USER was removed in KiCAD 9.0 return type_map.get(type_id, "unknown") diff --git a/python/commands/board/size.py b/python/commands/board/size.py index d8ba8a6..a4f9e2e 100644 --- a/python/commands/board/size.py +++ b/python/commands/board/size.py @@ -16,7 +16,7 @@ class BoardSizeCommands: self.board = board 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: if not self.board: return { @@ -36,35 +36,33 @@ class BoardSizeCommands: "errorDetails": "Both width and height are required" } - # Convert to internal units (nanometers) - scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm - width_nm = int(width * scale) - height_nm = int(height * scale) + # Create board outline using BoardOutlineCommands + # This properly creates edge cuts on Edge.Cuts layer + from commands.board.outline import BoardOutlineCommands + outline_commands = BoardOutlineCommands(self.board) - # Set board size using KiCAD 9.0 API - # Note: In KiCAD 9.0, SetSize takes two separate parameters instead of VECTOR2I - board_box = self.board.GetBoardEdgesBoundingBox() - try: - # Try KiCAD 9.0+ API (two parameters) - board_box.SetSize(width_nm, height_nm) - except TypeError: - # Fall back to older API (VECTOR2I) - board_box.SetSize(pcbnew.VECTOR2I(width_nm, height_nm)) + # Create rectangular outline centered at origin + result = outline_commands.add_board_outline({ + "shape": "rectangle", + "centerX": width / 2, # Center X + "centerY": height / 2, # Center Y + "width": width, + "height": height, + "unit": unit + }) - # Note: SetBoardEdgesBoundingBox might not exist in all versions - # The board bounding box is typically derived from actual edge cuts - # For now, we'll just note the size was calculated - logger.info(f"Board size set to {width}x{height} {unit}") - - return { - "success": True, - "message": f"Set board size to {width}x{height} {unit}", - "size": { - "width": width, - "height": height, - "unit": unit + if result.get("success"): + return { + "success": True, + "message": f"Created board outline: {width}x{height} {unit}", + "size": { + "width": width, + "height": height, + "unit": unit + } } - } + else: + return result except Exception as e: logger.error(f"Error setting board size: {str(e)}") diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 66f823f..aaad991 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -58,8 +58,9 @@ class BoardViewCommands: "unit": "mm" }, "layers": layers, - "title": self.board.GetTitleBlock().GetTitle(), - "activeLayer": self.board.GetActiveLayer() + "title": self.board.GetTitleBlock().GetTitle() + # 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.SetScale(1) 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.SetPlotValue(True) plot_opts.SetPlotReference(True) # 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") - + # Plot specified layers or all enabled layers + # Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer() if layers: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0 and self.board.IsLayerEnabled(layer_id): - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): 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() # Convert SVG to requested format @@ -165,7 +172,61 @@ class BoardViewCommands: pcbnew.LT_SIGNAL: "signal", pcbnew.LT_POWER: "power", pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper", - pcbnew.LT_USER: "user" + 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""" + 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) + } diff --git a/python/commands/component.py b/python/commands/component.py index a9da4e5..d395145 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -405,7 +405,7 @@ class ComponentCommands: "attributes": { "smd": module.GetAttributes() & pcbnew.FP_SMD, "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, - "virtual": module.GetAttributes() & pcbnew.FP_VIRTUAL + "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY } } } diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 6054353..9ef158a 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -33,65 +33,82 @@ class DesignRuleCommands: # Set clearance 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: - 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: - design_settings.SetCurrentViaSize(int(params["viaDiameter"] * scale)) + design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale)) + custom_values_set = True 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: - design_settings.SetCurrentMicroViaSize(int(params["microViaDiameter"] * scale)) + design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) if "microViaDrill" in params: - design_settings.SetCurrentMicroViaDrill(int(params["microViaDrill"] * scale)) + design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) # Set minimum values if "minTrackWidth" in params: design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale) if "minViaDiameter" in params: design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale) + + # KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead if "minViaDrill" in params: - design_settings.m_ViasMinDrill = int(params["minViaDrill"] * scale) + design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) + if "minMicroViaDiameter" in params: design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) if "minMicroViaDrill" in params: design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) - # Set hole diameter + # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill if "minHoleDiameter" in params: - design_settings.m_MinHoleDiameter = int(params["minHoleDiameter"] * scale) + design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) - # Set courtyard settings - if "requireCourtyard" in params: - design_settings.m_RequireCourtyards = params["requireCourtyard"] - if "courtyardClearance" in params: - design_settings.m_CourtyardMinClearance = int(params["courtyardClearance"] * scale) + # KiCAD 9.0: Added hole clearance settings + if "holeClearance" in params: + design_settings.m_HoleClearance = int(params["holeClearance"] * scale) + if "holeToHoleMin" in params: + 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 { "success": True, "message": "Updated design 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 - } + "rules": response_rules } except Exception as e: @@ -103,7 +120,7 @@ class DesignRuleCommands: } 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: if not self.board: return { @@ -115,24 +132,40 @@ class DesignRuleCommands: design_settings = self.board.GetDesignSettings() 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 { "success": True, - "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 - } + "rules": rules } except Exception as e: @@ -144,7 +177,13 @@ class DesignRuleCommands: } 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: if not self.board: return { @@ -155,38 +194,144 @@ class DesignRuleCommands: report_path = params.get("reportPath") - # Create DRC runner - drc = pcbnew.DRC(self.board) - - # Run DRC - drc.Run() + # Get the 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": "Cannot run DRC without a saved board file" + } - # Get violations - violations = [] - for marker in drc.GetMarkers(): - violations.append({ - "type": marker.GetErrorCode(), - "severity": "error", - "message": marker.GetDescription(), - "location": { - "x": marker.GetPos().x / 1000000, - "y": marker.GetPos().y / 1000000, - "unit": "mm" + # Find kicad-cli executable + kicad_cli = self._find_kicad_cli() + if not kicad_cli: + return { + "success": False, + "message": "kicad-cli not found", + "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH." + } + + # Create temporary JSON output file + 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 - if report_path: - report_path = os.path.abspath(os.path.expanduser(report_path)) - drc.WriteReport(report_path) + # Read JSON output + with open(json_output, 'r', encoding='utf-8') as f: + drc_data = json.load(f) + # 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 { - "success": True, - "message": f"Found {len(violations)} DRC violations", - "violations": violations, - "reportPath": report_path if report_path else None + "success": False, + "message": "DRC command timed out", + "errorDetails": "Command took longer than 600 seconds (10 minutes)" } - except Exception as e: logger.error(f"Error running DRC: {str(e)}") return { @@ -195,6 +340,50 @@ class DesignRuleCommands: "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]: """Get list of DRC violations""" try: diff --git a/python/commands/export.py b/python/commands/export.py index 255333d..e0749ed 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -1,4 +1,4 @@ -""" +""" Export command implementations for KiCAD interface """ @@ -63,31 +63,51 @@ class ExportCommands: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0: - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): layer_name = self.board.GetLayerName(layer_id) - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) # Generate drill files if requested drill_files = [] if generate_drill_files: - drill_writer = pcbnew.EXCELLON_WRITER(self.board) - drill_writer.SetFormat(True) - drill_writer.SetMapFileFormat(pcbnew.PLOT_FORMAT_GERBER) - - merge_npth = False # Keep plated/non-plated holes separate - drill_writer.SetOptions(merge_npth) - - drill_writer.CreateDrillandMapFilesSet(output_dir, True, generate_map_file) - - # Get list of generated drill files - for file in os.listdir(output_dir): - if file.endswith(".drl") or file.endswith(".cnc"): - drill_files.append(file) + # KiCAD 9.0: Use kicad-cli for more reliable drill file generation + # The Python API's EXCELLON_WRITER.SetOptions() signature changed + board_file = self.board.GetFileName() + kicad_cli = self._find_kicad_cli() + + if kicad_cli and board_file and os.path.exists(board_file): + import subprocess + # Generate drill files using kicad-cli + cmd = [ + kicad_cli, + 'pcb', 'export', 'drill', + '--output', output_dir, + '--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 { "success": True, @@ -137,7 +157,7 @@ class ExportCommands: # Create plot controller plotter = pcbnew.PLOT_CONTROLLER(self.board) - + # Set up plot options plot_opts = plotter.GetPlotOptions() plot_opts.SetOutputDirectory(os.path.dirname(output_path)) @@ -145,22 +165,28 @@ class ExportCommands: plot_opts.SetPlotFrameRef(frame_reference) plot_opts.SetPlotValue(True) plot_opts.SetPlotReference(True) - plot_opts.SetMonochrome(black_and_white) + plot_opts.SetBlackAndWhite(black_and_white) - # Set page size - page_sizes = { - "A4": (297, 210), - "A3": (420, 297), - "A2": (594, 420), - "A1": (841, 594), - "A0": (1189, 841), - "Letter": (279.4, 215.9), - "Legal": (355.6, 215.9), - "Tabloid": (431.8, 279.4) - } - if page_size in page_sizes: - height, width = page_sizes[page_size] - plot_opts.SetPageSettings((width, height)) + # KiCAD 9.0 page size handling: + # - SetPageSettings() was removed in KiCAD 9.0 + # - SetA4Output(bool) forces A4 page size when True + # - For other sizes, KiCAD auto-scales to fit the board + # - SetAutoScale(True) enables automatic scaling to fit page + if page_size == "A4": + plot_opts.SetA4Output(True) + else: + # For non-A4 sizes, disable A4 forcing and use auto-scale + plot_opts.SetA4Output(False) + plot_opts.SetAutoScale(True) + # Note: KiCAD 9.0 doesn't support explicit page size selection + # for formats other than A4. The PDF will auto-scale to fit. + 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 plotted_layers = [] @@ -168,22 +194,34 @@ class ExportCommands: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0: - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): layer_name = self.board.GetLayerName(layer_id) - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() 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 { "success": True, "message": "Exported PDF file", "file": { - "path": output_path, + "path": actual_output_path, + "requestedPath": output_path, "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.SetPlotValue(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 plotted_layers = [] @@ -238,13 +276,15 @@ class ExportCommands: for layer_name in layers: layer_id = self.board.GetLayerID(layer_name) if layer_id >= 0: - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) else: for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): layer_name = self.board.GetLayerName(layer_id) - plotter.PlotLayer(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() plotted_layers.append(layer_name) return { @@ -265,7 +305,11 @@ class ExportCommands: } 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: if not self.board: return { @@ -288,46 +332,108 @@ class ExportCommands: "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 output_path = os.path.abspath(os.path.expanduser(output_path)) os.makedirs(os.path.dirname(output_path), exist_ok=True) - # Get 3D viewer - viewer = self.board.Get3DViewer() - if not viewer: + # Find kicad-cli executable + kicad_cli = self._find_kicad_cli() + if not kicad_cli: return { "success": False, - "message": "3D viewer not available", - "errorDetails": "Could not initialize 3D viewer" + "message": "kicad-cli not found", + "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH." } - # Set export options - viewer.SetCopperLayersOn(include_copper) - viewer.SetSolderMaskLayersOn(include_solder_mask) - viewer.SetSilkScreenLayersOn(include_silkscreen) - viewer.Set3DModelsOn(include_components) + # Build command based on format + format_upper = format.upper() + + if format_upper == "STEP": + 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: return { "success": False, "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 { "success": True, - "message": f"Exported {format} file", + "message": f"Exported {format_upper} file", "file": { "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: logger.error(f"Error exporting 3D model: {str(e)}") return { @@ -368,7 +474,7 @@ class ExportCommands: component = { "reference": module.GetReference(), "value": module.GetValue(), - "footprint": module.GetFootprintName(), + "footprint": str(module.GetFPID()), "layer": self.board.GetLayerName(module.GetLayer()) } @@ -473,3 +579,44 @@ class ExportCommands: import json with open(path, 'w') as f: 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 diff --git a/setup-windows.ps1 b/setup-windows.ps1 index b17d9b2..523f103 100644 --- a/setup-windows.ps1 +++ b/setup-windows.ps1 @@ -79,6 +79,7 @@ function Find-KiCAD { $possiblePaths = @( "C:\Program Files\KiCad", "C:\Program Files (x86)\KiCad" + "$env:USERPROFILE\AppData\Local\Programs\KiCad" ) $versions = @("9.0", "9.1", "10.0", "8.0") @@ -114,6 +115,7 @@ if ($kicad) { Write-Info "Python Path: $($kicad.PythonLib)" } else { 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/" $script:Results.Errors += "KiCAD not found" } diff --git a/src/index.ts b/src/index.ts index d7b37c5..6f31054 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,13 +107,12 @@ async function shutdownServer(server: KiCADMcpServer) { } } -// Run the main function if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { - logger.error(`Unhandled error in main: ${error}`); - process.exit(1); - }); -} +// Run the main function - always run when imported as module entry point +// The import.meta.url check was failing on Windows due to path separators +main().catch((error) => { + console.error(`Unhandled error in main: ${error}`); + process.exit(1); +}); // For testing and programmatic usage export { KiCADMcpServer }; diff --git a/src/logger.ts b/src/logger.ts index d597e12..6a22d8b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -86,21 +86,10 @@ class Logger { private log(level: LogLevel, message: string): void { const timestamp = new Date().toISOString(); const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; - - // Log to console - switch (level) { - case 'error': - console.error(formattedMessage); - break; - case 'warn': - console.warn(formattedMessage); - break; - case 'info': - case 'debug': - default: - console.log(formattedMessage); - break; - } + + // Log to console.error (stderr) only - stdout is reserved for MCP protocol + // All log levels go to stderr to avoid corrupting STDIO MCP transport + console.error(formattedMessage); // Log to file try { diff --git a/src/server.ts b/src/server.ts index 3e8fd72..f2dec85 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import express from 'express'; -import { spawn, ChildProcess } from 'child_process'; +import { spawn, exec, ChildProcess } from 'child_process'; import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { logger } from './logger.js'; @@ -176,7 +176,6 @@ export class KiCADMcpServer { logger.info('Validating pcbnew module access...'); const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`; - const { exec } = require('child_process'); try { 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 - * + * * @param command The command to execute * @param params The parameters for the command * @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")); 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({ - request: { command, params }, + request: { command, params, timeout: commandTimeout }, resolve, reject }); - + // Process the queue if not already processing if (!this.processingRequest) { this.processNextRequest(); @@ -376,40 +384,46 @@ export class KiCADMcpServer { // Set up response handling let responseData = ''; - + let timeoutHandle: NodeJS.Timeout | null = null; + // Clear any previous listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('end'); } - + // Set up new listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.on('data', (data: Buffer) => { const chunk = data.toString(); logger.debug(`Received data chunk: ${chunk.length} bytes`); responseData += chunk; - + // Check if we have a complete response try { // Try to parse the response as JSON const result = JSON.parse(responseData); - + // If we get here, we have a valid JSON response 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 this.processingRequest = false; - + // Process next request if any setTimeout(() => this.processNextRequest(), 0); - + // Clear listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('end'); } - + // Resolve the promise with the result resolve(result); } catch (e) { @@ -417,26 +431,27 @@ export class KiCADMcpServer { } }); } - - // Set a timeout - const timeout = setTimeout(() => { - logger.error(`Command timeout: ${request.command}`); - + + // Set a timeout (use command-specific timeout or default) + const timeoutDuration = request.timeout || 30000; + timeoutHandle = setTimeout(() => { + logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`); + // Clear listeners if (this.pythonProcess?.stdout) { this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('end'); } - + // Reset processing flag this.processingRequest = false; - + // Process next request setTimeout(() => this.processNextRequest(), 0); - + // Reject the promise - reject(new Error(`Command timeout: ${request.command}`)); - }, 30000); // 30 seconds timeout + reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`)); + }, timeoutDuration); // Write the request to the Python process logger.debug(`Sending request: ${requestStr}`);