diff --git a/lib/EdgeSpatialHashIndex/EdgeSpatialHashIndex.ts b/lib/EdgeSpatialHashIndex/EdgeSpatialHashIndex.ts new file mode 100644 index 0000000..e2923dd --- /dev/null +++ b/lib/EdgeSpatialHashIndex/EdgeSpatialHashIndex.ts @@ -0,0 +1,44 @@ +import Flatbush from "flatbush" + +export interface ISpatialIndex { + insert(item: T, minX: number, minY: number, maxX: number, maxY: number): void + finish(): void + search(minX: number, minY: number, maxX: number, maxY: number): T[] + clear(): void +} + +export class EdgeSpatialHashIndex implements ISpatialIndex { + private index: Flatbush + private items: T[] = [] + private currentIndex = 0 + private capacity: number + + constructor(numItems: number) { + this.capacity = Math.max(1, numItems) + this.index = new Flatbush(this.capacity) + } + + insert(item: T, minX: number, minY: number, maxX: number, maxY: number) { + if (this.currentIndex >= this.index.numItems) { + throw new Error("Exceeded initial capacity") + } + this.items[this.currentIndex] = item + this.index.add(minX, minY, maxX, maxY) + this.currentIndex++ + } + + finish() { + this.index.finish() + } + + search(minX: number, minY: number, maxX: number, maxY: number): T[] { + const ids = this.index.search(minX, minY, maxX, maxY) + return ids.map((id) => this.items[id] || null).filter(Boolean) as T[] + } + + clear() { + this.items = [] + this.currentIndex = 0 + this.index = new Flatbush(this.capacity) + } +} diff --git a/lib/RectDiffPipeline.ts b/lib/RectDiffPipeline.ts index 425f004..4ce3a6d 100644 --- a/lib/RectDiffPipeline.ts +++ b/lib/RectDiffPipeline.ts @@ -2,6 +2,7 @@ import { BasePipelineSolver, definePipelineStep } from "@tscircuit/solver-utils" import type { SimpleRouteJson } from "./types/srj-types" import type { GridFill3DOptions } from "./solvers/rectdiff/types" import { RectDiffSolver } from "./solvers/RectDiffSolver" +import { GapFillSolverRepeater } from "./solvers/GapFillSolver/GapFillSolverManager" import type { CapacityMeshNode } from "./types/capacity-mesh-types" import type { GraphicsObject } from "graphics-debug" import { createBaseVisualization } from "./solvers/rectdiff/visualization" @@ -9,10 +10,16 @@ import { createBaseVisualization } from "./solvers/rectdiff/visualization" export interface RectDiffPipelineInput { simpleRouteJson: SimpleRouteJson gridOptions?: Partial + /** Maximum distance between edges to consider for gap filling (default: 10) */ + gapFillMaxEdgeDistance?: number + /** Number of gap fill iterations to run (default: 3) */ + gapFillIterations?: number } export class RectDiffPipeline extends BasePipelineSolver { rectDiffSolver?: RectDiffSolver + gapFillSolver?: GapFillSolverRepeater + override MAX_ITERATIONS: number = 100e6 override pipelineDef = [ definePipelineStep( @@ -30,6 +37,30 @@ export class RectDiffPipeline extends BasePipelineSolver }, }, ), + definePipelineStep( + "gapFillSolver", + GapFillSolverRepeater, + (instance) => { + const rectDiffSolver = + instance.getSolver("rectDiffSolver")! + const rectDiffState = (rectDiffSolver as any).state + + return [ + { + simpleRouteJson: instance.inputProblem.simpleRouteJson, + placedRects: rectDiffState.placed || [], + obstaclesByLayer: rectDiffState.obstaclesByLayer || [], + maxEdgeDistance: instance.inputProblem.gapFillMaxEdgeDistance ?? 10, + repeatCount: instance.inputProblem.gapFillIterations ?? 3, + }, + ] + }, + { + onSolved: () => { + // Gap fill completed + }, + }, + ), ] override getConstructorParams() { @@ -37,13 +68,60 @@ export class RectDiffPipeline extends BasePipelineSolver } override getOutput(): { meshNodes: CapacityMeshNode[] } { - return this.getSolver("rectDiffSolver")!.getOutput() + const rectDiffOutput = + this.getSolver("rectDiffSolver")!.getOutput() + const gapFillSolver = this.getSolver("gapFillSolver") + + if (!gapFillSolver) { + return rectDiffOutput + } + + const gapFillOutput = gapFillSolver.getOutput() + + return { + meshNodes: [...rectDiffOutput.meshNodes, ...gapFillOutput.filledRects], + } } override visualize(): GraphicsObject { - const solver = this.getSolver("rectDiffSolver") - if (solver) { - return solver.visualize() + const gapFillSolver = this.getSolver("gapFillSolver") + const rectDiffSolver = this.getSolver("rectDiffSolver") + + if (gapFillSolver && !gapFillSolver.solved) { + return gapFillSolver.visualize() + } + + if (rectDiffSolver) { + const baseViz = rectDiffSolver.visualize() + if (gapFillSolver?.solved) { + const gapFillOutput = gapFillSolver.getOutput() + const gapFillRects = gapFillOutput.filledRects.map((node) => { + const minZ = Math.min(...node.availableZ) + const colors = [ + { fill: "#dbeafe", stroke: "#3b82f6" }, + { fill: "#fef3c7", stroke: "#f59e0b" }, + { fill: "#d1fae5", stroke: "#10b981" }, + ] + const color = colors[minZ % colors.length]! + + return { + center: node.center, + width: node.width, + height: node.height, + fill: color.fill, + stroke: color.stroke, + label: `capacity node (gap fill)\nz: [${node.availableZ.join(", ")}]`, + } + }) + + return { + ...baseViz, + title: "RectDiff Pipeline (with Gap Fill)", + rects: [...(baseViz.rects || []), ...gapFillRects], + } + } + + return baseViz } // Show board and obstacles even before solver is initialized diff --git a/lib/solvers/GapFillSolver/GapFillSolver.ts b/lib/solvers/GapFillSolver/GapFillSolver.ts new file mode 100644 index 0000000..abd4b89 --- /dev/null +++ b/lib/solvers/GapFillSolver/GapFillSolver.ts @@ -0,0 +1,295 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { SimpleRouteJson } from "../../types/srj-types" +import type { Placed3D, XYRect } from "../rectdiff/types" +import type { CapacityMeshNode } from "../../types/capacity-mesh-types" +import { EdgeSpatialHashIndex } from "../../EdgeSpatialHashIndex/EdgeSpatialHashIndex" +import type { RectEdge } from "./types" +import { extractEdges } from "./extractEdges" +import { splitEdgesOnOverlaps } from "./splitEdgesOnOverlaps" +import { buildEdgeSpatialIndex } from "./buildEdgeSpatialIndex" +import { overlaps } from "../rectdiff/geometry" +import { visualizeBaseState } from "./visualizeBaseState" +import { createNodeFromTwoEdges } from "./expandEdgeToRect" +import { isNearbyParallelEdge } from "./isNearbyParallelEdge" +import { distanceBetweenEdges } from "./distanceBetweenEdges" + +const COLOR_MAP = { + edgeStroke: "#10b981", + filledGapFill: "#d1fae5", + filledGapStroke: "#10b981", +} + +export interface GapFillSolverInput { + simpleRouteJson: SimpleRouteJson + placedRects: Placed3D[] + obstaclesByLayer: XYRect[][] + maxEdgeDistance?: number +} + +type SubPhase = + | "SELECT_PRIMARY_EDGE" + | "FIND_NEARBY_EDGES" + | "EXPAND_POINT" + | "DONE" + +/** + * Gap Fill Solver - fills gaps between existing rectangles using edge analysis. + * Processes one edge per step for visualization. + */ +export class GapFillSolver extends BaseSolver { + private srj: SimpleRouteJson + private inputRects: Placed3D[] + private obstaclesByLayer: XYRect[][] + private layerCount: number + private maxEdgeDistance: number + private minTraceWidth: number + + private splitEdges: RectEdge[] + private splitEdgeSpatialIndex: EdgeSpatialHashIndex + + private phase: SubPhase + private currentEdgeIndex: number + private currentPrimaryEdge?: RectEdge + + private nearbyEdgeCandidateIndex: number + private currentNearbyEdges: RectEdge[] + + private filledRects: Placed3D[] + + constructor(input: GapFillSolverInput) { + super() + + const layerCount = input.simpleRouteJson.layerCount || 1 + const maxEdgeDistance = input.maxEdgeDistance ?? 2.0 + + const rectEdges = extractEdges(input.placedRects, input.obstaclesByLayer) + const splitEdges = splitEdgesOnOverlaps(rectEdges) + + const splitEdgeSpatialIndex = buildEdgeSpatialIndex( + splitEdges, + maxEdgeDistance, + ) + + this.srj = input.simpleRouteJson + this.inputRects = input.placedRects + this.obstaclesByLayer = input.obstaclesByLayer + this.layerCount = layerCount + this.maxEdgeDistance = maxEdgeDistance + this.minTraceWidth = input.simpleRouteJson.minTraceWidth + this.splitEdges = splitEdges + this.splitEdgeSpatialIndex = splitEdgeSpatialIndex + this.phase = "SELECT_PRIMARY_EDGE" + this.currentEdgeIndex = 0 + this.nearbyEdgeCandidateIndex = 0 + this.currentNearbyEdges = [] + this.filledRects = [] + } + + override _setup(): void { + this.stats = { + phase: "EDGE_ANALYSIS", + edgeIndex: 0, + totalEdges: this.splitEdges.length, + filledCount: 0, + } + } + + override _step(): void { + switch (this.phase) { + case "SELECT_PRIMARY_EDGE": + this.stepSelectPrimaryEdge() + break + case "FIND_NEARBY_EDGES": + this.stepFindNearbyEdges() + break + case "EXPAND_POINT": + this.stepExpandPoint() + break + case "DONE": + this.solved = true + break + } + + this.stats.phase = this.phase + this.stats.edgeIndex = this.currentEdgeIndex + this.stats.filledCount = this.filledRects.length + } + + private stepSelectPrimaryEdge(): void { + if (this.currentEdgeIndex >= this.splitEdges.length) { + this.phase = "DONE" + return + } + + this.currentPrimaryEdge = this.splitEdges[this.currentEdgeIndex] + this.nearbyEdgeCandidateIndex = 0 + this.currentNearbyEdges = [] + + this.phase = "FIND_NEARBY_EDGES" + } + + private stepFindNearbyEdges(): void { + const primaryEdge = this.currentPrimaryEdge! + + const padding = this.maxEdgeDistance + const minX = Math.min(primaryEdge.x1, primaryEdge.x2) - padding + const minY = Math.min(primaryEdge.y1, primaryEdge.y2) - padding + const maxX = Math.max(primaryEdge.x1, primaryEdge.x2) + padding + const maxY = Math.max(primaryEdge.y1, primaryEdge.y2) + padding + + const candidates = this.splitEdgeSpatialIndex.search(minX, minY, maxX, maxY) + + // Collect nearby parallel edges + this.currentNearbyEdges = [] + for (const candidate of candidates) { + if ( + candidate !== primaryEdge && + isNearbyParallelEdge( + primaryEdge, + candidate, + this.minTraceWidth, + this.maxEdgeDistance, + ) + ) { + this.currentNearbyEdges.push(candidate) + } + } + + const edgesWithDist = this.currentNearbyEdges.map((edge) => ({ + edge, + distance: distanceBetweenEdges(primaryEdge, edge), + })) + edgesWithDist.sort((a, b) => b.distance - a.distance) + this.currentNearbyEdges = edgesWithDist.map((e) => e.edge) + + this.phase = "EXPAND_POINT" + } + + private stepExpandPoint(): void { + const primaryEdge = this.currentPrimaryEdge! + + for (const nearbyEdge of this.currentNearbyEdges) { + const filledRect = createNodeFromTwoEdges(primaryEdge, nearbyEdge) + if (filledRect && this.isValidFill(filledRect)) { + this.filledRects.push(filledRect) + break + } + } + + this.currentEdgeIndex++ + this.phase = "SELECT_PRIMARY_EDGE" + this.currentNearbyEdges = [] + } + + private isValidFill(candidate: Placed3D): boolean { + const minSize = 0.01 + if (candidate.rect.width < minSize || candidate.rect.height < minSize) { + return false + } + + // Check filled rects + for (const existing of this.filledRects) { + if ( + candidate.zLayers.some((z) => existing.zLayers.includes(z)) && + overlaps(candidate.rect, existing.rect) + ) { + return false + } + } + + // Check input rects + for (const input of this.inputRects) { + if ( + candidate.zLayers.some((z) => input.zLayers.includes(z)) && + overlaps(candidate.rect, input.rect) + ) { + return false + } + } + + // Check obstacles + for (const z of candidate.zLayers) { + const obstacles = this.obstaclesByLayer[z] ?? [] + for (const obstacle of obstacles) { + if (overlaps(candidate.rect, obstacle)) { + return false + } + } + } + + return true + } + + override getOutput(): { filledRects: CapacityMeshNode[] } { + const filledRects: CapacityMeshNode[] = this.filledRects.map( + (placed: Placed3D, index: number) => ({ + capacityMeshNodeId: `gap-fill-${index}`, + x: placed.rect.x, + y: placed.rect.y, + 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, + availableZ: placed.zLayers, + layer: placed.zLayers[0]?.toString() ?? "0", + }), + ) + + return { filledRects } + } + + override visualize(): GraphicsObject { + const baseViz = visualizeBaseState( + this.inputRects, + this.obstaclesByLayer, + `Gap Fill (Edge ${this.currentEdgeIndex}/${this.splitEdges.length})`, + ) + + const points: NonNullable = [] + const lines: NonNullable = [] + + for (const edge of this.splitEdges) { + const isCurrent = edge === this.currentPrimaryEdge + + lines.push({ + points: [ + { x: edge.x1, y: edge.y1 }, + { x: edge.x2, y: edge.y2 }, + ], + strokeColor: COLOR_MAP.edgeStroke, + strokeWidth: isCurrent ? 0.2 : 0.1, + label: `${edge.side}\n(${edge.x1.toFixed(2)},${edge.y1.toFixed(2)})-(${edge.x2.toFixed(2)},${edge.y2.toFixed(2)})`, + }) + + if (isCurrent) { + points.push({ + x: (edge.x1 + edge.x2) / 2, + y: (edge.y1 + edge.y2) / 2, + }) + } + } + + for (const placed of this.filledRects) { + baseViz.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: COLOR_MAP.filledGapFill, + stroke: COLOR_MAP.filledGapStroke, + label: `filled gap\npos: (${placed.rect.x.toFixed(2)}, ${placed.rect.y.toFixed(2)})\nsize: ${placed.rect.width.toFixed(2)} × ${placed.rect.height.toFixed(2)}\nz: [${placed.zLayers.join(", ")}]`, + }) + } + + return { + ...baseViz, + points, + lines, + } + } +} diff --git a/lib/solvers/GapFillSolver/GapFillSolverManager.ts b/lib/solvers/GapFillSolver/GapFillSolverManager.ts new file mode 100644 index 0000000..11cddd4 --- /dev/null +++ b/lib/solvers/GapFillSolver/GapFillSolverManager.ts @@ -0,0 +1,97 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { CapacityMeshNode } from "../../types/capacity-mesh-types" +import { GapFillSolver, type GapFillSolverInput } from "./GapFillSolver" +import { visualizeBaseState } from "./visualizeBaseState" + +export interface GapFillSolverRepeaterInput extends GapFillSolverInput { + numberOfGapFillLoops?: number +} + +export class GapFillSolverRepeater extends BaseSolver { + private numberOfGapFillLoops: number + private currentLoop = 0 + private activeSubsolver: GapFillSolver | null = null + private allFilledRects: any[] = [] + + constructor(private input: GapFillSolverRepeaterInput) { + super() + this.numberOfGapFillLoops = input.numberOfGapFillLoops ?? 1 + } + + override _setup(): void { + this.allFilledRects = [] + this.startNextIteration() + } + + private startNextIteration(): void { + if (this.currentLoop >= this.numberOfGapFillLoops) { + this.solved = true + return + } + + this.activeSubsolver = new GapFillSolver({ + ...this.input, + placedRects: [...this.input.placedRects, ...this.allFilledRects], + }) + this.activeSubsolver._setup() + this.currentLoop++ + } + + override _step(): void { + if (!this.activeSubsolver) { + this.solved = true + return + } + + if (this.activeSubsolver.solved) { + const output = this.activeSubsolver.getOutput() + this.allFilledRects.push( + ...output.filledRects.map((node: any) => ({ + rect: { + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }, + zLayers: node.availableZ, + })), + ) + this.startNextIteration() + return + } + + this.activeSubsolver._step() + } + + override getOutput(): { filledRects: CapacityMeshNode[] } { + const filledRects: CapacityMeshNode[] = this.allFilledRects.map( + (placed, index) => ({ + capacityMeshNodeId: `gap-fill-${index}`, + x: placed.rect.x, + y: placed.rect.y, + 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, + availableZ: placed.zLayers, + layer: placed.zLayers[0]?.toString() ?? "0", + }), + ) + + return { filledRects: filledRects } + } + + override visualize(): GraphicsObject { + if (this.activeSubsolver) { + return this.activeSubsolver.visualize() + } + return visualizeBaseState( + this.input.placedRects, + this.input.obstaclesByLayer, + `Gap Fill Manager (Iteration ${this.currentLoop}/${this.numberOfGapFillLoops})`, + ) + } +} diff --git a/lib/solvers/GapFillSolver/buildEdgeSpatialIndex.ts b/lib/solvers/GapFillSolver/buildEdgeSpatialIndex.ts new file mode 100644 index 0000000..e5f5dff --- /dev/null +++ b/lib/solvers/GapFillSolver/buildEdgeSpatialIndex.ts @@ -0,0 +1,21 @@ +import { EdgeSpatialHashIndex } from "../../EdgeSpatialHashIndex/EdgeSpatialHashIndex" +import type { RectEdge } from "./types" + +export function buildEdgeSpatialIndex( + edges: RectEdge[], + maxEdgeDistance: number, +): EdgeSpatialHashIndex { + const index = new EdgeSpatialHashIndex(edges.length) + + for (const edge of edges) { + const minX = Math.min(edge.x1, edge.x2) - maxEdgeDistance + const minY = Math.min(edge.y1, edge.y2) - maxEdgeDistance + const maxX = Math.max(edge.x1, edge.x2) + maxEdgeDistance + const maxY = Math.max(edge.y1, edge.y2) + maxEdgeDistance + + index.insert(edge, minX, minY, maxX, maxY) + } + + index.finish() + return index +} diff --git a/lib/solvers/GapFillSolver/createEdgeSegment.ts b/lib/solvers/GapFillSolver/createEdgeSegment.ts new file mode 100644 index 0000000..2314fa2 --- /dev/null +++ b/lib/solvers/GapFillSolver/createEdgeSegment.ts @@ -0,0 +1,26 @@ +import type { RectEdge } from "./types" + +export function createEdgeSegment(params: { + edge: RectEdge + start: number + end: number +}): RectEdge { + const { edge, start, end } = params + const isHorizontal = Math.abs(edge.normal.y) > 0.5 + + if (isHorizontal) { + const length = edge.x2 - edge.x1 + return { + ...edge, + x1: edge.x1 + start * length, + x2: edge.x1 + end * length, + } + } else { + const length = edge.y2 - edge.y1 + return { + ...edge, + y1: edge.y1 + start * length, + y2: edge.y1 + end * length, + } + } +} diff --git a/lib/solvers/GapFillSolver/distanceBetweenEdges.ts b/lib/solvers/GapFillSolver/distanceBetweenEdges.ts new file mode 100644 index 0000000..c8ac6a2 --- /dev/null +++ b/lib/solvers/GapFillSolver/distanceBetweenEdges.ts @@ -0,0 +1,8 @@ +import type { RectEdge } from "./types" + +export function distanceBetweenEdges(edge1: RectEdge, edge2: RectEdge): number { + if (Math.abs(edge1.normal.y) > 0.5) { + return Math.abs(edge1.y1 - edge2.y1) + } + return Math.abs(edge1.x1 - edge2.x1) +} diff --git a/lib/solvers/GapFillSolver/expandEdgeToRect.ts b/lib/solvers/GapFillSolver/expandEdgeToRect.ts new file mode 100644 index 0000000..9fe36dd --- /dev/null +++ b/lib/solvers/GapFillSolver/expandEdgeToRect.ts @@ -0,0 +1,36 @@ +import type { Placed3D } from "../rectdiff/types" +import type { RectEdge } from "./types" + +export function createNodeFromTwoEdges( + primaryEdge: RectEdge, + nearbyEdge: RectEdge, +): Placed3D | null { + let rect: { x: number; y: number; width: number; height: number } + + if (Math.abs(primaryEdge.normal.x) > 0.5) { + const leftX = primaryEdge.normal.x > 0 ? primaryEdge.x1 : nearbyEdge.x1 + const rightX = primaryEdge.normal.x > 0 ? nearbyEdge.x1 : primaryEdge.x1 + + rect = { + x: leftX, + y: primaryEdge.y1, + width: rightX - leftX, + height: primaryEdge.y2 - primaryEdge.y1, + } + } else { + const bottomY = primaryEdge.normal.y > 0 ? primaryEdge.y1 : nearbyEdge.y1 + const topY = primaryEdge.normal.y > 0 ? nearbyEdge.y1 : primaryEdge.y1 + + rect = { + x: primaryEdge.x1, + y: bottomY, + width: primaryEdge.x2 - primaryEdge.x1, + height: topY - bottomY, + } + } + + return { + rect, + zLayers: [...primaryEdge.zLayers], + } +} diff --git a/lib/solvers/GapFillSolver/extractEdges.ts b/lib/solvers/GapFillSolver/extractEdges.ts new file mode 100644 index 0000000..08f9b82 --- /dev/null +++ b/lib/solvers/GapFillSolver/extractEdges.ts @@ -0,0 +1,110 @@ +import type { Placed3D, XYRect } from "../rectdiff/types" +import type { RectEdge } from "./types" + +export function extractEdges( + rects: Placed3D[], + obstaclesByLayer: XYRect[][], +): RectEdge[] { + const edges: RectEdge[] = [] + + for (const placed of rects) { + const { rect, zLayers } = placed + + edges.push({ + rect, + side: "top", + x1: rect.x, + y1: rect.y + rect.height, + x2: rect.x + rect.width, + y2: rect.y + rect.height, + normal: { x: 0, y: 1 }, + zLayers: [...zLayers], + }) + + edges.push({ + rect, + side: "bottom", + x1: rect.x, + y1: rect.y, + x2: rect.x + rect.width, + y2: rect.y, + normal: { x: 0, y: -1 }, + zLayers: [...zLayers], + }) + + edges.push({ + rect, + side: "right", + x1: rect.x + rect.width, + y1: rect.y, + x2: rect.x + rect.width, + y2: rect.y + rect.height, + normal: { x: 1, y: 0 }, + zLayers: [...zLayers], + }) + + edges.push({ + rect, + side: "left", + x1: rect.x, + y1: rect.y, + x2: rect.x, + y2: rect.y + rect.height, + normal: { x: -1, y: 0 }, + zLayers: [...zLayers], + }) + } + + for (let z = 0; z < obstaclesByLayer.length; z++) { + const obstacles = obstaclesByLayer[z] ?? [] + for (const rect of obstacles) { + const zLayers = [z] + + edges.push({ + rect, + side: "top", + x1: rect.x, + y1: rect.y + rect.height, + x2: rect.x + rect.width, + y2: rect.y + rect.height, + normal: { x: 0, y: 1 }, + zLayers, + }) + + edges.push({ + rect, + side: "bottom", + x1: rect.x, + y1: rect.y, + x2: rect.x + rect.width, + y2: rect.y, + normal: { x: 0, y: -1 }, + zLayers, + }) + + edges.push({ + rect, + side: "right", + x1: rect.x + rect.width, + y1: rect.y, + x2: rect.x + rect.width, + y2: rect.y + rect.height, + normal: { x: 1, y: 0 }, + zLayers, + }) + + edges.push({ + rect, + side: "left", + x1: rect.x, + y1: rect.y, + x2: rect.x, + y2: rect.y + rect.height, + normal: { x: -1, y: 0 }, + zLayers, + }) + } + } + + return edges +} diff --git a/lib/solvers/GapFillSolver/findOverlappingRects.ts b/lib/solvers/GapFillSolver/findOverlappingRects.ts new file mode 100644 index 0000000..d332fd3 --- /dev/null +++ b/lib/solvers/GapFillSolver/findOverlappingRects.ts @@ -0,0 +1,44 @@ +import type { Placed3D, XYRect } from "../rectdiff/types" +import { overlaps } from "../rectdiff/geometry" + +export function findOverlappingRects( + candidateRect: XYRect, + candidateZLayers: number[], + inputRects: Placed3D[], + filledRects: Placed3D[], + obstaclesByLayer: XYRect[][], +): XYRect[] { + const overlappingRects: XYRect[] = [] + + // Check filled rects + for (const existing of filledRects) { + if ( + candidateZLayers.some((z) => existing.zLayers.includes(z)) && + overlaps(candidateRect, existing.rect) + ) { + overlappingRects.push(existing.rect) + } + } + + // Check input rects + for (const input of inputRects) { + if ( + candidateZLayers.some((z) => input.zLayers.includes(z)) && + overlaps(candidateRect, input.rect) + ) { + overlappingRects.push(input.rect) + } + } + + // Check obstacles + for (const z of candidateZLayers) { + const obstacles = obstaclesByLayer[z] ?? [] + for (const obstacle of obstacles) { + if (overlaps(candidateRect, obstacle)) { + overlappingRects.push(obstacle) + } + } + } + + return overlappingRects +} diff --git a/lib/solvers/GapFillSolver/isNearbyParallelEdge.ts b/lib/solvers/GapFillSolver/isNearbyParallelEdge.ts new file mode 100644 index 0000000..5fe7db0 --- /dev/null +++ b/lib/solvers/GapFillSolver/isNearbyParallelEdge.ts @@ -0,0 +1,31 @@ +import { distanceBetweenEdges } from "./distanceBetweenEdges" +import type { RectEdge } from "./types" + +export function isNearbyParallelEdge( + primaryEdge: RectEdge, + candidate: RectEdge, + minTraceWidth: number, + maxEdgeDistance: number, +): boolean { + const dotProduct = + primaryEdge.normal.x * candidate.normal.x + + primaryEdge.normal.y * candidate.normal.y + + if (dotProduct >= -0.9) return false + + const sharedLayers = primaryEdge.zLayers.filter((z) => + candidate.zLayers.includes(z), + ) + if (sharedLayers.length === 0) return false + + const distance = distanceBetweenEdges(primaryEdge, candidate) + const minGap = Math.max(minTraceWidth, 0.1) + if (distance < minGap) { + return false + } + if (distance > maxEdgeDistance) { + return false + } + + return true +} diff --git a/lib/solvers/GapFillSolver/splitEdgeAroundRects.ts b/lib/solvers/GapFillSolver/splitEdgeAroundRects.ts new file mode 100644 index 0000000..f2de08a --- /dev/null +++ b/lib/solvers/GapFillSolver/splitEdgeAroundRects.ts @@ -0,0 +1,95 @@ +import type { RectEdge } from "./types" +import type { XYRect } from "../rectdiff/types" +import { createEdgeSegment } from "./createEdgeSegment" + +export function splitEdgeAroundRects( + edge: RectEdge, + overlappingRects: XYRect[], +): RectEdge[] { + const result: RectEdge[] = [] + const tolerance = 0.01 + const isHorizontal = Math.abs(edge.normal.y) > 0.5 + const overlappingRanges: Array<{ start: number; end: number }> = [] + + // Calculate which portions of the edge overlap with rectangles + for (const rect of overlappingRects) { + if (isHorizontal) { + // Edge is horizontal - check for x-overlap + if ( + Math.abs(edge.y1 - rect.y) > tolerance && + Math.abs(edge.y1 - (rect.y + rect.height)) > tolerance + ) { + continue // Rect doesn't align with this horizontal edge + } + + const overlapStart = Math.max(edge.x1, rect.x) + const overlapEnd = Math.min(edge.x2, rect.x + rect.width) + + if (overlapStart < overlapEnd) { + const edgeLength = edge.x2 - edge.x1 + overlappingRanges.push({ + start: (overlapStart - edge.x1) / edgeLength, + end: (overlapEnd - edge.x1) / edgeLength, + }) + } + } else { + // Edge is vertical - check for y-overlap + if ( + Math.abs(edge.x1 - rect.x) > tolerance && + Math.abs(edge.x1 - (rect.x + rect.width)) > tolerance + ) { + continue // Rect doesn't align with this vertical edge + } + + const overlapStart = Math.max(edge.y1, rect.y) + const overlapEnd = Math.min(edge.y2, rect.y + rect.height) + + if (overlapStart < overlapEnd) { + const edgeLength = edge.y2 - edge.y1 + overlappingRanges.push({ + start: (overlapStart - edge.y1) / edgeLength, + end: (overlapEnd - edge.y1) / edgeLength, + }) + } + } + } + + if (overlappingRanges.length === 0) { + // No overlaps found, return original edge + return [edge] + } + + // Merge overlapping ranges + overlappingRanges.sort((a, b) => a.start - b.start) + const merged: Array<{ start: number; end: number }> = [] + for (const range of overlappingRanges) { + if (merged.length === 0 || range.start > merged[merged.length - 1]!.end) { + merged.push(range) + } else { + merged[merged.length - 1]!.end = Math.max( + merged[merged.length - 1]!.end, + range.end, + ) + } + } + + // Extract free (non-overlapping) segments + let pos = 0 + for (const occupied of merged) { + if (pos < occupied.start) { + const freeSegment = createEdgeSegment({ + edge, + start: pos, + end: occupied.start, + }) + result.push(freeSegment) + } + pos = occupied.end + } + if (pos < 1) { + const freeSegment = createEdgeSegment({ edge, start: pos, end: 1 }) + result.push(freeSegment) + } + + return result +} diff --git a/lib/solvers/GapFillSolver/splitEdgesOnOverlaps.ts b/lib/solvers/GapFillSolver/splitEdgesOnOverlaps.ts new file mode 100644 index 0000000..91a0ed8 --- /dev/null +++ b/lib/solvers/GapFillSolver/splitEdgesOnOverlaps.ts @@ -0,0 +1,108 @@ +import { EdgeSpatialHashIndex } from "../../EdgeSpatialHashIndex/EdgeSpatialHashIndex" +import type { RectEdge } from "./types" +import { createEdgeSegment } from "./createEdgeSegment" + +export function splitEdgesOnOverlaps(edges: RectEdge[]): RectEdge[] { + const result: RectEdge[] = [] + const tolerance = 0.01 + + const spatialIndex = new EdgeSpatialHashIndex(edges.length) + for (const edge of edges) { + const minX = Math.min(edge.x1, edge.x2) + const minY = Math.min(edge.y1, edge.y2) + const maxX = Math.max(edge.x1, edge.x2) + const maxY = Math.max(edge.y1, edge.y2) + spatialIndex.insert(edge, minX, minY, maxX, maxY) + } + spatialIndex.finish() + + for (const edge of edges) { + const isHorizontal = Math.abs(edge.normal.y) > 0.5 + const overlappingRanges: Array<{ start: number; end: number }> = [] + + const minX = Math.min(edge.x1, edge.x2) + const minY = Math.min(edge.y1, edge.y2) + const maxX = Math.max(edge.x1, edge.x2) + const maxY = Math.max(edge.y1, edge.y2) + const nearby = spatialIndex.search(minX, minY, maxX, maxY) + + for (const other of nearby) { + if (edge === other) continue + if (edge.rect === other.rect) continue + if (!edge.zLayers.some((z) => other.zLayers.includes(z))) continue + + const isOtherHorizontal = Math.abs(other.normal.y) > 0.5 + if (isHorizontal !== isOtherHorizontal) continue + + if (isHorizontal) { + if (Math.abs(edge.y1 - other.y1) > tolerance) continue + + const overlapStart = Math.max(edge.x1, other.x1) + const overlapEnd = Math.min(edge.x2, other.x2) + + if (overlapStart < overlapEnd) { + const edgeLength = edge.x2 - edge.x1 + overlappingRanges.push({ + start: (overlapStart - edge.x1) / edgeLength, + end: (overlapEnd - edge.x1) / edgeLength, + }) + } + } else { + if (Math.abs(edge.x1 - other.x1) > tolerance) continue + + const overlapStart = Math.max(edge.y1, other.y1) + const overlapEnd = Math.min(edge.y2, other.y2) + + if (overlapStart < overlapEnd) { + const edgeLength = edge.y2 - edge.y1 + overlappingRanges.push({ + start: (overlapStart - edge.y1) / edgeLength, + end: (overlapEnd - edge.y1) / edgeLength, + }) + } + } + } + + if (overlappingRanges.length === 0) { + result.push(edge) + continue + } + + overlappingRanges.sort((a, b) => a.start - b.start) + const merged: Array<{ start: number; end: number }> = [] + for (const range of overlappingRanges) { + if (merged.length === 0 || range.start > merged[merged.length - 1]!.end) { + merged.push(range) + } else { + merged[merged.length - 1]!.end = Math.max( + merged[merged.length - 1]!.end, + range.end, + ) + } + } + + let pos = 0 + for (const occupied of merged) { + if (pos < occupied.start) { + const freeSegment = createEdgeSegment({ + edge, + start: pos, + end: occupied.start, + }) + result.push(freeSegment) + } + pos = occupied.end + } + if (pos < 1) { + const freeSegment = createEdgeSegment({ edge, start: pos, end: 1 }) + result.push(freeSegment) + } + } + const edgesWithLength = result.map((edge) => ({ + edge, + length: Math.abs(edge.x2 - edge.x1) + Math.abs(edge.y2 - edge.y1), + })) + edgesWithLength.sort((a, b) => b.length - a.length) + + return edgesWithLength.map((e) => e.edge) +} diff --git a/lib/solvers/GapFillSolver/types.ts b/lib/solvers/GapFillSolver/types.ts new file mode 100644 index 0000000..66a5a5e --- /dev/null +++ b/lib/solvers/GapFillSolver/types.ts @@ -0,0 +1,12 @@ +import type { XYRect } from "../rectdiff/types" + +export interface RectEdge { + rect: XYRect + side: "top" | "bottom" | "left" | "right" + x1: number + y1: number + x2: number + y2: number + normal: { x: number; y: number } + zLayers: number[] +} diff --git a/lib/solvers/GapFillSolver/visualizeBaseState.ts b/lib/solvers/GapFillSolver/visualizeBaseState.ts new file mode 100644 index 0000000..18ac90a --- /dev/null +++ b/lib/solvers/GapFillSolver/visualizeBaseState.ts @@ -0,0 +1,56 @@ +import type { GraphicsObject } from "graphics-debug" +import type { Placed3D, XYRect } from "../rectdiff/types" + +const COLOR_MAP = { + inputRectFill: "#f3f4f6", + inputRectStroke: "#9ca3af", + obstacleRectFill: "#fee2e2", + obstacleRectStroke: "#fc6e6eff", +} + +export function visualizeBaseState( + inputRects: Placed3D[], + obstaclesByLayer: XYRect[][], + title: string = "Gap Fill", +): GraphicsObject { + const rects: NonNullable = [] + + for (const placed of inputRects) { + 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: COLOR_MAP.inputRectFill, + stroke: COLOR_MAP.inputRectStroke, + label: `input rect\npos: (${placed.rect.x.toFixed(2)}, ${placed.rect.y.toFixed(2)})\nsize: ${placed.rect.width.toFixed(2)} × ${placed.rect.height.toFixed(2)}\nz: [${placed.zLayers.join(", ")}]`, + }) + } + + for (let z = 0; z < obstaclesByLayer.length; z++) { + const obstacles = obstaclesByLayer[z] ?? [] + for (const obstacle of obstacles) { + rects.push({ + center: { + x: obstacle.x + obstacle.width / 2, + y: obstacle.y + obstacle.height / 2, + }, + width: obstacle.width, + height: obstacle.height, + fill: COLOR_MAP.obstacleRectFill, + stroke: COLOR_MAP.obstacleRectStroke, + label: `obstacle\npos: (${obstacle.x.toFixed(2)}, ${obstacle.y.toFixed(2)})\nsize: ${obstacle.width.toFixed(2)} × ${obstacle.height.toFixed(2)}\nz: ${z}`, + }) + } + } + + return { + title, + coordinateSystem: "cartesian", + rects, + points: [], + lines: [], + } +} diff --git a/lib/solvers/rectdiff/geometry.ts b/lib/solvers/rectdiff/geometry.ts index f2b3b52..d29fa2a 100644 --- a/lib/solvers/rectdiff/geometry.ts +++ b/lib/solvers/rectdiff/geometry.ts @@ -10,11 +10,9 @@ export const lt = (a: number, b: number) => a < b - EPS export const lte = (a: number, b: number) => a < b + EPS export function overlaps(a: XYRect, b: XYRect) { - return !( - a.x + a.width <= b.x + EPS || - b.x + b.width <= a.x + EPS || - a.y + a.height <= b.y + EPS || - b.y + b.height <= a.y + EPS + return ( + Math.max(a.x, b.x) < Math.min(a.x + a.width, b.x + b.width) && + Math.max(a.y, b.y) < Math.min(a.y + a.height, b.y + b.height) ) } diff --git a/package.json b/package.json index 7ac089d..e0c8057 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "three": "^0.181.1", "tsup": "^8.5.1", "vite": "^6.0.11", - "@vitejs/plugin-react": "^4" + "@vitejs/plugin-react": "^4", + "flatbush": "^4.5.0" }, "peerDependencies": { "typescript": "^5" diff --git a/pages/features/gap-fill-solver/multi-layer-gap.page.tsx b/pages/features/gap-fill-solver/multi-layer-gap.page.tsx new file mode 100644 index 0000000..66589c2 --- /dev/null +++ b/pages/features/gap-fill-solver/multi-layer-gap.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { GapFillSolver } from "../../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../../lib/types/srj-types" +import type { Placed3D } from "../../../lib/solvers/rectdiff/types" +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import testData from "../../../test-assets/gap-fill/multi-layer-gap.json" + +export default () => { + const solver = useMemo( + () => + new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/features/gap-fill-solver/simple-two-rect-with-gap.page.tsx b/pages/features/gap-fill-solver/simple-two-rect-with-gap.page.tsx new file mode 100644 index 0000000..87eeb8d --- /dev/null +++ b/pages/features/gap-fill-solver/simple-two-rect-with-gap.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { GapFillSolver } from "../../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../../lib/types/srj-types" +import type { Placed3D } from "../../../lib/solvers/rectdiff/types" +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import testData from "../../../test-assets/gap-fill/simple-two-rect-with-gap.json" + +export default () => { + const solver = useMemo( + () => + new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/features/gap-fill-solver/staggered-rects.page.tsx b/pages/features/gap-fill-solver/staggered-rects.page.tsx new file mode 100644 index 0000000..f55cd43 --- /dev/null +++ b/pages/features/gap-fill-solver/staggered-rects.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { GapFillSolver } from "../../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../../lib/types/srj-types" +import type { Placed3D } from "../../../lib/solvers/rectdiff/types" +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import testData from "../../../test-assets/gap-fill/staggered-rects.json" + +export default () => { + const solver = useMemo( + () => + new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/features/gap-fill-solver/three-rects-tall-short-tall.page.tsx b/pages/features/gap-fill-solver/three-rects-tall-short-tall.page.tsx new file mode 100644 index 0000000..3802509 --- /dev/null +++ b/pages/features/gap-fill-solver/three-rects-tall-short-tall.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { GapFillSolver } from "../../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../../lib/types/srj-types" +import type { Placed3D } from "../../../lib/solvers/rectdiff/types" +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import testData from "../../../test-assets/gap-fill/three-rects-tall-short-tall.json" + +export default () => { + const solver = useMemo( + () => + new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/features/gap-fill-solver/three-rects-touching.page.tsx b/pages/features/gap-fill-solver/three-rects-touching.page.tsx new file mode 100644 index 0000000..ca5c9dc --- /dev/null +++ b/pages/features/gap-fill-solver/three-rects-touching.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { GapFillSolver } from "../../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../../lib/types/srj-types" +import type { Placed3D } from "../../../lib/solvers/rectdiff/types" +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import testData from "../../../test-assets/gap-fill/three-rects-touching.json" + +export default () => { + const solver = useMemo( + () => + new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/features/gap-fill-solver/vertical-and-horizontal-gaps.page.tsx b/pages/features/gap-fill-solver/vertical-and-horizontal-gaps.page.tsx new file mode 100644 index 0000000..1495f16 --- /dev/null +++ b/pages/features/gap-fill-solver/vertical-and-horizontal-gaps.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { GapFillSolver } from "../../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../../lib/types/srj-types" +import type { Placed3D } from "../../../lib/solvers/rectdiff/types" +import { GenericSolverDebugger } from "@tscircuit/solver-utils/react" +import testData from "../../../test-assets/gap-fill/vertical-and-horizontal-gaps.json" + +export default () => { + const solver = useMemo( + () => + new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/test-assets/gap-fill/multi-layer-gap.json b/test-assets/gap-fill/multi-layer-gap.json new file mode 100644 index 0000000..4d9003a --- /dev/null +++ b/test-assets/gap-fill/multi-layer-gap.json @@ -0,0 +1,17 @@ +{ + "simpleRouteJson": { + "layerCount": 2, + "minTraceWidth": 0.1, + "bounds": { "minX": 0, "minY": 0, "maxX": 10, "maxY": 10 }, + "connections": [], + "obstacles": [] + }, + "placedRects": [ + { "rect": { "x": 1, "y": 2, "width": 2, "height": 3 }, "zLayers": [0] }, + { "rect": { "x": 5, "y": 2, "width": 2, "height": 3 }, "zLayers": [0] }, + { "rect": { "x": 3, "y": 6, "width": 4, "height": 2 }, "zLayers": [1] }, + { "rect": { "x": 3, "y": 1, "width": 4, "height": 2 }, "zLayers": [1] } + ], + "obstaclesByLayer": [[], []], + "maxEdgeDistance": null +} diff --git a/test-assets/gap-fill/simple-two-rect-with-gap.json b/test-assets/gap-fill/simple-two-rect-with-gap.json new file mode 100644 index 0000000..be57e12 --- /dev/null +++ b/test-assets/gap-fill/simple-two-rect-with-gap.json @@ -0,0 +1,15 @@ +{ + "simpleRouteJson": { + "layerCount": 1, + "minTraceWidth": 0.1, + "bounds": { "minX": 0, "minY": 0, "maxX": 10, "maxY": 10 }, + "connections": [], + "obstacles": [] + }, + "placedRects": [ + { "rect": { "x": 1, "y": 3, "width": 3, "height": 4 }, "zLayers": [0] }, + { "rect": { "x": 5, "y": 3, "width": 3, "height": 4 }, "zLayers": [0] } + ], + "obstaclesByLayer": [[]], + "maxEdgeDistance": null +} diff --git a/test-assets/gap-fill/staggered-rects.json b/test-assets/gap-fill/staggered-rects.json new file mode 100644 index 0000000..8a5a81c --- /dev/null +++ b/test-assets/gap-fill/staggered-rects.json @@ -0,0 +1,15 @@ +{ + "simpleRouteJson": { + "layerCount": 1, + "minTraceWidth": 0.1, + "bounds": { "minX": 0, "minY": 0, "maxX": 10, "maxY": 10 }, + "connections": [], + "obstacles": [] + }, + "placedRects": [ + { "rect": { "x": 1, "y": 2, "width": 3, "height": 4 }, "zLayers": [0] }, + { "rect": { "x": 5, "y": 3, "width": 3, "height": 4 }, "zLayers": [0] } + ], + "obstaclesByLayer": [[]], + "maxEdgeDistance": null +} diff --git a/test-assets/gap-fill/three-rects-tall-short-tall.json b/test-assets/gap-fill/three-rects-tall-short-tall.json new file mode 100644 index 0000000..e4c1672 --- /dev/null +++ b/test-assets/gap-fill/three-rects-tall-short-tall.json @@ -0,0 +1,16 @@ +{ + "simpleRouteJson": { + "layerCount": 1, + "minTraceWidth": 0.1, + "bounds": { "minX": 0, "minY": 0, "maxX": 15, "maxY": 10 }, + "connections": [], + "obstacles": [] + }, + "placedRects": [ + { "rect": { "x": 1, "y": 1, "width": 3, "height": 8 }, "zLayers": [0] }, + { "rect": { "x": 6, "y": 3.5, "width": 3, "height": 3 }, "zLayers": [0] }, + { "rect": { "x": 11, "y": 1, "width": 3, "height": 8 }, "zLayers": [0] } + ], + "obstaclesByLayer": [[]], + "maxEdgeDistance": null +} diff --git a/test-assets/gap-fill/three-rects-touching.json b/test-assets/gap-fill/three-rects-touching.json new file mode 100644 index 0000000..bb9bbcc --- /dev/null +++ b/test-assets/gap-fill/three-rects-touching.json @@ -0,0 +1,16 @@ +{ + "simpleRouteJson": { + "layerCount": 1, + "minTraceWidth": 0.1, + "bounds": { "minX": 0, "minY": 0, "maxX": 15, "maxY": 10 }, + "connections": [], + "obstacles": [] + }, + "placedRects": [ + { "rect": { "x": 1, "y": 1, "width": 3, "height": 8 }, "zLayers": [0] }, + { "rect": { "x": 4, "y": 3.5, "width": 3, "height": 3 }, "zLayers": [0] }, + { "rect": { "x": 7, "y": 1, "width": 3, "height": 8 }, "zLayers": [0] } + ], + "obstaclesByLayer": [[]], + "maxEdgeDistance": 5.0 +} diff --git a/test-assets/gap-fill/vertical-and-horizontal-gaps.json b/test-assets/gap-fill/vertical-and-horizontal-gaps.json new file mode 100644 index 0000000..4ede4f6 --- /dev/null +++ b/test-assets/gap-fill/vertical-and-horizontal-gaps.json @@ -0,0 +1,17 @@ +{ + "simpleRouteJson": { + "layerCount": 1, + "minTraceWidth": 0.1, + "bounds": { "minX": 0, "minY": 0, "maxX": 12, "maxY": 12 }, + "connections": [], + "obstacles": [] + }, + "placedRects": [ + { "rect": { "x": 1, "y": 5, "width": 3, "height": 2 }, "zLayers": [0] }, + { "rect": { "x": 8, "y": 5, "width": 3, "height": 2 }, "zLayers": [0] }, + { "rect": { "x": 5, "y": 8, "width": 2, "height": 3 }, "zLayers": [0] }, + { "rect": { "x": 5, "y": 1, "width": 2, "height": 3 }, "zLayers": [0] } + ], + "obstaclesByLayer": [[]], + "maxEdgeDistance": null +} diff --git a/tests/gap-fill-solver/__snapshots__/multi-layer-gap.snap.svg b/tests/gap-fill-solver/__snapshots__/multi-layer-gap.snap.svg new file mode 100644 index 0000000..5b00fd1 --- /dev/null +++ b/tests/gap-fill-solver/__snapshots__/multi-layer-gap.snap.svg @@ -0,0 +1,75 @@ + \ No newline at end of file diff --git a/tests/gap-fill-solver/__snapshots__/simple-two-rect-with-gap.snap.svg b/tests/gap-fill-solver/__snapshots__/simple-two-rect-with-gap.snap.svg new file mode 100644 index 0000000..4bb1838 --- /dev/null +++ b/tests/gap-fill-solver/__snapshots__/simple-two-rect-with-gap.snap.svg @@ -0,0 +1,61 @@ + \ No newline at end of file diff --git a/tests/gap-fill-solver/__snapshots__/staggered-rects.snap.svg b/tests/gap-fill-solver/__snapshots__/staggered-rects.snap.svg new file mode 100644 index 0000000..6080516 --- /dev/null +++ b/tests/gap-fill-solver/__snapshots__/staggered-rects.snap.svg @@ -0,0 +1,61 @@ + \ No newline at end of file diff --git a/tests/gap-fill-solver/__snapshots__/three-rects-tall-short-tall.snap.svg b/tests/gap-fill-solver/__snapshots__/three-rects-tall-short-tall.snap.svg new file mode 100644 index 0000000..70e2c71 --- /dev/null +++ b/tests/gap-fill-solver/__snapshots__/three-rects-tall-short-tall.snap.svg @@ -0,0 +1,71 @@ + \ No newline at end of file diff --git a/tests/gap-fill-solver/__snapshots__/three-rects-touching.snap.svg b/tests/gap-fill-solver/__snapshots__/three-rects-touching.snap.svg new file mode 100644 index 0000000..f94f7d8 --- /dev/null +++ b/tests/gap-fill-solver/__snapshots__/three-rects-touching.snap.svg @@ -0,0 +1,71 @@ + \ No newline at end of file diff --git a/tests/gap-fill-solver/__snapshots__/vertical-and-horizontal-gaps.snap.svg b/tests/gap-fill-solver/__snapshots__/vertical-and-horizontal-gaps.snap.svg new file mode 100644 index 0000000..9e0863d --- /dev/null +++ b/tests/gap-fill-solver/__snapshots__/vertical-and-horizontal-gaps.snap.svg @@ -0,0 +1,108 @@ + \ No newline at end of file diff --git a/tests/gap-fill-solver/multi-layer-gap.test.ts b/tests/gap-fill-solver/multi-layer-gap.test.ts new file mode 100644 index 0000000..efd5712 --- /dev/null +++ b/tests/gap-fill-solver/multi-layer-gap.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { GapFillSolver } from "../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../test-assets/gap-fill/multi-layer-gap.json" + +test("Gap Fill: Multi-layer gap", () => { + const solver = new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }) + + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { + backgroundColor: "white", + }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/gap-fill-solver/simple-two-rect-with-gap.test.ts b/tests/gap-fill-solver/simple-two-rect-with-gap.test.ts new file mode 100644 index 0000000..5b1e14d --- /dev/null +++ b/tests/gap-fill-solver/simple-two-rect-with-gap.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { GapFillSolver } from "../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../test-assets/gap-fill/simple-two-rect-with-gap.json" + +test("Gap Fill: Simple two rects with gap", () => { + const solver = new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }) + + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { backgroundColor: "white" }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/gap-fill-solver/staggered-rects.test.ts b/tests/gap-fill-solver/staggered-rects.test.ts new file mode 100644 index 0000000..83b47ee --- /dev/null +++ b/tests/gap-fill-solver/staggered-rects.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { GapFillSolver } from "../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../test-assets/gap-fill/staggered-rects.json" + +test("Gap Fill: Staggered rects", () => { + const solver = new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }) + + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { backgroundColor: "white" }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/gap-fill-solver/three-rects-tall-short-tall.test.ts b/tests/gap-fill-solver/three-rects-tall-short-tall.test.ts new file mode 100644 index 0000000..9d19c57 --- /dev/null +++ b/tests/gap-fill-solver/three-rects-tall-short-tall.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { GapFillSolver } from "../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../test-assets/gap-fill/three-rects-tall-short-tall.json" + +test("Gap Fill: Three rects tall short tall", () => { + const solver = new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }) + + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { backgroundColor: "white" }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/gap-fill-solver/three-rects-touching.test.ts b/tests/gap-fill-solver/three-rects-touching.test.ts new file mode 100644 index 0000000..d8e7b60 --- /dev/null +++ b/tests/gap-fill-solver/three-rects-touching.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { GapFillSolver } from "../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../test-assets/gap-fill/three-rects-touching.json" + +test("Gap Fill: Three rects touching", () => { + const solver = new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }) + + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { backgroundColor: "white" }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/gap-fill-solver/vertical-and-horizontal-gaps.test.ts b/tests/gap-fill-solver/vertical-and-horizontal-gaps.test.ts new file mode 100644 index 0000000..245da69 --- /dev/null +++ b/tests/gap-fill-solver/vertical-and-horizontal-gaps.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test" +import { GapFillSolver } from "../../lib/solvers/GapFillSolver/GapFillSolver" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../test-assets/gap-fill/vertical-and-horizontal-gaps.json" + +test("Gap Fill: Vertical and horizontal gaps", () => { + const solver = new GapFillSolver({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }) + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { backgroundColor: "white" }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/rect-diff-pipeline/__snapshots__/keyboard-bugreport04.snap.svg b/tests/rect-diff-pipeline/__snapshots__/keyboard-bugreport04.snap.svg new file mode 100644 index 0000000..091ff8f --- /dev/null +++ b/tests/rect-diff-pipeline/__snapshots__/keyboard-bugreport04.snap.svg @@ -0,0 +1,603 @@ + \ No newline at end of file diff --git a/tests/rect-diff-pipeline/keyboard-bugreport04.test.ts b/tests/rect-diff-pipeline/keyboard-bugreport04.test.ts new file mode 100644 index 0000000..89d2ecf --- /dev/null +++ b/tests/rect-diff-pipeline/keyboard-bugreport04.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test" +import { RectDiffPipeline } from "../../lib/RectDiffPipeline" +import simpleRouteJson from "../../test-assets/bugreport04-aa1d41.json" +import { getSvgFromGraphicsObject } from "graphics-debug" +import type { SimpleRouteJson } from "../../lib/types/srj-types" + +test("RectDiffPipeline: Keyboard Bug Report 04", () => { + const solver = new RectDiffPipeline({ + simpleRouteJson: simpleRouteJson.simple_route_json as SimpleRouteJson, + }) + + solver.solve() + + expect( + getSvgFromGraphicsObject(solver.visualize(), { backgroundColor: "white" }), + ).toMatchSvgSnapshot(import.meta.path) +})