From 42c46c3c09c565d7cf068c31fcfefa1f46d22630 Mon Sep 17 00:00:00 2001 From: Archestra Bot Date: Sat, 16 May 2026 16:15:20 +0900 Subject: [PATCH 1/4] feat: merge same-net parallel traces that are close in X or Y --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 16 +- .../mergeSameNetParallelTraces.ts | 150 ++++++++++++++++++ .../mergeSameNetParallelTraces.test.ts | 99 ++++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts create mode 100644 tests/functions/mergeSameNetParallelTraces.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..3bbd058e 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -6,6 +6,7 @@ import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" import type { NetLabelPlacement } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { mergeSameNetParallelTraces } from "./mergeSameNetParallelTraces" /** * Defines the input structure for the TraceCleanupSolver. @@ -28,6 +29,7 @@ type PipelineStep = | "minimizing_turns" | "balancing_l_shapes" | "untangling_traces" + | "merging_same_net_traces" /** * The TraceCleanupSolver is responsible for improving the aesthetics and readability of schematic traces. @@ -84,6 +86,9 @@ export class TraceCleanupSolver extends BaseSolver { case "balancing_l_shapes": this._runBalanceLShapesStep() break + case "merging_same_net_traces": + this._runMergeSameNetTracesStep() + break } } @@ -108,13 +113,22 @@ export class TraceCleanupSolver extends BaseSolver { private _runBalanceLShapesStep() { if (this.traceIdQueue.length === 0) { - this.solved = true + this.pipelineStep = "merging_same_net_traces" return } this._processTrace("balancing_l_shapes") } + private _runMergeSameNetTracesStep() { + this.outputTraces = mergeSameNetParallelTraces( + this.outputTraces, + {} as Record, + ) + 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/mergeSameNetParallelTraces.ts b/lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts new file mode 100644 index 00000000..4cefbc5b --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts @@ -0,0 +1,150 @@ +import type { Point } from "graphics-debug" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const MERGE_THRESHOLD = 0.05 // grid units within which parallel segments are considered "same line" + +interface Segment { + p1: Point + p2: Point +} + +function getSegments(path: Point[]): Segment[] { + const segs: Segment[] = [] + for (let i = 0; i < path.length - 1; i++) { + segs.push({ p1: path[i], p2: path[i + 1] }) + } + return segs +} + +function isHorizontal(seg: Segment): boolean { + return Math.abs(seg.p1.y - seg.p2.y) < 1e-9 +} + +function isVertical(seg: Segment): boolean { + return Math.abs(seg.p1.x - seg.p2.x) < 1e-9 +} + +/** + * Given two segments on the same net that are parallel and close, + * return true if they are overlapping (share at least one point range). + */ +function segmentsOverlapOnAxis( + seg1: Segment, + seg2: Segment, + isHoriz: boolean, +): boolean { + if (isHoriz) { + const [min1, max1] = [ + Math.min(seg1.p1.x, seg1.p2.x), + Math.max(seg1.p1.x, seg1.p2.x), + ] + const [min2, max2] = [ + Math.min(seg2.p1.x, seg2.p2.x), + Math.max(seg2.p1.x, seg2.p2.x), + ] + return min1 <= max2 && min2 <= max1 + } else { + const [min1, max1] = [ + Math.min(seg1.p1.y, seg1.p2.y), + Math.max(seg1.p1.y, seg1.p2.y), + ] + const [min2, max2] = [ + Math.min(seg2.p1.y, seg2.p2.y), + Math.max(seg2.p1.y, seg2.p2.y), + ] + return min1 <= max2 && min2 <= max1 + } +} + +/** + * For all traces on the same net, merge any trace segments that are parallel + * and close together (within MERGE_THRESHOLD) by snapping the second trace's + * segment to exactly match the first trace's axis value. + * + * This cleans up near-duplicate parallel routes on the same net so they + * visually appear as a single line rather than two very close parallel lines. + */ +export function mergeSameNetParallelTraces( + traces: SolvedTracePath[], + globalConnMap: Record | Map>, +): SolvedTracePath[] { + // Build a map from netId to list of trace indices + const netToTraceIndices = new Map() + for (let i = 0; i < traces.length; i++) { + const trace = traces[i] + const netId = trace.netId ?? trace.mspPairId + if (!netToTraceIndices.has(netId)) { + netToTraceIndices.set(netId, []) + } + netToTraceIndices.get(netId)!.push(i) + } + + const updatedTraces = traces.map((t) => ({ + ...t, + tracePath: [...t.tracePath], + })) + + for (const [_netId, indices] of netToTraceIndices) { + if (indices.length < 2) continue + + // For each pair of traces on the same net + for (let a = 0; a < indices.length; a++) { + for (let b = a + 1; b < indices.length; b++) { + const traceA = updatedTraces[indices[a]] + const traceB = updatedTraces[indices[b]] + + const segsA = getSegments(traceA.tracePath) + const segsB = getSegments(traceB.tracePath) + + // Check horizontal segments close in Y, or vertical segments close in X + for (const segA of segsA) { + for (let si = 0; si < segsB.length; si++) { + const segB = segsB[si] + + if (isHorizontal(segA) && isHorizontal(segB)) { + const dy = Math.abs(segA.p1.y - segB.p1.y) + if (dy > 0 && dy <= MERGE_THRESHOLD) { + if (segmentsOverlapOnAxis(segA, segB, true)) { + // Snap segB's Y to segA's Y by updating the tracePath points + const targetY = segA.p1.y + const newPath = traceB.tracePath.map((pt) => { + if (Math.abs(pt.y - segB.p1.y) < 1e-9) { + return { ...pt, y: targetY } + } + return pt + }) + traceB.tracePath = newPath + segsB[si] = { + p1: { ...segB.p1, y: targetY }, + p2: { ...segB.p2, y: targetY }, + } + } + } + } else if (isVertical(segA) && isVertical(segB)) { + const dx = Math.abs(segA.p1.x - segB.p1.x) + if (dx > 0 && dx <= MERGE_THRESHOLD) { + if (segmentsOverlapOnAxis(segA, segB, false)) { + // Snap segB's X to segA's X + const targetX = segA.p1.x + const newPath = traceB.tracePath.map((pt) => { + if (Math.abs(pt.x - segB.p1.x) < 1e-9) { + return { ...pt, x: targetX } + } + return pt + }) + traceB.tracePath = newPath + segsB[si] = { + p1: { ...segB.p1, x: targetX }, + p2: { ...segB.p2, x: targetX }, + } + } + } + } + } + } + } + } + } + + return updatedTraces +} diff --git a/tests/functions/mergeSameNetParallelTraces.test.ts b/tests/functions/mergeSameNetParallelTraces.test.ts new file mode 100644 index 00000000..5f90e4f9 --- /dev/null +++ b/tests/functions/mergeSameNetParallelTraces.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { mergeSameNetParallelTraces } from "lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +describe("mergeSameNetParallelTraces", () => { + test("merges two horizontal segments on the same net that are very close in Y", () => { + const traceA: SolvedTracePath = { + mspPairId: "net1_A", + netId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ], + mspConnectionPairIds: ["net1_A"], + pinIds: ["p1", "p2"], + pins: [], + } as any + + const traceB: SolvedTracePath = { + mspPairId: "net1_B", + netId: "net1", + tracePath: [ + { x: 1, y: 0.03 }, // only 0.03 away — should be snapped to y=0 + { x: 3, y: 0.03 }, + ], + mspConnectionPairIds: ["net1_B"], + pinIds: ["p3", "p4"], + pins: [], + } as any + + const result = mergeSameNetParallelTraces([traceA, traceB], {}) + + // traceB's y should now be 0 (snapped to traceA) + expect(result[1].tracePath[0].y).toBeCloseTo(0) + expect(result[1].tracePath[1].y).toBeCloseTo(0) + }) + + test("does not merge segments on different nets", () => { + const traceA: SolvedTracePath = { + mspPairId: "net1_A", + netId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ], + mspConnectionPairIds: ["net1_A"], + pinIds: ["p1", "p2"], + pins: [], + } as any + + const traceB: SolvedTracePath = { + mspPairId: "net2_B", + netId: "net2", + tracePath: [ + { x: 1, y: 0.03 }, + { x: 3, y: 0.03 }, + ], + mspConnectionPairIds: ["net2_B"], + pinIds: ["p3", "p4"], + pins: [], + } as any + + const result = mergeSameNetParallelTraces([traceA, traceB], {}) + + // Different nets — traceB's y should NOT be changed + expect(result[1].tracePath[0].y).toBeCloseTo(0.03) + }) + + test("does not merge segments far apart", () => { + const traceA: SolvedTracePath = { + mspPairId: "net1_A", + netId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ], + mspConnectionPairIds: ["net1_A"], + pinIds: ["p1", "p2"], + pins: [], + } as any + + const traceB: SolvedTracePath = { + mspPairId: "net1_B", + netId: "net1", + tracePath: [ + { x: 1, y: 0.5 }, // 0.5 away — too far, should NOT be merged + { x: 3, y: 0.5 }, + ], + mspConnectionPairIds: ["net1_B"], + pinIds: ["p3", "p4"], + pins: [], + } as any + + const result = mergeSameNetParallelTraces([traceA, traceB], {}) + + // Should remain unchanged + expect(result[1].tracePath[0].y).toBeCloseTo(0.5) + }) +}) From f6d1b0103f4436c78dda1087813d929a71c48c9f Mon Sep 17 00:00:00 2001 From: Archestra Bot Date: Sat, 16 May 2026 16:25:11 +0900 Subject: [PATCH 2/4] fix: use correct property names for net identification in mergeSameNetParallelTraces --- .../TraceCleanupSolver/mergeSameNetParallelTraces.ts | 2 +- tests/functions/mergeSameNetParallelTraces.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts b/lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts index 4cefbc5b..955a2532 100644 --- a/lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts +++ b/lib/solvers/TraceCleanupSolver/mergeSameNetParallelTraces.ts @@ -72,7 +72,7 @@ export function mergeSameNetParallelTraces( const netToTraceIndices = new Map() for (let i = 0; i < traces.length; i++) { const trace = traces[i] - const netId = trace.netId ?? trace.mspPairId + const netId = trace.userNetId ?? trace.globalConnNetId ?? trace.mspPairId if (!netToTraceIndices.has(netId)) { netToTraceIndices.set(netId, []) } diff --git a/tests/functions/mergeSameNetParallelTraces.test.ts b/tests/functions/mergeSameNetParallelTraces.test.ts index 5f90e4f9..29c94d10 100644 --- a/tests/functions/mergeSameNetParallelTraces.test.ts +++ b/tests/functions/mergeSameNetParallelTraces.test.ts @@ -6,7 +6,7 @@ describe("mergeSameNetParallelTraces", () => { test("merges two horizontal segments on the same net that are very close in Y", () => { const traceA: SolvedTracePath = { mspPairId: "net1_A", - netId: "net1", + globalConnNetId: "net1", tracePath: [ { x: 0, y: 0 }, { x: 4, y: 0 }, @@ -18,7 +18,7 @@ describe("mergeSameNetParallelTraces", () => { const traceB: SolvedTracePath = { mspPairId: "net1_B", - netId: "net1", + globalConnNetId: "net1", tracePath: [ { x: 1, y: 0.03 }, // only 0.03 away — should be snapped to y=0 { x: 3, y: 0.03 }, @@ -38,7 +38,7 @@ describe("mergeSameNetParallelTraces", () => { test("does not merge segments on different nets", () => { const traceA: SolvedTracePath = { mspPairId: "net1_A", - netId: "net1", + globalConnNetId: "net1", tracePath: [ { x: 0, y: 0 }, { x: 4, y: 0 }, @@ -50,7 +50,7 @@ describe("mergeSameNetParallelTraces", () => { const traceB: SolvedTracePath = { mspPairId: "net2_B", - netId: "net2", + globalConnNetId: "net2", tracePath: [ { x: 1, y: 0.03 }, { x: 3, y: 0.03 }, @@ -69,7 +69,7 @@ describe("mergeSameNetParallelTraces", () => { test("does not merge segments far apart", () => { const traceA: SolvedTracePath = { mspPairId: "net1_A", - netId: "net1", + globalConnNetId: "net1", tracePath: [ { x: 0, y: 0 }, { x: 4, y: 0 }, @@ -81,7 +81,7 @@ describe("mergeSameNetParallelTraces", () => { const traceB: SolvedTracePath = { mspPairId: "net1_B", - netId: "net1", + globalConnNetId: "net1", tracePath: [ { x: 1, y: 0.5 }, // 0.5 away — too far, should NOT be merged { x: 3, y: 0.5 }, From feb52af9e022754a32bf84c98b57f23e80744f35 Mon Sep 17 00:00:00 2001 From: Archestra Bot Date: Sat, 16 May 2026 17:05:12 +0900 Subject: [PATCH 3/4] test: skip visual tests with snapshot mismatches due to improved trace merging --- tests/examples/example18.test.ts | 2 +- tests/examples/example19.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/examples/example18.test.ts b/tests/examples/example18.test.ts index c22989ff..ca9d8f1c 100644 --- a/tests/examples/example18.test.ts +++ b/tests/examples/example18.test.ts @@ -3,7 +3,7 @@ import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipeline import inputProblem from "../assets/example18.json" import "tests/fixtures/matcher" -test("example18", () => { +test.skip("example18", () => { const solver = new SchematicTracePipelineSolver(inputProblem as any) solver.solve() diff --git a/tests/examples/example19.test.ts b/tests/examples/example19.test.ts index 5b4fc2ce..9f450049 100644 --- a/tests/examples/example19.test.ts +++ b/tests/examples/example19.test.ts @@ -3,7 +3,7 @@ import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipeline import inputProblem from "../assets/example19.json" import "tests/fixtures/matcher" -test("example19", () => { +test.skip("example19", () => { const solver = new SchematicTracePipelineSolver(inputProblem as any) solver.solve() From a9fde38857c8354d27a8cf5033f1c797ea7c9753 Mon Sep 17 00:00:00 2001 From: Archestra Bot Date: Sun, 17 May 2026 01:49:52 +0900 Subject: [PATCH 4/4] fix: update snapshots and re-enable example18 and example19 tests --- .../examples/__snapshots__/example18.snap.svg | 32 +++++++------------ .../examples/__snapshots__/example19.snap.svg | 14 +++----- tests/examples/example18.test.ts | 2 +- tests/examples/example19.test.ts | 2 +- 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg index 8c7f05fc..bc8149cf 100644 --- a/tests/examples/__snapshots__/example18.snap.svg +++ b/tests/examples/__snapshots__/example18.snap.svg @@ -65,24 +65,19 @@ y-" data-x="1.7580660749999977" data-y="-3.3025814000000002" cx="494.02875093834 y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.29024555523955" r="3" fill="hsl(248, 100%, 50%, 0.8)" /> - + - + - + - + - + @@ -139,7 +134,7 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" - + @@ -167,28 +162,23 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" +globalConnNetId: connectivity_net0" data-x="-1.8574283249999997" data-y="0.9762093000000004" x="161.39522395803996" y="196.79342126794842" width="17.905209437554532" height="40.28672123449769" fill="#ef444466" stroke="#ef4444" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net0" data-x="1.5790330374999988" data-y="2.7275814000000005" x="469.0480260561722" y="40" width="17.90520943755456" height="40.28672123449769" fill="#ef444466" stroke="#ef4444" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net1" data-x="-2.31430995" data-y="-0.9762093000000004" x="120.4924180390637" y="371.58574098183345" width="17.905209437554532" height="40.28672123449769" fill="#00000066" stroke="#000000" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net2" data-x="1.982519574999999" data-y="0.85" x="493.97982495355666" y="219.2831969137558" width="40.28672123449769" height="17.905209437554532" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net3" data-x="1.982519574999999" data-y="-2" x="493.97982495355666" y="474.43243139890774" width="40.28672123449769" height="17.90520943755456" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011169933571428573" /> - + - + - + - + @@ -98,7 +94,7 @@ orientation: y+" data-x="3.3884680250000008" data-y="1.2997267500000007" cx="438 - + diff --git a/tests/examples/example18.test.ts b/tests/examples/example18.test.ts index ca9d8f1c..c22989ff 100644 --- a/tests/examples/example18.test.ts +++ b/tests/examples/example18.test.ts @@ -3,7 +3,7 @@ import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipeline import inputProblem from "../assets/example18.json" import "tests/fixtures/matcher" -test.skip("example18", () => { +test("example18", () => { const solver = new SchematicTracePipelineSolver(inputProblem as any) solver.solve() diff --git a/tests/examples/example19.test.ts b/tests/examples/example19.test.ts index 9f450049..5b4fc2ce 100644 --- a/tests/examples/example19.test.ts +++ b/tests/examples/example19.test.ts @@ -3,7 +3,7 @@ import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipeline import inputProblem from "../assets/example19.json" import "tests/fixtures/matcher" -test.skip("example19", () => { +test("example19", () => { const solver = new SchematicTracePipelineSolver(inputProblem as any) solver.solve()