diff --git a/lib/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver.ts b/lib/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver.ts new file mode 100644 index 0000000..c5fa3f1 --- /dev/null +++ b/lib/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver.ts @@ -0,0 +1,252 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { InputProblem } from "lib/types/InputProblem" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { MspConnectionPairId } from "../MspConnectionPairSolver/MspConnectionPairSolver" +import { visualizeInputProblem } from "../SchematicTracePipelineSolver/visualizeInputProblem" +import type { GraphicsObject } from "graphics-debug" + +type ConnNetId = string + +const EPS = 2e-3 + +interface SegmentRef { + mspPairId: MspConnectionPairId + segmentIndex: number + /** For horizontal segments: Y value. For vertical segments: X value. */ + coordinate: number + /** Start of the range along the other axis */ + rangeStart: number + /** End of the range along the other axis */ + rangeEnd: number +} + +/** + * This solver finds same-net trace segments that run parallel and close + * together, then snaps them to the same coordinate (Y for horizontal + * segments, X for vertical segments). + * + * This reduces visual clutter by consolidating nearly-parallel same-net + * traces into shared lines. + * + * Only merges segments from DIFFERENT traces (different mspPairIds) within + * the same net, and only when segments have genuine range overlap along + * their shared axis. + */ +export class SameNetTraceLineMergeSolver extends BaseSolver { + inputProblem: InputProblem + inputTracePaths: Array + correctedTraceMap: Record = {} + mergeThreshold: number + + constructor(params: { + inputProblem: InputProblem + inputTracePaths: Array + mergeThreshold?: number + }) { + super() + this.inputProblem = params.inputProblem + this.inputTracePaths = params.inputTracePaths + this.mergeThreshold = params.mergeThreshold ?? 0.06 + + for (const tracePath of this.inputTracePaths) { + this.correctedTraceMap[tracePath.mspPairId] = { + ...tracePath, + tracePath: tracePath.tracePath.map((p) => ({ ...p })), + } + } + } + + override getConstructorParams(): ConstructorParameters< + typeof SameNetTraceLineMergeSolver + >[0] { + return { + inputProblem: this.inputProblem, + inputTracePaths: this.inputTracePaths, + mergeThreshold: this.mergeThreshold, + } + } + + override _step() { + const netGroups = this.groupTracesByNet() + + for (const traces of Object.values(netGroups)) { + if (traces.length < 2) continue + this.mergeCloseSegmentsInNet(traces) + } + + this.solved = true + } + + private groupTracesByNet(): Record { + const groups: Record = {} + for (const trace of Object.values(this.correctedTraceMap)) { + const netId = trace.globalConnNetId + if (!groups[netId]) groups[netId] = [] + groups[netId].push(trace) + } + return groups + } + + private collectSegments(traces: SolvedTracePath[]): { + horizontal: SegmentRef[] + vertical: SegmentRef[] + } { + const horizontal: SegmentRef[] = [] + const vertical: SegmentRef[] = [] + + for (const trace of traces) { + const points = trace.tracePath + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i]! + const p2 = points[i + 1]! + + const isHorizontal = Math.abs(p1.y - p2.y) < EPS + const isVertical = Math.abs(p1.x - p2.x) < EPS + + if (isHorizontal) { + horizontal.push({ + mspPairId: trace.mspPairId, + segmentIndex: i, + coordinate: (p1.y + p2.y) / 2, + rangeStart: Math.min(p1.x, p2.x), + rangeEnd: Math.max(p1.x, p2.x), + }) + } else if (isVertical) { + vertical.push({ + mspPairId: trace.mspPairId, + segmentIndex: i, + coordinate: (p1.x + p2.x) / 2, + rangeStart: Math.min(p1.y, p2.y), + rangeEnd: Math.max(p1.y, p2.y), + }) + } + } + } + + return { horizontal, vertical } + } + + /** + * Find merge-worthy pairs using strict pairwise comparison. + * Uses union-find to group segments that are transitively close. + */ + private findMergeClusters(segments: SegmentRef[]): SegmentRef[][] { + if (segments.length < 2) return [] + + // Union-find + const parent: number[] = segments.map((_, i) => i) + const find = (i: number): number => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]!]! + i = parent[i]! + } + return i + } + const union = (a: number, b: number) => { + parent[find(a)] = find(b) + } + + // Pairwise comparison — only merge segments from different traces + 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]! + + // Must be from different traces + if (a.mspPairId === b.mspPairId) continue + + // Must be close in the perpendicular axis + if (Math.abs(a.coordinate - b.coordinate) >= this.mergeThreshold) + continue + + // Must have genuine range overlap along the shared axis + const overlapLen = + Math.min(a.rangeEnd, b.rangeEnd) - + Math.max(a.rangeStart, b.rangeStart) + if (overlapLen <= EPS) continue + + union(i, j) + } + } + + // Collect clusters + const clusterMap: Record = {} + for (let i = 0; i < segments.length; i++) { + const root = find(i) + if (!clusterMap[root]) clusterMap[root] = [] + clusterMap[root].push(segments[i]!) + } + + // Only return clusters with segments from multiple traces + return Object.values(clusterMap).filter((cluster) => { + if (cluster.length < 2) return false + const firstId = cluster[0]!.mspPairId + return cluster.some((s) => s.mspPairId !== firstId) + }) + } + + private applyMerge( + clusters: SegmentRef[][], + orientation: "horizontal" | "vertical", + ) { + for (const cluster of clusters) { + // Use weighted average by segment length for more stable merging + let totalLength = 0 + let weightedSum = 0 + for (const seg of cluster) { + const length = seg.rangeEnd - seg.rangeStart + weightedSum += seg.coordinate * length + totalLength += length + } + const targetCoord = + totalLength > 0 ? weightedSum / totalLength : cluster[0]!.coordinate + + for (const seg of cluster) { + const trace = this.correctedTraceMap[seg.mspPairId] + if (!trace) continue + + const p1 = trace.tracePath[seg.segmentIndex]! + const p2 = trace.tracePath[seg.segmentIndex + 1]! + + if (orientation === "horizontal") { + p1.y = targetCoord + p2.y = targetCoord + } else { + p1.x = targetCoord + p2.x = targetCoord + } + } + } + } + + private mergeCloseSegmentsInNet(traces: SolvedTracePath[]) { + const { horizontal, vertical } = this.collectSegments(traces) + + const hClusters = this.findMergeClusters(horizontal) + this.applyMerge(hClusters, "horizontal") + + const vClusters = this.findMergeClusters(vertical) + this.applyMerge(vClusters, "vertical") + } + + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.inputProblem) + + for (const trace of Object.values(this.correctedTraceMap)) { + graphics.lines!.push({ + points: trace.tracePath, + strokeColor: "blue", + }) + } + + // Also show original traces in faded color for comparison + for (const trace of this.inputTracePaths) { + graphics.lines!.push({ + points: trace.tracePath, + strokeColor: "rgba(200, 200, 200, 0.4)", + }) + } + + return graphics + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index c9d5a99..2c1ddc3 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -6,20 +6,22 @@ import type { GraphicsObject } from "graphics-debug" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { InputProblem } from "lib/types/InputProblem" +import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" import { MspConnectionPairSolver } from "../MspConnectionPairSolver/MspConnectionPairSolver" +import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +// biome-ignore lint: Used as a value in definePipelineStep +import { SameNetTraceLineMergeSolver } from "../SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver" import { SchematicTraceLinesSolver, type SolvedTracePath, } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" -import { TraceOverlapShiftSolver } from "../TraceOverlapShiftSolver/TraceOverlapShiftSolver" -import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" -import { visualizeInputProblem } from "./visualizeInputProblem" +import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" +import type { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" import { TraceLabelOverlapAvoidanceSolver } from "../TraceLabelOverlapAvoidanceSolver/TraceLabelOverlapAvoidanceSolver" +import { TraceOverlapShiftSolver } from "../TraceOverlapShiftSolver/TraceOverlapShiftSolver" import { correctPinsInsideChips } from "./correctPinsInsideChip" import { expandChipsToFitPins } from "./expandChipsToFitPins" -import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" -import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" -import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" +import { visualizeInputProblem } from "./visualizeInputProblem" type PipelineStep BaseSolver> = { solverName: string @@ -65,6 +67,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { schematicTraceLinesSolver?: SchematicTraceLinesSolver longDistancePairSolver?: LongDistancePairSolver traceOverlapShiftSolver?: TraceOverlapShiftSolver + sameNetTraceLineMergeSolver?: SameNetTraceLineMergeSolver netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver @@ -143,18 +146,36 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (_solver) => {}, }, ), + definePipelineStep( + "sameNetTraceLineMergeSolver", + SameNetTraceLineMergeSolver, + (instance) => [ + { + inputProblem: instance.inputProblem, + inputTracePaths: Object.values( + instance.traceOverlapShiftSolver?.correctedTraceMap ?? + Object.fromEntries( + instance + .longDistancePairSolver!.getOutput() + .allTracesMerged.map((p) => [p.mspPairId, p]), + ), + ), + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, - () => [ + (instance) => [ { - inputProblem: this.inputProblem, + inputProblem: instance.inputProblem, inputTraceMap: - this.traceOverlapShiftSolver?.correctedTraceMap ?? + instance.sameNetTraceLineMergeSolver?.correctedTraceMap ?? + instance.traceOverlapShiftSolver?.correctedTraceMap ?? Object.fromEntries( - this.longDistancePairSolver!.getOutput().allTracesMerged.map( - (p) => [p.mspPairId, p], - ), + instance + .longDistancePairSolver!.getOutput() + .allTracesMerged.map((p) => [p.mspPairId, p]), ), }, ], @@ -169,6 +190,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { TraceLabelOverlapAvoidanceSolver, (instance) => { const traceMap = + instance.sameNetTraceLineMergeSolver?.correctedTraceMap ?? instance.traceOverlapShiftSolver?.correctedTraceMap ?? Object.fromEntries( instance @@ -284,7 +306,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { } const constructorParams = pipelineStepDef.getConstructorParams(this) - // @ts-ignore + // @ts-expect-error this.activeSubSolver = new pipelineStepDef.solverClass(...constructorParams) ;(this as any)[pipelineStepDef.solverName] = this.activeSubSolver this.timeSpentOnPhase[pipelineStepDef.solverName] = 0 diff --git a/site/examples/example31-same-net-merge.page.tsx b/site/examples/example31-same-net-merge.page.tsx new file mode 100644 index 0000000..eee828f --- /dev/null +++ b/site/examples/example31-same-net-merge.page.tsx @@ -0,0 +1,63 @@ +import { PipelineDebugger } from "site/components/PipelineDebugger" +import type { InputProblem } from "lib/types/InputProblem" + +/** + * Example demonstrating same-net trace line merging. + * + * Three chips connected on two nets. The pin positions are set up so that + * traces on the same net will be routed as close parallel lines that should + * be merged onto the same Y or X coordinate. + */ +export const inputProblem: InputProblem = { + chips: [ + { + chipId: "U1", + center: { x: -3, y: 0 }, + width: 1.0, + height: 1.6, + pins: [ + { pinId: "U1.1", x: -3.5, y: 0.4 }, + { pinId: "U1.2", x: -3.5, y: -0.4 }, + { pinId: "U1.3", x: -2.5, y: 0.4 }, + { pinId: "U1.4", x: -2.5, y: -0.4 }, + ], + }, + { + chipId: "U2", + center: { x: 0, y: 1.5 }, + width: 1.0, + height: 1.0, + pins: [ + { pinId: "U2.1", x: -0.5, y: 1.5 }, + { pinId: "U2.2", x: 0.5, y: 1.5 }, + ], + }, + { + chipId: "U3", + center: { x: 3, y: 0 }, + width: 1.0, + height: 1.6, + pins: [ + { pinId: "U3.1", x: 2.5, y: 0.4 }, + { pinId: "U3.2", x: 2.5, y: -0.4 }, + { pinId: "U3.3", x: 3.5, y: 0.4 }, + { pinId: "U3.4", x: 3.5, y: -0.4 }, + ], + }, + ], + directConnections: [], + netConnections: [ + { + netId: "net_a", + pinIds: ["U1.3", "U2.1", "U3.1"], + }, + { + netId: "net_b", + pinIds: ["U1.4", "U2.2", "U3.2"], + }, + ], + availableNetLabelOrientations: {}, + maxMspPairDistance: 4, +} + +export default () => diff --git a/tests/examples/__snapshots__/example31.snap.svg b/tests/examples/__snapshots__/example31.snap.svg new file mode 100644 index 0000000..ba11def --- /dev/null +++ b/tests/examples/__snapshots__/example31.snap.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/examples/example31.test.ts b/tests/examples/example31.test.ts new file mode 100644 index 0000000..d6d5d45 --- /dev/null +++ b/tests/examples/example31.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import { inputProblem } from "site/examples/example31-same-net-merge.page" +import "tests/fixtures/matcher" + +test("example31 - same-net trace line merge", () => { + const solver = new SchematicTracePipelineSolver(inputProblem) + + solver.solve() + + expect(solver).toMatchSolverSnapshot(import.meta.path) +}) diff --git a/tests/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver.test.ts b/tests/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver.test.ts new file mode 100644 index 0000000..11b8447 --- /dev/null +++ b/tests/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver.test.ts @@ -0,0 +1,166 @@ +import { test, expect, describe } from "bun:test" +import { SameNetTraceLineMergeSolver } from "lib/solvers/SameNetTraceLineMergeSolver/SameNetTraceLineMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "lib/types/InputProblem" + +const minimalInputProblem: InputProblem = { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, +} + +function makeTrace( + mspPairId: string, + globalConnNetId: string, + tracePath: Array<{ x: number; y: number }>, +): SolvedTracePath { + return { + mspPairId, + dcConnNetId: globalConnNetId, + globalConnNetId, + pins: [] as any, + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [], + } +} + +describe("SameNetTraceLineMergeSolver", () => { + test("merges horizontal segments on the same net with close Y values", () => { + const traceA = makeTrace("pair1", "net1", [ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + { x: 3, y: 1.0 }, + { x: 6, y: 1.0 }, + ]) + const traceB = makeTrace("pair2", "net1", [ + { x: 0, y: 0.5 }, + { x: 3, y: 0.5 }, + { x: 3, y: 1.04 }, + { x: 6, y: 1.04 }, + ]) + + const solver = new SameNetTraceLineMergeSolver({ + inputProblem: minimalInputProblem, + inputTracePaths: [traceA, traceB], + mergeThreshold: 0.06, + }) + + solver.solve() + expect(solver.solved).toBe(true) + + const resultA = solver.correctedTraceMap["pair1"]! + const resultB = solver.correctedTraceMap["pair2"]! + + // The segments at Y=1.0 and Y=1.04 should be merged to the same Y + // (they overlap in X range [3,6]) + const mergedYA = resultA.tracePath[2]!.y + const mergedYB = resultB.tracePath[2]!.y + expect(mergedYA).toBeCloseTo(mergedYB, 6) + + // The segments at Y=0 and Y=0.5 should NOT be merged (too far apart) + expect(resultA.tracePath[0]!.y).toBeCloseTo(0, 6) + expect(resultB.tracePath[0]!.y).toBeCloseTo(0.5, 6) + }) + + test("merges vertical segments on the same net with close X values", () => { + const traceA = makeTrace("pair1", "net1", [ + { x: 0, y: 0 }, + { x: 2.0, y: 0 }, + { x: 2.0, y: 3 }, + ]) + const traceB = makeTrace("pair2", "net1", [ + { x: 0, y: 1 }, + { x: 2.05, y: 1 }, + { x: 2.05, y: 3 }, + ]) + + const solver = new SameNetTraceLineMergeSolver({ + inputProblem: minimalInputProblem, + inputTracePaths: [traceA, traceB], + mergeThreshold: 0.06, + }) + + solver.solve() + + const resultA = solver.correctedTraceMap["pair1"]! + const resultB = solver.correctedTraceMap["pair2"]! + + // Vertical segments at X=2.0 and X=2.05 should be merged + const mergedXA = resultA.tracePath[1]!.x + const mergedXB = resultB.tracePath[1]!.x + expect(mergedXA).toBeCloseTo(mergedXB, 6) + }) + + test("does NOT merge segments from different nets", () => { + const traceA = makeTrace("pair1", "net1", [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + ]) + const traceB = makeTrace("pair2", "net2", [ + { x: 0, y: 0.03 }, + { x: 5, y: 0.03 }, + ]) + + const solver = new SameNetTraceLineMergeSolver({ + inputProblem: minimalInputProblem, + inputTracePaths: [traceA, traceB], + mergeThreshold: 0.06, + }) + + solver.solve() + + const resultA = solver.correctedTraceMap["pair1"]! + const resultB = solver.correctedTraceMap["pair2"]! + + // Different nets: should NOT merge + expect(resultA.tracePath[0]!.y).toBeCloseTo(0, 6) + expect(resultB.tracePath[0]!.y).toBeCloseTo(0.03, 6) + }) + + test("does NOT merge segments without range overlap", () => { + const traceA = makeTrace("pair1", "net1", [ + { x: 0, y: 1.0 }, + { x: 2, y: 1.0 }, + ]) + const traceB = makeTrace("pair2", "net1", [ + { x: 5, y: 1.04 }, + { x: 7, y: 1.04 }, + ]) + + const solver = new SameNetTraceLineMergeSolver({ + inputProblem: minimalInputProblem, + inputTracePaths: [traceA, traceB], + mergeThreshold: 0.06, + }) + + solver.solve() + + const resultA = solver.correctedTraceMap["pair1"]! + const resultB = solver.correctedTraceMap["pair2"]! + + // No X-range overlap, should NOT merge + expect(resultA.tracePath[0]!.y).toBeCloseTo(1.0, 6) + expect(resultB.tracePath[0]!.y).toBeCloseTo(1.04, 6) + }) + + test("single trace in a net is not modified", () => { + const traceA = makeTrace("pair1", "net1", [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 5, y: 3 }, + ]) + + const solver = new SameNetTraceLineMergeSolver({ + inputProblem: minimalInputProblem, + inputTracePaths: [traceA], + }) + + solver.solve() + + const result = solver.correctedTraceMap["pair1"]! + expect(result.tracePath[0]!.y).toBeCloseTo(0, 6) + expect(result.tracePath[1]!.x).toBeCloseTo(5, 6) + }) +})