mirror of
https://github.com/bbenchoff/OrthoRoute.git
synced 2026-01-02 12:21:54 +00:00
750 lines
32 KiB
Python
750 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Rich KiCad IPC Interface - Full-featured KiCad data loading for the new architecture
|
|
Provides the same rich functionality as the legacy kicad_interface.py
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, List, Optional, Tuple
|
|
from dataclasses import dataclass
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class DRCRules:
|
|
"""Container for Design Rule Check information"""
|
|
netclasses: Dict[str, Dict] # netclass_name -> rules dict
|
|
default_track_width: float # mm
|
|
default_via_size: float # mm
|
|
default_via_drill: float # mm
|
|
default_clearance: float # mm
|
|
minimum_track_width: float # mm
|
|
minimum_via_size: float # mm
|
|
|
|
@dataclass
|
|
class BoardData:
|
|
"""Container for board data extracted from KiCad"""
|
|
filename: str
|
|
width: float # mm
|
|
height: float # mm
|
|
layers: int
|
|
nets: List[Dict]
|
|
components: List[Dict]
|
|
tracks: List[Dict]
|
|
vias: List[Dict]
|
|
pads: List[Dict]
|
|
bounds: Tuple[float, float, float, float] # min_x, min_y, max_x, max_y
|
|
drc_rules: Optional[DRCRules] = None
|
|
|
|
|
|
def fetch_board_and_drc():
|
|
"""Fetch board and DRC data using IPC API (proper method)"""
|
|
from kicad import KiCad
|
|
|
|
try:
|
|
kc = KiCad() # IPC session (nng under the hood)
|
|
board = kc.get_board() # Active PCB document
|
|
project = kc.get_project() # Project owning the board
|
|
|
|
# Layers / stackup
|
|
layer_cnt = board.get_copper_layer_count()
|
|
logger.info(f"[LAYER-DETECT] board.get_copper_layer_count() returned: {layer_cnt}")
|
|
stackup = board.get_stackup() if hasattr(board, 'get_stackup') else None
|
|
|
|
# Nets and classes
|
|
nets = board.get_nets() # [{id, name, ...}, ...]
|
|
net_names = [n.get("name", "") for n in nets if n.get("name")]
|
|
netclass_by_net = board.get_netclass_for_nets(net_names) if net_names else {}
|
|
|
|
# All net classes (for defaults/fallbacks)
|
|
all_netclasses = {nc.get("name", "Default"): nc for nc in project.get_net_classes()}
|
|
|
|
# Pad polygons (for DRC keepouts)
|
|
# returns dict keyed by pad or (ref, pad_name) -> polygon(s)
|
|
pad_polys = board.get_pad_shapes_as_polygons(include_holes=False) if hasattr(board, 'get_pad_shapes_as_polygons') else {}
|
|
|
|
return {
|
|
"board": board,
|
|
"layer_cnt": layer_cnt,
|
|
"stackup": stackup,
|
|
"nets": nets,
|
|
"netclass_by_net": netclass_by_net,
|
|
"all_netclasses": all_netclasses,
|
|
"pad_polys": pad_polys,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"IPC DRC fetch failed: {e}")
|
|
return None
|
|
|
|
def _ipc_retry(func, desc: str, max_retries: int = 3, sleep_s: float = 0.5):
|
|
last_err = None
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
return func()
|
|
except Exception as e:
|
|
msg = str(e)
|
|
last_err = e
|
|
logger.warning(f"IPC '{desc}' failed (attempt {attempt}/{max_retries}): {msg}")
|
|
if "Timed out" in msg or "AS_BUSY" in msg or "busy" in msg.lower():
|
|
time.sleep(sleep_s)
|
|
continue
|
|
break
|
|
if last_err:
|
|
raise last_err
|
|
|
|
|
|
class RichKiCadInterface:
|
|
"""Rich interface to KiCad via IPC API with full data extraction"""
|
|
|
|
def __init__(self):
|
|
self.client = None
|
|
self.board = None
|
|
self.connected = False
|
|
|
|
def connect(self) -> bool:
|
|
"""Connect to KiCad via IPC API"""
|
|
try:
|
|
# Ensure kipy is importable from user site
|
|
try:
|
|
from kipy import KiCad # type: ignore
|
|
except ImportError:
|
|
import site
|
|
user_site = site.getusersitepackages()
|
|
if user_site and user_site not in sys.path:
|
|
sys.path.insert(0, user_site)
|
|
from kipy import KiCad # retry
|
|
|
|
# Gather credentials if provided by KiCad runtime
|
|
api_socket = os.environ.get('KICAD_API_SOCKET')
|
|
api_token = os.environ.get('KICAD_API_TOKEN')
|
|
timeout_ms = 120000 # 2 minutes - increased for large geometry commits (3000+ tracks/vias)
|
|
if api_socket or api_token:
|
|
self.client = KiCad(socket_path=api_socket, kicad_token=api_token, timeout_ms=timeout_ms)
|
|
else:
|
|
self.client = KiCad(timeout_ms=timeout_ms)
|
|
|
|
# Get board to confirm connection - try different methods
|
|
try:
|
|
# Method 1: Try get_board directly
|
|
self.board = _ipc_retry(self.client.get_board, "get_board", max_retries=2, sleep_s=0.5)
|
|
except Exception as e1:
|
|
logger.warning(f"get_board failed: {e1}")
|
|
try:
|
|
# Method 2: Try getting open documents first
|
|
docs = self.client.get_open_documents()
|
|
if docs and len(docs) > 0:
|
|
# Use first open document
|
|
self.board = docs[0]
|
|
logger.info(f"Retrieved board from open documents: {getattr(self.board, 'name', 'Unknown')}")
|
|
else:
|
|
raise Exception("No open documents found")
|
|
except Exception as e2:
|
|
logger.warning(f"get_open_documents failed: {e2}")
|
|
# Method 3: Try direct board access
|
|
self.board = self.client.board
|
|
if not self.board:
|
|
raise Exception("No board available through any method")
|
|
|
|
self.connected = True
|
|
logger.info("Connected to KiCad IPC API and retrieved board")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to KiCad: {e}")
|
|
self.connected = False
|
|
return False
|
|
|
|
def get_board_filename(self) -> str:
|
|
"""Get the current board filename using KiCad Python API"""
|
|
if not self.connected or not self.board:
|
|
logger.warning("Not connected to KiCad board")
|
|
return "Unknown"
|
|
|
|
board = self.board
|
|
try:
|
|
# Try multiple methods to get the board filename
|
|
if hasattr(board, 'GetFileName') and board.GetFileName():
|
|
filename = board.GetFileName()
|
|
elif hasattr(board, 'filename') and board.filename:
|
|
filename = board.filename
|
|
elif hasattr(board, 'name') and board.name:
|
|
filename = board.name
|
|
elif hasattr(board, '_board') and hasattr(board._board, 'GetFileName'):
|
|
filename = board._board.GetFileName()
|
|
elif hasattr(board, 'board') and hasattr(board.board, 'GetFileName'):
|
|
filename = board.board.GetFileName()
|
|
else:
|
|
filename = "Unknown"
|
|
|
|
# Extract just the filename from full path
|
|
if filename and filename != "Unknown":
|
|
filename = os.path.basename(filename)
|
|
|
|
logger.info(f"Board filename: {filename}")
|
|
return filename
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Could not get board filename: {e}")
|
|
return "Unknown"
|
|
|
|
def get_board_data(self) -> Optional[Dict]:
|
|
"""Extract comprehensive board data from KiCad"""
|
|
if not self.connected or not self.board:
|
|
logger.error("Not connected to KiCad")
|
|
return None
|
|
|
|
try:
|
|
board = self.board
|
|
logger.info("Extracting comprehensive board data from KiCad...")
|
|
|
|
# Get filename
|
|
filename = self.get_board_filename()
|
|
|
|
# Extract pads with polygon shapes
|
|
logger.info("Extracting pads with detailed geometry...")
|
|
pads = self._extract_pads(board)
|
|
logger.info(f"Found {len(pads)} pads - extracting with polygon shapes")
|
|
|
|
# Extract components
|
|
logger.info("Extracting components...")
|
|
components = self._extract_components(board)
|
|
|
|
# Extract tracks
|
|
logger.info("Extracting existing tracks...")
|
|
tracks = self._extract_tracks(board)
|
|
logger.info(f"Loaded {len(tracks)} existing tracks from KiCad")
|
|
|
|
# Extract zones (copper pours)
|
|
logger.info("Extracting zones...")
|
|
zones = self._extract_zones(board)
|
|
logger.info(f"Found {len(zones)} zones")
|
|
|
|
# Extract nets with pad connectivity
|
|
logger.info("Extracting nets with connectivity...")
|
|
nets_data = self._extract_nets(board, pads)
|
|
routable_nets = [net for net in nets_data.values() if len(net.get('pads', [])) >= 2]
|
|
|
|
|
|
logger.info(f"Found {len(nets_data)} nets with pads")
|
|
logger.info(f"Created {len(routable_nets)} routable nets (excluding 0 plane-connected nets)")
|
|
|
|
# Calculate board dimensions
|
|
logger.info("Calculating board dimensions...")
|
|
bounds, width, height = self._calculate_board_dimensions(board)
|
|
logger.info(f"Board dimensions calculated from geometry: {width:.1f} x {height:.1f} mm")
|
|
|
|
# Generate airwires for visualization
|
|
logger.info("Generating airwires...")
|
|
airwires = self._generate_airwires(routable_nets)
|
|
logger.info(f"Generated {len(airwires)} airwires from {len(routable_nets)} nets")
|
|
logger.info(f" Including 0 partially routed nets")
|
|
logger.info(f" Filtered out 0 nets with copper pours")
|
|
|
|
# Extract DRC rules
|
|
logger.info("Extracting DRC rules...")
|
|
drc_rules = self._extract_drc_rules(board)
|
|
logger.info(f"Extracted DRC rules: {len(drc_rules.get('netclasses', {}))} netclasses")
|
|
|
|
# Get layer count and names
|
|
layer_count, layer_names = self._get_layer_info(board)
|
|
logger.info(f"Large backplane detected ({len(pads)} pads), using {layer_count} copper layers")
|
|
|
|
# Build comprehensive board data
|
|
board_data = {
|
|
'filename': filename,
|
|
'pads': pads,
|
|
'components': components,
|
|
'tracks': tracks,
|
|
'zones': zones,
|
|
'nets': nets_data,
|
|
'airwires': airwires,
|
|
'bounds': bounds,
|
|
'width': width,
|
|
'height': height,
|
|
'layers': layer_count,
|
|
'layer_names': layer_names,
|
|
'drc_rules': drc_rules
|
|
}
|
|
|
|
logger.info(f"Extracted board data: {filename} ({width:.1f}x{height:.1f}mm, {layer_count} copper layers)")
|
|
logger.info(f" {len(routable_nets)} routable nets, {len(components)} components, {len(tracks)} tracks, {len(zones)} zones")
|
|
logger.info(f" Generated {len(airwires)} airwires for visualization")
|
|
logger.info(f" Extracted {len(drc_rules.get('netclasses', {}))} netclasses with design rules")
|
|
|
|
return board_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to extract board data: {e}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
return None
|
|
|
|
def _extract_pads(self, board) -> List[Dict]:
|
|
"""Extract all pads with detailed geometry using KiCad API"""
|
|
pads = []
|
|
try:
|
|
# Use the correct KiCad API method
|
|
all_pads = _ipc_retry(board.get_pads, "get_pads", max_retries=3, sleep_s=0.7)
|
|
logger.info(f"Found {len(all_pads)} pads using KiCad API")
|
|
|
|
for i, p in enumerate(all_pads):
|
|
try:
|
|
# Extract pad data using object attributes (not dictionary access)
|
|
pos = getattr(p, 'position', None)
|
|
x = float(getattr(pos, 'x', 0.0)) / 1000000.0 if pos is not None else 0.0 # Convert nm to mm
|
|
y = float(getattr(pos, 'y', 0.0)) / 1000000.0 if pos is not None else 0.0 # Convert nm to mm
|
|
|
|
net_obj = getattr(p, 'net', None)
|
|
net_name = getattr(net_obj, 'name', '') if net_obj else ''
|
|
net_code = getattr(net_obj, 'code', 0) if net_obj else 0
|
|
|
|
pad_number = getattr(p, 'number', '')
|
|
|
|
# Get pad geometry from padstack
|
|
padstack = getattr(p, 'padstack', None)
|
|
width = 1.0 # Default
|
|
height = 1.0 # Default
|
|
drill = 0.0
|
|
|
|
if padstack:
|
|
# Get drill diameter
|
|
drill_obj = getattr(padstack, 'drill', None)
|
|
if drill_obj:
|
|
drill_dia = getattr(drill_obj, 'diameter', None)
|
|
if drill_dia and hasattr(drill_dia, 'x'):
|
|
drill = float(getattr(drill_dia, 'x', 0.0)) / 1000000.0
|
|
|
|
# Get pad size from copper layers
|
|
copper_layers = getattr(padstack, 'copper_layers', [])
|
|
if copper_layers and len(copper_layers) > 0:
|
|
first_layer = copper_layers[0]
|
|
size = getattr(first_layer, 'size', None)
|
|
if size:
|
|
width = float(getattr(size, 'x', 1000000.0)) / 1000000.0
|
|
height = float(getattr(size, 'y', 1000000.0)) / 1000000.0
|
|
|
|
# Get component reference
|
|
footprint = getattr(p, 'footprint', None)
|
|
component_ref = getattr(footprint, 'reference', '') if footprint else ''
|
|
|
|
# Determine layers
|
|
layers = []
|
|
if drill > 0:
|
|
layers = ['F.Cu', 'B.Cu'] # Through-hole
|
|
else:
|
|
layers = ['F.Cu'] # SMD (assume front)
|
|
|
|
pad_data = {
|
|
'component': component_ref,
|
|
'name': pad_number,
|
|
'net_name': net_name,
|
|
'net_code': net_code,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height,
|
|
'drill': drill,
|
|
'layers': layers,
|
|
'type': 'through_hole' if drill > 0 else 'smd'
|
|
}
|
|
|
|
pads.append(pad_data)
|
|
|
|
# Log first few pads for debugging (gated behind DEBUG level)
|
|
if i < 5 and logger.isEnabledFor(logging.DEBUG):
|
|
pad_type = "through-hole" if drill > 0 else "SMD"
|
|
logger.debug(f"{pad_type} Pad {i}: pos=({x:.2f}, {y:.2f}), size=({width:.2f}x{height:.2f}) (SMD) [{pad_type}], net='{net_name}'")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error extracting pad {i}: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting pads: {e}")
|
|
|
|
return pads
|
|
|
|
def _extract_components(self, board) -> List[Dict]:
|
|
"""Extract component information using KiCad API"""
|
|
components = []
|
|
try:
|
|
# Use the correct KiCad API method
|
|
footprints = _ipc_retry(board.get_footprints, "get_footprints", max_retries=3, sleep_s=0.5)
|
|
logger.info(f"Found {len(footprints)} footprints using KiCad API")
|
|
|
|
for fp in footprints:
|
|
try:
|
|
# Extract component data using object attributes
|
|
reference = getattr(fp, 'reference', '')
|
|
value = getattr(fp, 'value', '')
|
|
library_id = getattr(fp, 'library_id', '')
|
|
|
|
pos = getattr(fp, 'position', None)
|
|
x = float(getattr(pos, 'x', 0.0)) / 1000000.0 if pos else 0.0 # Convert nm to mm
|
|
y = float(getattr(pos, 'y', 0.0)) / 1000000.0 if pos else 0.0 # Convert nm to mm
|
|
|
|
rotation = getattr(fp, 'orientation', 0.0)
|
|
layer = getattr(fp, 'layer', 'F.Cu')
|
|
|
|
component_data = {
|
|
'reference': reference,
|
|
'value': value,
|
|
'footprint': library_id,
|
|
'x': x,
|
|
'y': y,
|
|
'rotation': rotation,
|
|
'layer': layer
|
|
}
|
|
|
|
components.append(component_data)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error extracting component: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting components: {e}")
|
|
|
|
return components
|
|
|
|
def _extract_tracks(self, board) -> List[Dict]:
|
|
"""Extract existing tracks/traces using KiCad API"""
|
|
tracks = []
|
|
try:
|
|
# Use the correct KiCad API method
|
|
board_tracks = _ipc_retry(board.get_tracks, "get_tracks", max_retries=3, sleep_s=0.5)
|
|
logger.info(f"Found {len(board_tracks)} tracks using KiCad API")
|
|
|
|
for track in board_tracks:
|
|
try:
|
|
track_data = {
|
|
'start_x': track.get('start_x', 0),
|
|
'start_y': track.get('start_y', 0),
|
|
'end_x': track.get('end_x', 0),
|
|
'end_y': track.get('end_y', 0),
|
|
'width': track.get('width', 0.2),
|
|
'layer': track.get('layer', 'F.Cu'),
|
|
'net_name': track.get('net_name', '')
|
|
}
|
|
|
|
tracks.append(track_data)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error extracting track: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting tracks: {e}")
|
|
|
|
return tracks
|
|
|
|
def _extract_zones(self, board) -> List[Dict]:
|
|
"""Extract copper zones/pours"""
|
|
zones = []
|
|
try:
|
|
# KiCad zone extraction would go here
|
|
# For now, return empty list as zones are complex
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Error extracting zones: {e}")
|
|
|
|
return zones
|
|
|
|
def _extract_nets(self, board, pads: List[Dict]) -> Dict[str, Dict]:
|
|
"""Extract nets with pad connectivity"""
|
|
nets = {}
|
|
|
|
# Group pads by net
|
|
for pad in pads:
|
|
net_name = pad.get('net_name', '')
|
|
if not net_name:
|
|
continue
|
|
|
|
if net_name not in nets:
|
|
nets[net_name] = {
|
|
'name': net_name,
|
|
'code': pad.get('net_code', 0),
|
|
'pads': []
|
|
}
|
|
|
|
nets[net_name]['pads'].append(pad)
|
|
|
|
return nets
|
|
|
|
def _calculate_board_dimensions(self, board) -> Tuple[Tuple[float, float, float, float], float, float]:
|
|
"""Calculate board dimensions from KiCad API or pad positions"""
|
|
try:
|
|
# IPC method doesn't have get_board_info - skip API board bounds
|
|
# We'll calculate from pad positions below
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Board dimensions API unavailable (expected with IPC): {e}")
|
|
|
|
# Fallback: calculate from actual pad positions
|
|
try:
|
|
pads = _ipc_retry(board.get_pads, "get_pads", max_retries=3, sleep_s=0.5)
|
|
if pads and len(pads) > 0:
|
|
pad_positions = []
|
|
for p in pads: # Use all pads for accurate bounds calculation
|
|
try:
|
|
pos = getattr(p, 'position', None)
|
|
if pos is not None:
|
|
x = float(getattr(pos, 'x', 0.0)) / 1000000.0
|
|
y = float(getattr(pos, 'y', 0.0)) / 1000000.0
|
|
pad_positions.append((x, y))
|
|
except:
|
|
continue
|
|
|
|
if pad_positions:
|
|
min_x = min(pos[0] for pos in pad_positions) - 5.0 # 5mm margin
|
|
max_x = max(pos[0] for pos in pad_positions) + 5.0
|
|
min_y = min(pos[1] for pos in pad_positions) - 5.0
|
|
max_y = max(pos[1] for pos in pad_positions) + 5.0
|
|
width = max_x - min_x
|
|
height = max_y - min_y
|
|
logger.info(f"Calculated bounds from pad positions: ({min_x:.1f}, {min_y:.1f}) to ({max_x:.1f}, {max_y:.1f})")
|
|
return (min_x, min_y, max_x, max_y), width, height
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Could not calculate board dimensions from pads: {e}")
|
|
|
|
# Final fallback
|
|
return (0, 0, 100, 100), 100, 100
|
|
|
|
def _generate_airwires(self, nets: List[Dict]) -> List[Dict]:
|
|
"""Generate airwires for unrouted connections"""
|
|
airwires = []
|
|
|
|
for net in nets:
|
|
pads = net.get('pads', [])
|
|
if len(pads) < 2:
|
|
continue
|
|
|
|
# Create airwires between all pad pairs (minimum spanning tree would be better)
|
|
for i, pad1 in enumerate(pads[:-1]):
|
|
pad2 = pads[i + 1]
|
|
|
|
airwire = {
|
|
'net_name': net['name'],
|
|
'start_x': pad1['x'],
|
|
'start_y': pad1['y'],
|
|
'end_x': pad2['x'],
|
|
'end_y': pad2['y'],
|
|
'start_component': pad1.get('component', ''),
|
|
'end_component': pad2.get('component', ''),
|
|
'start_pad': pad1.get('name', ''),
|
|
'end_pad': pad2.get('name', '')
|
|
}
|
|
|
|
airwires.append(airwire)
|
|
|
|
return airwires
|
|
|
|
def _get_layer_info(self, board) -> tuple:
|
|
"""Get copper layer count and names using KiCad API with multiple detection methods
|
|
|
|
Returns:
|
|
Tuple of (layer_count: int, layer_names: List[str])
|
|
"""
|
|
|
|
# Method 1: Use BoardStackup layers API with material_name (MOST RELIABLE)
|
|
try:
|
|
stackup = _ipc_retry(board.get_stackup, "get_stackup", max_retries=3, sleep_s=0.5)
|
|
if stackup and hasattr(stackup, 'layers'):
|
|
copper_layers = []
|
|
for layer in stackup.layers:
|
|
# Check material_name for exact match
|
|
if hasattr(layer, 'material_name') and layer.material_name == 'copper':
|
|
copper_layers.append(layer)
|
|
|
|
if copper_layers:
|
|
layer_count = len(copper_layers)
|
|
layer_names = [getattr(l, 'user_name', f'Layer{i}') for i, l in enumerate(copper_layers)]
|
|
logger.info(f"Got layer count from BoardStackup.material_name: {layer_count} copper layers")
|
|
logger.info(f"Copper layers: {layer_names}")
|
|
return (layer_count, layer_names)
|
|
except Exception as e:
|
|
logger.warning(f"BoardStackup material_name detection failed: {e}")
|
|
|
|
# Method 2: Use direct IPC API for copper layer count (returns count only, generate names)
|
|
try:
|
|
layer_count = _ipc_retry(board.get_copper_layer_count, "get_copper_layer_count", max_retries=3, sleep_s=0.5)
|
|
if layer_count and layer_count > 0:
|
|
logger.info(f"Got layer count from IPC API: {layer_count} copper layers")
|
|
# Generate standard layer names
|
|
layer_names = self._generate_layer_names(layer_count)
|
|
return (layer_count, layer_names)
|
|
else:
|
|
logger.warning(f"Method 2 returned invalid layer count: {layer_count}")
|
|
except Exception as e:
|
|
logger.warning(f"Method 2 failed - IPC copper layer count: {e}")
|
|
|
|
# Method 3: Try to get layer stack
|
|
try:
|
|
stackup = _ipc_retry(board.get_stackup, "get_stackup", max_retries=3, sleep_s=0.5)
|
|
if stackup and isinstance(stackup, (list, tuple)):
|
|
copper_layers = [layer for layer in stackup if 'Cu' in str(layer)]
|
|
if copper_layers:
|
|
layer_count = len(copper_layers)
|
|
logger.info(f"Got layer count from stackup: {layer_count}")
|
|
layer_names = self._generate_layer_names(layer_count)
|
|
return (layer_count, layer_names)
|
|
except Exception as e:
|
|
logger.debug(f"Method 3 failed - stackup: {e}")
|
|
|
|
# Method 4: Try to detect from layer names
|
|
try:
|
|
# Common approach - try to get layers info
|
|
layers_info = _ipc_retry(board.get_layers, "get_layers", max_retries=3, sleep_s=0.5)
|
|
if layers_info:
|
|
if isinstance(layers_info, dict):
|
|
copper_layers = [name for name in layers_info.keys() if 'Cu' in name]
|
|
elif isinstance(layers_info, (list, tuple)):
|
|
copper_layers = [layer for layer in layers_info if 'Cu' in str(layer)]
|
|
else:
|
|
copper_layers = []
|
|
|
|
if copper_layers:
|
|
logger.info(f"Got layer count from layers info: {len(copper_layers)}")
|
|
return (len(copper_layers), copper_layers)
|
|
except Exception as e:
|
|
logger.debug(f"Method 4 failed - layers info: {e}")
|
|
|
|
# Method 5: Try common layer names (KiCad standard) by probing
|
|
try:
|
|
standard_layers = [
|
|
'F.Cu', 'In1.Cu', 'In2.Cu', 'In3.Cu', 'In4.Cu', 'In5.Cu',
|
|
'In6.Cu', 'In7.Cu', 'In8.Cu', 'In9.Cu', 'In10.Cu', 'In11.Cu',
|
|
'In12.Cu', 'In13.Cu', 'In14.Cu', 'In15.Cu', 'In16.Cu', 'In17.Cu',
|
|
'In18.Cu', 'In19.Cu', 'In20.Cu', 'In21.Cu', 'In22.Cu', 'In23.Cu',
|
|
'In24.Cu', 'In25.Cu', 'In26.Cu', 'In27.Cu', 'In28.Cu', 'In29.Cu',
|
|
'In30.Cu', 'B.Cu'
|
|
]
|
|
|
|
detected_layers = []
|
|
for layer_name in standard_layers:
|
|
try:
|
|
# Try to access layer properties to see if it exists
|
|
layer_info = _ipc_retry(board.get_layer_info, "get_layer_info", layer_name, max_retries=1, sleep_s=0.1)
|
|
if layer_info:
|
|
detected_layers.append(layer_name)
|
|
except:
|
|
continue # Layer doesn't exist
|
|
|
|
if len(detected_layers) >= 2: # At least F.Cu and B.Cu
|
|
logger.info(f"Detected layers by probing: {detected_layers}")
|
|
return (len(detected_layers), detected_layers)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Method 4 failed - layer probing: {e}")
|
|
|
|
# Fallback: Default to 2 layers but log the issue
|
|
logger.error("CRITICAL: All layer count detection methods failed!")
|
|
logger.error("This means board.get_copper_layer_count() is not working")
|
|
logger.error("Check KiCad version (requires 9.0.5+) and IPC API connection")
|
|
logger.warning("Could not detect layer count using any method - defaulting to 2 layers")
|
|
logger.warning("This may cause routing to fail on multi-layer boards!")
|
|
return (2, ['F.Cu', 'B.Cu'])
|
|
|
|
def _generate_layer_names(self, layer_count: int) -> list:
|
|
"""Generate standard KiCad layer names for given layer count"""
|
|
if layer_count == 2:
|
|
return ['F.Cu', 'B.Cu']
|
|
elif layer_count < 2:
|
|
return ['F.Cu'] # Should never happen
|
|
else:
|
|
# Generate: F.Cu, In1.Cu, In2.Cu, ..., In(N-2).Cu, B.Cu
|
|
layers = ['F.Cu']
|
|
for i in range(1, layer_count - 1):
|
|
layers.append(f'In{i}.Cu')
|
|
layers.append('B.Cu')
|
|
return layers
|
|
|
|
def _extract_drc_rules(self, board) -> Dict:
|
|
"""Extract design rules and netclasses using IPC API (proper method)"""
|
|
logger.info("Extracting DRC rules using IPC API...")
|
|
|
|
# Try IPC-based DRC extraction first
|
|
try:
|
|
ipc_data = fetch_board_and_drc()
|
|
if ipc_data and ipc_data.get('all_netclasses'):
|
|
return self._process_ipc_drc_data(ipc_data)
|
|
except Exception as e:
|
|
logger.warning(f"IPC-based DRC extraction failed: {e}")
|
|
|
|
# Fallback to safe defaults
|
|
logger.info("Using fallback DRC defaults")
|
|
return {
|
|
'netclasses': {
|
|
'Default': {
|
|
'track_width': 0.2,
|
|
'via_size': 0.8,
|
|
'clearance': 0.2
|
|
}
|
|
},
|
|
'default_track_width': 0.2,
|
|
'default_via_size': 0.8,
|
|
'default_clearance': 0.2,
|
|
'netclass_by_net': {},
|
|
'pad_polygons': {}
|
|
}
|
|
|
|
def _process_ipc_drc_data(self, ipc_data: Dict) -> Dict:
|
|
"""Process IPC DRC data into our expected format"""
|
|
drc_data = {
|
|
'netclasses': {},
|
|
'default_track_width': 0.2,
|
|
'default_via_size': 0.8,
|
|
'default_clearance': 0.2,
|
|
'netclass_by_net': ipc_data.get('netclass_by_net', {}),
|
|
'pad_polygons': ipc_data.get('pad_polys', {})
|
|
}
|
|
|
|
# Process netclasses from IPC data
|
|
all_netclasses = ipc_data.get('all_netclasses', {})
|
|
|
|
for nc_name, nc_data in all_netclasses.items():
|
|
if not nc_data:
|
|
continue
|
|
|
|
# Extract netclass properties (IPC format)
|
|
track_width = nc_data.get('track_width', nc_data.get('TrackWidth', 0.2))
|
|
via_size = nc_data.get('via_size', nc_data.get('ViaDiameter', 0.8))
|
|
clearance = nc_data.get('clearance', nc_data.get('Clearance', 0.2))
|
|
|
|
# Convert to mm if needed (KiCad sometimes returns nanometers)
|
|
if track_width > 10: # Likely in nanometers or micrometers
|
|
track_width = track_width / 1000000 # Convert nm to mm
|
|
if via_size > 10:
|
|
via_size = via_size / 1000000
|
|
if clearance > 10:
|
|
clearance = clearance / 1000000
|
|
|
|
drc_data['netclasses'][nc_name] = {
|
|
'track_width': track_width,
|
|
'via_size': via_size,
|
|
'clearance': clearance
|
|
}
|
|
|
|
logger.info(f" NetClass '{nc_name}': track={track_width:.3f}mm via={via_size:.3f}mm clearance={clearance:.3f}mm")
|
|
|
|
# Set defaults from Default netclass
|
|
if nc_name == 'Default':
|
|
drc_data['default_track_width'] = track_width
|
|
drc_data['default_via_size'] = via_size
|
|
drc_data['default_clearance'] = clearance
|
|
|
|
# Ensure we have at least a Default netclass
|
|
if 'Default' not in drc_data['netclasses']:
|
|
drc_data['netclasses']['Default'] = {
|
|
'track_width': drc_data['default_track_width'],
|
|
'via_size': drc_data['default_via_size'],
|
|
'clearance': drc_data['default_clearance']
|
|
}
|
|
logger.info(f" NetClass 'Default' (fallback): track={drc_data['default_track_width']:.3f}mm via={drc_data['default_via_size']:.3f}mm clearance={drc_data['default_clearance']:.3f}mm")
|
|
|
|
return drc_data |