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..955a2532 --- /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.userNetId ?? trace.globalConnNetId ?? 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/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/functions/mergeSameNetParallelTraces.test.ts b/tests/functions/mergeSameNetParallelTraces.test.ts new file mode 100644 index 00000000..29c94d10 --- /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", + globalConnNetId: "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", + globalConnNetId: "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", + globalConnNetId: "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", + globalConnNetId: "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", + globalConnNetId: "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", + globalConnNetId: "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) + }) +})