From 86fdb46feb341922963554d4f0dac23ecf0920c6 Mon Sep 17 00:00:00 2001 From: Mithun Dhanasuthan Date: Fri, 15 May 2026 18:15:25 -0400 Subject: [PATCH] Merge nearby same-net trace segments --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 13 +- .../mergeSameNetSegments.ts | 201 ++++++++++++++++++ .../mergeSameNetSegments.test.ts | 71 +++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 lib/solvers/TraceCleanupSolver/mergeSameNetSegments.ts create mode 100644 tests/solvers/TraceCleanupSolver/mergeSameNetSegments.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..52130b5b 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -20,6 +20,7 @@ interface TraceCleanupSolverInput { import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" import { is4PointRectangle } from "./is4PointRectangle" +import { mergeSameNetSegments } from "./mergeSameNetSegments" /** * Represents the different stages or steps within the trace cleanup pipeline. @@ -27,6 +28,7 @@ import { is4PointRectangle } from "./is4PointRectangle" type PipelineStep = | "minimizing_turns" | "balancing_l_shapes" + | "merging_same_net_segments" | "untangling_traces" /** @@ -84,6 +86,9 @@ export class TraceCleanupSolver extends BaseSolver { case "balancing_l_shapes": this._runBalanceLShapesStep() break + case "merging_same_net_segments": + this._runMergeSameNetSegmentsStep() + break } } @@ -108,13 +113,19 @@ export class TraceCleanupSolver extends BaseSolver { private _runBalanceLShapesStep() { if (this.traceIdQueue.length === 0) { - this.solved = true + this.pipelineStep = "merging_same_net_segments" return } this._processTrace("balancing_l_shapes") } + private _runMergeSameNetSegmentsStep() { + this.outputTraces = mergeSameNetSegments(this.outputTraces) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.solved = true + } + private _processTrace(step: "minimizing_turns" | "balancing_l_shapes") { const targetMspConnectionPairId = this.traceIdQueue.shift()! this.activeTraceId = targetMspConnectionPairId diff --git a/lib/solvers/TraceCleanupSolver/mergeSameNetSegments.ts b/lib/solvers/TraceCleanupSolver/mergeSameNetSegments.ts new file mode 100644 index 00000000..b6cda0e6 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/mergeSameNetSegments.ts @@ -0,0 +1,201 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "./simplifyPath" + +const EPS = 1e-6 +const DEFAULT_MERGE_DISTANCE = 0.15 + +type Orientation = "horizontal" | "vertical" + +interface SegmentRef { + traceIndex: number + segmentIndex: number + orientation: Orientation + fixedCoordinate: number + rangeStart: number + rangeEnd: number +} + +const getSegmentRef = ( + traceIndex: number, + segmentIndex: number, + start: Point, + end: Point, +): SegmentRef | null => { + if (Math.abs(start.y - end.y) < EPS) { + return { + traceIndex, + segmentIndex, + orientation: "horizontal", + fixedCoordinate: start.y, + rangeStart: Math.min(start.x, end.x), + rangeEnd: Math.max(start.x, end.x), + } + } + + if (Math.abs(start.x - end.x) < EPS) { + return { + traceIndex, + segmentIndex, + orientation: "vertical", + fixedCoordinate: start.x, + rangeStart: Math.min(start.y, end.y), + rangeEnd: Math.max(start.y, end.y), + } + } + + return null +} + +const getSegments = ( + traces: SolvedTracePath[], + traceIndex: number, + options: { includeTerminals: boolean }, +): SegmentRef[] => { + const trace = traces[traceIndex]! + const refs: SegmentRef[] = [] + const startIndex = options.includeTerminals ? 0 : 1 + const endIndex = options.includeTerminals + ? trace.tracePath.length - 1 + : trace.tracePath.length - 2 + + for (let segmentIndex = startIndex; segmentIndex < endIndex; segmentIndex++) { + const start = trace.tracePath[segmentIndex]! + const end = trace.tracePath[segmentIndex + 1]! + const ref = getSegmentRef(traceIndex, segmentIndex, start, end) + if (ref) refs.push(ref) + } + + return refs +} + +const rangesOverlap = (a: SegmentRef, b: SegmentRef) => + Math.min(a.rangeEnd, b.rangeEnd) - Math.max(a.rangeStart, b.rangeStart) > EPS + +const wouldOverlapDifferentNet = ( + traces: SolvedTracePath[], + source: SegmentRef, + fixedCoordinate: number, +) => { + for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) { + const trace = traces[traceIndex]! + if (trace.globalConnNetId === traces[source.traceIndex]!.globalConnNetId) { + continue + } + + for ( + let segmentIndex = 0; + segmentIndex < trace.tracePath.length - 1; + segmentIndex++ + ) { + const ref = getSegmentRef( + traceIndex, + segmentIndex, + trace.tracePath[segmentIndex]!, + trace.tracePath[segmentIndex + 1]!, + ) + if (!ref) continue + if (ref.orientation !== source.orientation) continue + if (Math.abs(ref.fixedCoordinate - fixedCoordinate) > EPS) continue + if (rangesOverlap(source, ref)) return true + } + } + + return false +} + +const snapSegmentFixedCoordinate = ( + trace: SolvedTracePath, + segmentIndex: number, + orientation: Orientation, + fixedCoordinate: number, +) => { + const tracePath = trace.tracePath.map((point) => ({ ...point })) + const start = tracePath[segmentIndex]! + const end = tracePath[segmentIndex + 1]! + + if (orientation === "horizontal") { + start.y = fixedCoordinate + end.y = fixedCoordinate + } else { + start.x = fixedCoordinate + end.x = fixedCoordinate + } + + return { + ...trace, + tracePath: simplifyPath(tracePath), + } +} + +export const mergeSameNetSegments = ( + traces: SolvedTracePath[], + mergeDistance = DEFAULT_MERGE_DISTANCE, +): SolvedTracePath[] => { + const mergedTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + const traceIndexesByNet = new Map() + for (let traceIndex = 0; traceIndex < mergedTraces.length; traceIndex++) { + const netId = mergedTraces[traceIndex]!.globalConnNetId + const traceIndexes = traceIndexesByNet.get(netId) ?? [] + traceIndexes.push(traceIndex) + traceIndexesByNet.set(netId, traceIndexes) + } + + for (const traceIndexes of traceIndexesByNet.values()) { + if (traceIndexes.length < 2) continue + + let changed = true + while (changed) { + changed = false + + for (const traceIndex of traceIndexes.slice(1)) { + const candidates = getSegments(mergedTraces, traceIndex, { + includeTerminals: false, + }) + + for (const candidate of candidates) { + const target = traceIndexes + .filter((targetTraceIndex) => targetTraceIndex !== traceIndex) + .flatMap((targetTraceIndex) => + getSegments(mergedTraces, targetTraceIndex, { + includeTerminals: true, + }), + ) + .find( + (other) => + other.orientation === candidate.orientation && + Math.abs(other.fixedCoordinate - candidate.fixedCoordinate) <= + mergeDistance && + Math.abs(other.fixedCoordinate - candidate.fixedCoordinate) > + EPS && + rangesOverlap(candidate, other) && + !wouldOverlapDifferentNet( + mergedTraces, + candidate, + other.fixedCoordinate, + ), + ) + + if (!target) continue + + mergedTraces[traceIndex] = snapSegmentFixedCoordinate( + mergedTraces[traceIndex]!, + candidate.segmentIndex, + candidate.orientation, + target.fixedCoordinate, + ) + changed = true + break + } + + if (changed) break + } + } + } + + return mergedTraces +} diff --git a/tests/solvers/TraceCleanupSolver/mergeSameNetSegments.test.ts b/tests/solvers/TraceCleanupSolver/mergeSameNetSegments.test.ts new file mode 100644 index 00000000..4c79d677 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/mergeSameNetSegments.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { mergeSameNetSegments } from "lib/solvers/TraceCleanupSolver/mergeSameNetSegments" + +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: Array<{ x: number; y: number }>, +): SolvedTracePath => + ({ + mspPairId, + dcConnNetId: globalConnNetId, + globalConnNetId, + pins: [ + { pinId: `${mspPairId}-a`, chipId: "U1", ...tracePath[0]! }, + { + pinId: `${mspPairId}-b`, + chipId: "U1", + ...tracePath[tracePath.length - 1]!, + }, + ], + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [`${mspPairId}-a`, `${mspPairId}-b`], + }) as SolvedTracePath + +test("snaps nearby internal same-net horizontal segments onto the same y", () => { + const [first, second] = mergeSameNetSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 0, y: 0.12 }, + { x: 0, y: 1.12 }, + { x: 4, y: 1.12 }, + { x: 4, y: 0.12 }, + ]), + ]) + + expect(first!.tracePath[1]!.y).toBe(1) + expect(first!.tracePath[2]!.y).toBe(1) + expect(second!.tracePath[1]!.y).toBe(1) + expect(second!.tracePath[2]!.y).toBe(1) + expect(second!.tracePath[0]!.y).toBe(0.12) + expect(second!.tracePath[3]!.y).toBe(0.12) +}) + +test("does not snap nearby segments from different nets", () => { + const [first, second] = mergeSameNetSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("trace-b", "net-2", [ + { x: 0, y: 0.12 }, + { x: 0, y: 1.12 }, + { x: 4, y: 1.12 }, + { x: 4, y: 0.12 }, + ]), + ]) + + expect(first!.tracePath[1]!.y).toBe(1) + expect(first!.tracePath[2]!.y).toBe(1) + expect(second!.tracePath[1]!.y).toBe(1.12) + expect(second!.tracePath[2]!.y).toBe(1.12) +})