diff --git a/lib/RectDiffPipeline.ts b/lib/RectDiffPipeline.ts index 425f004..b2f34bd 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 { EdgeSpatialHashIndexManager } from "./solvers/GapFillSolver/EdgeSpatialHashIndexManager" 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?: EdgeSpatialHashIndexManager + override MAX_ITERATIONS: number = 100e6 override pipelineDef = [ definePipelineStep( @@ -30,6 +37,30 @@ export class RectDiffPipeline extends BasePipelineSolver }, }, ), + definePipelineStep( + "gapFillSolver", + EdgeSpatialHashIndexManager, + (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,62 @@ 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.meshNodes], + } } 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.meshNodes.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/data-structures/FlatbushIndex.ts b/lib/data-structures/FlatbushIndex.ts new file mode 100644 index 0000000..dc58c79 --- /dev/null +++ b/lib/data-structures/FlatbushIndex.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 FlatbushIndex 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/solvers/GapFillSolver/EdgeSpatialHashIndex.ts b/lib/solvers/GapFillSolver/EdgeSpatialHashIndex.ts new file mode 100644 index 0000000..75ed9f7 --- /dev/null +++ b/lib/solvers/GapFillSolver/EdgeSpatialHashIndex.ts @@ -0,0 +1,369 @@ +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 { FlatbushIndex } from "../../data-structures/FlatbushIndex" +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" + +const COLOR_MAP = { + edgeStroke: "#10b981", + filledGapFill: "#d1fae5", + filledGapStroke: "#10b981", +} + +export interface EdgeSpatialHashIndexInput { + simpleRouteJson: SimpleRouteJson + placedRects: Placed3D[] + obstaclesByLayer: XYRect[][] + maxEdgeDistance?: number +} + +type SubPhase = + | "SELECT_PRIMARY_EDGE" + | "FIND_NEARBY_EDGES" + | "EXPAND_POINT" + | "DONE" + +interface EdgeSpatialHashIndexState { + srj: SimpleRouteJson + inputRects: Placed3D[] + obstaclesByLayer: XYRect[][] + layerCount: number + maxEdgeDistance: number + minTraceWidth: number + + edges: RectEdge[] + edgeSpatialIndex: FlatbushIndex + + phase: SubPhase + currentEdgeIndex: number + currentPrimaryEdge?: RectEdge + + nearbyEdgeCandidateIndex: number + currentNearbyEdges: RectEdge[] + + filledRects: Placed3D[] +} + +/** + * Gap Fill Solver - fills gaps between existing rectangles using edge analysis. + * Processes one edge per step for visualization. + */ +export class EdgeSpatialHashIndex extends BaseSolver { + private state!: EdgeSpatialHashIndexState + + constructor(input: EdgeSpatialHashIndexInput) { + super() + this.state = this.initState(input) + } + + private initState( + input: EdgeSpatialHashIndexInput, + ): EdgeSpatialHashIndexState { + const layerCount = input.simpleRouteJson.layerCount || 1 + const maxEdgeDistance = input.maxEdgeDistance ?? 2.0 + + const rawEdges = extractEdges(input.placedRects, input.obstaclesByLayer) + const edges = splitEdgesOnOverlaps(rawEdges) + + const edgeSpatialIndex = buildEdgeSpatialIndex(edges, maxEdgeDistance) + + return { + srj: input.simpleRouteJson, + inputRects: input.placedRects, + obstaclesByLayer: input.obstaclesByLayer, + layerCount, + maxEdgeDistance, + minTraceWidth: input.simpleRouteJson.minTraceWidth, + edges, + edgeSpatialIndex, + phase: "SELECT_PRIMARY_EDGE", + currentEdgeIndex: 0, + nearbyEdgeCandidateIndex: 0, + currentNearbyEdges: [], + filledRects: [], + } + } + + override _setup(): void { + this.stats = { + phase: "EDGE_ANALYSIS", + edgeIndex: 0, + totalEdges: this.state.edges.length, + filledCount: 0, + } + } + + override _step(): void { + switch (this.state.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.state.phase + this.stats.edgeIndex = this.state.currentEdgeIndex + this.stats.filledCount = this.state.filledRects.length + } + + private stepSelectPrimaryEdge(): void { + if (this.state.currentEdgeIndex >= this.state.edges.length) { + this.state.phase = "DONE" + return + } + + this.state.currentPrimaryEdge = + this.state.edges[this.state.currentEdgeIndex] + this.state.nearbyEdgeCandidateIndex = 0 + this.state.currentNearbyEdges = [] + + this.state.phase = "FIND_NEARBY_EDGES" + } + + private stepFindNearbyEdges(): void { + const primaryEdge = this.state.currentPrimaryEdge! + + const padding = this.state.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.state.edgeSpatialIndex.search( + minX, + minY, + maxX, + maxY, + ) + + // Collect nearby parallel edges + this.state.currentNearbyEdges = [] + for (const candidate of candidates) { + if ( + candidate !== primaryEdge && + this.isNearbyParallelEdge(primaryEdge, candidate) + ) { + this.state.currentNearbyEdges.push(candidate) + } + } + + const edgesWithDist = this.state.currentNearbyEdges.map((edge) => ({ + edge, + distance: this.distanceBetweenEdges(primaryEdge, edge), + })) + edgesWithDist.sort((a, b) => b.distance - a.distance) + this.state.currentNearbyEdges = edgesWithDist.map((e) => e.edge) + + this.state.phase = "EXPAND_POINT" + } + + private stepExpandPoint(): void { + const primaryEdge = this.state.currentPrimaryEdge! + + for (const nearbyEdge of this.state.currentNearbyEdges) { + const filledRect = this.expandEdgeToRect(primaryEdge, nearbyEdge) + if (filledRect && this.isValidFill(filledRect)) { + this.state.filledRects.push(filledRect) + break + } + } + + this.state.currentEdgeIndex++ + this.state.phase = "SELECT_PRIMARY_EDGE" + this.state.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.state.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.state.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.state.obstaclesByLayer[z] ?? [] + for (const obstacle of obstacles) { + if (overlaps(candidate.rect, obstacle)) { + return false + } + } + } + + return true + } + + private expandEdgeToRect( + 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], + } + } + + private isNearbyParallelEdge( + primaryEdge: RectEdge, + candidate: RectEdge, + ): 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 = this.distanceBetweenEdges(primaryEdge, candidate) + const minGap = Math.max(this.state.minTraceWidth, 0.1) + if (distance < minGap) { + return false + } + if (distance > this.state.maxEdgeDistance) { + return false + } + + return true + } + + private 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) + } + + override getOutput(): { meshNodes: CapacityMeshNode[] } { + const meshNodes: CapacityMeshNode[] = this.state.filledRects.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 { meshNodes } + } + + override visualize(): GraphicsObject { + const baseViz = visualizeBaseState( + this.state.inputRects, + this.state.obstaclesByLayer, + `Gap Fill (Edge ${this.state.currentEdgeIndex}/${this.state.edges.length})`, + ) + + const points: NonNullable = [] + const lines: NonNullable = [] + + for (const edge of this.state.edges) { + const isCurrent = edge === this.state.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.state.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/EdgeSpatialHashIndexManager.ts b/lib/solvers/GapFillSolver/EdgeSpatialHashIndexManager.ts new file mode 100644 index 0000000..c26f55b --- /dev/null +++ b/lib/solvers/GapFillSolver/EdgeSpatialHashIndexManager.ts @@ -0,0 +1,104 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { CapacityMeshNode } from "../../types/capacity-mesh-types" +import { + EdgeSpatialHashIndex, + type EdgeSpatialHashIndexInput, +} from "./EdgeSpatialHashIndex" +import { visualizeBaseState } from "./visualizeBaseState" + +export interface EdgeSpatialHashIndexManagerInput + extends EdgeSpatialHashIndexInput { + repeatCount?: number +} + +export class EdgeSpatialHashIndexManager extends BaseSolver { + private input: EdgeSpatialHashIndexManagerInput + private repeatCount: number + private currentIteration: number = 0 + private activeSubsolver: EdgeSpatialHashIndex | null = null + private allFilledRects: any[] = [] + + constructor(input: EdgeSpatialHashIndexManagerInput) { + super() + this.input = input + this.repeatCount = input.repeatCount ?? 1 + } + + override _setup(): void { + this.currentIteration = 0 + this.allFilledRects = [] + this.startNextIteration() + } + + private startNextIteration(): void { + if (this.currentIteration >= this.repeatCount) { + this.solved = true + return + } + + this.activeSubsolver = new EdgeSpatialHashIndex({ + ...this.input, + placedRects: [...this.input.placedRects, ...this.allFilledRects], + }) + this.activeSubsolver._setup() + this.currentIteration++ + } + + override _step(): void { + if (!this.activeSubsolver) { + this.solved = true + return + } + + if (this.activeSubsolver.solved) { + const output = this.activeSubsolver.getOutput() + this.allFilledRects.push( + ...output.meshNodes.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(): { meshNodes: CapacityMeshNode[] } { + const meshNodes: 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 { meshNodes } + } + + override visualize(): GraphicsObject { + if (this.activeSubsolver) { + return this.activeSubsolver.visualize() + } + return visualizeBaseState( + this.input.placedRects, + this.input.obstaclesByLayer, + `Gap Fill Manager (Iteration ${this.currentIteration}/${this.repeatCount})`, + ) + } +} diff --git a/lib/solvers/GapFillSolver/buildEdgeSpatialIndex.ts b/lib/solvers/GapFillSolver/buildEdgeSpatialIndex.ts new file mode 100644 index 0000000..ca20600 --- /dev/null +++ b/lib/solvers/GapFillSolver/buildEdgeSpatialIndex.ts @@ -0,0 +1,21 @@ +import { FlatbushIndex } from "../../data-structures/FlatbushIndex" +import type { RectEdge } from "./types" + +export function buildEdgeSpatialIndex( + edges: RectEdge[], + maxEdgeDistance: number, +): FlatbushIndex { + const index = new FlatbushIndex(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/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/splitEdgesOnOverlaps.ts b/lib/solvers/GapFillSolver/splitEdgesOnOverlaps.ts new file mode 100644 index 0000000..ce23662 --- /dev/null +++ b/lib/solvers/GapFillSolver/splitEdgesOnOverlaps.ts @@ -0,0 +1,108 @@ +import { FlatbushIndex } from "../../data-structures/FlatbushIndex" +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 FlatbushIndex(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/test-cases/multi-layer-gap.json b/lib/solvers/GapFillSolver/test-cases/multi-layer-gap.json new file mode 100644 index 0000000..4d9003a --- /dev/null +++ b/lib/solvers/GapFillSolver/test-cases/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/lib/solvers/GapFillSolver/test-cases/simple-two-rect-with-gap.json b/lib/solvers/GapFillSolver/test-cases/simple-two-rect-with-gap.json new file mode 100644 index 0000000..be57e12 --- /dev/null +++ b/lib/solvers/GapFillSolver/test-cases/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/lib/solvers/GapFillSolver/test-cases/staggered-rects.json b/lib/solvers/GapFillSolver/test-cases/staggered-rects.json new file mode 100644 index 0000000..8a5a81c --- /dev/null +++ b/lib/solvers/GapFillSolver/test-cases/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/lib/solvers/GapFillSolver/test-cases/three-rects-tall-short-tall.json b/lib/solvers/GapFillSolver/test-cases/three-rects-tall-short-tall.json new file mode 100644 index 0000000..e4c1672 --- /dev/null +++ b/lib/solvers/GapFillSolver/test-cases/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/lib/solvers/GapFillSolver/test-cases/three-rects-touching.json b/lib/solvers/GapFillSolver/test-cases/three-rects-touching.json new file mode 100644 index 0000000..bb9bbcc --- /dev/null +++ b/lib/solvers/GapFillSolver/test-cases/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/lib/solvers/GapFillSolver/test-cases/vertical-and-horizontal-gaps.json b/lib/solvers/GapFillSolver/test-cases/vertical-and-horizontal-gaps.json new file mode 100644 index 0000000..4ede4f6 --- /dev/null +++ b/lib/solvers/GapFillSolver/test-cases/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/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/repro/gap-fill-solver/multi-layer-gap.page.tsx b/pages/repro/gap-fill-solver/multi-layer-gap.page.tsx new file mode 100644 index 0000000..e8ec0ea --- /dev/null +++ b/pages/repro/gap-fill-solver/multi-layer-gap.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { EdgeSpatialHashIndex } from "../../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +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 "../../../lib/solvers/GapFillSolver/test-cases/multi-layer-gap.json" + +export default () => { + const solver = useMemo( + () => + new EdgeSpatialHashIndex({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/repro/gap-fill-solver/simple-two-rect-with-gap.page.tsx b/pages/repro/gap-fill-solver/simple-two-rect-with-gap.page.tsx new file mode 100644 index 0000000..758cb04 --- /dev/null +++ b/pages/repro/gap-fill-solver/simple-two-rect-with-gap.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { EdgeSpatialHashIndex } from "../../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +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 "../../../lib/solvers/GapFillSolver/test-cases/simple-two-rect-with-gap.json" + +export default () => { + const solver = useMemo( + () => + new EdgeSpatialHashIndex({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/repro/gap-fill-solver/staggered-rects.page.tsx b/pages/repro/gap-fill-solver/staggered-rects.page.tsx new file mode 100644 index 0000000..e54e607 --- /dev/null +++ b/pages/repro/gap-fill-solver/staggered-rects.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { EdgeSpatialHashIndex } from "../../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +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 "../../../lib/solvers/GapFillSolver/test-cases/staggered-rects.json" + +export default () => { + const solver = useMemo( + () => + new EdgeSpatialHashIndex({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/repro/gap-fill-solver/three-rects-tall-short-tall.page.tsx b/pages/repro/gap-fill-solver/three-rects-tall-short-tall.page.tsx new file mode 100644 index 0000000..fabd038 --- /dev/null +++ b/pages/repro/gap-fill-solver/three-rects-tall-short-tall.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { EdgeSpatialHashIndex } from "../../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +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 "../../../lib/solvers/GapFillSolver/test-cases/three-rects-tall-short-tall.json" + +export default () => { + const solver = useMemo( + () => + new EdgeSpatialHashIndex({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/repro/gap-fill-solver/three-rects-touching.page.tsx b/pages/repro/gap-fill-solver/three-rects-touching.page.tsx new file mode 100644 index 0000000..8bee74c --- /dev/null +++ b/pages/repro/gap-fill-solver/three-rects-touching.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { EdgeSpatialHashIndex } from "../../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +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 "../../../lib/solvers/GapFillSolver/test-cases/three-rects-touching.json" + +export default () => { + const solver = useMemo( + () => + new EdgeSpatialHashIndex({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} diff --git a/pages/repro/gap-fill-solver/vertical-and-horizontal-gaps.page.tsx b/pages/repro/gap-fill-solver/vertical-and-horizontal-gaps.page.tsx new file mode 100644 index 0000000..627b37e --- /dev/null +++ b/pages/repro/gap-fill-solver/vertical-and-horizontal-gaps.page.tsx @@ -0,0 +1,21 @@ +import { useMemo } from "react" +import { EdgeSpatialHashIndex } from "../../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +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 "../../../lib/solvers/GapFillSolver/test-cases/vertical-and-horizontal-gaps.json" + +export default () => { + const solver = useMemo( + () => + new EdgeSpatialHashIndex({ + simpleRouteJson: testData.simpleRouteJson as SimpleRouteJson, + placedRects: testData.placedRects as Placed3D[], + obstaclesByLayer: testData.obstaclesByLayer, + maxEdgeDistance: testData.maxEdgeDistance ?? undefined, + }), + [], + ) + + return +} 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..a8054f7 --- /dev/null +++ b/tests/gap-fill-solver/multi-layer-gap.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { EdgeSpatialHashIndex } from "../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../lib/solvers/GapFillSolver/test-cases/multi-layer-gap.json" + +test("Gap Fill: Multi-layer gap", () => { + const solver = new EdgeSpatialHashIndex({ + 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..253edb2 --- /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 { EdgeSpatialHashIndex } from "../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../lib/solvers/GapFillSolver/test-cases/simple-two-rect-with-gap.json" + +test("Gap Fill: Simple two rects with gap", () => { + const solver = new EdgeSpatialHashIndex({ + 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..235dab0 --- /dev/null +++ b/tests/gap-fill-solver/staggered-rects.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { EdgeSpatialHashIndex } from "../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../lib/solvers/GapFillSolver/test-cases/staggered-rects.json" + +test("Gap Fill: Staggered rects", () => { + const solver = new EdgeSpatialHashIndex({ + 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..c877272 --- /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 { EdgeSpatialHashIndex } from "../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../lib/solvers/GapFillSolver/test-cases/three-rects-tall-short-tall.json" + +test("Gap Fill: Three rects tall short tall", () => { + const solver = new EdgeSpatialHashIndex({ + 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..cb3b4f7 --- /dev/null +++ b/tests/gap-fill-solver/three-rects-touching.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { EdgeSpatialHashIndex } from "../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../lib/solvers/GapFillSolver/test-cases/three-rects-touching.json" + +test("Gap Fill: Three rects touching", () => { + const solver = new EdgeSpatialHashIndex({ + 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..27c4dc8 --- /dev/null +++ b/tests/gap-fill-solver/vertical-and-horizontal-gaps.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test" +import { EdgeSpatialHashIndex } from "../../lib/solvers/GapFillSolver/EdgeSpatialHashIndex" +import type { SimpleRouteJson } from "../../lib/types/srj-types" +import type { Placed3D } from "../../lib/solvers/rectdiff/types" +import { getSvgFromGraphicsObject } from "graphics-debug" +import testData from "../../lib/solvers/GapFillSolver/test-cases/vertical-and-horizontal-gaps.json" + +test("Gap Fill: Vertical and horizontal gaps", () => { + const solver = new EdgeSpatialHashIndex({ + 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..7c39602 --- /dev/null +++ b/tests/rect-diff-pipeline/__snapshots__/keyboard-bugreport04.snap.svg @@ -0,0 +1,619 @@ + \ 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) +})