- 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>
404 lines
16 KiB
Python
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() |