diff --git a/lib/index.ts b/lib/index.ts index 3985b32ac..7899d3530 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" export * from "./types/InputProblem" export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2" +export * from "./solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver" diff --git a/lib/solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver.ts b/lib/solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver.ts new file mode 100644 index 000000000..4ef5f4d6d --- /dev/null +++ b/lib/solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver.ts @@ -0,0 +1,132 @@ +import type { InputProblem } from "lib/types/InputProblem" +import type { GraphicsObject, Line } from "graphics-debug" +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" + +interface SameNetTraceMergerSolverInput { + inputProblem: InputProblem + traces: SolvedTracePath[] + threshold?: number +} + +/** + * SameNetTraceMergerSolver snaps nearly-collinear same-net trace segments + * to the same coordinate, eliminating slight offsets introduced by routing. + * Horizontal segments close in Y and vertical segments close in X are merged. + * Runs after traceCleanupSolver in the pipeline. + */ +export class SameNetTraceMergerSolver extends BaseSolver { + private input: SameNetTraceMergerSolverInput + outputTraces: SolvedTracePath[] + readonly threshold: number + + constructor(input: SameNetTraceMergerSolverInput) { + super() + this.input = input + this.threshold = input.threshold ?? 0.15 + this.outputTraces = input.traces.map((t) => ({ + ...t, + tracePath: t.tracePath.map((p) => ({ x: p.x, y: p.y })), + })) + } + + override _step() { + this._mergeCollinearSegments() + this.solved = true + } + + getOutput() { + return { + traces: this.outputTraces, + } + } + + private _mergeCollinearSegments() { + const netGroups = new Map() + for (let i = 0; i < this.outputTraces.length; i++) { + const netId = this.outputTraces[i]!.globalConnNetId + if (!netGroups.has(netId)) netGroups.set(netId, []) + netGroups.get(netId)!.push(i) + } + for (const traceIndices of netGroups.values()) { + if (traceIndices.length < 2) continue + for (let a = 0; a < traceIndices.length; a++) { + for (let b = a + 1; b < traceIndices.length; b++) { + this._trySnapTraces(traceIndices[a]!, traceIndices[b]!) + } + } + } + } + + /** + * Compare every segment of trace A against every segment of trace B. + * Snap collinear overlapping segments to their average coordinate. + */ + private _trySnapTraces(idxA: number, idxB: number) { + const pathA = this.outputTraces[idxA]!.tracePath + const pathB = this.outputTraces[idxB]!.tracePath + for (let i = 0; i < pathA.length - 1; i++) { + const a1 = pathA[i]! + const a2 = pathA[i + 1]! + const isHorizA = Math.abs(a1.y - a2.y) < 1e-6 + const isVertA = Math.abs(a1.x - a2.x) < 1e-6 + if (!isHorizA && !isVertA) continue + for (let j = 0; j < pathB.length - 1; j++) { + const b1 = pathB[j]! + const b2 = pathB[j + 1]! + if (isHorizA && Math.abs(b1.y - b2.y) < 1e-6) { + const dy = Math.abs(a1.y - b1.y) + if (dy > 1e-6 && dy < this.threshold) { + const xMinA = Math.min(a1.x, a2.x) + const xMaxA = Math.max(a1.x, a2.x) + const xMinB = Math.min(b1.x, b2.x) + const xMaxB = Math.max(b1.x, b2.x) + if (xMinA < xMaxB && xMinB < xMaxA) { + const avgY = (a1.y + b1.y) / 2 + a1.y = avgY + a2.y = avgY + b1.y = avgY + b2.y = avgY + } + } + } else if (isVertA && Math.abs(b1.x - b2.x) < 1e-6) { + const dx = Math.abs(a1.x - b1.x) + if (dx > 1e-6 && dx < this.threshold) { + const yMinA = Math.min(a1.y, a2.y) + const yMaxA = Math.max(a1.y, a2.y) + const yMinB = Math.min(b1.y, b2.y) + const yMaxB = Math.max(b1.y, b2.y) + if (yMinA < yMaxB && yMinB < yMaxA) { + const avgX = (a1.x + b1.x) / 2 + a1.x = avgX + a2.x = avgX + b1.x = avgX + b2.x = avgX + } + } + } + } + } + } + + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.input.inputProblem, { + chipAlpha: 0.1, + connectionAlpha: 0.1, + }) + if (!graphics.lines) graphics.lines = [] + if (!graphics.points) graphics.points = [] + if (!graphics.rects) graphics.rects = [] + if (!graphics.circles) graphics.circles = [] + if (!graphics.texts) graphics.texts = [] + for (const trace of this.outputTraces) { + const line: Line = { + points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), + strokeColor: "blue", + } + graphics.lines!.push(line) + } + return graphics + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c1..f5cbe8884 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -26,6 +26,7 @@ import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/ import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" +import { SameNetTraceMergerSolver } from "../SameNetTraceMergerSolver/SameNetTraceMergerSolver" type PipelineStep BaseSolver> = { solverName: string @@ -75,6 +76,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver traceCleanupSolver?: TraceCleanupSolver + sameNetTraceMergerSolver?: SameNetTraceMergerSolver example28Solver?: Example28Solver availableNetOrientationSolver?: AvailableNetOrientationSolver vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver @@ -217,11 +219,22 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetTraceMergerSolver", + SameNetTraceMergerSolver, + (instance) => { + const traces = + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces + return [{ inputProblem: instance.inputProblem, traces }] + }, + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetTraceMergerSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -237,6 +250,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { ), definePipelineStep("example28Solver", Example28Solver, (instance) => { const traces = + instance.sameNetTraceMergerSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -411,7 +425,6 @@ export class SchematicTracePipelineSolver extends BaseSolver { return visualizations[0]! } - // Simple combination of visualizations const finalGraphics = { points: visualizations.flatMap((v) => v.points || []), rects: visualizations.flatMap((v) => v.rects || []), diff --git a/tests/assets/SameNetTraceMerger.test.input.json b/tests/assets/SameNetTraceMerger.test.input.json new file mode 100644 index 000000000..981aefbbd --- /dev/null +++ b/tests/assets/SameNetTraceMerger.test.input.json @@ -0,0 +1,79 @@ +{ + "inputProblem": { + "chips": [ + { + "chipId": "U1", + "center": { "x": 0, "y": 0 }, + "width": 1.6, + "height": 1.0, + "pins": [ + { "pinId": "U1.1", "x": -0.8, "y": 0.3 }, + { "pinId": "U1.2", "x": -0.8, "y": 0.0 }, + { "pinId": "U1.3", "x": -0.8, "y": -0.3 } + ] + }, + { + "chipId": "C1", + "center": { "x": -2.5, "y": 0.3 }, + "width": 0.5, + "height": 0.5, + "pins": [ + { "pinId": "C1.1", "x": -2.5, "y": 0.55 }, + { "pinId": "C1.2", "x": -2.5, "y": 0.05 } + ] + }, + { + "chipId": "C2", + "center": { "x": -2.5, "y": -0.3 }, + "width": 0.5, + "height": 0.5, + "pins": [ + { "pinId": "C2.1", "x": -2.5, "y": -0.05 }, + { "pinId": "C2.2", "x": -2.5, "y": -0.55 } + ] + } + ], + "directConnections": [ + { "pinIds": ["U1.1", "C1.1"], "netId": "VCC" }, + { "pinIds": ["U1.2", "C2.1"], "netId": "EN" } + ], + "netConnections": [{ "netId": "GND", "pinIds": ["U1.3", "C1.2", "C2.2"] }], + "availableNetLabelOrientations": { + "VCC": ["y+"], + "EN": ["x-"], + "GND": ["y-"] + } + }, + "traces": [ + { + "mspPairId": "U1.3-C1.2", + "dcConnNetId": "GND", + "globalConnNetId": "GND", + "userNetId": "GND", + "pins": [], + "mspConnectionPairIds": [], + "pinIds": ["U1.3", "C1.2"], + "tracePath": [ + { "x": -0.8, "y": -0.3 }, + { "x": -1.8, "y": -0.3 }, + { "x": -1.8, "y": 0.05 }, + { "x": -2.5, "y": 0.05 } + ] + }, + { + "mspPairId": "U1.3-C2.2", + "dcConnNetId": "GND", + "globalConnNetId": "GND", + "userNetId": "GND", + "pins": [], + "mspConnectionPairIds": [], + "pinIds": ["U1.3", "C2.2"], + "tracePath": [ + { "x": -0.8, "y": -0.3 }, + { "x": -1.82, "y": -0.3 }, + { "x": -1.82, "y": -0.55 }, + { "x": -2.5, "y": -0.55 } + ] + } + ] +} diff --git a/tests/same-net-trace-merger.test.ts b/tests/same-net-trace-merger.test.ts new file mode 100644 index 000000000..16351e877 --- /dev/null +++ b/tests/same-net-trace-merger.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "bun:test" +import { SameNetTraceMergerSolver } from "lib/solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import inputData from "./assets/SameNetTraceMerger.test.input.json" + +const emptyProblem = { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, +} + +// Helper to build a minimal SolvedTracePath +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + points: Array<{ x: number; y: number }>, +): SolvedTracePath => + ({ + mspPairId, + globalConnNetId, + dcConnNetId: globalConnNetId, + pins: [] as any, + mspConnectionPairIds: [], + pinIds: [], + tracePath: points, + }) as SolvedTracePath + +test("snaps two nearly-horizontal same-net segments to same Y", () => { + const traces = [ + makeTrace("t1", "net.GND", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + makeTrace("t2", "net.GND", [ + { x: 0, y: 0.1 }, + { x: 1, y: 0.1 }, + ]), + ] + const solver = new SameNetTraceMergerSolver({ + inputProblem: emptyProblem, + traces, + }) + solver.solve() + const out = solver.getOutput().traces + expect(out[0]!.tracePath[0]!.y).toBe(out[1]!.tracePath[0]!.y) +}) + +test("does NOT snap segments from different nets", () => { + const traces = [ + makeTrace("t1", "net.VCC", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + makeTrace("t2", "net.GND", [ + { x: 0, y: 0.1 }, + { x: 1, y: 0.1 }, + ]), + ] + const solver = new SameNetTraceMergerSolver({ + inputProblem: emptyProblem, + traces, + }) + solver.solve() + const out = solver.getOutput().traces + expect(out[0]!.tracePath[0]!.y).toBe(0) + expect(out[1]!.tracePath[0]!.y).toBe(0.1) +}) + +test("does NOT snap segments farther apart than threshold", () => { + const traces = [ + makeTrace("t1", "net.GND", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + makeTrace("t2", "net.GND", [ + { x: 0, y: 0.5 }, + { x: 1, y: 0.5 }, + ]), + ] + const solver = new SameNetTraceMergerSolver({ + inputProblem: emptyProblem, + traces, + threshold: 0.15, + }) + solver.solve() + const out = solver.getOutput().traces + expect(out[0]!.tracePath[0]!.y).toBe(0) + expect(out[1]!.tracePath[0]!.y).toBe(0.5) +}) + +test("snaps two nearly-vertical same-net segments to same X", () => { + const traces = [ + makeTrace("t1", "net.GND", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + ]), + makeTrace("t2", "net.GND", [ + { x: 0.08, y: 0 }, + { x: 0.08, y: 1 }, + ]), + ] + const solver = new SameNetTraceMergerSolver({ + inputProblem: emptyProblem, + traces, + }) + solver.solve() + const out = solver.getOutput().traces + expect(out[0]!.tracePath[0]!.x).toBe(out[1]!.tracePath[0]!.x) +}) + +test("SameNetTraceMergerSolver snapshot", async () => { + const solver = new SameNetTraceMergerSolver(inputData as any) + solver.solve() + await expect(solver).toMatchSolverSnapshot(import.meta.path) +})