From 196df85138f3c4eb66ed60191bae8449efcac3b7 Mon Sep 17 00:00:00 2001 From: ron1nrest Date: Sat, 16 May 2026 12:56:20 +0500 Subject: [PATCH 1/4] feat: merge nearby parallel same-net traces (#34) Add MergeParallelTracesSolver to align and consolidate close parallel segments on the same net, wired into the pipeline after overlap shift. --- .../MergeParallelTracesSolver.ts | 72 ++++ .../mergeParallelTraceSegments.ts | 322 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 45 ++- .../mergeParallelTraceSegments.test.ts | 141 ++++++++ 4 files changed, 566 insertions(+), 14 deletions(-) create mode 100644 lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts create mode 100644 lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts create mode 100644 tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts diff --git a/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts b/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts new file mode 100644 index 00000000..79fd1b32 --- /dev/null +++ b/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts @@ -0,0 +1,72 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { MspConnectionPairId } from "lib/solvers/MspConnectionPairSolver/MspConnectionPairSolver" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import type { InputProblem } from "lib/types/InputProblem" +import { + DEFAULT_MERGE_DISTANCE, + mergeParallelTraceSegments, +} from "./mergeParallelTraceSegments" + +export class MergeParallelTracesSolver extends BaseSolver { + inputProblem: InputProblem + inputTracePaths: SolvedTracePath[] + mergeDistance: number + + correctedTraceMap: Record = {} + + constructor(params: { + inputProblem: InputProblem + inputTracePaths: SolvedTracePath[] + mergeDistance?: number + }) { + super() + this.inputProblem = params.inputProblem + this.inputTracePaths = params.inputTracePaths + this.mergeDistance = params.mergeDistance ?? DEFAULT_MERGE_DISTANCE + + for (const tracePath of this.inputTracePaths) { + this.correctedTraceMap[tracePath.mspPairId] = tracePath + } + } + + override getConstructorParams(): ConstructorParameters< + typeof MergeParallelTracesSolver + >[0] { + return { + inputProblem: this.inputProblem, + inputTracePaths: this.inputTracePaths, + mergeDistance: this.mergeDistance, + } + } + + override _step() { + const merged = mergeParallelTraceSegments( + this.inputTracePaths, + this.mergeDistance, + ) + + this.correctedTraceMap = Object.fromEntries( + merged.map((trace) => [trace.mspPairId, trace]), + ) + this.solved = true + } + + getOutput(): { traces: SolvedTracePath[] } { + return { traces: Object.values(this.correctedTraceMap) } + } + + override visualize() { + const graphics = visualizeInputProblem(this.inputProblem) + graphics.lines = graphics.lines || [] + + for (const trace of Object.values(this.correctedTraceMap)) { + graphics.lines.push({ + points: trace.tracePath, + strokeColor: "teal", + }) + } + + return graphics + } +} diff --git a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts new file mode 100644 index 00000000..cf9b554a --- /dev/null +++ b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts @@ -0,0 +1,322 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "lib/solvers/TraceCleanupSolver/simplifyPath" + +const EPS = 1e-6 +export 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 findConsolidatableSegmentPair = ( + traces: SolvedTracePath[], + indexA: number, + indexB: number, + mergeDistance: number, +): SegmentRef | null => { + const traceA = traces[indexA]! + const traceB = traces[indexB]! + if (traceA.tracePath.length !== 2 || traceB.tracePath.length !== 2) { + return null + } + + const segmentA = getSegmentRef( + indexA, + 0, + traceA.tracePath[0]!, + traceA.tracePath[1]!, + ) + const segmentB = getSegmentRef( + indexB, + 0, + traceB.tracePath[0]!, + traceB.tracePath[1]!, + ) + if (!segmentA || !segmentB) return null + if (segmentA.orientation !== segmentB.orientation) return null + if ( + Math.abs(segmentA.fixedCoordinate - segmentB.fixedCoordinate) > mergeDistance + ) { + return null + } + if (!rangesOverlap(segmentA, segmentB)) return null + + return segmentA +} + +const mergeTracePair = ( + kept: SolvedTracePath, + removed: SolvedTracePath, + canonical: SegmentRef, +): SolvedTracePath => { + const tracePath = kept.tracePath.map((point) => { + const p = { ...point } + if (canonical.orientation === "horizontal") { + p.y = canonical.fixedCoordinate + } else { + p.x = canonical.fixedCoordinate + } + return p + }) + + const pinIds = [...new Set([...kept.pinIds, ...removed.pinIds])] + const pinsById = new Map( + [...kept.pins, ...removed.pins].map((pin) => [pin.pinId, pin]), + ) + + return { + ...kept, + tracePath: simplifyPath(tracePath), + mspConnectionPairIds: [ + ...new Set([ + ...kept.mspConnectionPairIds, + ...removed.mspConnectionPairIds, + ]), + ], + pinIds, + pins: pinIds.map((pinId) => pinsById.get(pinId)!), + } +} + +const consolidateRedundantParallelTraces = ( + traces: SolvedTracePath[], + mergeDistance: number, +): SolvedTracePath[] => { + const result = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + let changed = true + while (changed) { + changed = false + + outer: for (let indexA = 0; indexA < result.length; indexA++) { + for (let indexB = indexA + 1; indexB < result.length; indexB++) { + if (result[indexA]!.globalConnNetId !== result[indexB]!.globalConnNetId) { + continue + } + + const canonical = findConsolidatableSegmentPair( + result, + indexA, + indexB, + mergeDistance, + ) + if (!canonical) continue + if ( + wouldOverlapDifferentNet(result, canonical, canonical.fixedCoordinate) + ) { + continue + } + + result[indexA] = mergeTracePair( + result[indexA]!, + result[indexB]!, + canonical, + ) + result.splice(indexB, 1) + changed = true + break outer + } + } + } + + return result +} + +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), + } +} + +/** + * Snaps nearby parallel same-net trace segments onto a shared X or Y axis. + * Internal segments align to overlapping segments on sibling traces in the net. + */ +export const mergeParallelTraceSegments = ( + 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 consolidateRedundantParallelTraces(mergedTraces, mergeDistance) +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c..176f9105 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -12,6 +12,7 @@ import { type SolvedTracePath, } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { TraceOverlapShiftSolver } from "../TraceOverlapShiftSolver/TraceOverlapShiftSolver" +import { MergeParallelTracesSolver } from "../MergeParallelTracesSolver/MergeParallelTracesSolver" import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" import { colorAvailableNetOrientationLabels } from "./colorAvailableNetOrientationLabels" import { visualizeInputProblem } from "./visualizeInputProblem" @@ -71,6 +72,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { schematicTraceLinesSolver?: SchematicTraceLinesSolver longDistancePairSolver?: LongDistancePairSolver traceOverlapShiftSolver?: TraceOverlapShiftSolver + mergeParallelTracesSolver?: MergeParallelTracesSolver netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver @@ -154,19 +156,25 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (_solver) => {}, }, ), + definePipelineStep( + "mergeParallelTracesSolver", + MergeParallelTracesSolver, + () => [ + { + inputProblem: this.inputProblem, + inputTracePaths: Object.values( + this.traceOverlapShiftSolver!.correctedTraceMap, + ), + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, () => [ { inputProblem: this.inputProblem, - inputTraceMap: - this.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - this.longDistancePairSolver!.getOutput().allTracesMerged.map( - (p) => [p.mspPairId, p], - ), - ), + inputTraceMap: this.getRoutedTraceMap(), }, ], { @@ -179,13 +187,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { "traceLabelOverlapAvoidanceSolver", TraceLabelOverlapAvoidanceSolver, (instance) => { - const traceMap = - instance.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - instance - .longDistancePairSolver!.getOutput() - .allTracesMerged.map((p) => [p.mspPairId, p]), - ) + const traceMap = instance.getRoutedTraceMap() const traces = Object.values(traceMap) const netLabelPlacements = instance.netLabelPlacementSolver!.netLabelPlacements @@ -320,6 +322,21 @@ export class SchematicTracePipelineSolver extends BaseSolver { currentPipelineStepIndex = 0 + getRoutedTraceMap(): Record { + if (this.mergeParallelTracesSolver) { + return this.mergeParallelTracesSolver.correctedTraceMap + } + if (this.traceOverlapShiftSolver) { + return this.traceOverlapShiftSolver.correctedTraceMap + } + return Object.fromEntries( + this.longDistancePairSolver!.getOutput().allTracesMerged.map((p) => [ + p.mspPairId, + p, + ]), + ) + } + private cloneAndCorrectInputProblem(original: InputProblem): InputProblem { const cloned: InputProblem = structuredClone({ ...original, diff --git a/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts b/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts new file mode 100644 index 00000000..7b1c6e12 --- /dev/null +++ b/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { mergeParallelTraceSegments } from "lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments" + +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] = mergeParallelTraceSegments([ + 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("snaps nearby internal same-net vertical segments onto the same x", () => { + const [first, second] = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 4 }, + { x: 0, y: 4 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 0.12, y: 0 }, + { x: 1.12, y: 0 }, + { x: 1.12, y: 4 }, + { x: 0.12, y: 4 }, + ]), + ]) + + expect(first!.tracePath[1]!.x).toBe(1) + expect(first!.tracePath[2]!.x).toBe(1) + expect(second!.tracePath[1]!.x).toBe(1) + expect(second!.tracePath[2]!.x).toBe(1) + expect(second!.tracePath[0]!.x).toBe(0.12) + expect(second!.tracePath[3]!.x).toBe(0.12) +}) + +test("merges two close parallel same-net traces into one", () => { + const result = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 0, y: 1.1 }, + { x: 4, y: 1.1 }, + ]), + ]) + + expect(result).toHaveLength(1) + expect(result[0]!.tracePath).toEqual([ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]) + expect(result[0]!.globalConnNetId).toBe("net-1") + expect(result[0]!.mspConnectionPairIds).toEqual(["trace-a", "trace-b"]) + expect(result[0]!.pinIds).toEqual([ + "trace-a-a", + "trace-a-b", + "trace-b-a", + "trace-b-b", + ]) +}) + +test("merges two close parallel vertical same-net traces into one", () => { + const result = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 2, y: 0 }, + { x: 2, y: 5 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 2.12, y: 0 }, + { x: 2.12, y: 5 }, + ]), + ]) + + expect(result).toHaveLength(1) + expect(result[0]!.tracePath).toEqual([ + { x: 2, y: 0 }, + { x: 2, y: 5 }, + ]) +}) + +test("does not snap nearby segments from different nets", () => { + const [first, second] = mergeParallelTraceSegments([ + 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) +}) From dd42118f87d547bc1dd2a15dfd9b63c4e43d39e8 Mon Sep 17 00:00:00 2001 From: ron1nrest Date: Sat, 16 May 2026 13:16:40 +0500 Subject: [PATCH 2/4] style: fix biome format in mergeParallelTraceSegments Co-authored-by: Cursor --- .../mergeParallelTraceSegments.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts index cf9b554a..b0ae7717 100644 --- a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts +++ b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts @@ -131,7 +131,8 @@ const findConsolidatableSegmentPair = ( if (!segmentA || !segmentB) return null if (segmentA.orientation !== segmentB.orientation) return null if ( - Math.abs(segmentA.fixedCoordinate - segmentB.fixedCoordinate) > mergeDistance + Math.abs(segmentA.fixedCoordinate - segmentB.fixedCoordinate) > + mergeDistance ) { return null } @@ -189,7 +190,9 @@ const consolidateRedundantParallelTraces = ( outer: for (let indexA = 0; indexA < result.length; indexA++) { for (let indexB = indexA + 1; indexB < result.length; indexB++) { - if (result[indexA]!.globalConnNetId !== result[indexB]!.globalConnNetId) { + if ( + result[indexA]!.globalConnNetId !== result[indexB]!.globalConnNetId + ) { continue } From 86112e7b4e93e8a7754508b25fde4af36e08e40a Mon Sep 17 00:00:00 2001 From: ron1nrest Date: Sat, 16 May 2026 13:18:12 +0500 Subject: [PATCH 3/4] fix: keep pins tuple when merging parallel trace pairs Co-authored-by: Cursor --- .../mergeParallelTraceSegments.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts index b0ae7717..6b6a9482 100644 --- a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts +++ b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts @@ -156,11 +156,6 @@ const mergeTracePair = ( return p }) - const pinIds = [...new Set([...kept.pinIds, ...removed.pinIds])] - const pinsById = new Map( - [...kept.pins, ...removed.pins].map((pin) => [pin.pinId, pin]), - ) - return { ...kept, tracePath: simplifyPath(tracePath), @@ -170,8 +165,7 @@ const mergeTracePair = ( ...removed.mspConnectionPairIds, ]), ], - pinIds, - pins: pinIds.map((pinId) => pinsById.get(pinId)!), + pinIds: [...new Set([...kept.pinIds, ...removed.pinIds])], } } From a8e7223884e74d0520fcd5ec37a0f45fff83118d Mon Sep 17 00:00:00 2001 From: ron1nrest Date: Sat, 16 May 2026 13:52:32 +0500 Subject: [PATCH 4/4] feat: add TraceCombineSolver pipeline phase for same-net segments Co-authored-by: Cursor --- .../SchematicTracePipelineSolver.ts | 28 +++---- .../TraceCombineSolver.ts} | 28 ++++--- .../combineSameNetTraceSegments.ts} | 39 +++++----- .../TraceCombineSolver_repro29.input.json | 51 ++++++++++++ .../TraceCombineSolver_repro29.test.ts | 22 ++++++ .../TraceCombineSolver_repro29.snap.svg | 77 +++++++++++++++++++ .../combineSameNetTraceSegments.test.ts} | 63 ++------------- 7 files changed, 201 insertions(+), 107 deletions(-) rename lib/solvers/{MergeParallelTracesSolver/MergeParallelTracesSolver.ts => TraceCombineSolver/TraceCombineSolver.ts} (72%) rename lib/solvers/{MergeParallelTracesSolver/mergeParallelTraceSegments.ts => TraceCombineSolver/combineSameNetTraceSegments.ts} (88%) create mode 100644 tests/assets/TraceCombineSolver_repro29.input.json create mode 100644 tests/solvers/TraceCombineSolver/TraceCombineSolver_repro29.test.ts create mode 100644 tests/solvers/TraceCombineSolver/__snapshots__/TraceCombineSolver_repro29.snap.svg rename tests/solvers/{MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts => TraceCombineSolver/combineSameNetTraceSegments.test.ts} (53%) diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 176f9105..d043e4e4 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -12,7 +12,7 @@ import { type SolvedTracePath, } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { TraceOverlapShiftSolver } from "../TraceOverlapShiftSolver/TraceOverlapShiftSolver" -import { MergeParallelTracesSolver } from "../MergeParallelTracesSolver/MergeParallelTracesSolver" +import { TraceCombineSolver } from "../TraceCombineSolver/TraceCombineSolver" import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" import { colorAvailableNetOrientationLabels } from "./colorAvailableNetOrientationLabels" import { visualizeInputProblem } from "./visualizeInputProblem" @@ -72,7 +72,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { schematicTraceLinesSolver?: SchematicTraceLinesSolver longDistancePairSolver?: LongDistancePairSolver traceOverlapShiftSolver?: TraceOverlapShiftSolver - mergeParallelTracesSolver?: MergeParallelTracesSolver + traceCombineSolver?: TraceCombineSolver netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver @@ -156,18 +156,14 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (_solver) => {}, }, ), - definePipelineStep( - "mergeParallelTracesSolver", - MergeParallelTracesSolver, - () => [ - { - inputProblem: this.inputProblem, - inputTracePaths: Object.values( - this.traceOverlapShiftSolver!.correctedTraceMap, - ), - }, - ], - ), + definePipelineStep("traceCombineSolver", TraceCombineSolver, () => [ + { + inputProblem: this.inputProblem, + inputTracePaths: Object.values( + this.traceOverlapShiftSolver!.correctedTraceMap, + ), + }, + ]), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, @@ -323,8 +319,8 @@ export class SchematicTracePipelineSolver extends BaseSolver { currentPipelineStepIndex = 0 getRoutedTraceMap(): Record { - if (this.mergeParallelTracesSolver) { - return this.mergeParallelTracesSolver.correctedTraceMap + if (this.traceCombineSolver) { + return this.traceCombineSolver.correctedTraceMap } if (this.traceOverlapShiftSolver) { return this.traceOverlapShiftSolver.correctedTraceMap diff --git a/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts similarity index 72% rename from lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts rename to lib/solvers/TraceCombineSolver/TraceCombineSolver.ts index 79fd1b32..1ac5a85b 100644 --- a/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts +++ b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts @@ -4,26 +4,30 @@ import type { MspConnectionPairId } from "lib/solvers/MspConnectionPairSolver/Ms import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" import type { InputProblem } from "lib/types/InputProblem" import { - DEFAULT_MERGE_DISTANCE, - mergeParallelTraceSegments, -} from "./mergeParallelTraceSegments" + combineSameNetTraceSegments, + DEFAULT_COMBINE_DISTANCE, +} from "./combineSameNetTraceSegments" -export class MergeParallelTracesSolver extends BaseSolver { +/** + * Pipeline phase that combines nearby parallel trace segments on the same net + * by snapping them to a shared X or Y coordinate. + */ +export class TraceCombineSolver extends BaseSolver { inputProblem: InputProblem inputTracePaths: SolvedTracePath[] - mergeDistance: number + combineDistance: number correctedTraceMap: Record = {} constructor(params: { inputProblem: InputProblem inputTracePaths: SolvedTracePath[] - mergeDistance?: number + combineDistance?: number }) { super() this.inputProblem = params.inputProblem this.inputTracePaths = params.inputTracePaths - this.mergeDistance = params.mergeDistance ?? DEFAULT_MERGE_DISTANCE + this.combineDistance = params.combineDistance ?? DEFAULT_COMBINE_DISTANCE for (const tracePath of this.inputTracePaths) { this.correctedTraceMap[tracePath.mspPairId] = tracePath @@ -31,23 +35,23 @@ export class MergeParallelTracesSolver extends BaseSolver { } override getConstructorParams(): ConstructorParameters< - typeof MergeParallelTracesSolver + typeof TraceCombineSolver >[0] { return { inputProblem: this.inputProblem, inputTracePaths: this.inputTracePaths, - mergeDistance: this.mergeDistance, + combineDistance: this.combineDistance, } } override _step() { - const merged = mergeParallelTraceSegments( + const combined = combineSameNetTraceSegments( this.inputTracePaths, - this.mergeDistance, + this.combineDistance, ) this.correctedTraceMap = Object.fromEntries( - merged.map((trace) => [trace.mspPairId, trace]), + combined.map((trace) => [trace.mspPairId, trace]), ) this.solved = true } diff --git a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts b/lib/solvers/TraceCombineSolver/combineSameNetTraceSegments.ts similarity index 88% rename from lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts rename to lib/solvers/TraceCombineSolver/combineSameNetTraceSegments.ts index 6b6a9482..ddaa24f4 100644 --- a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts +++ b/lib/solvers/TraceCombineSolver/combineSameNetTraceSegments.ts @@ -3,7 +3,7 @@ import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/Sche import { simplifyPath } from "lib/solvers/TraceCleanupSolver/simplifyPath" const EPS = 1e-6 -export const DEFAULT_MERGE_DISTANCE = 0.15 +export const DEFAULT_COMBINE_DISTANCE = 0.15 type Orientation = "horizontal" | "vertical" @@ -108,7 +108,7 @@ const findConsolidatableSegmentPair = ( traces: SolvedTracePath[], indexA: number, indexB: number, - mergeDistance: number, + combineDistance: number, ): SegmentRef | null => { const traceA = traces[indexA]! const traceB = traces[indexB]! @@ -132,7 +132,7 @@ const findConsolidatableSegmentPair = ( if (segmentA.orientation !== segmentB.orientation) return null if ( Math.abs(segmentA.fixedCoordinate - segmentB.fixedCoordinate) > - mergeDistance + combineDistance ) { return null } @@ -171,7 +171,7 @@ const mergeTracePair = ( const consolidateRedundantParallelTraces = ( traces: SolvedTracePath[], - mergeDistance: number, + combineDistance: number, ): SolvedTracePath[] => { const result = traces.map((trace) => ({ ...trace, @@ -194,7 +194,7 @@ const consolidateRedundantParallelTraces = ( result, indexA, indexB, - mergeDistance, + combineDistance, ) if (!canonical) continue if ( @@ -242,22 +242,19 @@ const snapSegmentFixedCoordinate = ( } } -/** - * Snaps nearby parallel same-net trace segments onto a shared X or Y axis. - * Internal segments align to overlapping segments on sibling traces in the net. - */ -export const mergeParallelTraceSegments = ( +/** Aligns and merges nearby parallel same-net trace segments onto a shared axis. */ +export const combineSameNetTraceSegments = ( traces: SolvedTracePath[], - mergeDistance = DEFAULT_MERGE_DISTANCE, + combineDistance = DEFAULT_COMBINE_DISTANCE, ): SolvedTracePath[] => { - const mergedTraces = traces.map((trace) => ({ + const combinedTraces = 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 + for (let traceIndex = 0; traceIndex < combinedTraces.length; traceIndex++) { + const netId = combinedTraces[traceIndex]!.globalConnNetId const traceIndexes = traceIndexesByNet.get(netId) ?? [] traceIndexes.push(traceIndex) traceIndexesByNet.set(netId, traceIndexes) @@ -271,7 +268,7 @@ export const mergeParallelTraceSegments = ( changed = false for (const traceIndex of traceIndexes.slice(1)) { - const candidates = getSegments(mergedTraces, traceIndex, { + const candidates = getSegments(combinedTraces, traceIndex, { includeTerminals: false, }) @@ -279,7 +276,7 @@ export const mergeParallelTraceSegments = ( const target = traceIndexes .filter((targetTraceIndex) => targetTraceIndex !== traceIndex) .flatMap((targetTraceIndex) => - getSegments(mergedTraces, targetTraceIndex, { + getSegments(combinedTraces, targetTraceIndex, { includeTerminals: true, }), ) @@ -287,12 +284,12 @@ export const mergeParallelTraceSegments = ( (other) => other.orientation === candidate.orientation && Math.abs(other.fixedCoordinate - candidate.fixedCoordinate) <= - mergeDistance && + combineDistance && Math.abs(other.fixedCoordinate - candidate.fixedCoordinate) > EPS && rangesOverlap(candidate, other) && !wouldOverlapDifferentNet( - mergedTraces, + combinedTraces, candidate, other.fixedCoordinate, ), @@ -300,8 +297,8 @@ export const mergeParallelTraceSegments = ( if (!target) continue - mergedTraces[traceIndex] = snapSegmentFixedCoordinate( - mergedTraces[traceIndex]!, + combinedTraces[traceIndex] = snapSegmentFixedCoordinate( + combinedTraces[traceIndex]!, candidate.segmentIndex, candidate.orientation, target.fixedCoordinate, @@ -315,5 +312,5 @@ export const mergeParallelTraceSegments = ( } } - return consolidateRedundantParallelTraces(mergedTraces, mergeDistance) + return consolidateRedundantParallelTraces(combinedTraces, combineDistance) } diff --git a/tests/assets/TraceCombineSolver_repro29.input.json b/tests/assets/TraceCombineSolver_repro29.input.json new file mode 100644 index 00000000..3680f349 --- /dev/null +++ b/tests/assets/TraceCombineSolver_repro29.input.json @@ -0,0 +1,51 @@ +{ + "inputProblem": { + "chips": [ + { + "chipId": "U1", + "center": { "x": 0, "y": 0 }, + "width": 2, + "height": 2, + "pins": [ + { "pinId": "U1.1", "x": -1, "y": 0.5 }, + { "pinId": "U1.2", "x": 1, "y": 0.5 } + ] + } + ], + "directConnections": [ + { + "pinIds": ["U1.1", "U1.2"], + "netId": "SIG" + } + ], + "netConnections": [] + }, + "inputTracePaths": [ + { + "mspPairId": "trace-a", + "dcConnNetId": "net-sig", + "globalConnNetId": "net-sig", + "userNetId": "SIG", + "pins": [ + { "pinId": "U1.1", "chipId": "U1", "x": -1, "y": 0.5 }, + { "pinId": "U1.2", "chipId": "U1", "x": 1, "y": 0.5 } + ], + "tracePath": [{ "x": 0, "y": 1 }, { "x": 4, "y": 1 }], + "mspConnectionPairIds": ["trace-a"], + "pinIds": ["U1.1", "U1.2"] + }, + { + "mspPairId": "trace-b", + "dcConnNetId": "net-sig", + "globalConnNetId": "net-sig", + "userNetId": "SIG", + "pins": [ + { "pinId": "U1.1", "chipId": "U1", "x": -1, "y": 0.5 }, + { "pinId": "U1.2", "chipId": "U1", "x": 1, "y": 0.5 } + ], + "tracePath": [{ "x": 0, "y": 1.1 }, { "x": 4, "y": 1.1 }], + "mspConnectionPairIds": ["trace-b"], + "pinIds": ["U1.1", "U1.2"] + } + ] +} diff --git a/tests/solvers/TraceCombineSolver/TraceCombineSolver_repro29.test.ts b/tests/solvers/TraceCombineSolver/TraceCombineSolver_repro29.test.ts new file mode 100644 index 00000000..ccc77d86 --- /dev/null +++ b/tests/solvers/TraceCombineSolver/TraceCombineSolver_repro29.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test" +import { TraceCombineSolver } from "lib/solvers/TraceCombineSolver/TraceCombineSolver" +import input from "../../assets/TraceCombineSolver_repro29.input.json" +import "tests/fixtures/matcher" + +test("TraceCombineSolver_repro29 combines close parallel same-net traces", () => { + const solver = new TraceCombineSolver({ + inputProblem: input.inputProblem as any, + inputTracePaths: input.inputTracePaths as any, + }) + solver.solve() + + const traces = solver.getOutput().traces + expect(traces).toHaveLength(1) + expect(traces[0]!.tracePath).toEqual([ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]) + expect(traces[0]!.mspConnectionPairIds).toEqual(["trace-a", "trace-b"]) + + expect(solver).toMatchSolverSnapshot(import.meta.path) +}) diff --git a/tests/solvers/TraceCombineSolver/__snapshots__/TraceCombineSolver_repro29.snap.svg b/tests/solvers/TraceCombineSolver/__snapshots__/TraceCombineSolver_repro29.snap.svg new file mode 100644 index 00000000..f4ed3633 --- /dev/null +++ b/tests/solvers/TraceCombineSolver/__snapshots__/TraceCombineSolver_repro29.snap.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts b/tests/solvers/TraceCombineSolver/combineSameNetTraceSegments.test.ts similarity index 53% rename from tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts rename to tests/solvers/TraceCombineSolver/combineSameNetTraceSegments.test.ts index 7b1c6e12..5bf678e7 100644 --- a/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts +++ b/tests/solvers/TraceCombineSolver/combineSameNetTraceSegments.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" -import { mergeParallelTraceSegments } from "lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments" +import { combineSameNetTraceSegments } from "lib/solvers/TraceCombineSolver/combineSameNetTraceSegments" const makeTrace = ( mspPairId: string, @@ -25,7 +25,7 @@ const makeTrace = ( }) as SolvedTracePath test("snaps nearby internal same-net horizontal segments onto the same y", () => { - const [first, second] = mergeParallelTraceSegments([ + const [first, second] = combineSameNetTraceSegments([ makeTrace("trace-a", "net-1", [ { x: 0, y: 0 }, { x: 0, y: 1 }, @@ -48,32 +48,8 @@ test("snaps nearby internal same-net horizontal segments onto the same y", () => expect(second!.tracePath[3]!.y).toBe(0.12) }) -test("snaps nearby internal same-net vertical segments onto the same x", () => { - const [first, second] = mergeParallelTraceSegments([ - makeTrace("trace-a", "net-1", [ - { x: 0, y: 0 }, - { x: 1, y: 0 }, - { x: 1, y: 4 }, - { x: 0, y: 4 }, - ]), - makeTrace("trace-b", "net-1", [ - { x: 0.12, y: 0 }, - { x: 1.12, y: 0 }, - { x: 1.12, y: 4 }, - { x: 0.12, y: 4 }, - ]), - ]) - - expect(first!.tracePath[1]!.x).toBe(1) - expect(first!.tracePath[2]!.x).toBe(1) - expect(second!.tracePath[1]!.x).toBe(1) - expect(second!.tracePath[2]!.x).toBe(1) - expect(second!.tracePath[0]!.x).toBe(0.12) - expect(second!.tracePath[3]!.x).toBe(0.12) -}) - test("merges two close parallel same-net traces into one", () => { - const result = mergeParallelTraceSegments([ + const result = combineSameNetTraceSegments([ makeTrace("trace-a", "net-1", [ { x: 0, y: 1 }, { x: 4, y: 1 }, @@ -89,37 +65,10 @@ test("merges two close parallel same-net traces into one", () => { { x: 0, y: 1 }, { x: 4, y: 1 }, ]) - expect(result[0]!.globalConnNetId).toBe("net-1") - expect(result[0]!.mspConnectionPairIds).toEqual(["trace-a", "trace-b"]) - expect(result[0]!.pinIds).toEqual([ - "trace-a-a", - "trace-a-b", - "trace-b-a", - "trace-b-b", - ]) -}) - -test("merges two close parallel vertical same-net traces into one", () => { - const result = mergeParallelTraceSegments([ - makeTrace("trace-a", "net-1", [ - { x: 2, y: 0 }, - { x: 2, y: 5 }, - ]), - makeTrace("trace-b", "net-1", [ - { x: 2.12, y: 0 }, - { x: 2.12, y: 5 }, - ]), - ]) - - expect(result).toHaveLength(1) - expect(result[0]!.tracePath).toEqual([ - { x: 2, y: 0 }, - { x: 2, y: 5 }, - ]) }) -test("does not snap nearby segments from different nets", () => { - const [first, second] = mergeParallelTraceSegments([ +test("does not combine nearby segments from different nets", () => { + const [first, second] = combineSameNetTraceSegments([ makeTrace("trace-a", "net-1", [ { x: 0, y: 0 }, { x: 0, y: 1 }, @@ -135,7 +84,5 @@ test("does not snap nearby segments from different nets", () => { ]) 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) })