""" Design rules command implementations for KiCAD interface """ import os import pcbnew import logging from typing import Dict, Any, Optional, List, Tuple logger = logging.getLogger("kicad_interface") class DesignRuleCommands: """Handles design rule checking and configuration""" def __init__(self, board: Optional[pcbnew.BOARD] = None): """Initialize with optional board instance""" self.board = board def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: """Set design rules for the PCB""" try: if not self.board: return { "success": False, "message": "No board is loaded", "errorDetails": "Load or create a board first", } design_settings = self.board.GetDesignSettings() # Convert mm to nanometers for KiCAD internal units scale = 1000000 # mm to nm # Set clearance if "clearance" in params: 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 if "trackWidth" in params: design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale)) custom_values_set = True # Via settings if "viaDiameter" in params: design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale)) custom_values_set = True if "viaDrill" in params: design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale)) custom_values_set = True # 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.m_MicroViasMinSize = int( params["microViaDiameter"] * scale ) if "microViaDrill" in params: 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_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 ) # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill if "minHoleDiameter" in params: design_settings.m_MinThroughDrill = int( params["minHoleDiameter"] * 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": response_rules, } except Exception as e: logger.error(f"Error setting design rules: {str(e)}") return { "success": False, "message": "Failed to set design rules", "errorDetails": str(e), } def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get current design rules - KiCAD 9.0 compatible""" try: if not self.board: return { "success": False, "message": "No board is loaded", "errorDetails": "Load or create a board first", } 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": rules} except Exception as e: logger.error(f"Error getting design rules: {str(e)}") return { "success": False, "message": "Failed to get design rules", "errorDetails": str(e), } def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Design Rule Check using kicad-cli""" import subprocess import json import tempfile import platform import shutil try: if not self.board: return { "success": False, "message": "No board is loaded", "errorDetails": "Load or create a board first", } report_path = params.get("reportPath") # 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", } # 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, } # 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: dict[str, int] = {} 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") # Extract location from first item's pos (kicad-cli JSON format) items = violation.get("items", []) loc_x, loc_y = 0, 0 if items and "pos" in items[0]: loc_x = items[0]["pos"].get("x", 0) loc_y = items[0]["pos"].get("y", 0) violations.append( { "type": vtype, "severity": vseverity, "message": violation.get("description", ""), "location": { "x": loc_x, "y": loc_y, "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": 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 { "success": False, "message": "Failed to run DRC", "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 Note: This command internally uses run_drc() which calls kicad-cli. The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0. This implementation provides backward compatibility by parsing kicad-cli output. """ import json try: if not self.board: return { "success": False, "message": "No board is loaded", "errorDetails": "Load or create a board first", } severity = params.get("severity", "all") # Run DRC using kicad-cli (this saves violations to JSON file) drc_result = self.run_drc({}) if not drc_result.get("success"): return drc_result # Return the error from run_drc # Read violations from the saved JSON file violations_file = drc_result.get("violationsFile") if not violations_file or not os.path.exists(violations_file): return { "success": False, "message": "Violations file not found", "errorDetails": "run_drc did not create violations file", } # Load violations from file with open(violations_file, "r", encoding="utf-8") as f: data = json.load(f) all_violations = data.get("violations", []) # Filter by severity if specified if severity != "all": filtered_violations = [ v for v in all_violations if v.get("severity") == severity ] else: filtered_violations = all_violations return { "success": True, "violations": filtered_violations, "violationsFile": violations_file, # Include file path for reference } except Exception as e: logger.error(f"Error getting DRC violations: {str(e)}") return { "success": False, "message": "Failed to get DRC violations", "errorDetails": str(e), }