diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 00000000..188a39b9 --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,164 @@ +import type { Point } from "@tscircuit/math-utils" +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +export interface SameNetTraceMergeSolverParams { + traces: SolvedTracePath[] + gapThreshold?: number +} + +type Axis = "x" | "y" + +interface StraightTrace { + trace: SolvedTracePath + axis: Axis + fixed: number + min: number + max: number + start: Point + end: Point +} + +const DEFAULT_GAP_THRESHOLD = 0.15 +const EPSILON = 1e-9 + +const nearlyEqual = (a: number, b: number) => Math.abs(a - b) <= EPSILON + +const idsFor = (trace: SolvedTracePath) => + trace.mspConnectionPairIds.length > 0 + ? trace.mspConnectionPairIds + : [trace.mspPairId] + +const asStraightTrace = (trace: SolvedTracePath): StraightTrace | null => { + if (trace.tracePath.length < 2) return null + + const first = trace.tracePath[0]! + const last = trace.tracePath[trace.tracePath.length - 1]! + const isHorizontal = trace.tracePath.every((p) => nearlyEqual(p.y, first.y)) + const isVertical = trace.tracePath.every((p) => nearlyEqual(p.x, first.x)) + + if (!isHorizontal && !isVertical) return null + + if (isHorizontal) { + const xs = trace.tracePath.map((p) => p.x) + return { + trace, + axis: "x", + fixed: first.y, + min: Math.min(...xs), + max: Math.max(...xs), + start: first, + end: last, + } + } + + const ys = trace.tracePath.map((p) => p.y) + return { + trace, + axis: "y", + fixed: first.x, + min: Math.min(...ys), + max: Math.max(...ys), + start: first, + end: last, + } +} + +const getGap = (a: StraightTrace, b: StraightTrace) => { + if (a.max < b.min) return b.min - a.max + if (b.max < a.min) return a.min - b.max + return 0 +} + +const canMerge = (a: StraightTrace, b: StraightTrace, gapThreshold: number) => { + if (a.trace.globalConnNetId !== b.trace.globalConnNetId) return false + if (a.axis !== b.axis) return false + if (!nearlyEqual(a.fixed, b.fixed)) return false + return getGap(a, b) <= gapThreshold +} + +const mergePair = (a: StraightTrace, b: StraightTrace): SolvedTracePath => { + const min = Math.min(a.min, b.min) + const max = Math.max(a.max, b.max) + const tracePath = + a.axis === "x" + ? [ + { x: min, y: a.fixed }, + { x: max, y: a.fixed }, + ] + : [ + { x: a.fixed, y: min }, + { x: a.fixed, y: max }, + ] + + const mspConnectionPairIds = [...idsFor(a.trace), ...idsFor(b.trace)] + const pinIds = Array.from(new Set([...a.trace.pinIds, ...b.trace.pinIds])) + + return { + ...a.trace, + mspPairId: mspConnectionPairIds.join("+"), + mspConnectionPairIds, + pinIds, + pins: [a.trace.pins[0], b.trace.pins[1]], + tracePath, + } +} + +/** + * Merges straight, collinear trace segments on the same global net when their + * endpoints are already touching or separated by only a tiny gap. + */ +export class SameNetTraceMergeSolver extends BaseSolver { + private outputTraces: SolvedTracePath[] + private gapThreshold: number + + constructor(params: SameNetTraceMergeSolverParams) { + super() + this.outputTraces = [...params.traces] + this.gapThreshold = params.gapThreshold ?? DEFAULT_GAP_THRESHOLD + } + + override getConstructorParams(): ConstructorParameters< + typeof SameNetTraceMergeSolver + >[0] { + return { + traces: this.outputTraces, + gapThreshold: this.gapThreshold, + } + } + + override _step() { + let merged = true + + while (merged) { + merged = false + + for (let i = 0; i < this.outputTraces.length; i++) { + const a = asStraightTrace(this.outputTraces[i]!) + if (!a) continue + + for (let j = i + 1; j < this.outputTraces.length; j++) { + const b = asStraightTrace(this.outputTraces[j]!) + if (!b) continue + if (!canMerge(a, b, this.gapThreshold)) continue + + const mergedTrace = mergePair(a, b) + this.outputTraces.splice(j, 1) + this.outputTraces.splice(i, 1, mergedTrace) + merged = true + break + } + + if (merged) break + } + } + + this.solved = true + } + + getOutput() { + return { + traces: this.outputTraces, + } + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c..c9784289 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 { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver" type PipelineStep BaseSolver> = { solverName: string @@ -75,6 +76,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver traceCleanupSolver?: TraceCleanupSolver + sameNetTraceMergeSolver?: SameNetTraceMergeSolver example28Solver?: Example28Solver availableNetOrientationSolver?: AvailableNetOrientationSolver vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver @@ -217,11 +219,21 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetTraceMergeSolver", + SameNetTraceMergeSolver, + (instance) => [ + { + traces: instance.traceCleanupSolver!.getOutput().traces, + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -237,6 +249,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { ), definePipelineStep("example28Solver", Example28Solver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces diff --git a/tests/same-net-trace-merge.test.ts b/tests/same-net-trace-merge.test.ts new file mode 100644 index 00000000..380460c7 --- /dev/null +++ b/tests/same-net-trace-merge.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test" +import { SameNetTraceMergeSolver } from "lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const baseTrace = (overrides: Partial): SolvedTracePath => ({ + mspPairId: "trace", + dcConnNetId: "dc", + globalConnNetId: "net-a", + userNetId: "net-a", + pins: [ + { pinId: "p1", chipId: "u1", x: 0, y: 0 }, + { pinId: "p2", chipId: "u2", x: 1, y: 1 }, + ], + tracePath: [], + mspConnectionPairIds: [], + pinIds: [], + ...overrides, +}) + +test("merges collinear same-net horizontal segments separated by a small gap", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + baseTrace({ + mspPairId: "a", + tracePath: [ + { x: 0, y: 1 }, + { x: 2, y: 1 }, + ], + }), + baseTrace({ + mspPairId: "b", + tracePath: [ + { x: 2.08, y: 1 }, + { x: 4, y: 1 }, + ], + }), + ], + gapThreshold: 0.15, + }) + + solver.solve() + + expect(solver.getOutput().traces).toHaveLength(1) + expect(solver.getOutput().traces[0].tracePath).toEqual([ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]) + expect(solver.getOutput().traces[0].mspConnectionPairIds).toEqual(["a", "b"]) +}) + +test("does not merge close collinear segments on different nets", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + baseTrace({ + mspPairId: "a", + globalConnNetId: "net-a", + tracePath: [ + { x: 0, y: 1 }, + { x: 2, y: 1 }, + ], + }), + baseTrace({ + mspPairId: "b", + globalConnNetId: "net-b", + tracePath: [ + { x: 2.08, y: 1 }, + { x: 4, y: 1 }, + ], + }), + ], + gapThreshold: 0.15, + }) + + solver.solve() + + expect(solver.getOutput().traces).toHaveLength(2) +}) + +test("does not merge segments when the gap is larger than the threshold", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + baseTrace({ + mspPairId: "a", + tracePath: [ + { x: 0, y: 1 }, + { x: 2, y: 1 }, + ], + }), + baseTrace({ + mspPairId: "b", + tracePath: [ + { x: 2.5, y: 1 }, + { x: 4, y: 1 }, + ], + }), + ], + gapThreshold: 0.15, + }) + + solver.solve() + + expect(solver.getOutput().traces).toHaveLength(2) +})