kicad-mcp/kicad_mcp/utils/model3d_analyzer.py
Ryan Malloy 995dfd57c1 Add comprehensive advanced KiCad features and fix MCP compatibility issues
- Implement 3D model analysis and mechanical constraints checking
- Add advanced DRC rule customization for HDI, RF, and automotive applications
- Create symbol library management with analysis and validation tools
- Implement PCB layer stack-up analysis with impedance calculations
- Fix Context parameter validation errors causing client failures
- Add enhanced tool annotations with examples for better LLM compatibility
- Include comprehensive test coverage improvements (22.21% coverage)
- Add CLAUDE.md documentation for development guidance

New Advanced Tools:
• 3D model analysis: analyze_3d_models, check_mechanical_constraints
• Advanced DRC: create_drc_rule_set, analyze_pcb_drc_violations
• Symbol management: analyze_symbol_library, validate_symbol_library
• Layer analysis: analyze_pcb_stackup, calculate_trace_impedance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 15:57:46 -06:00

404 lines
16 KiB
Python

"""
3D Model Analysis utilities for KiCad PCB files.
Provides functionality to analyze 3D models, visualizations, and mechanical constraints
from KiCad PCB files including component placement, clearances, and board dimensions.
"""
import json
import os
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple, Any
import logging
logger = logging.getLogger(__name__)
@dataclass
class Component3D:
"""Represents a 3D component with position and model information."""
reference: str
position: Tuple[float, float, float] # X, Y, Z coordinates in mm
rotation: Tuple[float, float, float] # Rotation around X, Y, Z axes
model_path: Optional[str]
model_scale: Tuple[float, float, float] = (1.0, 1.0, 1.0)
model_offset: Tuple[float, float, float] = (0.0, 0.0, 0.0)
footprint: Optional[str] = None
value: Optional[str] = None
@dataclass
class BoardDimensions:
"""PCB board physical dimensions and constraints."""
width: float # mm
height: float # mm
thickness: float # mm
outline_points: List[Tuple[float, float]] # Board outline coordinates
holes: List[Tuple[float, float, float]] # Hole positions and diameters
keepout_areas: List[Dict[str, Any]] # Keepout zones
@dataclass
class MechanicalAnalysis:
"""Results of mechanical/3D analysis."""
board_dimensions: BoardDimensions
components: List[Component3D]
clearance_violations: List[Dict[str, Any]]
height_analysis: Dict[str, float] # min, max, average heights
mechanical_constraints: List[str] # Constraint violations or warnings
class Model3DAnalyzer:
"""Analyzer for 3D models and mechanical aspects of KiCad PCBs."""
def __init__(self, pcb_file_path: str):
"""Initialize with PCB file path."""
self.pcb_file_path = pcb_file_path
self.pcb_data = None
self._load_pcb_data()
def _load_pcb_data(self) -> None:
"""Load and parse PCB file data."""
try:
with open(self.pcb_file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Parse S-expression format (simplified)
self.pcb_data = content
except Exception as e:
logger.error(f"Failed to load PCB file {self.pcb_file_path}: {e}")
self.pcb_data = None
def extract_3d_components(self) -> List[Component3D]:
"""Extract 3D component information from PCB data."""
components = []
if not self.pcb_data:
return components
# Parse footprint modules with 3D models
footprint_pattern = r'\(footprint\s+"([^"]+)"[^)]*\(at\s+([\d.-]+)\s+([\d.-]+)(?:\s+([\d.-]+))?\)'
model_pattern = r'\(model\s+"([^"]+)"[^)]*\(at\s+\(xyz\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\)\)[^)]*\(scale\s+\(xyz\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\)\)'
reference_pattern = r'\(fp_text\s+reference\s+"([^"]+)"'
value_pattern = r'\(fp_text\s+value\s+"([^"]+)"'
# Find all footprints
for footprint_match in re.finditer(footprint_pattern, self.pcb_data, re.MULTILINE):
footprint_name = footprint_match.group(1)
x_pos = float(footprint_match.group(2))
y_pos = float(footprint_match.group(3))
rotation = float(footprint_match.group(4)) if footprint_match.group(4) else 0.0
# Extract the footprint section
start_pos = footprint_match.start()
footprint_section = self._extract_footprint_section(start_pos)
# Find reference and value within this footprint
ref_match = re.search(reference_pattern, footprint_section)
val_match = re.search(value_pattern, footprint_section)
reference = ref_match.group(1) if ref_match else "Unknown"
value = val_match.group(1) if val_match else ""
# Find 3D model within this footprint
model_match = re.search(model_pattern, footprint_section)
if model_match:
model_path = model_match.group(1)
model_x = float(model_match.group(2))
model_y = float(model_match.group(3))
model_z = float(model_match.group(4))
scale_x = float(model_match.group(5))
scale_y = float(model_match.group(6))
scale_z = float(model_match.group(7))
component = Component3D(
reference=reference,
position=(x_pos, y_pos, 0.0), # Z will be calculated from model
rotation=(0.0, 0.0, rotation),
model_path=model_path,
model_scale=(scale_x, scale_y, scale_z),
model_offset=(model_x, model_y, model_z),
footprint=footprint_name,
value=value
)
components.append(component)
logger.info(f"Extracted {len(components)} 3D components from PCB")
return components
def _extract_footprint_section(self, start_pos: int) -> str:
"""Extract a complete footprint section from PCB data."""
if not self.pcb_data:
return ""
# Find the matching closing parenthesis
level = 0
i = start_pos
while i < len(self.pcb_data):
if self.pcb_data[i] == '(':
level += 1
elif self.pcb_data[i] == ')':
level -= 1
if level == 0:
return self.pcb_data[start_pos:i+1]
i += 1
return self.pcb_data[start_pos:start_pos + 10000] # Fallback
def analyze_board_dimensions(self) -> BoardDimensions:
"""Analyze board physical dimensions and constraints."""
if not self.pcb_data:
return BoardDimensions(0, 0, 1.6, [], [], [])
# Extract board outline (Edge.Cuts layer)
edge_pattern = r'\(gr_line\s+\(start\s+([\d.-]+)\s+([\d.-]+)\)\s+\(end\s+([\d.-]+)\s+([\d.-]+)\)\s+\(stroke[^)]*\)\s+\(layer\s+"Edge\.Cuts"\)'
outline_points = []
for match in re.finditer(edge_pattern, self.pcb_data):
start_x, start_y = float(match.group(1)), float(match.group(2))
end_x, end_y = float(match.group(3)), float(match.group(4))
outline_points.extend([(start_x, start_y), (end_x, end_y)])
# Calculate board dimensions
if outline_points:
x_coords = [p[0] for p in outline_points]
y_coords = [p[1] for p in outline_points]
width = max(x_coords) - min(x_coords)
height = max(y_coords) - min(y_coords)
else:
width = height = 0
# Extract board thickness from stackup (if available) or default to 1.6mm
thickness = 1.6
thickness_pattern = r'\(thickness\s+([\d.]+)\)'
thickness_match = re.search(thickness_pattern, self.pcb_data)
if thickness_match:
thickness = float(thickness_match.group(1))
# Find holes
holes = []
hole_pattern = r'\(pad[^)]*\(type\s+thru_hole\)[^)]*\(at\s+([\d.-]+)\s+([\d.-]+)\)[^)]*\(size\s+([\d.-]+)'
for match in re.finditer(hole_pattern, self.pcb_data):
x, y, diameter = float(match.group(1)), float(match.group(2)), float(match.group(3))
holes.append((x, y, diameter))
return BoardDimensions(
width=width,
height=height,
thickness=thickness,
outline_points=list(set(outline_points)), # Remove duplicates
holes=holes,
keepout_areas=[] # TODO: Extract keepout zones
)
def analyze_component_heights(self, components: List[Component3D]) -> Dict[str, float]:
"""Analyze component height distribution."""
heights = []
for component in components:
if component.model_path:
# Estimate height from model scale and type
estimated_height = self._estimate_component_height(component)
heights.append(estimated_height)
if not heights:
return {"min": 0, "max": 0, "average": 0, "count": 0}
return {
"min": min(heights),
"max": max(heights),
"average": sum(heights) / len(heights),
"count": len(heights)
}
def _estimate_component_height(self, component: Component3D) -> float:
"""Estimate component height based on footprint and model."""
# Component height estimation based on common footprint patterns
footprint_heights = {
# SMD packages
"0402": 0.6,
"0603": 0.95,
"0805": 1.35,
"1206": 1.7,
# IC packages
"SOIC": 2.65,
"QFP": 1.75,
"BGA": 1.5,
"TQFP": 1.4,
# Through-hole
"DIP": 4.0,
"TO-220": 4.5,
"TO-92": 4.5,
}
# Check footprint name for height hints
footprint = component.footprint or ""
for pattern, height in footprint_heights.items():
if pattern in footprint.upper():
return height * component.model_scale[2] # Apply Z scaling
# Default height based on model scale
return 2.0 * component.model_scale[2]
def check_clearance_violations(self, components: List[Component3D],
board_dims: BoardDimensions) -> List[Dict[str, Any]]:
"""Check for 3D clearance violations between components."""
violations = []
# Component-to-component clearance
for i, comp1 in enumerate(components):
for j, comp2 in enumerate(components[i+1:], i+1):
distance = self._calculate_3d_distance(comp1, comp2)
min_clearance = self._get_minimum_clearance(comp1, comp2)
if distance < min_clearance:
violations.append({
"type": "component_clearance",
"component1": comp1.reference,
"component2": comp2.reference,
"distance": distance,
"required_clearance": min_clearance,
"severity": "warning" if distance > min_clearance * 0.8 else "error"
})
# Board edge clearance
for component in components:
edge_distance = self._distance_to_board_edge(component, board_dims)
min_edge_clearance = 0.5 # 0.5mm minimum edge clearance
if edge_distance < min_edge_clearance:
violations.append({
"type": "board_edge_clearance",
"component": component.reference,
"distance": edge_distance,
"required_clearance": min_edge_clearance,
"severity": "warning"
})
return violations
def _calculate_3d_distance(self, comp1: Component3D, comp2: Component3D) -> float:
"""Calculate 3D distance between two components."""
dx = comp1.position[0] - comp2.position[0]
dy = comp1.position[1] - comp2.position[1]
dz = comp1.position[2] - comp2.position[2]
return (dx*dx + dy*dy + dz*dz) ** 0.5
def _get_minimum_clearance(self, comp1: Component3D, comp2: Component3D) -> float:
"""Get minimum required clearance between components."""
# Base clearance rules (can be made more sophisticated)
base_clearance = 0.2 # 0.2mm base clearance
# Larger clearance for high-power components
if any(keyword in (comp1.value or "") + (comp2.value or "")
for keyword in ["POWER", "REGULATOR", "MOSFET"]):
return base_clearance + 1.0
return base_clearance
def _distance_to_board_edge(self, component: Component3D,
board_dims: BoardDimensions) -> float:
"""Calculate minimum distance from component to board edge."""
if not board_dims.outline_points:
return float('inf')
# Simplified calculation - distance to bounding rectangle
x_coords = [p[0] for p in board_dims.outline_points]
y_coords = [p[1] for p in board_dims.outline_points]
min_x, max_x = min(x_coords), max(x_coords)
min_y, max_y = min(y_coords), max(y_coords)
comp_x, comp_y = component.position[0], component.position[1]
# Distance to each edge
distances = [
comp_x - min_x, # Left edge
max_x - comp_x, # Right edge
comp_y - min_y, # Bottom edge
max_y - comp_y # Top edge
]
return min(distances)
def generate_3d_visualization_data(self) -> Dict[str, Any]:
"""Generate data structure for 3D visualization."""
components = self.extract_3d_components()
board_dims = self.analyze_board_dimensions()
height_analysis = self.analyze_component_heights(components)
clearance_violations = self.check_clearance_violations(components, board_dims)
return {
"board_dimensions": {
"width": board_dims.width,
"height": board_dims.height,
"thickness": board_dims.thickness,
"outline": board_dims.outline_points,
"holes": board_dims.holes
},
"components": [
{
"reference": comp.reference,
"position": comp.position,
"rotation": comp.rotation,
"model_path": comp.model_path,
"footprint": comp.footprint,
"value": comp.value,
"estimated_height": self._estimate_component_height(comp)
}
for comp in components
],
"height_analysis": height_analysis,
"clearance_violations": clearance_violations,
"stats": {
"total_components": len(components),
"components_with_3d_models": len([c for c in components if c.model_path]),
"violation_count": len(clearance_violations)
}
}
def perform_mechanical_analysis(self) -> MechanicalAnalysis:
"""Perform comprehensive mechanical analysis."""
components = self.extract_3d_components()
board_dims = self.analyze_board_dimensions()
height_analysis = self.analyze_component_heights(components)
clearance_violations = self.check_clearance_violations(components, board_dims)
# Generate mechanical constraints and warnings
constraints = []
if height_analysis["max"] > 10.0: # 10mm height limit example
constraints.append(f"Board height {height_analysis['max']:.1f}mm exceeds 10mm limit")
if board_dims.width > 100 or board_dims.height > 100:
constraints.append(f"Board dimensions {board_dims.width:.1f}x{board_dims.height:.1f}mm are large")
if len(clearance_violations) > 0:
constraints.append(f"{len(clearance_violations)} clearance violations found")
return MechanicalAnalysis(
board_dimensions=board_dims,
components=components,
clearance_violations=clearance_violations,
height_analysis=height_analysis,
mechanical_constraints=constraints
)
def analyze_pcb_3d_models(pcb_file_path: str) -> Dict[str, Any]:
"""Convenience function to analyze 3D models in a PCB file."""
try:
analyzer = Model3DAnalyzer(pcb_file_path)
return analyzer.generate_3d_visualization_data()
except Exception as e:
logger.error(f"Failed to analyze 3D models in {pcb_file_path}: {e}")
return {"error": str(e)}
def get_mechanical_constraints(pcb_file_path: str) -> MechanicalAnalysis:
"""Get mechanical analysis and constraints for a PCB."""
analyzer = Model3DAnalyzer(pcb_file_path)
return analyzer.perform_mechanical_analysis()