Add [tool.black] config to pyproject.toml and Black hook to .pre-commit-config.yaml (rev 26.3.1), then auto-format all Python source and test files with line-length=100, target-version=py310. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
459 lines
19 KiB
Python
459 lines
19 KiB
Python
"""
|
|
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),
|
|
}
|