@typecad/typecad
Version:
🤖programmatically 💥create 🛰️hardware
482 lines (481 loc) • 20.7 kB
JavaScript
// Converted to ESM for compatibility with ESM test runners
import { PadResolver } from './pad_resolver.js';
import chalk from 'chalk';
import SExpr from 's-expression.js';
import logger from '../../src/logging.js';
const S = new SExpr();
/**
* Builds routing obstacles from PCB elements.
* Converts components, pads, zones, and other PCB features into obstacle representations
* for the routing grid.
*/
export class ObstacleBuilder {
/**
* Build all obstacles from a PCB instance.
*
* @param pcb - The PCB to extract obstacles from
* @param defaultClearance - Default clearance for obstacles in mm
* @param additionalComponents - Additional components to include (e.g., from pins being routed)
* @returns Array of routing obstacles
*
* @example
* ```ts
* const obstacles = ObstacleBuilder.buildFromPCB(pcb, 0.2);
* obstacles.forEach(obs => grid.addObstacle(obs));
* ```
*/
static buildFromPCB(pcb, defaultClearance = 0.2, additionalComponents) {
const obstacles = [];
logger.debug(chalk.blue(`[ObstacleBuilder] Building obstacles from PCB`));
// Get components from PCB (access private fields through any for MVP)
// Collect from both #components and #stagedComponents
const allComponents = [];
// Prefer internal getters to avoid JS private field access issues
const components = pcb._getComponents?.() || pcb['#components'];
const stagedComponents = pcb._getStagedComponents?.() || pcb['#stagedComponents'];
if (components) {
allComponents.push(...components);
}
if (stagedComponents) {
allComponents.push(...stagedComponents);
}
// Add any additional components (e.g., from pins being routed)
if (additionalComponents) {
allComponents.push(...additionalComponents);
}
if (allComponents.length > 0) {
logger.debug(chalk.blue(`[ObstacleBuilder] Processing ${allComponents.length} components (${components?.length || 0} active, ${stagedComponents?.length || 0} staged, ${additionalComponents?.length || 0} from pins)`));
let failedComponents = 0;
const failedRefs = [];
for (const component of allComponents) {
if (component.dnp) {
continue; // Skip DNP components
}
// Treat placed vias as circular pad obstacles so traces respect via-to-trace clearance
if (component.via === true && component.viaData) {
try {
const viaObs = this.buildFromVia(component, defaultClearance);
if (viaObs) {
obstacles.push(viaObs);
}
continue; // Via handled, skip normal component pad processing
}
catch (e) {
logger.warn(chalk.yellow(`[ObstacleBuilder] Error processing via obstacle: ${e?.message || e}`));
continue;
}
}
try {
// Add pad obstacles with net-aware collision detection
// Pads block routes on different nets but not on same net
const padObstacles = this.buildFromComponentPads(component, defaultClearance, pcb);
obstacles.push(...padObstacles);
}
catch (error) {
failedComponents++;
failedRefs.push(component.reference);
logger.warn(chalk.yellow(`[ObstacleBuilder] Error processing component ${component.reference}: ${error.message}`));
}
}
if (failedComponents > 0) {
logger.warn(chalk.yellow(`[ObstacleBuilder] Failed to process ${failedComponents}/${allComponents.length} components: ${failedRefs.join(', ')}`));
}
}
else {
logger.debug(chalk.yellow(`[ObstacleBuilder] No components found in PCB`));
}
// Add zones
const zones = pcb['#zones'];
if (zones) {
logger.debug(chalk.blue(`[ObstacleBuilder] Processing ${zones.length} filled zones`));
for (const zone of zones) {
const zoneObstacle = this.buildFromZone(zone);
if (zoneObstacle) {
obstacles.push(zoneObstacle);
}
}
}
// Add keepout zones
const keepoutZones = pcb._getKeepoutZones?.() || pcb['#keepoutZones'];
if (keepoutZones) {
logger.debug(chalk.blue(`[ObstacleBuilder] Processing ${keepoutZones.length} keepout zones`));
for (const keepout of keepoutZones) {
const keepoutObstacle = this.buildFromKeepoutZone(keepout);
if (keepoutObstacle) {
obstacles.push(keepoutObstacle);
}
}
}
// Add board outlines as keepout
const outlines = pcb.outlines;
if (outlines && outlines.length > 0) {
logger.debug(chalk.blue(`[ObstacleBuilder] Processing ${outlines.length} board outlines`));
for (const outline of outlines) {
const outlineObstacle = this.buildFromOutline(outline, defaultClearance);
if (outlineObstacle) {
obstacles.push(outlineObstacle);
}
}
}
// Add existing tracks as obstacles (from committed tracks)
const grLines = pcb['#grLines'];
if (grLines && grLines.length > 0) {
logger.debug(chalk.blue(`[ObstacleBuilder] Processing ${grLines.length} committed tracks`));
for (const line of grLines) {
// Only process copper layer tracks
if (line.layer.endsWith('.Cu')) {
// Preserve net info so same-net routes can branch/merge correctly
const trackObstacle = this.buildFromTrack(line, line.strokeWidth, defaultClearance, line.net);
obstacles.push(trackObstacle);
}
}
}
// Add staged tracks as obstacles (tracks created but not yet committed via create())
const stagedOutlines = pcb._getStagedOutlines();
if (stagedOutlines && stagedOutlines.length > 0) {
let stagedTrackCount = 0;
for (const outline of stagedOutlines) {
if (outline.elements) {
for (const element of outline.elements) {
if (element.type === 'line' && element.layer.endsWith('.Cu')) {
const line = element;
const trackObstacle = this.buildFromTrack(line, line.strokeWidth, defaultClearance, line.net);
obstacles.push(trackObstacle);
stagedTrackCount++;
}
}
}
}
if (stagedTrackCount > 0) {
logger.debug(chalk.blue(`[ObstacleBuilder] Added ${stagedTrackCount} staged track obstacles from previous autoroute calls`));
}
}
logger.debug(chalk.green(`[ObstacleBuilder] Built ${obstacles.length} total obstacles`));
return obstacles;
}
/**
* Build an obstacle from a component's body (courtyard).
*
* @param component - The component
* @param clearance - Clearance around component in mm
* @returns Component body obstacle, or null if unable to determine bounds
*/
static buildFromComponent(component, clearance) {
try {
// Parse footprint to get courtyard bounds
const footprintSExpr = component.footprint_lib(component.footprint);
const parsed = S.parse(footprintSExpr);
// Extract courtyard or fabrication bounds
const bounds = this.extractComponentBounds(parsed, component);
if (!bounds) {
// Fallback: use simple bounding box estimate
return this.buildSimpleComponentObstacle(component, clearance);
}
// Determine which layer the component is on
const layer = component.pcb.side === 'back' ? 'B.Cu' : 'F.Cu';
return {
type: 'component',
bounds,
layers: [layer],
clearance,
priority: 1
};
}
catch (error) {
console.warn(chalk_1.default.yellow(`[ObstacleBuilder] Could not build obstacle for ${component.reference}: ${error.message}`));
return this.buildSimpleComponentObstacle(component, clearance);
}
}
/**
* Build obstacles from a component's pads.
*
* @param component - The component
* @param clearance - Clearance around pads in mm
* @param pcb - PCB instance to resolve nets
* @returns Array of pad obstacles
*/
static buildFromComponentPads(component, clearance, pcb) {
const obstacles = [];
const pads = PadResolver.getAllPadGeometries(component);
// Precompute (reference:pin) -> net map once per component to avoid repeated scans
let pinNetMap = null;
const schematic = pcb.Schematic;
if (schematic && Array.isArray(schematic.Nodes)) {
pinNetMap = new Map();
for (const schematicNode of schematic.Nodes) {
const netName = schematicNode?.name;
const nodes = schematicNode?.nodes;
if (!netName || !Array.isArray(nodes))
continue;
for (const p of nodes) {
const key = `${p.reference}:${String(p.number)}`;
if (!pinNetMap.has(key)) {
pinNetMap.set(key, netName);
}
}
}
}
for (const pad of pads) {
// Resolve net for this pad from precomputed map
let netName;
if (pinNetMap) {
netName = pinNetMap.get(`${component.reference}:${String(pad.number)}`);
}
// Log pad info for debugging
const layerInfo = pad.type === 'thru_hole' ? `all layers (${pad.layers.join(', ')})` : pad.layer;
// Pad-level debug info (logged via logger so it respects TYPECAD_DEBUG/TYPECAD_QUIET)
logger.debug(chalk.gray(`[ObstacleBuilder] Pad ${component.reference}.${pad.number}: ${pad.type} on ${layerInfo}, net: ${netName || 'none'}`));
// Always create obstacles for pads, even without nets (unconnected pads still block routing)
const obstacle = this.buildFromPad(pad, clearance, netName);
if (obstacle) {
obstacles.push(obstacle);
}
}
return obstacles;
}
/**
* Build an obstacle from a pad geometry.
* @private
*/
static buildFromPad(pad, clearance, net) {
// Calculate bounding box based on pad shape
let halfWidth = pad.size.width / 2;
let halfHeight = pad.size.height / 2;
// For rotated pads, calculate axis-aligned bounding box
if (pad.rotation !== 0) {
const rotRad = (pad.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(rotRad));
const sin = Math.abs(Math.sin(rotRad));
halfWidth = (pad.size.width * cos + pad.size.height * sin) / 2;
halfHeight = (pad.size.width * sin + pad.size.height * cos) / 2;
}
// For through-hole pads, use all layers they exist on (typically all copper layers)
// For SMD pads, use only the specific layer(s) they're on
const layers = pad.layers && pad.layers.length > 0 ? pad.layers : [pad.layer];
return {
type: 'pad',
bounds: {
minX: pad.center.x - halfWidth,
maxX: pad.center.x + halfWidth,
minY: pad.center.y - halfHeight,
maxY: pad.center.y + halfHeight
},
layers, // Use all layers for through-hole pads
clearance,
net, // Net-aware: obstacles on same net don't block
priority: 2, // Higher priority than component body
padShape: {
shape: (pad.shape === 'custom' ? 'rect' : pad.shape),
center: { x: pad.center.x, y: pad.center.y },
width: pad.size.width,
height: pad.size.height,
rotation: pad.rotation || 0
}
};
}
/**
* Build an obstacle from a filled zone.
*
* @param zone - The filled zone
* @returns Zone obstacle, or null if invalid
*/
static buildFromZone(zone) {
if (!zone.polygon || zone.polygon.length === 0) {
return null;
}
// Calculate bounding box from polygon
const bounds = this.calculatePolygonBounds(zone.polygon);
return {
type: 'zone',
bounds,
layers: zone.layers,
net: zone.net,
clearance: zone.clearance || 0.2,
priority: 0, // Lower priority - zones can often be routed over
polygon: { points: zone.polygon }
};
}
/**
* Build an obstacle from a keepout zone.
*
* @param zone - The keepout zone
* @returns Keepout obstacle, or null if tracks are allowed
*/
static buildFromKeepoutZone(zone) {
// Check if tracks are restricted in this keepout
if (zone.restrictions?.tracks === false) {
return null; // Tracks are allowed, no obstacle
}
if (!zone.polygon || zone.polygon.length === 0) {
return null;
}
const bounds = this.calculatePolygonBounds(zone.polygon);
return {
type: 'keepout',
bounds,
layers: zone.layers,
clearance: 0, // Keepout zones are absolute
priority: 10, // Very high priority - absolute no-go zones
polygon: { points: zone.polygon }
};
}
/**
* Build an obstacle from a track.
*
* @param track - The track (IGrLine)
* @param width - Track width in mm
* @param clearance - Clearance around track in mm
* @param net - Net name for net-aware routing (optional)
* @returns Track obstacle
*/
static buildFromTrack(track, width, clearance, net, isManualRoute) {
// Calculate bounding box for the track segment
const minX = Math.min(track.start.x, track.end.x) - width / 2;
const maxX = Math.max(track.start.x, track.end.x) + width / 2;
const minY = Math.min(track.start.y, track.end.y) - width / 2;
const maxY = Math.max(track.start.y, track.end.y) + width / 2;
return {
type: 'track',
bounds: { minX, maxX, minY, maxY },
layers: [track.layer],
clearance,
net, // Net-aware: tracks on same net don't block (unless isManualRoute is true)
priority: 1,
isManualRoute, // Manual routes block even on same net
segment: { x1: track.start.x, y1: track.start.y, x2: track.end.x, y2: track.end.y, width }
};
}
/**
* Build an obstacle from a board outline.
*
* @param outline - The board outline
* @param clearance - Clearance from outline in mm
* @returns Outline obstacle (everything outside the outline is blocked)
*/
static buildFromOutline(outline, clearance) {
// For now, treat outline as a simple bounding box keepout
// TODO: Implement proper polygon-based outline handling
return {
type: 'outline',
bounds: {
minX: outline.x,
maxX: outline.x + outline.width,
minY: outline.y,
maxY: outline.y + outline.height
},
layers: ['F.Cu', 'B.Cu'], // Affects all copper layers
clearance,
priority: 10 // Very high priority
};
}
/**
* Build an obstacle from a plated through via component.
* Represent as a circular pad on all copper layers to enforce
* trace-to-via clearances during routing.
*/
static buildFromVia(component, clearance) {
const vd = component?.viaData;
if (!vd || typeof vd.at?.x !== 'number' || typeof vd.at?.y !== 'number') {
return null;
}
const size = typeof vd.size === 'number' ? vd.size : 0.6; // default via size
const center = { x: vd.at.x, y: vd.at.y };
const half = size / 2;
// Use declared layers if provided, otherwise affect all copper layers
const layers = Array.isArray(vd.layers) && vd.layers.length > 0
? vd.layers
: ['F.Cu', 'B.Cu'];
return {
type: 'pad',
bounds: {
minX: center.x - half,
maxX: center.x + half,
minY: center.y - half,
maxY: center.y + half,
},
layers,
clearance,
priority: 1,
padShape: {
shape: 'circle',
center,
width: size,
height: size,
rotation: 0,
},
};
}
/**
* Extract component bounds from footprint S-expression.
* Looks for courtyard or fabrication layer bounds.
* @private
*/
static extractComponentBounds(footprintSExpr, component) {
// This is a simplified version - real implementation would parse
// courtyard polygons from the footprint
// For MVP, we'll use a simple approach: get all pads and create bounding box
const pads = pad_resolver_1.PadResolver.getAllPadGeometries(component);
if (pads.length === 0) {
return null;
}
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const pad of pads) {
const halfWidth = pad.size.width / 2;
const halfHeight = pad.size.height / 2;
minX = Math.min(minX, pad.center.x - halfWidth);
maxX = Math.max(maxX, pad.center.x + halfWidth);
minY = Math.min(minY, pad.center.y - halfHeight);
maxY = Math.max(maxY, pad.center.y + halfHeight);
}
// Expand bounds slightly to include component body
const expansion = 1.0; // 1mm expansion around pads
return {
minX: minX - expansion,
maxX: maxX + expansion,
minY: minY - expansion,
maxY: maxY + expansion
};
}
/**
* Build a simple component obstacle using estimated dimensions.
* @private
*/
static buildSimpleComponentObstacle(component, clearance) {
// Estimate component size as 5mm x 5mm for unknown footprints
const estimatedSize = 5.0;
const layer = component.pcb.side === 'back' ? 'B.Cu' : 'F.Cu';
return {
type: 'component',
bounds: {
minX: component.pcb.x - estimatedSize / 2,
maxX: component.pcb.x + estimatedSize / 2,
minY: component.pcb.y - estimatedSize / 2,
maxY: component.pcb.y + estimatedSize / 2
},
layers: [layer],
clearance,
priority: 1
};
}
/**
* Calculate bounding box from a polygon.
* @private
*/
static calculatePolygonBounds(polygon) {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const point of polygon) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return { minX, maxX, minY, maxY };
}
}
// exported above