diff --git a/lib/solvers/RectDiffSolver.ts b/lib/solvers/RectDiffSolver.ts index 34bda41..ab05043 100644 --- a/lib/solvers/RectDiffSolver.ts +++ b/lib/solvers/RectDiffSolver.ts @@ -19,6 +19,7 @@ import { findUncoveredPoints, calculateCoverage, } from "./rectdiff/gapfill/engine" +import { EdgeExpansionGapFillSubSolver } from "./rectdiff/subsolvers/EdgeExpansionGapFillSubSolver" /** * A streaming, one-step-per-iteration solver for capacity mesh generation. @@ -28,6 +29,7 @@ export class RectDiffSolver extends BaseSolver { private gridOptions: Partial private state!: RectDiffState private _meshNodes: CapacityMeshNode[] = [] + private gapFillSubSolver?: EdgeExpansionGapFillSubSolver constructor(opts: { simpleRouteJson: SimpleRouteJson @@ -53,7 +55,43 @@ export class RectDiffSolver extends BaseSolver { } else if (this.state.phase === "EXPANSION") { stepExpansion(this.state) } else if (this.state.phase === "GAP_FILL") { - this.state.phase = "DONE" + // Initialize gap fill subsolver on first entry + if (!this.gapFillSubSolver) { + this.gapFillSubSolver = new EdgeExpansionGapFillSubSolver({ + bounds: this.state.bounds, + layerCount: this.state.layerCount, + obstacles: this.state.obstaclesByLayer, + existingPlaced: this.state.placed, + existingPlacedByLayer: this.state.placedByLayer, + options: { + minSingle: this.state.options.minSingle, + minMulti: this.state.options.minMulti, + maxAspectRatio: this.state.options.maxAspectRatio, + maxMultiLayerSpan: this.state.options.maxMultiLayerSpan, + }, + }) + } + + // Step the subsolver + if (!this.gapFillSubSolver.solved) { + this.gapFillSubSolver.step() + } else { + // Merge gap-fill results into main state + const gapFillOutput = this.gapFillSubSolver.getOutput() + this.state.placed.push(...gapFillOutput.newPlaced) + + // Update placedByLayer + for (const placed of gapFillOutput.newPlaced) { + for (const z of placed.zLayers) { + if (!this.state.placedByLayer[z]) { + this.state.placedByLayer[z] = [] + } + this.state.placedByLayer[z]!.push(placed.rect) + } + } + + this.state.phase = "DONE" + } } else if (this.state.phase === "DONE") { // Finalize once if (!this.solved) { @@ -75,7 +113,18 @@ export class RectDiffSolver extends BaseSolver { if (this.solved || this.state.phase === "DONE") { return 1 } - return computeProgress(this.state) + + const baseProgress = computeProgress(this.state) + + // If in GAP_FILL phase, factor in subsolver progress + if (this.state.phase === "GAP_FILL" && this.gapFillSubSolver) { + const gapFillProgress = this.gapFillSubSolver.computeProgress() + // GAP_FILL is the last phase before DONE, so weight it appropriately + // Assume GRID+EXPANSION is 90%, GAP_FILL is remaining 10% + return 0.9 + gapFillProgress * 0.1 + } + + return baseProgress * 0.9 // Scale down to leave room for GAP_FILL } override getOutput(): { meshNodes: CapacityMeshNode[] } { @@ -129,6 +178,11 @@ export class RectDiffSolver extends BaseSolver { /** Streaming visualization: board + obstacles + current placements. */ override visualize(): GraphicsObject { + // If in GAP_FILL phase, delegate to subsolver visualization + if (this.state?.phase === "GAP_FILL" && this.gapFillSubSolver) { + return this.gapFillSubSolver.visualize() + } + const rects: NonNullable = [] const points: NonNullable = [] const lines: NonNullable = [] // Initialize lines array @@ -163,9 +217,15 @@ export class RectDiffSolver extends BaseSolver { }) } - // obstacles (rect & oval as bounding boxes) + // obstacles (rect & oval as bounding boxes) with layer information for (const obstacle of this.srj.obstacles ?? []) { if (obstacle.type === "rect" || obstacle.type === "oval") { + // Get layer information if available + const layerInfo = + obstacle.layers && obstacle.layers.length > 0 + ? `\nz:${obstacle.layers.join(",")}` + : "" + rects.push({ center: { x: obstacle.center.x, y: obstacle.center.y }, width: obstacle.width, @@ -173,7 +233,7 @@ export class RectDiffSolver extends BaseSolver { fill: "#fee2e2", stroke: "#ef4444", layer: "obstacle", - label: "obstacle", + label: `obstacle ${layerInfo}`, }) } } diff --git a/lib/solvers/rectdiff/candidates.ts b/lib/solvers/rectdiff/candidates.ts index 4bc59e1..428f8e4 100644 --- a/lib/solvers/rectdiff/candidates.ts +++ b/lib/solvers/rectdiff/candidates.ts @@ -183,7 +183,7 @@ export function longestFreeSpanAroundZ(params: { */ export function computeDefaultGridSizes(bounds: XYRect): number[] { const ref = Math.max(bounds.width, bounds.height) - return [ref / 8, ref / 16, ref / 32] + return [ref / 32] } /** diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts new file mode 100644 index 0000000..dae12ab --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts @@ -0,0 +1,180 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts +import type { GapFillNode, Direction, EdgeExpansionGapFillState } from "./types" +import type { XYRect } from "../types" + +const EPS = 1e-9 + +/** + * Calculate how far a node can expand in a given direction before hitting blockers. + * + * **What it does:** + * Determines the maximum distance a gap-fill node can expand in a specific direction + * (up, down, left, or right) before it would collide with obstacles, existing capacity + * nodes, or other gap-fill nodes. Returns the available expansion distance in millimeters. + * + * **How it works:** + * 1. **Collects blockers** on the same z-layers as the node: + * - Existing capacity nodes from the main RectDiff solver + * - Board obstacles/components + * - Other gap-fill nodes currently being expanded + * - Previously placed gap-fill nodes + * + * 2. **Calculates initial distance** to the board boundary in the expansion direction + * + * 3. **Checks each blocker** to see if it's in the expansion path: + * - For vertical expansion (up/down): checks if blocker overlaps in x-axis + * - For horizontal expansion (left/right): checks if blocker overlaps in y-axis + * - If blocking, calculates distance to the blocker and takes the minimum + * + * 4. **Returns** the minimum distance (clamped to non-negative) + * + * **Usage:** + * Called during expansion planning to evaluate potential expansion directions. + * The result is then clamped by `calculateMaxExpansion()` to respect aspect ratio + * constraints before actually expanding the node. + * + * **Example:** + * ``` + * Node at (10, 10) with size 5x2mm expanding "right" + * Board width: 100mm + * Blocker at (20, 10) with size 3x2mm + * + * Initial distance: 100 - (10 + 5) = 85mm + * Blocker distance: 20 - (10 + 5) = 5mm + * Returns: 5mm (limited by blocker, not board boundary) + * ``` + * + * @param params.node - The gap-fill node to check expansion for + * @param params.direction - Direction to expand (up/down/left/right) + * @param ctx - State containing bounds, obstacles, and existing placements + * @returns Available expansion distance in millimeters (0 if blocked immediately) + * + * @internal This is an internal helper function for the edge expansion gap-fill algorithm + */ +export function calculateAvailableSpace( + params: { node: GapFillNode; direction: Direction }, + ctx: EdgeExpansionGapFillState, +): number { + const { node, direction } = params + const { bounds, existingPlacedByLayer, obstacles, nodes, newPlaced } = ctx + + // Get all potential blockers on the same layers + const blockers: XYRect[] = [] + + // Add existing placed rects + for (const layer of node.zLayers) { + if (existingPlacedByLayer[layer]) { + blockers.push(...existingPlacedByLayer[layer]!) + } + } + + // Add obstacles + for (const layer of node.zLayers) { + if (obstacles[layer]) { + blockers.push(...obstacles[layer]!) + } + } + + // Add other gap-fill nodes (already expanded) + for (const otherNode of nodes) { + if (otherNode.id !== node.id) { + blockers.push(otherNode.rect) + } + } + + // Add newly placed rects + for (const placed of newPlaced) { + // Check if layers overlap + const hasCommonLayer = node.zLayers.some((z) => placed.zLayers.includes(z)) + if (hasCommonLayer) { + blockers.push(placed.rect) + } + } + + const rect = node.rect + let maxDistance = Infinity + + switch (direction) { + case "up": { + // Expanding upward (increasing y) + maxDistance = bounds.y + bounds.height - (rect.y + rect.height) + + for (const obstacle of blockers) { + // Check if obstacle is above and overlaps in x + if ( + obstacle.y >= rect.y + rect.height - EPS && + !( + obstacle.x >= rect.x + rect.width - EPS || + rect.x >= obstacle.x + obstacle.width - EPS + ) + ) { + const dist = obstacle.y - (rect.y + rect.height) + maxDistance = Math.min(maxDistance, dist) + } + } + break + } + + case "down": { + // Expanding downward (decreasing y) + maxDistance = rect.y - bounds.y + + for (const obstacle of blockers) { + // Check if obstacle is below and overlaps in x + if ( + obstacle.y + obstacle.height <= rect.y + EPS && + !( + obstacle.x >= rect.x + rect.width - EPS || + rect.x >= obstacle.x + obstacle.width - EPS + ) + ) { + const dist = rect.y - (obstacle.y + obstacle.height) + maxDistance = Math.min(maxDistance, dist) + } + } + break + } + + case "right": { + // Expanding rightward (increasing x) + maxDistance = bounds.x + bounds.width - (rect.x + rect.width) + + for (const obstacle of blockers) { + // Check if obstacle is to the right and overlaps in y + if ( + obstacle.x >= rect.x + rect.width - EPS && + !( + obstacle.y >= rect.y + rect.height - EPS || + rect.y >= obstacle.y + obstacle.height - EPS + ) + ) { + const dist = obstacle.x - (rect.x + rect.width) + maxDistance = Math.min(maxDistance, dist) + } + } + break + } + + case "left": { + // Expanding leftward (decreasing x) + maxDistance = rect.x - bounds.x + + for (const obstacle of blockers) { + // Check if obstacle is to the left and overlaps in y + if ( + obstacle.x + obstacle.width <= rect.x + EPS && + !( + obstacle.y >= rect.y + rect.height - EPS || + rect.y >= obstacle.y + obstacle.height - EPS + ) + ) { + const dist = rect.x - (obstacle.x + obstacle.width) + maxDistance = Math.min(maxDistance, dist) + } + } + break + } + } + + return Math.max(0, maxDistance) +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/calculateMaxExpansion.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateMaxExpansion.ts new file mode 100644 index 0000000..e50fcdb --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateMaxExpansion.ts @@ -0,0 +1,51 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/calculateMaxExpansion.ts +import type { Direction } from "./types" + +/** + * Calculate the maximum expansion amount that respects aspect ratio constraint. + * + * When expanding a node, we need to ensure the resulting rectangle doesn't exceed + * the maximum aspect ratio. This function calculates how much we can expand in a + * given direction while staying within the aspect ratio limit. + * + * @param currentWidth - Current width of the node + * @param currentHeight - Current height of the node + * @param direction - Direction of expansion (up/down/left/right) + * @param available - Available space to expand into + * @param maxAspectRatio - Maximum allowed aspect ratio (width/height or height/width), or null for no limit + * @returns Maximum expansion amount that respects aspect ratio, clamped to available space + */ +export function calculateMaxExpansion(params: { + currentWidth: number + currentHeight: number + direction: Direction + available: number + maxAspectRatio: number | null +}): number { + const { currentWidth, currentHeight, direction, available, maxAspectRatio } = + params + + // If no aspect ratio constraint, return full available space + if (maxAspectRatio === null) { + return available + } + + let maxExpansion = available + + if (direction === "left" || direction === "right") { + // Expanding horizontally + // We want: (currentWidth + expansion) / currentHeight <= maxAspectRatio + // So: expansion <= currentHeight * maxAspectRatio - currentWidth + const maxWidth = currentHeight * maxAspectRatio + maxExpansion = Math.min(available, maxWidth - currentWidth) + } else { + // Expanding vertically (up or down) + // We want: (currentHeight + expansion) / currentWidth <= maxAspectRatio + // So: expansion <= currentWidth * maxAspectRatio - currentHeight + const maxHeight = currentWidth * maxAspectRatio + maxExpansion = Math.min(available, maxHeight - currentHeight) + } + + // Ensure we don't return negative values + return Math.max(0, maxExpansion) +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/calculatePotentialArea.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/calculatePotentialArea.ts new file mode 100644 index 0000000..e6d521f --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/calculatePotentialArea.ts @@ -0,0 +1,17 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/calculatePotentialArea.ts +import type { GapFillNode, Direction, EdgeExpansionGapFillState } from "./types" +import { calculateAvailableSpace } from "./calculateAvailableSpace" + +export function calculatePotentialArea( + params: { node: GapFillNode; direction: Direction }, + ctx: EdgeExpansionGapFillState, +): number { + const { node, direction } = params + const available = calculateAvailableSpace({ node, direction }, ctx) + + if (direction === "up" || direction === "down") { + return available * node.rect.width + } else { + return available * node.rect.height + } +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts new file mode 100644 index 0000000..f804215 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts @@ -0,0 +1,15 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts +import type { EdgeExpansionGapFillState } from "./types" + +export function computeProgress(state: EdgeExpansionGapFillState): number { + if (state.phase === "DONE") { + return 1 + } + + const totalObstacles = state.edgeExpansionObstacles.length + if (totalObstacles === 0) { + return 1 + } + + return state.currentObstacleIndex / totalObstacles +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts new file mode 100644 index 0000000..1c9e0e1 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts @@ -0,0 +1,162 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts +import type { XYRect } from "../types" +import type { GapFillNode } from "./types" +import { overlaps } from "../geometry" + +/** + * Create initial seed nodes around an obstacle for gap-fill expansion. + * + * **What it does:** + * Generates tiny seed rectangles at each of the 4 edges (top, bottom, left, right) + * of an obstacle. These seed nodes will later expand outward to fill available + * routing space around the obstacle. Creates one node per edge position per layer, + * but only if the position isn't blocked by another obstacle on that layer. + * + * **How it works:** + * 1. **Defines 4 edge positions** around the obstacle: + * - Top: Above the obstacle, same width, very thin height + * - Bottom: Below the obstacle, same width, very thin height + * - Right: To the right, same height, very thin width + * - Left: To the left, same height, very thin width + * + * 2. **For each position and each layer:** + * - Checks if the position overlaps with any obstacle on that layer + * - If blocked, skips creating a node for that position/layer combination + * - If not blocked, creates a single-layer gap-fill node + * + * 3. **Node properties:** + * - Initial size: Very thin (minTraceWidth × initialEdgeThicknessFactor) + * - Direction: Set based on which edge (top→up, bottom→down, etc.) + * - Can expand: All nodes start with `canExpand: true` + * - Single layer: Each node exists on exactly one z-layer + * + * **Why this is needed:** + * These seed nodes serve as starting points for expansion. They're placed immediately + * adjacent to obstacles, then expanded outward until they hit blockers (other obstacles, + * board boundaries, or existing capacity nodes). This fills gaps around components. + * + * **Usage:** + * Called once per obstacle in `stepExpansion()` when starting to process a new obstacle. + * The returned nodes are then filtered by `filterOverlappingNodes()` to remove any that + * would immediately overlap with existing capacity nodes before expansion begins. + * + * **Example:** + * ``` + * Obstacle: 10x10mm at (50, 50) + * minTraceWidth: 0.15mm + * initialEdgeThicknessFactor: 0.01 + * + * Creates 4 seed nodes (one per edge): + * - Top: (50, 60) 10mm wide × 0.0015mm tall, expands upward + * - Bottom: (50, 49.9985) 10mm wide × 0.0015mm tall, expands downward + * - Right: (60, 50) 0.0015mm wide × 10mm tall, expands rightward + * - Left: (49.9985, 50) 0.0015mm wide × 10mm tall, expands leftward + * ``` + * + * @param params.obstacle - The obstacle rectangle to create nodes around + * @param params.obstacleIndex - Index of obstacle in sorted array (used for node IDs) + * @param params.layerCount - Total number of z-layers on the board + * @param params.obstaclesByLayer - All obstacles indexed by layer (for overlap checking) + * @param params.minTraceWidth - Estimated minimum trace width (used to calculate seed thickness) + * @param params.initialEdgeThicknessFactor - Factor to multiply minTraceWidth for seed thickness + * @returns Array of seed gap-fill nodes (0-4 nodes per layer, typically 0-16 total for 4 layers) + * + * @internal This is an internal helper function for the edge expansion gap-fill algorithm + */ +export function createNodesFromObstacle(params: { + obstacle: XYRect + obstacleIndex: number + layerCount: number + obstaclesByLayer: XYRect[][] + minTraceWidth: number + initialEdgeThicknessFactor?: number +}): GapFillNode[] { + const { + obstacle, + obstacleIndex, + layerCount, + obstaclesByLayer, + minTraceWidth, + initialEdgeThicknessFactor = 0.01, + } = params + + const EDGE_THICKNESS = minTraceWidth * initialEdgeThicknessFactor + + const nodes: GapFillNode[] = [] + + // Define the 4 edge positions + const positions = [ + { + name: "top", + rect: { + x: obstacle.x, + y: obstacle.y + obstacle.height, + width: obstacle.width, + height: EDGE_THICKNESS, + }, + direction: "up" as const, + }, + { + name: "bottom", + rect: { + x: obstacle.x, + y: obstacle.y - EDGE_THICKNESS, + width: obstacle.width, + height: EDGE_THICKNESS, + }, + direction: "down" as const, + }, + { + name: "right", + rect: { + x: obstacle.x + obstacle.width, + y: obstacle.y, + width: EDGE_THICKNESS, + height: obstacle.height, + }, + direction: "right" as const, + }, + { + name: "left", + rect: { + x: obstacle.x - EDGE_THICKNESS, + y: obstacle.y, + width: EDGE_THICKNESS, + height: obstacle.height, + }, + direction: "left" as const, + }, + ] + + // For each position, create a single-layer node for each layer + for (const position of positions) { + for (let z = 0; z < layerCount; z++) { + // Check if this position is blocked by an obstacle on this layer + let isBlocked = false + + if (obstaclesByLayer[z]) { + for (const obs of obstaclesByLayer[z]!) { + if (overlaps(position.rect, obs)) { + isBlocked = true + break + } + } + } + + // Only create node if not blocked + if (!isBlocked) { + nodes.push({ + id: `obs${obstacleIndex}_${position.name}_z${z}`, + rect: { ...position.rect }, + zLayers: [z], + direction: position.direction, + obstacleIndex, + canExpand: true, + hasEverExpanded: false, + }) + } + } + } + + return nodes +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/expandNode.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/expandNode.ts new file mode 100644 index 0000000..1bcfbd3 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/expandNode.ts @@ -0,0 +1,27 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/expandNode.ts +import type { GapFillNode, Direction } from "./types" + +export function expandNode(params: { + node: GapFillNode + direction: Direction + amount: number +}): void { + const { node, direction, amount } = params + + switch (direction) { + case "up": + node.rect.height += amount + break + case "down": + node.rect.y -= amount + node.rect.height += amount + break + case "right": + node.rect.width += amount + break + case "left": + node.rect.x -= amount + node.rect.width += amount + break + } +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/filterOverlappingNodes.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/filterOverlappingNodes.ts new file mode 100644 index 0000000..a042730 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/filterOverlappingNodes.ts @@ -0,0 +1,79 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/filterOverlappingNodes.ts +import type { GapFillNode } from "./types" +import type { XYRect, Placed3D } from "../types" +import { overlaps } from "../geometry" + +/** + * Filters out initial seed nodes that would immediately overlap with existing elements. + * + * **Context:** + * When processing each obstacle, we create 4 tiny seed rectangles (one at each edge). + * However, the space around an obstacle might already be occupied by: + * - Capacity nodes created by the main RectDiff solver + * - Other obstacles/components on the board + * - Gap-fill nodes we've already placed from previous obstacles + * + * **Why this is needed:** + * Without filtering, we'd attempt to place and expand nodes in already-occupied space, + * causing immediate validation failures and overlaps. This prevents wasted work and errors. + * + * **Current usage:** + * Called once per obstacle after creating initial seed nodes, before expansion begins. + * Typically filters out 0-8 nodes (out of 4 initial nodes × layers) depending on how + * crowded the area around the obstacle is. + * + * @param nodes - Initial seed nodes created around an obstacle (4 edge positions × layers) + * @param existingPlacedByLayer - Capacity nodes from main solver, indexed by layer + * @param obstaclesByLayer - All board obstacles/components, indexed by layer + * @param newPlaced - Gap-fill nodes already placed from previously processed obstacles + * @returns Filtered list of nodes that can be safely placed and expanded + * + * @internal This is an internal helper function for the edge expansion gap-fill algorithm + */ +export function filterOverlappingNodes(params: { + nodes: GapFillNode[] + existingPlacedByLayer: XYRect[][] + obstaclesByLayer: XYRect[][] + newPlaced: Placed3D[] +}): GapFillNode[] { + const { nodes, existingPlacedByLayer, obstaclesByLayer, newPlaced } = params + + const validNodes = nodes.filter((node) => { + // For each layer this node occupies, check for overlaps + for (const z of node.zLayers) { + // Check overlap with existing capacity nodes on this layer + if (existingPlacedByLayer[z]) { + for (const existing of existingPlacedByLayer[z]!) { + if (overlaps(node.rect, existing)) { + return false + } + } + } + + // Check overlap with obstacles on this layer + if (obstaclesByLayer[z]) { + for (const obstacle of obstaclesByLayer[z]!) { + if (overlaps(node.rect, obstacle)) { + return false + } + } + } + } + + // Check overlap with previously placed gap-fill nodes + for (const placed of newPlaced) { + // Check if they share any layers + const hasCommonLayer = node.zLayers.some((nz) => + placed.zLayers.includes(nz), + ) + + if (hasCommonLayer && overlaps(node.rect, placed.rect)) { + return false + } + } + + return true + }) + + return validNodes +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts new file mode 100644 index 0000000..c3b6c12 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts @@ -0,0 +1,89 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts +import type { + EdgeExpansionGapFillState, + EdgeExpansionGapFillOptions, + EdgeExpansionObstacle, +} from "./types" +import type { XYRect, Placed3D } from "../types" + +export function initState(params: { + bounds: XYRect + layerCount: number + obstacles: XYRect[][] + existingPlaced: Placed3D[] + existingPlacedByLayer: XYRect[][] + options?: Partial +}): EdgeExpansionGapFillState { + const { + bounds, + layerCount, + obstacles, + existingPlaced, + existingPlacedByLayer, + options = {}, + } = params + + const defaultOptions: EdgeExpansionGapFillOptions = { + minRequiredExpandSpace: 0.05, + minSingle: { width: 0.3, height: 0.3 }, + minMulti: { width: 1.2, height: 1.2, minLayers: 2 }, + maxAspectRatio: 3, + maxMultiLayerSpan: undefined, + initialEdgeThicknessFactor: 0.01, // 1% of minTraceWidth + estimatedMinTraceWidthFactor: 0.01, // 1% of board size + } + + // Build EdgeExpansionObstacle array by grouping obstacles across layers + // Each unique physical obstacle (identified by exact coordinates) gets one entry + const obstacleMap = new Map() + + for (let z = 0; z < layerCount; z++) { + const layerObstacles = obstacles[z] ?? [] + for (let i = 0; i < layerObstacles.length; i++) { + const rect = layerObstacles[i]! + // Use exact coordinates as key to identify same physical obstacle + const key = `${rect.x},${rect.y},${rect.width},${rect.height}` + + if (obstacleMap.has(key)) { + // Add this layer to existing obstacle + obstacleMap.get(key)!.zLayers.push(z) + } else { + // Create new obstacle entry + const center = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + } + obstacleMap.set(key, { + srjObstacleIndex: i, // Store first index we found (for debugging) + rect, + center, + zLayers: [z], + area: rect.width * rect.height, + }) + } + } + } + + // Convert to array and sort by area descending (largest first) + const edgeExpansionObstacles = Array.from(obstacleMap.values()) + edgeExpansionObstacles.sort((a, b) => b.area - a.area) + + return { + options: { ...defaultOptions, ...options }, + bounds, + layerCount, + obstacles, + edgeExpansionObstacles, + existingPlaced, + existingPlacedByLayer, + phase: "PROCESSING", + processingPhase: "INIT_NODES", + currentObstacleIndex: 0, + nodes: [], + currentRound: [], + currentRoundIndex: 0, + currentDirection: null, + currentNodeId: null, + newPlaced: [], + } +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/mergeAdjacentLayerNodes.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/mergeAdjacentLayerNodes.ts new file mode 100644 index 0000000..b7aab8f --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/mergeAdjacentLayerNodes.ts @@ -0,0 +1,62 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/mergeAdjacentLayerNodes.ts +import type { GapFillNode } from "./types" + +const EPS = 1e-9 + +/** + * Try to merge a node with nodes on adjacent layers (z±1). + * Merges happen when: + * - Nodes are on consecutive z-indices + * - They have exact same dimensions (x, y, width, height) + */ +export function mergeAdjacentLayerNodes(params: { + node: GapFillNode + allNodes: GapFillNode[] +}): void { + const { node, allNodes } = params + + // Find nodes that could potentially merge with this node + const candidatesForMerge = allNodes.filter((other) => { + if (other.id === node.id) return false + if (other.obstacleIndex !== node.obstacleIndex) return false + if (other.direction !== node.direction) return false + + // Check if dimensions match exactly + const rectMatches = + Math.abs(other.rect.x - node.rect.x) < EPS && + Math.abs(other.rect.y - node.rect.y) < EPS && + Math.abs(other.rect.width - node.rect.width) < EPS && + Math.abs(other.rect.height - node.rect.height) < EPS + + if (!rectMatches) return false + + // Check if on adjacent layers + for (const nodeZ of node.zLayers) { + for (const otherZ of other.zLayers) { + if (Math.abs(nodeZ - otherZ) === 1) { + return true + } + } + } + + return false + }) + + // Merge all matching nodes into this node + for (const candidate of candidatesForMerge) { + // Combine zLayers + const combinedLayers = [...node.zLayers, ...candidate.zLayers] + const uniqueLayers = Array.from(new Set(combinedLayers)).sort( + (a, b) => a - b, + ) + node.zLayers = uniqueLayers + + // Update the ID to reflect merged layers + const layerStr = uniqueLayers.join("_") + const baseId = node.id.replace(/_z\d+.*$/, "") + node.id = `${baseId}_z${layerStr}` + + // Mark candidate for removal by clearing its zLayers + candidate.zLayers = [] + } +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts new file mode 100644 index 0000000..c5ebd5d --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts @@ -0,0 +1,589 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts +import type { EdgeExpansionGapFillState, Direction, GapFillNode } from "./types" +import type { XYRect, Placed3D } from "../types" +import { createNodesFromObstacle } from "./createNodesFromObstacle" +import { filterOverlappingNodes } from "./filterOverlappingNodes" +import { calculateAvailableSpace } from "./calculateAvailableSpace" +import { calculatePotentialArea } from "./calculatePotentialArea" +import { calculateMaxExpansion } from "./calculateMaxExpansion" +import { expandNode } from "./expandNode" +import { mergeAdjacentLayerNodes } from "./mergeAdjacentLayerNodes" +import { overlaps } from "../geometry" + +const ALL_DIRECTIONS: Direction[] = ["up", "down", "left", "right"] + +/** + * Get the opposite direction (the direction pointing toward the parent obstacle) + */ +function getOppositeDirection(direction: Direction): Direction { + const opposites: Record = { + up: "down", + down: "up", + left: "right", + right: "left", + } + return opposites[direction] +} + +/** + * Execute one micro-step of the edge expansion gap-fill algorithm. + * + * **What it does:** + * This is the core incremental step function that processes obstacles one at a time, + * creating seed nodes around each obstacle and expanding them to fill available space. + * Each call performs exactly one small operation (e.g., creating initial nodes for an + * obstacle, or expanding one node in one direction by a small amount). + * + * **Algorithm phases:** + * 1. **INIT_NODES**: When starting a new obstacle, creates 4 tiny seed rectangles + * (one at each edge: top, bottom, left, right) around the obstacle. Filters out + * nodes that would immediately overlap with existing capacity nodes or obstacles. + * + * 2. **PLAN_ROUND**: For each active node, evaluates all possible expansion + * directions (except the one pointing toward the parent obstacle). Calculates + * available space, respects aspect ratio constraints, and selects the best + * expansion candidate based on potential area gained. + * + * 3. **CHOOSE_DIRECTION**: Picks the best expansion direction for the current node + * based on potential area calculations. + * + * 4. **EXPAND_NODE**: Expands one node in one direction by the calculated amount. + * If aspect ratio limits are hit but space remains, spawns child nodes to fill + * the remaining gap. This allows recursive gap filling. + * + * 5. **FINALIZE_OBSTACLE**: When a node can no longer expand, merges it with adjacent-layer + * nodes that have identical dimensions. When all nodes for an obstacle are done, + * finalizes them and moves to the next obstacle. + * + * **Granularity:** + * Designed for visualization - each call performs minimal work so the solver can be + * stepped incrementally for real-time visualization. Typical execution: + * - One obstacle processed per many steps (one step per node expansion) + * - One node expanded per step (one direction, one amount) + * - Child nodes spawned immediately when aspect ratio limits hit + * + * **Usage:** + * Called repeatedly by `EdgeExpansionGapFillSubSolver._step()` until it returns `false` + * (indicating all obstacles are processed). The BaseSolver framework handles the + * iteration loop for incremental solving. + * + * @param state - Mutable state object tracking current obstacle, nodes, expansion progress + * @returns `true` if more work remains (keep calling), `false` if all obstacles processed + * + * @internal This is an internal function used by EdgeExpansionGapFillSubSolver + */ +export function stepExpansion(state: EdgeExpansionGapFillState): boolean { + // Check if we're completely done + if (state.currentObstacleIndex >= state.edgeExpansionObstacles.length) { + state.phase = "DONE" + return false + } + + // State machine: handle current processing phase + switch (state.processingPhase) { + case "INIT_NODES": + return handleInitNodes(state) + + case "PLAN_ROUND": + return handlePlanRound(state) + + case "CHOOSE_DIRECTION": + return handleChooseDirection(state) + + case "EXPAND_NODE": + return handleExpandNode(state) + + case "FINALIZE_OBSTACLE": + return handleFinalizeObstacle(state) + + default: + // Should never happen + throw new Error(`Unknown processing phase: ${state.processingPhase}`) + } +} + +/** + * Phase 1: Initialize nodes for the current obstacle + */ +function handleInitNodes(state: EdgeExpansionGapFillState): boolean { + // Get the current obstacle + const obstacle = state.edgeExpansionObstacles[state.currentObstacleIndex] + + if (!obstacle) { + // No obstacle at this index, skip to next + state.currentObstacleIndex++ + return true + } + + // Estimate min trace width from board size + const minTraceWidth = + Math.min(state.bounds.width, state.bounds.height) * + state.options.estimatedMinTraceWidthFactor + + // Create single-layer nodes for each of the 4 edge positions around the obstacle + const allNodes = createNodesFromObstacle({ + obstacle: obstacle.rect, + obstacleIndex: state.currentObstacleIndex, + layerCount: state.layerCount, + obstaclesByLayer: state.obstacles, + minTraceWidth, + initialEdgeThicknessFactor: state.options.initialEdgeThicknessFactor, + }) + + // Filter out nodes that overlap with existing capacity nodes, obstacles, or previously placed gap-fill nodes + const validNodes = filterOverlappingNodes({ + nodes: allNodes, + existingPlacedByLayer: state.existingPlacedByLayer, + obstaclesByLayer: state.obstacles, + newPlaced: state.newPlaced, + }) + + if (validNodes.length === 0) { + // No valid nodes for this obstacle, skip to next + state.currentObstacleIndex++ + // Stay in INIT_NODES phase for the next obstacle + return true + } + + // We have valid nodes, prepare for expansion + state.nodes = validNodes + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null + + // Transition to planning phase + state.processingPhase = "PLAN_ROUND" + return true +} + +/** + * Phase 2: Plan the next round of expansions + */ +function handlePlanRound(state: EdgeExpansionGapFillState): boolean { + // Calculate potential area for each node in each direction + const candidates: Array<{ + node: GapFillNode + direction: Direction + potentialArea: number + }> = [] + + for (const node of state.nodes) { + if (!node.canExpand) continue + + // Determine forbidden direction (toward parent obstacle) + const forbiddenDirection = getOppositeDirection(node.direction) + + for (const direction of ALL_DIRECTIONS) { + // Skip direction that points toward parent obstacle + if (direction === forbiddenDirection) { + continue + } + + let available = calculateAvailableSpace({ node, direction }, state) + + // Skip if no space available + if (available < state.options.minRequiredExpandSpace) { + continue + } + + // Clamp expansion to respect aspect ratio + const maxExpansion = calculateMaxExpansion({ + currentWidth: node.rect.width, + currentHeight: node.rect.height, + direction, + available, + maxAspectRatio: state.options.maxAspectRatio, + }) + + // Use clamped expansion amount + available = maxExpansion + + if (available < state.options.minRequiredExpandSpace) { + continue + } + + // Determine minimum size based on layer count + const isMultiLayer = + node.zLayers.length >= state.options.minMulti.minLayers + const minWidth = isMultiLayer + ? state.options.minMulti.width + : state.options.minSingle.width + const minHeight = isMultiLayer + ? state.options.minMulti.height + : state.options.minSingle.height + + // Check if expanding would result in a valid node + // We need to check BOTH dimensions, not just the one being expanded + let newWidth = node.rect.width + let newHeight = node.rect.height + + if (direction === "left" || direction === "right") { + newWidth += available + } else { + newHeight += available + } + + // Both dimensions must meet minimum requirements + if (newWidth < minWidth || newHeight < minHeight) { + continue + } + + const potentialArea = calculatePotentialArea({ node, direction }, state) + + if (potentialArea > 0) { + candidates.push({ node, direction, potentialArea }) + } + } + } + + if (candidates.length === 0) { + // No more expansion possible, finalize this obstacle + state.processingPhase = "FINALIZE_OBSTACLE" + return true + } + + // Sort by potential area (descending) + candidates.sort((a, b) => b.potentialArea - a.potentialArea) + + // Group by node for this round (one expansion per node per round) + const seenNodeIds = new Set() + const roundNodes: GapFillNode[] = [] + + for (const candidate of candidates) { + if (!seenNodeIds.has(candidate.node.id)) { + seenNodeIds.add(candidate.node.id) + roundNodes.push(candidate.node) + } + } + + state.currentRound = roundNodes + state.currentRoundIndex = 0 + state.currentDirection = null + + // Transition to choosing direction for first node + state.processingPhase = "CHOOSE_DIRECTION" + return true +} + +/** + * Phase 3: Choose the best expansion direction for the current node + */ +function handleChooseDirection(state: EdgeExpansionGapFillState): boolean { + // Check if we've processed all nodes in this round + if (state.currentRoundIndex >= state.currentRound.length) { + // Round complete - remove merged nodes and start a new round + state.nodes = state.nodes.filter((node) => node.zLayers.length > 0) + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null + state.currentNodeId = null + + // Transition back to planning + state.processingPhase = "PLAN_ROUND" + return true + } + + const currentNode = state.currentRound[state.currentRoundIndex]! + + let bestDirection: Direction | null = null + let bestPotentialArea = 0 + + // Determine forbidden direction (toward parent obstacle) + const forbiddenDirection = getOppositeDirection(currentNode.direction) + + for (const direction of ALL_DIRECTIONS) { + // Skip direction that points toward parent obstacle + if (direction === forbiddenDirection) { + continue + } + + let available = calculateAvailableSpace( + { node: currentNode, direction }, + state, + ) + + if (available < state.options.minRequiredExpandSpace) continue + + // Clamp expansion to respect aspect ratio + const maxExpansion = calculateMaxExpansion({ + currentWidth: currentNode.rect.width, + currentHeight: currentNode.rect.height, + direction, + available, + maxAspectRatio: state.options.maxAspectRatio, + }) + + // Use clamped expansion amount + available = maxExpansion + + if (available < state.options.minRequiredExpandSpace) continue + + // Determine minimum size based on layer count + const isMultiLayer = + currentNode.zLayers.length >= state.options.minMulti.minLayers + const minWidth = isMultiLayer + ? state.options.minMulti.width + : state.options.minSingle.width + const minHeight = isMultiLayer + ? state.options.minMulti.height + : state.options.minSingle.height + + // Check if expanding would result in a valid node + // We need to check BOTH dimensions, not just the one being expanded + let newWidth = currentNode.rect.width + let newHeight = currentNode.rect.height + + if (direction === "left" || direction === "right") { + newWidth += available + } else { + newHeight += available + } + + // Both dimensions must meet minimum requirements + if (newWidth < minWidth || newHeight < minHeight) { + continue + } + + const potentialArea = calculatePotentialArea( + { node: currentNode, direction }, + state, + ) + + if (potentialArea > bestPotentialArea) { + bestPotentialArea = potentialArea + bestDirection = direction + } + } + + if (bestDirection === null) { + // No valid direction, this node is done expanding + currentNode.canExpand = false + + // Try to merge with adjacent layer nodes + mergeAdjacentLayerNodes({ node: currentNode, allNodes: state.nodes }) + + // Move to next node in the round + state.currentRoundIndex++ + state.currentDirection = null + state.currentNodeId = null + + // Stay in CHOOSE_DIRECTION phase to process next node + return true + } + + // We found a direction, prepare to expand + state.currentDirection = bestDirection + state.currentNodeId = currentNode.id + + // Transition to expansion + state.processingPhase = "EXPAND_NODE" + return true +} + +/** + * Phase 4: Expand the current node in the chosen direction + */ +function handleExpandNode(state: EdgeExpansionGapFillState): boolean { + const currentNode = state.currentRound[state.currentRoundIndex]! + + if (!state.currentDirection) { + throw new Error("EXPAND_NODE phase but no direction selected") + } + + // Calculate available space + let available = calculateAvailableSpace( + { node: currentNode, direction: state.currentDirection }, + state, + ) + + // Clamp expansion to respect aspect ratio + const maxExpansion = calculateMaxExpansion({ + currentWidth: currentNode.rect.width, + currentHeight: currentNode.rect.height, + direction: state.currentDirection, + available, + maxAspectRatio: state.options.maxAspectRatio, + }) + + // Use clamped expansion amount + available = maxExpansion + + if (available >= state.options.minRequiredExpandSpace) { + // Perform the expansion + expandNode({ + node: currentNode, + direction: state.currentDirection, + amount: available, + }) + + // Mark that this node has successfully expanded + currentNode.hasEverExpanded = true + + // Check if we hit aspect ratio limit and have remaining space + const originalAvailable = calculateAvailableSpace( + { node: currentNode, direction: state.currentDirection }, + state, + ) + const remainingSpace = originalAvailable - available + + if (remainingSpace >= state.options.minRequiredExpandSpace) { + // Spawn child node in the remaining gap + spawnChildNode(state, currentNode, state.currentDirection) + } + } + + // Move to next node in the round + state.currentRoundIndex++ + state.currentDirection = null + state.currentNodeId = null + + // Transition back to choosing direction for next node + state.processingPhase = "CHOOSE_DIRECTION" + return true +} + +/** + * Phase 5: Finalize the current obstacle (merge nodes, add to output, move to next obstacle) + */ +function handleFinalizeObstacle(state: EdgeExpansionGapFillState): boolean { + // Do a final merge pass on all remaining nodes + // This catches cases where multiple nodes finished expanding at the same time + for (const node of state.nodes) { + if (node.zLayers.length > 0) { + mergeAdjacentLayerNodes({ node, allNodes: state.nodes }) + } + } + + // Remove nodes that were merged (empty zLayers), never expanded, or below minimum size + const finalNodes = state.nodes.filter((node) => { + if (node.zLayers.length === 0) { + return false + } + + if (!node.hasEverExpanded) { + return false + } + + // Check if node meets minimum size requirements + const isMultiLayer = node.zLayers.length >= state.options.minMulti.minLayers + const minWidth = isMultiLayer + ? state.options.minMulti.width + : state.options.minSingle.width + const minHeight = isMultiLayer + ? state.options.minMulti.height + : state.options.minSingle.height + + const meetsMinimum = + node.rect.width >= minWidth && node.rect.height >= minHeight + + return meetsMinimum + }) + + // Add remaining valid nodes to newPlaced + for (const node of finalNodes) { + state.newPlaced.push({ + rect: { ...node.rect }, + zLayers: [...node.zLayers], + }) + } + + // Reset state for next obstacle + state.nodes = [] + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null + state.currentNodeId = null + state.currentObstacleIndex++ + + // Transition back to initializing nodes for next obstacle + state.processingPhase = "INIT_NODES" + return true +} + +/** + * Helper: Spawn a child node to fill remaining gap after aspect ratio limit + */ +function spawnChildNode( + state: EdgeExpansionGapFillState, + parentNode: GapFillNode, + direction: Direction, +): void { + // Use estimated min trace width to calculate initial thickness for child nodes + const minTraceWidth = + Math.min(state.bounds.width, state.bounds.height) * + state.options.estimatedMinTraceWidthFactor + const EDGE_THICKNESS = + minTraceWidth * state.options.initialEdgeThicknessFactor + + let childRect: XYRect + + if (direction === "up") { + childRect = { + x: parentNode.rect.x, + y: parentNode.rect.y + parentNode.rect.height, + width: parentNode.rect.width, + height: EDGE_THICKNESS, + } + } else if (direction === "down") { + childRect = { + x: parentNode.rect.x, + y: parentNode.rect.y - EDGE_THICKNESS, + width: parentNode.rect.width, + height: EDGE_THICKNESS, + } + } else if (direction === "right") { + childRect = { + x: parentNode.rect.x + parentNode.rect.width, + y: parentNode.rect.y, + width: EDGE_THICKNESS, + height: parentNode.rect.height, + } + } else { + // left + childRect = { + x: parentNode.rect.x - EDGE_THICKNESS, + y: parentNode.rect.y, + width: EDGE_THICKNESS, + height: parentNode.rect.height, + } + } + + const childNode: GapFillNode = { + id: `${parentNode.id}_child${Date.now()}`, + rect: childRect, + zLayers: [...parentNode.zLayers], + direction: parentNode.direction, + obstacleIndex: parentNode.obstacleIndex, + canExpand: true, + hasEverExpanded: false, + } + + // Validate child doesn't overlap + const childOverlaps = + // Check existing placed + state.existingPlacedByLayer.some( + (layer, z) => + childNode.zLayers.includes(z) && + layer?.some((existing) => overlaps(childNode.rect, existing)), + ) || + // Check obstacles + state.obstacles.some( + (layer, z) => + childNode.zLayers.includes(z) && + layer?.some((obs) => overlaps(childNode.rect, obs)), + ) || + // Check other nodes + state.nodes.some( + (n) => n.id !== childNode.id && overlaps(childNode.rect, n.rect), + ) || + // Check newPlaced + state.newPlaced.some( + (placed) => + childNode.zLayers.some((z) => placed.zLayers.includes(z)) && + overlaps(childNode.rect, placed.rect), + ) + + if (!childOverlaps) { + state.nodes.push(childNode) + } +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/types.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/types.ts new file mode 100644 index 0000000..9e3fd6d --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/types.ts @@ -0,0 +1,90 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/types.ts +import type { XYRect, Placed3D } from "../types" + +export type Direction = "up" | "down" | "left" | "right" + +/** + * Represents a unique physical obstacle with layer information. + * This ensures each physical obstacle is tracked consistently across layers. + */ +export type EdgeExpansionObstacle = { + /** Original index in the SRJ obstacles array (for reference/debugging) */ + srjObstacleIndex: number + /** The actual rectangle (x, y, width, height) */ + rect: XYRect + /** Computed center for easy access */ + center: { x: number; y: number } + /** Which z-layers this obstacle exists on */ + zLayers: number[] + /** Area for sorting (larger obstacles first) */ + area: number +} + +export type GapFillNode = { + id: string + rect: XYRect + zLayers: number[] + direction: Direction + obstacleIndex: number + canExpand: boolean + hasEverExpanded: boolean +} + +export type EdgeExpansionGapFillOptions = { + minRequiredExpandSpace: number + minSingle: { width: number; height: number } + minMulti: { width: number; height: number; minLayers: number } + maxAspectRatio: number | null + maxMultiLayerSpan: number | undefined + /** + * Starting width of seed rectangles placed around component edges, as a fraction of minTraceWidth. + * These tiny seed rects expand to fill available routing space. Smaller values = finer initial placement. + * Default: 0.01 (1% of minTraceWidth, typically ~0.0015mm for 0.15mm traces) + */ + initialEdgeThicknessFactor: number + /** + * Estimated minimum trace width as a fraction of board size (min(width, height)). + * Used to calculate initial edge node thickness when actual minTraceWidth isn't available. + * Typical PCB trace widths are 0.1-0.2mm, and boards are often 50-200mm, so 1% is a reasonable estimate. + * Default: 0.01 (1% of board size, e.g., ~1mm for a 100mm board) + */ + estimatedMinTraceWidthFactor: number +} + +export type Phase = "PROCESSING" | "DONE" + +/** + * Internal processing sub-phases for the step expansion algorithm + */ +export type ProcessingPhase = + | "INIT_NODES" // Creating initial seed nodes for current obstacle + | "PLAN_ROUND" // Calculating expansion candidates for the round + | "CHOOSE_DIRECTION" // Selecting best expansion direction for current node + | "EXPAND_NODE" // Performing the actual expansion + | "FINALIZE_OBSTACLE" // Merging and finalizing nodes when obstacle is done + +export type EdgeExpansionGapFillState = { + // Configuration + options: EdgeExpansionGapFillOptions + bounds: XYRect + layerCount: number + + // Input data + obstacles: XYRect[][] // Keep for per-layer overlap checking + edgeExpansionObstacles: EdgeExpansionObstacle[] // Sorted by size, largest first + existingPlaced: Placed3D[] + existingPlacedByLayer: XYRect[][] + + // Current state + phase: Phase + processingPhase: ProcessingPhase // Internal state machine phase + currentObstacleIndex: number // Index into edgeExpansionObstacles array + nodes: GapFillNode[] + currentRound: GapFillNode[] + currentRoundIndex: number + currentDirection: Direction | null + currentNodeId: string | null + + // Output + newPlaced: Placed3D[] +} diff --git a/lib/solvers/rectdiff/engine.ts b/lib/solvers/rectdiff/engine.ts index 963625b..faca03f 100644 --- a/lib/solvers/rectdiff/engine.ts +++ b/lib/solvers/rectdiff/engine.ts @@ -263,21 +263,6 @@ export function stepGrid(state: RectDiffState): void { state.consumedSeedsThisGrid = 0 return } else { - if (!state.edgeAnalysisDone) { - const minSize = Math.min(minSingle.width, minSingle.height) - state.candidates = computeEdgeCandidates3D({ - bounds: state.bounds, - minSize, - layerCount: state.layerCount, - obstaclesByLayer: state.obstaclesByLayer, - placedByLayer: state.placedByLayer, - hardPlacedByLayer, - }) - state.edgeAnalysisDone = true - state.totalSeedsThisGrid = state.candidates.length - state.consumedSeedsThisGrid = 0 - return - } state.phase = "EXPANSION" state.expansionIndex = 0 return diff --git a/lib/solvers/rectdiff/subsolvers/EdgeExpansionGapFillSubSolver.ts b/lib/solvers/rectdiff/subsolvers/EdgeExpansionGapFillSubSolver.ts new file mode 100644 index 0000000..b9425e7 --- /dev/null +++ b/lib/solvers/rectdiff/subsolvers/EdgeExpansionGapFillSubSolver.ts @@ -0,0 +1,225 @@ +// lib/solvers/rectdiff/subsolvers/EdgeExpansionGapFillSubSolver.ts +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { XYRect, Placed3D } from "../types" +import type { + EdgeExpansionGapFillState, + EdgeExpansionGapFillOptions, +} from "../edge-expansion-gapfill/types" +import { initState } from "../edge-expansion-gapfill/initState" +import { stepExpansion } from "../edge-expansion-gapfill/stepExpansion" +import { computeProgress } from "../edge-expansion-gapfill/computeProgress" + +/** + * A subsolver that fills gaps by expanding rectangles from obstacle boundaries. + * + * Unlike the grid-based GapFillSubSolver, this approach: + * 1. Processes one obstacle at a time + * 2. Places 8 expansion rects (4 edges + 4 corners) around each obstacle + * 3. Filters out rects that overlap with existing capacity nodes + * 4. Expands valid rects to fill remaining space + * + * This creates a more connected mesh by filling gaps between existing nodes. + */ +export class EdgeExpansionGapFillSubSolver extends BaseSolver { + private state: EdgeExpansionGapFillState + private bounds: XYRect + + constructor(params: { + bounds: XYRect + layerCount: number + obstacles: XYRect[][] + existingPlaced: Placed3D[] + existingPlacedByLayer: XYRect[][] + options?: Partial + }) { + super() + this.bounds = params.bounds + this.state = initState({ + bounds: params.bounds, + layerCount: params.layerCount, + obstacles: params.obstacles, + existingPlaced: params.existingPlaced, + existingPlacedByLayer: params.existingPlacedByLayer, + options: params.options, + }) + } + + override _setup() { + // State is initialized in constructor, nothing to do here + } + + /** + * Execute one step of the gap fill algorithm. + * Super granular: expands one node in one direction per step. + */ + override _step() { + const stillWorking = stepExpansion(this.state) + if (!stillWorking) { + this.solved = true + } + } + + /** + * Calculate progress as a value between 0 and 1. + */ + computeProgress(): number { + return computeProgress(this.state) + } + + /** + * Get newly placed gap-fill rectangles. + */ + getNewPlaced(): Placed3D[] { + return this.state.newPlaced + } + + /** + * Get all placed rectangles (existing + new). + */ + getAllPlaced(): Placed3D[] { + return [...this.state.existingPlaced, ...this.state.newPlaced] + } + + override getOutput() { + return { + newPlaced: this.state.newPlaced, + allPlaced: this.getAllPlaced(), + } + } + + /** + * Visualization: show current obstacle being processed and expansion nodes. + */ + override visualize(): GraphicsObject { + const rects: NonNullable = [] + const points: NonNullable = [] + + // Board bounds (subtle) + rects.push({ + center: { + x: this.bounds.x + this.bounds.width / 2, + y: this.bounds.y + this.bounds.height / 2, + }, + width: this.bounds.width, + height: this.bounds.height, + fill: "none", + stroke: "#e5e7eb", + label: "", + }) + + // Show existing placed nodes (subtle gray) + for (const placed of this.state.existingPlaced) { + rects.push({ + center: { + x: placed.rect.x + placed.rect.width / 2, + y: placed.rect.y + placed.rect.height / 2, + }, + width: placed.rect.width, + height: placed.rect.height, + fill: "#f3f4f6", + stroke: "#d1d5db", + label: "", + }) + } + + // Show obstacles (red) + for (let z = 0; z < this.state.layerCount; z++) { + if (this.state.obstacles[z]) { + for (const obstacle of this.state.obstacles[z]!) { + rects.push({ + center: { + x: obstacle.x + obstacle.width / 2, + y: obstacle.y + obstacle.height / 2, + }, + width: obstacle.width, + height: obstacle.height, + fill: "#fee2e2", + stroke: "#ef4444", + label: "", + }) + } + } + } + + // Highlight current obstacle being processed (yellow) + const currentObstacle = + this.state.edgeExpansionObstacles[this.state.currentObstacleIndex] + + if (currentObstacle && this.state.nodes.length > 0) { + rects.push({ + center: { + x: currentObstacle.center.x, + y: currentObstacle.center.y, + }, + width: currentObstacle.rect.width, + height: currentObstacle.rect.height, + fill: "#fef3c7", + stroke: "#f59e0b", + label: "processing", + }) + } + + // Show current expansion nodes (blue) + for (const node of this.state.nodes) { + const isCurrentNode = node.id === this.state.currentNodeId + + // Calculate minimum visual size relative to board dimensions + const boardScale = Math.min(this.bounds.width, this.bounds.height) + const MIN_VISUAL_SIZE = boardScale * 0.001 + + let displayRect = { ...node.rect } + + // Apply minimum visual size for edges (only to the thin dimension) + if (node.direction === "up" || node.direction === "down") { + // Horizontal edge - ensure minimum height + if (displayRect.height < MIN_VISUAL_SIZE) { + displayRect.height = MIN_VISUAL_SIZE + } + } else { + // Vertical edge - ensure minimum width + if (displayRect.width < MIN_VISUAL_SIZE) { + displayRect.width = MIN_VISUAL_SIZE + } + } + + rects.push({ + center: { + x: displayRect.x + displayRect.width / 2, + y: displayRect.y + displayRect.height / 2, + }, + width: displayRect.width, + height: displayRect.height, + fill: isCurrentNode ? "#fef3c7" : "#dbeafe", + stroke: isCurrentNode ? "#f59e0b" : "#3b82f6", + label: isCurrentNode ? "expanding" : "", + }) + } + + // Show newly placed gap-fill nodes (green) + for (const placed of this.state.newPlaced) { + rects.push({ + center: { + x: placed.rect.x + placed.rect.width / 2, + y: placed.rect.y + placed.rect.height / 2, + }, + width: placed.rect.width, + height: placed.rect.height, + fill: "#bbf7d0", + stroke: "#22c55e", + label: "gap-fill", + }) + } + + const totalObstacles = this.state.edgeExpansionObstacles.length + const obstacleProgress = `${this.state.currentObstacleIndex}/${totalObstacles}` + const nodesActive = this.state.nodes.length + + return { + title: `EdgeExpansionGapFill (obs: ${obstacleProgress}, nodes: ${nodesActive})`, + coordinateSystem: "cartesian", + rects, + points, + } + } +} diff --git a/tests/__snapshots__/board-outline.snap.svg b/tests/__snapshots__/board-outline.snap.svg index 0e96bff..4cc87db 100644 --- a/tests/__snapshots__/board-outline.snap.svg +++ b/tests/__snapshots__/board-outline.snap.svg @@ -1,18 +1,20 @@ -