From 89e37553024f4fa1fb92c04288c1a5b90c5d6a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?0hm=E2=98=98=EF=B8=8F?= Date: Tue, 16 Dec 2025 11:59:27 +0530 Subject: [PATCH 1/3] WIP WIP WIP WIP --- lib/solvers/RectDiffSolver.ts | 68 ++- lib/solvers/rectdiff/candidates.ts | 2 +- .../calculateAvailableSpace.ts | 133 +++++ .../calculateMaxExpansion.ts | 51 ++ .../calculatePotentialArea.ts | 17 + .../edge-expansion-gapfill/computeProgress.ts | 15 + .../createNodesFromObstacle.ts | 102 ++++ .../edge-expansion-gapfill/expandNode.ts | 27 + .../filterOverlappingNodes.ts | 79 +++ .../edge-expansion-gapfill/initState.ts | 88 +++ .../mergeAdjacentLayerNodes.ts | 62 +++ .../edge-expansion-gapfill/stepExpansion.ts | 524 ++++++++++++++++++ .../rectdiff/edge-expansion-gapfill/types.ts | 79 +++ .../validateInitialNodes.ts | 36 ++ .../validateNoOverlaps.ts | 92 +++ lib/solvers/rectdiff/engine.ts | 15 - .../EdgeExpansionGapFillSubSolver.ts | 225 ++++++++ 17 files changed, 1595 insertions(+), 20 deletions(-) create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/calculateMaxExpansion.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/calculatePotentialArea.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/expandNode.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/filterOverlappingNodes.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/mergeAdjacentLayerNodes.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/types.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts create mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts create mode 100644 lib/solvers/rectdiff/subsolvers/EdgeExpansionGapFillSubSolver.ts 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..c7819db --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts @@ -0,0 +1,133 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts +import type { GapFillNode, Direction, EdgeExpansionGapFillState } from "./types" +import type { XYRect } from "../types" + +const EPS = 1e-9 + +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..157ff71 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts @@ -0,0 +1,102 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts +import type { XYRect } from "../types" +import type { GapFillNode } from "./types" +import { overlaps } from "../geometry" + +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..af80f94 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts @@ -0,0 +1,88 @@ +// 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", + 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..453aa0a --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts @@ -0,0 +1,524 @@ +// 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 { validateInitialNodes } from "./validateInitialNodes" +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 { validateNoOverlaps } from "./validateNoOverlaps" +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. **Node Creation**: 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. **Expansion Planning**: 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. **Node Expansion**: 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. + * + * 4. **Completion**: 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 { + // If we're done processing all obstacles, mark as done + if (state.currentObstacleIndex >= state.edgeExpansionObstacles.length) { + state.phase = "DONE" + return false + } + + // If we have no nodes for current obstacle, create them + if (state.nodes.length === 0 && state.currentRound.length === 0) { + // Get the current obstacle from the sorted array + const obstacle = state.edgeExpansionObstacles[state.currentObstacleIndex] + + if (!obstacle) { + // No more obstacles to process + 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, + }) + + // VALIDATE: Check if initial nodes overlap with parent obstacle + validateInitialNodes({ + nodes: allNodes, + obstacles: state.obstacles, + }) + + // Filter out nodes that overlap with existing capacity nodes, obstacles, or previously placed gap-fill nodes + // Now checks per-layer since nodes are single-layer + const validNodes = filterOverlappingNodes({ + nodes: allNodes, + existingPlacedByLayer: state.existingPlacedByLayer, + obstaclesByLayer: state.obstacles, + newPlaced: state.newPlaced, + }) + + if (validNodes.length === 0) { + // No valid nodes for this obstacle, move to next + state.currentObstacleIndex++ + return true + } + + state.nodes = validNodes + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null + } + + // If current round is empty, start a new round + if (state.currentRound.length === 0) { + // 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 for this obstacle + + // 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 + + // Log removed if needed for debugging + + return meetsMinimum + }) + + // VALIDATION: Ensure no overlaps exist (should never happen, will throw if it does) + validateNoOverlaps({ nodes: finalNodes, state }) + + // Add remaining valid nodes to newPlaced + for (const node of finalNodes) { + state.newPlaced.push({ + rect: { ...node.rect }, + zLayers: [...node.zLayers], + }) + } + + // Move to next obstacle + state.nodes = [] + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null + state.currentNodeId = null + state.currentObstacleIndex++ + return true + } + + // Sort by potential area (descending) + candidates.sort((a, b) => b.potentialArea - a.potentialArea) + + // Group by node for this 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 + } + + // Process one node in one direction + if (state.currentRoundIndex >= state.currentRound.length) { + // Round complete + // Remove merged nodes (those with empty zLayers) from state.nodes + state.nodes = state.nodes.filter((node) => node.zLayers.length > 0) + + // Start new round + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null + state.currentNodeId = null + return true + } + + const currentNode = state.currentRound[state.currentRoundIndex]! + + // If we haven't chosen a direction yet, pick the best one + if (state.currentDirection === null) { + 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 + state.currentRoundIndex++ + state.currentDirection = null + state.currentNodeId = null + return true + } + + state.currentDirection = bestDirection + state.currentNodeId = currentNode.id + } + + // Expand in the chosen direction + 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) { + const oldWidth = currentNode.rect.width + const oldHeight = currentNode.rect.height + + 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 + // Calculate child node position and size + // 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 (state.currentDirection === "up") { + childRect = { + x: currentNode.rect.x, + y: currentNode.rect.y + currentNode.rect.height, + width: currentNode.rect.width, + height: EDGE_THICKNESS, + } + } else if (state.currentDirection === "down") { + childRect = { + x: currentNode.rect.x, + y: currentNode.rect.y - EDGE_THICKNESS, + width: currentNode.rect.width, + height: EDGE_THICKNESS, + } + } else if (state.currentDirection === "right") { + childRect = { + x: currentNode.rect.x + currentNode.rect.width, + y: currentNode.rect.y, + width: EDGE_THICKNESS, + height: currentNode.rect.height, + } + } else { + // left + childRect = { + x: currentNode.rect.x - EDGE_THICKNESS, + y: currentNode.rect.y, + width: EDGE_THICKNESS, + height: currentNode.rect.height, + } + } + + const childNode: GapFillNode = { + id: `${currentNode.id}_child${Date.now()}`, + rect: childRect, + zLayers: [...currentNode.zLayers], + direction: currentNode.direction, + obstacleIndex: currentNode.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) + } + } + } + + // Move to next node + state.currentRoundIndex++ + state.currentDirection = null + state.currentNodeId = null + + return true +} 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..612d4be --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/types.ts @@ -0,0 +1,79 @@ +// 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" + +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 + 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/edge-expansion-gapfill/validateInitialNodes.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts new file mode 100644 index 0000000..bc8675a --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts @@ -0,0 +1,36 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts +import type { GapFillNode } from "./types" +import type { XYRect } from "../types" +import { overlaps } from "../geometry" + +/** + * Validates that initial nodes do not overlap with their parent obstacles. + * Throws an error if any overlap is detected. + */ +export function validateInitialNodes(params: { + nodes: GapFillNode[] + obstacles: XYRect[][] +}): void { + const { nodes, obstacles } = params + + for (const node of nodes) { + // Get the parent obstacle on each layer this node exists on + for (const layer of node.zLayers) { + if (!obstacles[layer]) continue + + const parentObstacle = obstacles[layer]![node.obstacleIndex] + if (!parentObstacle) continue + + // Check if node overlaps with parent obstacle + if (overlaps(node.rect, parentObstacle)) { + throw new Error( + `VALIDATION ERROR: Initial node ${node.id} overlaps with parent obstacle on layer ${layer}. ` + + `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + + `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + + `Parent: {x:${parentObstacle.x.toFixed(3)}, y:${parentObstacle.y.toFixed(3)}, ` + + `w:${parentObstacle.width.toFixed(3)}, h:${parentObstacle.height.toFixed(3)}}`, + ) + } + } + } +} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts new file mode 100644 index 0000000..f25edc9 --- /dev/null +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts @@ -0,0 +1,92 @@ +// lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts +import type { GapFillNode, EdgeExpansionGapFillState } from "./types" +import { overlaps } from "../geometry" + +/** + * Strict validation function to ensure no overlaps exist. + * This should NEVER find overlaps - if it does, it's a bug in the expansion logic. + * Throws an error if any overlap is detected. + */ +export function validateNoOverlaps(params: { + nodes: GapFillNode[] + state: EdgeExpansionGapFillState +}): void { + const { nodes, state } = params + + for (const node of nodes) { + // Check each layer the node occupies + for (const z of node.zLayers) { + // 1. Check overlap with obstacles on this layer + if (state.obstacles[z]) { + for (const obstacle of state.obstacles[z]!) { + if (overlaps(node.rect, obstacle)) { + throw new Error( + `VALIDATION ERROR: Node ${node.id} overlaps with obstacle on layer ${z}. ` + + `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + + `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + + `Obstacle: {x:${obstacle.x.toFixed(3)}, y:${obstacle.y.toFixed(3)}, ` + + `w:${obstacle.width.toFixed(3)}, h:${obstacle.height.toFixed(3)}}`, + ) + } + } + } + + // 2. Check overlap with existing placed capacity nodes on this layer + if (state.existingPlacedByLayer[z]) { + for (const existing of state.existingPlacedByLayer[z]!) { + if (overlaps(node.rect, existing)) { + throw new Error( + `VALIDATION ERROR: Node ${node.id} overlaps with existing capacity node on layer ${z}. ` + + `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + + `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + + `Existing: {x:${existing.x.toFixed(3)}, y:${existing.y.toFixed(3)}, ` + + `w:${existing.width.toFixed(3)}, h:${existing.height.toFixed(3)}}`, + ) + } + } + } + } + + // 3. Check overlap with previously placed gap-fill nodes + for (const placed of state.newPlaced) { + // Check if they share any layers + const sharedLayers = node.zLayers.filter((z) => + placed.zLayers.includes(z), + ) + + if (sharedLayers.length > 0) { + if (overlaps(node.rect, placed.rect)) { + throw new Error( + `VALIDATION ERROR: Node ${node.id} overlaps with previously placed gap-fill node on layers ${sharedLayers.join(",")}. ` + + `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + + `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + + `Placed: {x:${placed.rect.x.toFixed(3)}, y:${placed.rect.y.toFixed(3)}, ` + + `w:${placed.rect.width.toFixed(3)}, h:${placed.rect.height.toFixed(3)}}`, + ) + } + } + } + + // 4. Check overlap with other nodes being validated in this batch + for (const otherNode of nodes) { + if (node.id === otherNode.id) continue + + // Check if they share any layers + const sharedLayers = node.zLayers.filter((z) => + otherNode.zLayers.includes(z), + ) + + if (sharedLayers.length > 0) { + if (overlaps(node.rect, otherNode.rect)) { + throw new Error( + `VALIDATION ERROR: Node ${node.id} overlaps with node ${otherNode.id} on layers ${sharedLayers.join(",")}. ` + + `Node1: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + + `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + + `Node2: {x:${otherNode.rect.x.toFixed(3)}, y:${otherNode.rect.y.toFixed(3)}, ` + + `w:${otherNode.rect.width.toFixed(3)}, h:${otherNode.rect.height.toFixed(3)}}`, + ) + } + } + } + } +} 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, + } + } +} From 834444bd850f007118614607752233ebcc655a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?0hm=E2=98=98=EF=B8=8F?= Date: Tue, 16 Dec 2025 20:25:51 +0530 Subject: [PATCH 2/3] WIP --- .../calculateAvailableSpace.ts | 47 ++ .../createNodesFromObstacle.ts | 60 ++ .../edge-expansion-gapfill/initState.ts | 1 + .../edge-expansion-gapfill/stepExpansion.ts | 733 ++++++++++-------- .../rectdiff/edge-expansion-gapfill/types.ts | 11 + .../validateInitialNodes.ts | 36 - .../validateNoOverlaps.ts | 92 --- 7 files changed, 518 insertions(+), 462 deletions(-) delete mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts delete mode 100644 lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts index c7819db..dae12ab 100644 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts @@ -4,6 +4,53 @@ 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, diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts index 157ff71..1c9e0e1 100644 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/createNodesFromObstacle.ts @@ -3,6 +3,66 @@ 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 diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts index af80f94..c3b6c12 100644 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/initState.ts @@ -77,6 +77,7 @@ export function initState(params: { existingPlaced, existingPlacedByLayer, phase: "PROCESSING", + processingPhase: "INIT_NODES", currentObstacleIndex: 0, nodes: [], currentRound: [], diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts index 453aa0a..c5ebd5d 100644 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/stepExpansion.ts @@ -2,14 +2,12 @@ import type { EdgeExpansionGapFillState, Direction, GapFillNode } from "./types" import type { XYRect, Placed3D } from "../types" import { createNodesFromObstacle } from "./createNodesFromObstacle" -import { validateInitialNodes } from "./validateInitialNodes" 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 { validateNoOverlaps } from "./validateNoOverlaps" import { overlaps } from "../geometry" const ALL_DIRECTIONS: Direction[] = ["up", "down", "left", "right"] @@ -37,20 +35,23 @@ function getOppositeDirection(direction: Direction): Direction { * obstacle, or expanding one node in one direction by a small amount). * * **Algorithm phases:** - * 1. **Node Creation**: When starting a new obstacle, creates 4 tiny seed rectangles + * 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. **Expansion Planning**: For each active node, evaluates all possible expansion + * 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. **Node Expansion**: Expands one node in one direction by the calculated amount. + * 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. * - * 4. **Completion**: When a node can no longer expand, merges it with adjacent-layer + * 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. * @@ -72,245 +73,105 @@ function getOppositeDirection(direction: Direction): Direction { * @internal This is an internal function used by EdgeExpansionGapFillSubSolver */ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { - // If we're done processing all obstacles, mark as done + // Check if we're completely done if (state.currentObstacleIndex >= state.edgeExpansionObstacles.length) { state.phase = "DONE" return false } - // If we have no nodes for current obstacle, create them - if (state.nodes.length === 0 && state.currentRound.length === 0) { - // Get the current obstacle from the sorted array - const obstacle = state.edgeExpansionObstacles[state.currentObstacleIndex] + // State machine: handle current processing phase + switch (state.processingPhase) { + case "INIT_NODES": + return handleInitNodes(state) - if (!obstacle) { - // No more obstacles to process - state.currentObstacleIndex++ - return true - } + case "PLAN_ROUND": + return handlePlanRound(state) - // 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, - }) - - // VALIDATE: Check if initial nodes overlap with parent obstacle - validateInitialNodes({ - nodes: allNodes, - obstacles: state.obstacles, - }) + case "CHOOSE_DIRECTION": + return handleChooseDirection(state) - // Filter out nodes that overlap with existing capacity nodes, obstacles, or previously placed gap-fill nodes - // Now checks per-layer since nodes are single-layer - const validNodes = filterOverlappingNodes({ - nodes: allNodes, - existingPlacedByLayer: state.existingPlacedByLayer, - obstaclesByLayer: state.obstacles, - newPlaced: state.newPlaced, - }) + case "EXPAND_NODE": + return handleExpandNode(state) - if (validNodes.length === 0) { - // No valid nodes for this obstacle, move to next - state.currentObstacleIndex++ - return true - } + case "FINALIZE_OBSTACLE": + return handleFinalizeObstacle(state) - state.nodes = validNodes - state.currentRound = [] - state.currentRoundIndex = 0 - state.currentDirection = null + default: + // Should never happen + throw new Error(`Unknown processing phase: ${state.processingPhase}`) } +} - // If current round is empty, start a new round - if (state.currentRound.length === 0) { - // 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 for this obstacle - - // 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 - - // Log removed if needed for debugging - - return meetsMinimum - }) - - // VALIDATION: Ensure no overlaps exist (should never happen, will throw if it does) - validateNoOverlaps({ nodes: finalNodes, state }) - - // Add remaining valid nodes to newPlaced - for (const node of finalNodes) { - state.newPlaced.push({ - rect: { ...node.rect }, - zLayers: [...node.zLayers], - }) - } - - // Move to next obstacle - state.nodes = [] - state.currentRound = [] - state.currentRoundIndex = 0 - state.currentDirection = null - state.currentNodeId = null - state.currentObstacleIndex++ - return true - } +/** + * Phase 1: Initialize nodes for the current obstacle + */ +function handleInitNodes(state: EdgeExpansionGapFillState): boolean { + // Get the current obstacle + const obstacle = state.edgeExpansionObstacles[state.currentObstacleIndex] - // Sort by potential area (descending) - candidates.sort((a, b) => b.potentialArea - a.potentialArea) + if (!obstacle) { + // No obstacle at this index, skip to next + state.currentObstacleIndex++ + return true + } - // Group by node for this round - const seenNodeIds = new Set() - const roundNodes: GapFillNode[] = [] + // 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, + }) - for (const candidate of candidates) { - if (!seenNodeIds.has(candidate.node.id)) { - seenNodeIds.add(candidate.node.id) - roundNodes.push(candidate.node) - } - } + // 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, + }) - state.currentRound = roundNodes - state.currentRoundIndex = 0 - state.currentDirection = null + 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 } - // Process one node in one direction - if (state.currentRoundIndex >= state.currentRound.length) { - // Round complete - // Remove merged nodes (those with empty zLayers) from state.nodes - state.nodes = state.nodes.filter((node) => node.zLayers.length > 0) + // We have valid nodes, prepare for expansion + state.nodes = validNodes + state.currentRound = [] + state.currentRoundIndex = 0 + state.currentDirection = null - // Start new round - state.currentRound = [] - state.currentRoundIndex = 0 - state.currentDirection = null - state.currentNodeId = null - return true - } + // Transition to planning phase + state.processingPhase = "PLAN_ROUND" + return true +} - const currentNode = state.currentRound[state.currentRoundIndex]! +/** + * 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 + }> = [] - // If we haven't chosen a direction yet, pick the best one - if (state.currentDirection === null) { - let bestDirection: Direction | null = null - let bestPotentialArea = 0 + for (const node of state.nodes) { + if (!node.canExpand) continue // Determine forbidden direction (toward parent obstacle) - const forbiddenDirection = getOppositeDirection(currentNode.direction) + const forbiddenDirection = getOppositeDirection(node.direction) for (const direction of ALL_DIRECTIONS) { // Skip direction that points toward parent obstacle @@ -318,17 +179,17 @@ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { continue } - let available = calculateAvailableSpace( - { node: currentNode, direction }, - state, - ) + let available = calculateAvailableSpace({ node, direction }, state) - if (available < state.options.minRequiredExpandSpace) continue + // Skip if no space available + if (available < state.options.minRequiredExpandSpace) { + continue + } // Clamp expansion to respect aspect ratio const maxExpansion = calculateMaxExpansion({ - currentWidth: currentNode.rect.width, - currentHeight: currentNode.rect.height, + currentWidth: node.rect.width, + currentHeight: node.rect.height, direction, available, maxAspectRatio: state.options.maxAspectRatio, @@ -337,11 +198,13 @@ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { // Use clamped expansion amount available = maxExpansion - if (available < state.options.minRequiredExpandSpace) continue + if (available < state.options.minRequiredExpandSpace) { + continue + } // Determine minimum size based on layer count const isMultiLayer = - currentNode.zLayers.length >= state.options.minMulti.minLayers + node.zLayers.length >= state.options.minMulti.minLayers const minWidth = isMultiLayer ? state.options.minMulti.width : state.options.minSingle.width @@ -351,8 +214,8 @@ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { // 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 + let newWidth = node.rect.width + let newHeight = node.rect.height if (direction === "left" || direction === "right") { newWidth += available @@ -365,36 +228,169 @@ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { continue } - const potentialArea = calculatePotentialArea( - { node: currentNode, direction }, - state, - ) + const potentialArea = calculatePotentialArea({ node, direction }, state) - if (potentialArea > bestPotentialArea) { - bestPotentialArea = potentialArea - bestDirection = direction + 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 + } - if (bestDirection === null) { - // No valid direction, this node is done expanding - currentNode.canExpand = false + // Sort by potential area (descending) + candidates.sort((a, b) => b.potentialArea - a.potentialArea) - // Try to merge with adjacent layer nodes - mergeAdjacentLayerNodes({ node: currentNode, allNodes: state.nodes }) + // Group by node for this round (one expansion per node per round) + const seenNodeIds = new Set() + const roundNodes: GapFillNode[] = [] - // Move to next node - state.currentRoundIndex++ - state.currentDirection = null - state.currentNodeId = null - return true + 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 - state.currentDirection = bestDirection - state.currentNodeId = currentNode.id + // Transition back to planning + state.processingPhase = "PLAN_ROUND" + return true } - // Expand in the chosen direction + 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, @@ -413,9 +409,7 @@ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { available = maxExpansion if (available >= state.options.minRequiredExpandSpace) { - const oldWidth = currentNode.rect.width - const oldHeight = currentNode.rect.height - + // Perform the expansion expandNode({ node: currentNode, direction: state.currentDirection, @@ -434,91 +428,162 @@ export function stepExpansion(state: EdgeExpansionGapFillState): boolean { if (remainingSpace >= state.options.minRequiredExpandSpace) { // Spawn child node in the remaining gap - // Calculate child node position and size - // 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 (state.currentDirection === "up") { - childRect = { - x: currentNode.rect.x, - y: currentNode.rect.y + currentNode.rect.height, - width: currentNode.rect.width, - height: EDGE_THICKNESS, - } - } else if (state.currentDirection === "down") { - childRect = { - x: currentNode.rect.x, - y: currentNode.rect.y - EDGE_THICKNESS, - width: currentNode.rect.width, - height: EDGE_THICKNESS, - } - } else if (state.currentDirection === "right") { - childRect = { - x: currentNode.rect.x + currentNode.rect.width, - y: currentNode.rect.y, - width: EDGE_THICKNESS, - height: currentNode.rect.height, - } - } else { - // left - childRect = { - x: currentNode.rect.x - EDGE_THICKNESS, - y: currentNode.rect.y, - width: EDGE_THICKNESS, - height: currentNode.rect.height, - } - } + spawnChildNode(state, currentNode, state.currentDirection) + } + } - const childNode: GapFillNode = { - id: `${currentNode.id}_child${Date.now()}`, - rect: childRect, - zLayers: [...currentNode.zLayers], - direction: currentNode.direction, - obstacleIndex: currentNode.obstacleIndex, - canExpand: true, - hasEverExpanded: false, - } + // Move to next node in the round + state.currentRoundIndex++ + state.currentDirection = null + state.currentNodeId = null - // 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) - } + // 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 }) } } - // Move to next node - state.currentRoundIndex++ + // 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 index 612d4be..9e3fd6d 100644 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/types.ts +++ b/lib/solvers/rectdiff/edge-expansion-gapfill/types.ts @@ -53,6 +53,16 @@ export type EdgeExpansionGapFillOptions = { 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 @@ -67,6 +77,7 @@ export type EdgeExpansionGapFillState = { // Current state phase: Phase + processingPhase: ProcessingPhase // Internal state machine phase currentObstacleIndex: number // Index into edgeExpansionObstacles array nodes: GapFillNode[] currentRound: GapFillNode[] diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts deleted file mode 100644 index bc8675a..0000000 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts +++ /dev/null @@ -1,36 +0,0 @@ -// lib/solvers/rectdiff/edge-expansion-gapfill/validateInitialNodes.ts -import type { GapFillNode } from "./types" -import type { XYRect } from "../types" -import { overlaps } from "../geometry" - -/** - * Validates that initial nodes do not overlap with their parent obstacles. - * Throws an error if any overlap is detected. - */ -export function validateInitialNodes(params: { - nodes: GapFillNode[] - obstacles: XYRect[][] -}): void { - const { nodes, obstacles } = params - - for (const node of nodes) { - // Get the parent obstacle on each layer this node exists on - for (const layer of node.zLayers) { - if (!obstacles[layer]) continue - - const parentObstacle = obstacles[layer]![node.obstacleIndex] - if (!parentObstacle) continue - - // Check if node overlaps with parent obstacle - if (overlaps(node.rect, parentObstacle)) { - throw new Error( - `VALIDATION ERROR: Initial node ${node.id} overlaps with parent obstacle on layer ${layer}. ` + - `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + - `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + - `Parent: {x:${parentObstacle.x.toFixed(3)}, y:${parentObstacle.y.toFixed(3)}, ` + - `w:${parentObstacle.width.toFixed(3)}, h:${parentObstacle.height.toFixed(3)}}`, - ) - } - } - } -} diff --git a/lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts b/lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts deleted file mode 100644 index f25edc9..0000000 --- a/lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts +++ /dev/null @@ -1,92 +0,0 @@ -// lib/solvers/rectdiff/edge-expansion-gapfill/validateNoOverlaps.ts -import type { GapFillNode, EdgeExpansionGapFillState } from "./types" -import { overlaps } from "../geometry" - -/** - * Strict validation function to ensure no overlaps exist. - * This should NEVER find overlaps - if it does, it's a bug in the expansion logic. - * Throws an error if any overlap is detected. - */ -export function validateNoOverlaps(params: { - nodes: GapFillNode[] - state: EdgeExpansionGapFillState -}): void { - const { nodes, state } = params - - for (const node of nodes) { - // Check each layer the node occupies - for (const z of node.zLayers) { - // 1. Check overlap with obstacles on this layer - if (state.obstacles[z]) { - for (const obstacle of state.obstacles[z]!) { - if (overlaps(node.rect, obstacle)) { - throw new Error( - `VALIDATION ERROR: Node ${node.id} overlaps with obstacle on layer ${z}. ` + - `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + - `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + - `Obstacle: {x:${obstacle.x.toFixed(3)}, y:${obstacle.y.toFixed(3)}, ` + - `w:${obstacle.width.toFixed(3)}, h:${obstacle.height.toFixed(3)}}`, - ) - } - } - } - - // 2. Check overlap with existing placed capacity nodes on this layer - if (state.existingPlacedByLayer[z]) { - for (const existing of state.existingPlacedByLayer[z]!) { - if (overlaps(node.rect, existing)) { - throw new Error( - `VALIDATION ERROR: Node ${node.id} overlaps with existing capacity node on layer ${z}. ` + - `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + - `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + - `Existing: {x:${existing.x.toFixed(3)}, y:${existing.y.toFixed(3)}, ` + - `w:${existing.width.toFixed(3)}, h:${existing.height.toFixed(3)}}`, - ) - } - } - } - } - - // 3. Check overlap with previously placed gap-fill nodes - for (const placed of state.newPlaced) { - // Check if they share any layers - const sharedLayers = node.zLayers.filter((z) => - placed.zLayers.includes(z), - ) - - if (sharedLayers.length > 0) { - if (overlaps(node.rect, placed.rect)) { - throw new Error( - `VALIDATION ERROR: Node ${node.id} overlaps with previously placed gap-fill node on layers ${sharedLayers.join(",")}. ` + - `Node: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + - `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + - `Placed: {x:${placed.rect.x.toFixed(3)}, y:${placed.rect.y.toFixed(3)}, ` + - `w:${placed.rect.width.toFixed(3)}, h:${placed.rect.height.toFixed(3)}}`, - ) - } - } - } - - // 4. Check overlap with other nodes being validated in this batch - for (const otherNode of nodes) { - if (node.id === otherNode.id) continue - - // Check if they share any layers - const sharedLayers = node.zLayers.filter((z) => - otherNode.zLayers.includes(z), - ) - - if (sharedLayers.length > 0) { - if (overlaps(node.rect, otherNode.rect)) { - throw new Error( - `VALIDATION ERROR: Node ${node.id} overlaps with node ${otherNode.id} on layers ${sharedLayers.join(",")}. ` + - `Node1: {x:${node.rect.x.toFixed(3)}, y:${node.rect.y.toFixed(3)}, ` + - `w:${node.rect.width.toFixed(3)}, h:${node.rect.height.toFixed(3)}}. ` + - `Node2: {x:${otherNode.rect.x.toFixed(3)}, y:${otherNode.rect.y.toFixed(3)}, ` + - `w:${otherNode.rect.width.toFixed(3)}, h:${otherNode.rect.height.toFixed(3)}}`, - ) - } - } - } - } -} From 5ac9e143b2f48635df884fb6016c9e479d72848e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?0hm=E2=98=98=EF=B8=8F?= Date: Tue, 16 Dec 2025 20:28:37 +0530 Subject: [PATCH 3/3] WIP --- tests/__snapshots__/board-outline.snap.svg | 26 ++++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 @@ -