UNPKG

@typecad/typecad

Version:

🤖programmatically 💥create 🛰️hardware

759 lines (758 loc) • 37.4 kB
// Converted to ESM for compatibility with ESM test runners import chalk from 'chalk'; import logger from '../../src/logging.js'; /** * Grid-based spatial representation for PCB routing. * Discretizes continuous PCB space into a uniform grid for pathfinding algorithms. */ export class RoutingGrid { /** * Creates a new routing grid. * * @param bounds - World-space bounds of the routing area in mm * @param gridResolution - Size of each grid cell in mm (e.g., 0.1 = 10 cells per mm) * @param layers - List of copper layers to route on (e.g., ['F.Cu', 'B.Cu']) * * @example * ```ts * const grid = new RoutingGrid( * { minX: 0, maxX: 100, minY: 0, maxY: 80 }, * 0.1, // 0.1mm per cell * ['F.Cu', 'B.Cu'] * ); * ``` */ constructor(bounds, gridResolution, layers) { this.bounds = bounds; this.gridResolution = gridResolution; this.layers = layers; this.cells = new Map(); this.maxObstacleClearance = 0; // Calculate grid dimensions this.gridWidth = Math.ceil((bounds.maxX - bounds.minX) / gridResolution); this.gridHeight = Math.ceil((bounds.maxY - bounds.minY) / gridResolution); this.maxObstacleHalfWidthMm = 0; logger.debug(chalk.blue(`[RoutingGrid] Created grid: ${this.gridWidth}x${this.gridHeight} cells (${this.gridWidth * this.gridHeight * layers.length} total across ${layers.length} layers)`)); logger.debug(chalk.blue(`[RoutingGrid] Resolution: ${gridResolution}mm, Bounds: ${bounds.minX},${bounds.minY} to ${bounds.maxX},${bounds.maxY}`)); } /** * Add an obstacle to the grid. * Marks all cells within the obstacle bounds as occupied. * Note: Clearance is NOT applied here - it's checked during routing in isOccupied(). * * @param obstacle - The obstacle to add */ addObstacle(obstacle) { // Expand wildcard layer specifications (e.g., '*.Cu') to actual grid layers const expandLayers = (layers) => { if (!layers || layers.length === 0) return []; // If any entry is '*.Cu', apply to all grid layers if (layers.some(l => l === '*.Cu')) { return this.layers; } return layers; }; const targetLayers = expandLayers(obstacle.layers); for (const layer of targetLayers) { if (!this.layers.includes(layer)) { continue; // Skip layers we're not routing on } // Convert world bounds to grid bounds (without clearance expansion) // Clearance is applied during routing checks, not during obstacle placement const gridBounds = this.worldBoundsToGridBounds(obstacle.bounds.minX, obstacle.bounds.maxX, obstacle.bounds.minY, obstacle.bounds.maxY); // Helper to mark a single cell as occupied const occupyCell = (gx, gy, obstacleHalfWidthMm) => { const key = this.getCellKey(layer, gx, gy); const existingCell = this.cells.get(key); if (existingCell) { existingCell.occupied = true; if (obstacle.type === 'keepout' || obstacle.type === 'outline') { existingCell.absoluteBlock = true; } if (obstacle.type === 'pad') { existingCell.pad = true; } // Track the strongest clearance requirement for this occupied cell const obsClr = obstacle.clearance ?? 0; if (obsClr > (existingCell.clearance ?? 0)) { existingCell.clearance = obsClr; } // Preserve the maximum half width seen for any track occupying this cell if (typeof obstacleHalfWidthMm === 'number') { existingCell.obstacleHalfWidthMm = Math.max(existingCell.obstacleHalfWidthMm ?? 0, obstacleHalfWidthMm); } if (obstacle.net) { if (!existingCell.net) { existingCell.net = obstacle.net; } else if (existingCell.net === obstacle.net) { // same-net, keep as-is } else { // different-net overlap: keep prior owner to avoid overriding pad ownership } } if (obstacle.isManualRoute) { existingCell.isManualRoute = true; existingCell.cost = Math.min(existingCell.cost, 0.2); } } else { const obsClr = obstacle.clearance ?? 0; this.cells.set(key, { x: gx, y: gy, layer, occupied: true, net: obstacle.net, isManualRoute: obstacle.isManualRoute, absoluteBlock: (obstacle.type === 'keepout' || obstacle.type === 'outline') ? true : undefined, pad: obstacle.type === 'pad' ? true : undefined, cost: obstacle.isManualRoute ? 0.2 : 1.0, clearance: obsClr > 0 ? obsClr : undefined, obstacleHalfWidthMm: typeof obstacleHalfWidthMm === 'number' ? obstacleHalfWidthMm : undefined }); } }; // If we have precise segment geometry (tracks), rasterize against cell rectangles // using a robust segment–rectangle intersection where the rectangle is expanded // by the track half-width. This avoids "holes" at coarse grid resolutions. const seg = obstacle.segment; if (obstacle.type === 'track' && seg) { const halfWidth = seg.width / 2; // Helpers for geometry const pointInRect = (x, y, minX, minY, maxX, maxY) => x >= minX && x <= maxX && y >= minY && y <= maxY; const orientation = (ax, ay, bx, by, cx, cy) => { const val = (by - ay) * (cx - bx) - (bx - ax) * (cy - by); if (Math.abs(val) < 1e-12) return 0; return (val > 0) ? 1 : 2; // 1: cw, 2: ccw }; const onSegment = (ax, ay, bx, by, px, py) => px <= Math.max(ax, bx) + 1e-12 && px + 1e-12 >= Math.min(ax, bx) && py <= Math.max(ay, by) + 1e-12 && py + 1e-12 >= Math.min(ay, by); const segmentsIntersect = (a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y) => { const o1 = orientation(a1x, a1y, a2x, a2y, b1x, b1y); const o2 = orientation(a1x, a1y, a2x, a2y, b2x, b2y); const o3 = orientation(b1x, b1y, b2x, b2y, a1x, a1y); const o4 = orientation(b1x, b1y, b2x, b2y, a2x, a2y); if (o1 !== o2 && o3 !== o4) return true; if (o1 === 0 && onSegment(a1x, a1y, a2x, a2y, b1x, b1y)) return true; if (o2 === 0 && onSegment(a1x, a1y, a2x, a2y, b2x, b2y)) return true; if (o3 === 0 && onSegment(b1x, b1y, b2x, b2y, a1x, a1y)) return true; if (o4 === 0 && onSegment(b1x, b1y, b2x, b2y, a2x, a2y)) return true; return false; }; const x1 = seg.x1, y1 = seg.y1, x2 = seg.x2, y2 = seg.y2; // Iterate grid cells in the bounding box for (let gx = gridBounds.minX; gx <= gridBounds.maxX; gx++) { for (let gy = gridBounds.minY; gy <= gridBounds.maxY; gy++) { // Cell rectangle (world) const center = this.gridToWorld(gx, gy); const halfCell = this.gridResolution / 2; // Expand rectangle by track half-width to model thick segment footprint const minX = center.x - halfCell - halfWidth; const maxX = center.x + halfCell + halfWidth; const minY = center.y - halfCell - halfWidth; const maxY = center.y + halfCell + halfWidth; // Quick accept: either endpoint inside expanded rect let hit = pointInRect(x1, y1, minX, minY, maxX, maxY) || pointInRect(x2, y2, minX, minY, maxX, maxY); if (!hit) { // Check intersection with expanded rectangle edges const edges = [ [minX, minY, maxX, minY], [maxX, minY, maxX, maxY], [maxX, maxY, minX, maxY], [minX, maxY, minX, minY] ]; for (const e of edges) { if (segmentsIntersect(x1, y1, x2, y2, e[0], e[1], e[2], e[3])) { hit = true; break; } } } if (hit) { // Mark occupied and record the half width for clearance checks occupyCell(gx, gy, halfWidth); } } } // Ensure our global search window considers wide tracks this.maxObstacleClearance = Math.max(this.maxObstacleClearance, obstacle.clearance ?? 0, halfWidth); this.maxObstacleHalfWidthMm = Math.max(this.maxObstacleHalfWidthMm, halfWidth); } else if (obstacle.type === 'pad' && obstacle.padShape) { // Precise rasterization for pad shapes via rectangle–polygon intersection. // Convert pad shape into a polygon in world coords, then test overlap with cell rectangles. const { shape, center: c, width: w, height: h, rotation } = obstacle.padShape; const halfW = w / 2; const halfH = h / 2; const rotRad = (rotation * Math.PI) / 180; // forward rotation to world const cosR = Math.cos(rotRad); const sinR = Math.sin(rotRad); const toWorld = (lx, ly) => ({ x: c.x + lx * cosR - ly * sinR, y: c.y + lx * sinR + ly * cosR, }); const poly = []; const pushWorld = (lx, ly) => { poly.push(toWorld(lx, ly)); }; const ARC_STEPS = 12; // resolution for circular/oval approximation if (shape === 'rect' || shape === 'roundrect') { // Approximate roundrect as rect (corner radius unavailable) pushWorld(-halfW, -halfH); pushWorld(halfW, -halfH); pushWorld(halfW, halfH); pushWorld(-halfW, halfH); } else if (shape === 'circle') { const r = Math.min(halfW, halfH); for (let i = 0; i < ARC_STEPS; i++) { const t = (2 * Math.PI * i) / ARC_STEPS; pushWorld(r * Math.cos(t), r * Math.sin(t)); } } else if (shape === 'oval') { if (w >= h) { const r = halfH; const inner = Math.max(0, halfW - r); // Top straight edge pushWorld(-inner, r); pushWorld(inner, r); // Right semicircle: +90 -> -90 degrees for (let s = 1; s < ARC_STEPS; s++) { const ang = (Math.PI / 2) - (Math.PI * s) / ARC_STEPS; pushWorld(inner + r * Math.cos(ang), r * Math.sin(ang)); } // Bottom straight edge pushWorld(inner, -r); pushWorld(-inner, -r); // Left semicircle: -90 -> +90 degrees for (let s = 1; s < ARC_STEPS; s++) { const ang = (-Math.PI / 2) + (Math.PI * s) / ARC_STEPS; pushWorld(-inner + r * Math.cos(ang), r * Math.sin(ang)); } } else { const r = halfW; const inner = Math.max(0, halfH - r); // Top semicircle (center at y=+inner): 180 -> 0 degrees for (let s = 0; s <= ARC_STEPS; s++) { const ang = Math.PI - (Math.PI * s) / ARC_STEPS; pushWorld(r * Math.cos(ang), inner + r * Math.sin(ang)); } // Right straight side to bottom pushWorld(r, -inner); // Bottom semicircle: 0 -> 180 degrees for (let s = 0; s <= ARC_STEPS; s++) { const ang = (Math.PI * s) / ARC_STEPS; pushWorld(r * Math.cos(ang), -inner + r * Math.sin(ang)); } // Left straight side back to top pushWorld(-r, inner); } } else { // Fallback: occupy full bounding box for (let gx = gridBounds.minX; gx <= gridBounds.maxX; gx++) { for (let gy = gridBounds.minY; gy <= gridBounds.maxY; gy++) { occupyCell(gx, gy); } } } if (poly.length > 0) { const pointInPolygon = (x, y) => { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i].x, yi = poly[i].y; const xj = poly[j].x, yj = poly[j].y; const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-12) + xi); if (intersect) inside = !inside; } return inside; }; const onSegment = (ax, ay, bx, by, px, py) => (px <= Math.max(ax, bx) + 1e-12 && px + 1e-12 >= Math.min(ax, bx) && py <= Math.max(ay, by) + 1e-12 && py + 1e-12 >= Math.min(ay, by)); const orientation = (ax, ay, bx, by, cx, cy) => { const val = (by - ay) * (cx - bx) - (bx - ax) * (cy - by); if (Math.abs(val) < 1e-12) return 0; return (val > 0) ? 1 : 2; }; const segmentsIntersect = (a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y) => { const o1 = orientation(a1x, a1y, a2x, a2y, b1x, b1y); const o2 = orientation(a1x, a1y, a2x, a2y, b2x, b2y); const o3 = orientation(b1x, b1y, b2x, b2y, a1x, a1y); const o4 = orientation(b1x, b1y, b2x, b2y, a2x, a2y); if (o1 !== o2 && o3 !== o4) return true; if (o1 === 0 && onSegment(a1x, a1y, a2x, a2y, b1x, b1y)) return true; if (o2 === 0 && onSegment(a1x, a1y, a2x, a2y, b2x, b2y)) return true; if (o3 === 0 && onSegment(b1x, b1y, b2x, b2y, a1x, a1y)) return true; if (o4 === 0 && onSegment(b1x, b1y, b2x, b2y, a2x, a2y)) return true; return false; }; const halfCell = this.gridResolution / 2; for (let gx = gridBounds.minX; gx <= gridBounds.maxX; gx++) { for (let gy = gridBounds.minY; gy <= gridBounds.maxY; gy++) { const centerCell = this.gridToWorld(gx, gy); const minX = centerCell.x - halfCell; const maxX = centerCell.x + halfCell; const minY = centerCell.y - halfCell; const maxY = centerCell.y + halfCell; let hit = false; // 1) Rect corner inside polygon if (!hit) { const rectCorners = [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY } ]; for (const p of rectCorners) { if (pointInPolygon(p.x, p.y)) { hit = true; break; } } } // 2) Polygon vertex inside rect if (!hit) { const pointInRect = (x, y) => (x >= minX - 1e-12 && x <= maxX + 1e-12 && y >= minY - 1e-12 && y <= maxY + 1e-12); for (let i = 0; i < poly.length; i++) { if (pointInRect(poly[i].x, poly[i].y)) { hit = true; break; } } } // 3) Edge intersections if (!hit) { const rectEdges = [ [minX, minY, maxX, minY], [maxX, minY, maxX, maxY], [maxX, maxY, minX, maxY], [minX, maxY, minX, minY] ]; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const x1 = poly[j].x, y1 = poly[j].y; const x2 = poly[i].x, y2 = poly[i].y; for (const e of rectEdges) { if (segmentsIntersect(x1, y1, x2, y2, e[0], e[1], e[2], e[3])) { hit = true; break; } } if (hit) break; } } if (hit) occupyCell(gx, gy); } } } } else if (obstacle.polygon && obstacle.polygon.points && obstacle.polygon.points.length >= 3) { // Precise rasterization for polygon obstacles (zones, keepouts): // mark a grid cell occupied if the polygon and the cell rectangle intersect in any way. const pts = obstacle.polygon.points; // Point in polygon (ray casting) const pointInPolygon = (x, y) => { let inside = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const xi = pts[i].x, yi = pts[i].y; const xj = pts[j].x, yj = pts[j].y; // Check if edge crosses the horizontal ray at y and to the right of x const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-12) + xi); if (intersect) inside = !inside; } return inside; }; // Segment intersection helpers const onSegment = (ax, ay, bx, by, px, py) => { return px <= Math.max(ax, bx) + 1e-12 && px + 1e-12 >= Math.min(ax, bx) && py <= Math.max(ay, by) + 1e-12 && py + 1e-12 >= Math.min(ay, by); }; const orientation = (ax, ay, bx, by, cx, cy) => { const val = (by - ay) * (cx - bx) - (bx - ax) * (cy - by); if (Math.abs(val) < 1e-12) return 0; return (val > 0) ? 1 : 2; // 1: clockwise, 2: counterclockwise }; const segmentsIntersect = (a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y) => { const o1 = orientation(a1x, a1y, a2x, a2y, b1x, b1y); const o2 = orientation(a1x, a1y, a2x, a2y, b2x, b2y); const o3 = orientation(b1x, b1y, b2x, b2y, a1x, a1y); const o4 = orientation(b1x, b1y, b2x, b2y, a2x, a2y); if (o1 !== o2 && o3 !== o4) return true; // Colinear special cases if (o1 === 0 && onSegment(a1x, a1y, a2x, a2y, b1x, b1y)) return true; if (o2 === 0 && onSegment(a1x, a1y, a2x, a2y, b2x, b2y)) return true; if (o3 === 0 && onSegment(b1x, b1y, b2x, b2y, a1x, a1y)) return true; if (o4 === 0 && onSegment(b1x, b1y, b2x, b2y, a2x, a2y)) return true; return false; }; const halfCell = this.gridResolution / 2; for (let gx = gridBounds.minX; gx <= gridBounds.maxX; gx++) { for (let gy = gridBounds.minY; gy <= gridBounds.maxY; gy++) { const center = this.gridToWorld(gx, gy); const minX = center.x - halfCell; const maxX = center.x + halfCell; const minY = center.y - halfCell; const maxY = center.y + halfCell; // Quick reject with AABB overlap against polygon's bounding box // Compute polygon bounds lazily per cell might be expensive; rely on gridBounds coarse filter instead // 1) Any rect corner inside polygon? const rectCorners = [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY } ]; let hit = false; for (const p of rectCorners) { if (pointInPolygon(p.x, p.y)) { hit = true; break; } } // 2) Any polygon vertex inside the rect? const pointInRect = (x, y) => (x >= minX - 1e-12 && x <= maxX + 1e-12 && y >= minY - 1e-12 && y <= maxY + 1e-12); if (!hit) { for (let i = 0; i < pts.length; i++) { if (pointInRect(pts[i].x, pts[i].y)) { hit = true; break; } } } // 3) Any polygon edge intersect any rect edge? if (!hit) { const rectEdges = [ [minX, minY, maxX, minY], [maxX, minY, maxX, maxY], [maxX, maxY, minX, maxY], [minX, maxY, minX, minY] ]; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const x1 = pts[j].x, y1 = pts[j].y; const x2 = pts[i].x, y2 = pts[i].y; for (const e of rectEdges) { if (segmentsIntersect(x1, y1, x2, y2, e[0], e[1], e[2], e[3])) { hit = true; break; } } if (hit) break; } } if (hit) occupyCell(gx, gy); } } } else { // Fallback: occupy full bounding box (pads, components, zones, keepouts, outlines) for (let gx = gridBounds.minX; gx <= gridBounds.maxX; gx++) { for (let gy = gridBounds.minY; gy <= gridBounds.maxY; gy++) { occupyCell(gx, gy); } } } } // Track grid-wide maximum obstacle clearance for efficient search radius this.maxObstacleClearance = Math.max(this.maxObstacleClearance, obstacle.clearance ?? 0); } /** * Check if a specific grid cell belongs to a pad area on a given layer. */ isPadCell(x, y, layer) { const key = this.getCellKey(layer, x, y); const cell = this.cells.get(key); return !!cell?.pad; } /** * Check if a grid cell is occupied. * * @param x - Grid X coordinate * @param y - Grid Y coordinate * @param layer - Layer to check * @param clearance - Additional clearance to check (in mm, not grid cells) * @param net - Net we're routing - obstacles on same net don't block * @returns True if occupied, false if available for routing */ isOccupied(x, y, layer, clearance = 0, net, considerObstacleClearance = false, sumObstacleClearance = false) { // Check if coordinates are within bounds if (!this.isInBounds(x, y)) { return true; // Out of bounds = occupied } // Fast path: no clearance and not considering obstacle clearance => exact cell only if (clearance <= 0 && !considerObstacleClearance) { return this.isCellOccupied(x, y, layer, net); } // Determine search radius in cells. If considering obstacle clearance, use the // maximum of the trace clearance and any obstacle's required clearance; otherwise, use trace clearance only. // Note: Do not add an extra grid cell of safety margin here; doing so can // over-inflate effective clearances and close narrow but valid channels // (e.g., escaping from middle pads in fine-pitch footprints). const searchClearanceMm = (considerObstacleClearance ? (sumObstacleClearance ? (clearance + this.maxObstacleClearance + this.maxObstacleHalfWidthMm) : Math.max(clearance, this.maxObstacleClearance)) : clearance); const searchRadiusCells = Math.ceil(searchClearanceMm / this.gridResolution); for (let dx = -searchRadiusCells; dx <= searchRadiusCells; dx++) { for (let dy = -searchRadiusCells; dy <= searchRadiusCells; dy++) { const nx = x + dx; const ny = y + dy; if (!this.isInBounds(nx, ny)) continue; const key = this.getCellKey(layer, nx, ny); const cell = this.cells.get(key); if (!cell || !cell.occupied) continue; // Same-net exemption (except for absolute blocks and manual-route handling below) if (net && cell.net && cell.net === net && !cell.isManualRoute && !cell.absoluteBlock) { continue; } // Effective clearance is the stricter of the trace's and the obstacle's requirement; do not add // a full grid cell of margin, which can be overly conservative near dense pads. const obstacleClr = considerObstacleClearance ? (cell.clearance ?? 0) : 0; // For track obstacles, use edge-to-edge clearance: the new trace centerline must stay // at least (obstacleHalfWidth + clearance) away from the obstacle centerline. // This is the correct PCB clearance calculation. const obstacleHalfWidthMm = cell.obstacleHalfWidthMm ?? 0; const baseClearance = sumObstacleClearance ? (clearance + obstacleClr) : Math.max(clearance, obstacleClr); const requiredClearanceMm = baseClearance + obstacleHalfWidthMm; const distanceMm = Math.sqrt(dx * dx + dy * dy) * this.gridResolution; const edgeToEdgeDistance = Math.max(0, distanceMm - obstacleHalfWidthMm); // Absolute blocks always block within required clearance if (cell.absoluteBlock && edgeToEdgeDistance <= requiredClearanceMm) { return true; } // Manual routes: block different nets; allow same net if (cell.isManualRoute) { if (!(net && cell.net && cell.net === net)) { if (edgeToEdgeDistance <= requiredClearanceMm) return true; } continue; } // General occupied cell: blocks different nets within required clearance if (edgeToEdgeDistance <= requiredClearanceMm) { return true; } } } return false; } /** * Check if a single cell is occupied (internal helper). * @private */ isCellOccupied(x, y, layer, net) { const key = this.getCellKey(layer, x, y); const cell = this.cells.get(key); if (!cell) { return false; // No cell = not occupied } if (!cell.occupied) { return false; // Cell exists but not occupied } // Absolute blocks (keepouts, outlines) always block regardless of net if (cell.absoluteBlock) { return true; } // Manual routes: allow traversal on the same net, block otherwise // This lets other connections on the same net connect/branch from a // manually specified path while still protecting it from other nets. if (cell.isManualRoute) { if (net && cell.net && cell.net === net) { return false; } return true; } // If the cell is occupied by the same net, it's not blocking if (net && cell.net && cell.net === net) { return false; } return true; // Occupied by different net or no net } /** * Get the routing cost for a grid cell. * * @param x - Grid X coordinate * @param y - Grid Y coordinate * @param layer - Layer to check * @returns Cost multiplier (1.0 = normal, higher = more expensive) */ getCellCost(x, y, layer) { const key = this.getCellKey(layer, x, y); const cell = this.cells.get(key); return cell?.cost || 1.0; } /** * Set the routing cost for a grid cell. * Useful for biasing routes toward or away from certain areas. * * @param x - Grid X coordinate * @param y - Grid Y coordinate * @param layer - Layer * @param cost - Cost multiplier */ setCellCost(x, y, layer, cost) { const key = this.getCellKey(layer, x, y); let cell = this.cells.get(key); if (!cell) { cell = { x, y, layer, occupied: false, cost }; this.cells.set(key, cell); } else { cell.cost = cost; } } /** * Convert world coordinates (mm) to grid coordinates. * * @param worldX - X coordinate in mm * @param worldY - Y coordinate in mm * @returns Grid coordinates */ worldToGrid(worldX, worldY) { return { x: Math.floor((worldX - this.bounds.minX) / this.gridResolution), y: Math.floor((worldY - this.bounds.minY) / this.gridResolution) }; } /** * Convert grid coordinates to world coordinates (mm). * Returns the center of the grid cell. * * @param gridX - Grid X coordinate * @param gridY - Grid Y coordinate * @returns World coordinates in mm */ gridToWorld(gridX, gridY) { return { x: this.bounds.minX + (gridX + 0.5) * this.gridResolution, y: this.bounds.minY + (gridY + 0.5) * this.gridResolution }; } /** * Check if grid coordinates are within bounds. * * @param x - Grid X coordinate * @param y - Grid Y coordinate * @returns True if in bounds */ isInBounds(x, y) { return x >= 0 && x < this.gridWidth && y >= 0 && y < this.gridHeight; } /** * Get the cell key for the internal map. * @private */ getCellKey(layer, x, y) { return `${layer}:${x}:${y}`; } /** * Get a cell by coordinates, if present. */ getCell(x, y, layer) { const key = this.getCellKey(layer, x, y); return this.cells.get(key); } /** * Convert world bounds to grid bounds. * @private */ worldBoundsToGridBounds(worldMinX, worldMaxX, worldMinY, worldMaxY) { const topLeft = this.worldToGrid(worldMinX, worldMinY); const bottomRight = this.worldToGrid(worldMaxX, worldMaxY); return { minX: Math.max(0, topLeft.x), maxX: Math.min(this.gridWidth - 1, bottomRight.x), minY: Math.max(0, topLeft.y), maxY: Math.min(this.gridHeight - 1, bottomRight.y) }; } /** * Get grid dimensions. */ getDimensions() { return { width: this.gridWidth, height: this.gridHeight, layers: this.layers.length }; } /** * Get grid resolution in mm. */ getResolution() { return this.gridResolution; } /** * Get the list of layers in the grid. */ getLayers() { return [...this.layers]; } /** * Get grid bounds in world coordinates. */ getBounds() { return { ...this.bounds }; } /** * Clear all obstacles from the grid. * Useful for rebuilding the grid with different obstacles. */ clear() { this.cells.clear(); this.maxObstacleClearance = 0; this.maxObstacleHalfWidthMm = 0; logger.debug(chalk_1.default.blue(`[RoutingGrid] Cleared all cells`)); } /** * Get statistics about the grid. */ getStats() { const totalCells = this.gridWidth * this.gridHeight * this.layers.length; const occupiedCells = Array.from(this.cells.values()).filter(c => c.occupied).length; const freeCells = totalCells - occupiedCells; return { totalCells, occupiedCells, freeCells, occupancyPercent: (occupiedCells / totalCells) * 100 }; } } // exported above