From 3d821dd0667d5881681d63cd512098a81807734a Mon Sep 17 00:00:00 2001 From: Tanmay Choudhary Date: Wed, 20 May 2026 02:51:01 +0200 Subject: [PATCH 1/3] Add same-net trace merge solver --- .../SameNetTraceMergeSolver.ts | 168 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 13 ++ tests/same-net-trace-merge.test.ts | 103 +++++++++++ 3 files changed, 284 insertions(+) create mode 100644 lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts create mode 100644 tests/same-net-trace-merge.test.ts diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 00000000..5b56461e --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,168 @@ +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) +}) From 212c83f0f730e7e47eabfae645df44116c57fc75 Mon Sep 17 00:00:00 2001 From: Tanmay Choudhary Date: Wed, 20 May 2026 02:52:46 +0200 Subject: [PATCH 2/3] Format same-net trace merge solver --- .../SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts index 5b56461e..188a39b9 100644 --- a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -70,11 +70,7 @@ const getGap = (a: StraightTrace, b: StraightTrace) => { return 0 } -const canMerge = ( - a: StraightTrace, - b: StraightTrace, - gapThreshold: number, -) => { +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 From 880ebd4ea2c0bf96afcfdf208168da57ea4246b6 Mon Sep 17 00:00:00 2001 From: Tanmay Choudhary Date: Wed, 20 May 2026 07:54:09 +0200 Subject: [PATCH 3/3] chore(ci): re-trigger format check after formatting fix