diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..43fffcf1 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -2,6 +2,7 @@ import type { InputProblem } from "lib/types/InputProblem" import type { GraphicsObject, Line } from "graphics-debug" import { minimizeTurnsWithFilteredLabels } from "./minimizeTurnsWithFilteredLabels" import { balanceZShapes } from "./balanceZShapes" +import { mergeSameNetCloseTraces } from "./mergeSameNetCloseTraces" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" @@ -25,6 +26,7 @@ import { is4PointRectangle } from "./is4PointRectangle" * Represents the different stages or steps within the trace cleanup pipeline. */ type PipelineStep = + | "merging_same_net_traces" | "minimizing_turns" | "balancing_l_shapes" | "untangling_traces" @@ -66,10 +68,10 @@ export class TraceCleanupSolver extends BaseSolver { this.outputTraces = output.traces this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "merging_same_net_traces" } else if (this.activeSubSolver.failed) { this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "merging_same_net_traces" } return } @@ -78,6 +80,9 @@ export class TraceCleanupSolver extends BaseSolver { case "untangling_traces": this._runUntangleTracesStep() break + case "merging_same_net_traces": + this._runMergeSameNetTracesStep() + break case "minimizing_turns": this._runMinimizeTurnsStep() break @@ -87,6 +92,14 @@ export class TraceCleanupSolver extends BaseSolver { } } + private _runMergeSameNetTracesStep() { + this.outputTraces = mergeSameNetCloseTraces( + Array.from(this.tracesMap.values()), + ) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.pipelineStep = "minimizing_turns" + } + private _runUntangleTracesStep() { this.activeSubSolver = new UntangleTraceSubsolver({ ...this.input, diff --git a/lib/solvers/TraceCleanupSolver/mergeSameNetCloseTraces.ts b/lib/solvers/TraceCleanupSolver/mergeSameNetCloseTraces.ts new file mode 100644 index 00000000..464045b1 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/mergeSameNetCloseTraces.ts @@ -0,0 +1,117 @@ +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const EPS = 2e-3 + +/** + * Default maximum perpendicular distance for two parallel same-net segments + * to be considered "close together" and therefore mergeable. + */ +const DEFAULT_CLOSE_THRESHOLD = 0.15 + +interface InternalSegment { + traceIndex: number + pointIndex: number + isHorizontal: boolean + /** Y for horizontal segments, X for vertical segments */ + coord: number + /** Lower bound on the parallel axis */ + parallelStart: number + /** Upper bound on the parallel axis */ + parallelEnd: number +} + +/** + * Aligns parallel segments from same-net traces that are close together but + * not perfectly co-linear so they share a common Y (horizontal) or X + * (vertical) coordinate. Only segments that are strictly internal (neither + * endpoint is a pin endpoint of the trace path) are considered so that + * adjustments cannot detach a trace from its pin. + */ +export function mergeSameNetCloseTraces( + traces: SolvedTracePath[], + closeThreshold: number = DEFAULT_CLOSE_THRESHOLD, +): SolvedTracePath[] { + const newTraces = traces.map((t) => ({ + ...t, + tracePath: t.tracePath.map((p) => ({ ...p })), + })) + + const groupsByNet: Record = {} + newTraces.forEach((t, i) => { + if (!groupsByNet[t.globalConnNetId]) groupsByNet[t.globalConnNetId] = [] + groupsByNet[t.globalConnNetId]!.push(i) + }) + + for (const traceIndices of Object.values(groupsByNet)) { + if (traceIndices.length < 2) continue + + const collectInternalSegments = (): InternalSegment[] => { + const segments: InternalSegment[] = [] + for (const ti of traceIndices) { + const path = newTraces[ti]!.tracePath + // Segments touching path endpoints connect to pins; skip them so we + // never alter pin connections. + for (let pi = 1; pi < path.length - 2; pi++) { + const p1 = path[pi]! + const p2 = path[pi + 1]! + const isHorz = + Math.abs(p1.y - p2.y) < EPS && Math.abs(p1.x - p2.x) >= EPS + const isVert = + Math.abs(p1.x - p2.x) < EPS && Math.abs(p1.y - p2.y) >= EPS + if (!isHorz && !isVert) continue + segments.push({ + traceIndex: ti, + pointIndex: pi, + isHorizontal: isHorz, + coord: isHorz ? (p1.y + p2.y) / 2 : (p1.x + p2.x) / 2, + parallelStart: isHorz ? Math.min(p1.x, p2.x) : Math.min(p1.y, p2.y), + parallelEnd: isHorz ? Math.max(p1.x, p2.x) : Math.max(p1.y, p2.y), + }) + } + } + return segments + } + + let changed = true + let safetyIterations = 0 + while (changed && safetyIterations < 32) { + changed = false + safetyIterations++ + const segments = collectInternalSegments() + + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]! + const b = segments[j]! + if (a.traceIndex === b.traceIndex) continue + if (a.isHorizontal !== b.isHorizontal) continue + + const parallelOverlap = + Math.min(a.parallelEnd, b.parallelEnd) - + Math.max(a.parallelStart, b.parallelStart) + if (parallelOverlap <= EPS) continue + + const perpDist = Math.abs(a.coord - b.coord) + if (perpDist < EPS) continue + if (perpDist > closeThreshold) continue + + const newCoord = (a.coord + b.coord) / 2 + + const apply = (s: InternalSegment) => { + const path = newTraces[s.traceIndex]!.tracePath + const key: "x" | "y" = s.isHorizontal ? "y" : "x" + path[s.pointIndex]![key] = newCoord + path[s.pointIndex + 1]![key] = newCoord + s.coord = newCoord + } + + apply(a) + apply(b) + changed = true + } + } + } + } + + return newTraces +} diff --git a/tests/solvers/TraceCleanupSolver/mergeSameNetCloseTraces.test.ts b/tests/solvers/TraceCleanupSolver/mergeSameNetCloseTraces.test.ts new file mode 100644 index 00000000..26bfd8cd --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/mergeSameNetCloseTraces.test.ts @@ -0,0 +1,151 @@ +import { test, expect } from "bun:test" +import { mergeSameNetCloseTraces } from "lib/solvers/TraceCleanupSolver/mergeSameNetCloseTraces" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: { x: number; y: number }[], +): SolvedTracePath => + ({ + mspPairId, + globalConnNetId, + dcConnNetId: globalConnNetId, + pins: [], + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [], + }) as unknown as SolvedTracePath + +test("merges two parallel same-net horizontal segments that are close", () => { + // Two same-net traces with nearly aligned horizontal middle segments. + // path A goes (0,0) -> (0,1) -> (5,1) -> (5,2) + // path B goes (0,0) -> (0,1.05) -> (5,1.05) -> (5,2) + const traces = [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 5, y: 1 }, + { x: 5, y: 2 }, + ]), + makeTrace("b", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1.05 }, + { x: 5, y: 1.05 }, + { x: 5, y: 2 }, + ]), + ] + + const merged = mergeSameNetCloseTraces(traces) + + // The middle horizontal segments should now share the same Y value. + const aMidY = merged[0]!.tracePath[1]!.y + const bMidY = merged[1]!.tracePath[1]!.y + expect(Math.abs(aMidY - bMidY)).toBeLessThan(1e-6) + expect(merged[0]!.tracePath[2]!.y).toBeCloseTo(aMidY) + expect(merged[1]!.tracePath[2]!.y).toBeCloseTo(bMidY) +}) + +test("merges two parallel same-net vertical segments that are close", () => { + const traces = [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 5 }, + { x: 2, y: 5 }, + ]), + makeTrace("b", "net1", [ + { x: 0, y: 0 }, + { x: 1.05, y: 0 }, + { x: 1.05, y: 5 }, + { x: 2, y: 5 }, + ]), + ] + + const merged = mergeSameNetCloseTraces(traces) + expect( + Math.abs(merged[0]!.tracePath[1]!.x - merged[1]!.tracePath[1]!.x), + ).toBeLessThan(1e-6) +}) + +test("does not merge segments from different nets", () => { + const traces = [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 5, y: 1 }, + { x: 5, y: 2 }, + ]), + makeTrace("b", "net2", [ + { x: 0, y: 0 }, + { x: 0, y: 1.05 }, + { x: 5, y: 1.05 }, + { x: 5, y: 2 }, + ]), + ] + + const merged = mergeSameNetCloseTraces(traces) + expect(merged[0]!.tracePath[1]!.y).toBe(1) + expect(merged[1]!.tracePath[1]!.y).toBe(1.05) +}) + +test("does not merge segments that are too far apart", () => { + const traces = [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 5, y: 1 }, + { x: 5, y: 2 }, + ]), + makeTrace("b", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 3 }, + { x: 5, y: 3 }, + { x: 5, y: 4 }, + ]), + ] + + const merged = mergeSameNetCloseTraces(traces) + expect(merged[0]!.tracePath[1]!.y).toBe(1) + expect(merged[1]!.tracePath[1]!.y).toBe(3) +}) + +test("does not move endpoint segments connected to pins", () => { + // Both traces only have endpoint segments — should not be modified. + const traces = [ + makeTrace("a", "net1", [ + { x: 0, y: 1 }, + { x: 5, y: 1 }, + ]), + makeTrace("b", "net1", [ + { x: 0, y: 1.05 }, + { x: 5, y: 1.05 }, + ]), + ] + + const merged = mergeSameNetCloseTraces(traces) + expect(merged[0]!.tracePath[0]!.y).toBe(1) + expect(merged[1]!.tracePath[0]!.y).toBe(1.05) +}) + +test("does not merge non-overlapping parallel segments", () => { + // Same-net, parallel, close Y, but x-ranges do not overlap. + const traces = [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + ]), + makeTrace("b", "net1", [ + { x: 3, y: 0 }, + { x: 3, y: 1.05 }, + { x: 5, y: 1.05 }, + { x: 5, y: 2 }, + ]), + ] + + const merged = mergeSameNetCloseTraces(traces) + expect(merged[0]!.tracePath[1]!.y).toBe(1) + expect(merged[1]!.tracePath[1]!.y).toBe(1.05) +})