From 97f10f1b6c5fbf9dd807d47727a971f0829119d0 Mon Sep 17 00:00:00 2001 From: tungnguyentu Date: Fri, 15 May 2026 10:35:28 +0700 Subject: [PATCH 1/2] fix: snap same-net close parallel traces to shared coordinate (#34) Adds a new `snapping_same_net_parallel_traces` step in TraceCleanupSolver (after untangling, before turn minimisation). For each net that has multiple traces, the step finds internal horizontal/vertical segment pairs that are within 0.15 schematic units of each other and have overlapping ranges, then snaps both to their midpoint coordinate so they appear as a single line. Pin-anchored endpoints (first and last path segments) are never moved, preserving port positions. The loop iterates until stable to handle chains of 3+ nearly-aligned traces converging to a common coordinate. Fixes #34 /claim #34 --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 17 +- .../snapSameNetParallelTraces.ts | 142 ++++++++++++++++ .../snapSameNetParallelTraces.test.ts | 154 ++++++++++++++++++ 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts create mode 100644 tests/solvers/TraceCleanupSolver/snapSameNetParallelTraces.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..ed20fc23 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -20,11 +20,13 @@ interface TraceCleanupSolverInput { import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" import { is4PointRectangle } from "./is4PointRectangle" +import { snapSameNetParallelTraces } from "./snapSameNetParallelTraces" /** * Represents the different stages or steps within the trace cleanup pipeline. */ type PipelineStep = + | "snapping_same_net_parallel_traces" | "minimizing_turns" | "balancing_l_shapes" | "untangling_traces" @@ -66,10 +68,10 @@ export class TraceCleanupSolver extends BaseSolver { this.outputTraces = output.traces this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "snapping_same_net_parallel_traces" } else if (this.activeSubSolver.failed) { this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "snapping_same_net_parallel_traces" } return } @@ -78,6 +80,9 @@ export class TraceCleanupSolver extends BaseSolver { case "untangling_traces": this._runUntangleTracesStep() break + case "snapping_same_net_parallel_traces": + this._runSnapSameNetParallelTracesStep() + break case "minimizing_turns": this._runMinimizeTurnsStep() break @@ -94,6 +99,14 @@ export class TraceCleanupSolver extends BaseSolver { }) } + private _runSnapSameNetParallelTracesStep() { + const snapped = snapSameNetParallelTraces(Array.from(this.tracesMap.values())) + this.outputTraces = snapped + this.tracesMap = new Map(snapped.map((t) => [t.mspPairId, t])) + this.traceIdQueue = Array.from(this.input.allTraces.map((e) => e.mspPairId)) + this.pipelineStep = "minimizing_turns" + } + private _runMinimizeTurnsStep() { if (this.traceIdQueue.length === 0) { this.pipelineStep = "balancing_l_shapes" diff --git a/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts b/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts new file mode 100644 index 00000000..131ec4a8 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts @@ -0,0 +1,142 @@ +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const SNAP_THRESHOLD = 0.15 + +interface Segment { + traceIdx: number + segIdx: number // index of the start point in tracePath (segment is from segIdx to segIdx+1) + isHorizontal: boolean + coord: number // y for horizontal, x for vertical + rangeMin: number // x-min for horizontal, y-min for vertical + rangeMax: number // x-max for horizontal, y-max for vertical +} + +function getSegments( + traces: SolvedTracePath[], +): Segment[] { + const segments: Segment[] = [] + for (let ti = 0; ti < traces.length; ti++) { + const { tracePath } = traces[ti] + for (let si = 1; si < tracePath.length - 2; si++) { + // Only internal segments (skip first and last segments which are pin-anchored) + const p1 = tracePath[si] + const p2 = tracePath[si + 1] + const dx = Math.abs(p2.x - p1.x) + const dy = Math.abs(p2.y - p1.y) + if (dy < 1e-9 && dx > 1e-9) { + // Horizontal segment + segments.push({ + traceIdx: ti, + segIdx: si, + isHorizontal: true, + coord: p1.y, + rangeMin: Math.min(p1.x, p2.x), + rangeMax: Math.max(p1.x, p2.x), + }) + } else if (dx < 1e-9 && dy > 1e-9) { + // Vertical segment + segments.push({ + traceIdx: ti, + segIdx: si, + isHorizontal: false, + coord: p1.x, + rangeMin: Math.min(p1.y, p2.y), + rangeMax: Math.max(p1.y, p2.y), + }) + } + } + } + return segments +} + +function rangesOverlap( + aMin: number, + aMax: number, + bMin: number, + bMax: number, +): boolean { + return aMax > bMin && bMax > aMin +} + +function snapCoord( + traces: SolvedTracePath[], + seg: Segment, + newCoord: number, +): void { + const trace = traces[seg.traceIdx] + const path = trace.tracePath + const si = seg.segIdx + const oldCoord = seg.coord + const delta = newCoord - oldCoord + + if (seg.isHorizontal) { + // Adjust y of both endpoints of this segment + path[si] = { ...path[si], y: path[si].y + delta } + path[si + 1] = { ...path[si + 1], y: path[si + 1].y + delta } + } else { + // Adjust x of both endpoints of this segment + path[si] = { ...path[si], x: path[si].x + delta } + path[si + 1] = { ...path[si + 1], x: path[si + 1].x + delta } + } +} + +/** + * For traces on the same net, snap close parallel internal segments to the + * midpoint coordinate so they appear as one line (makes same Y or same X). + * Only modifies internal segments to preserve pin anchor positions. + */ +export function snapSameNetParallelTraces( + traces: SolvedTracePath[], +): SolvedTracePath[] { + // Group trace indices by globalConnNetId + const netGroups = new Map() + for (let i = 0; i < traces.length; i++) { + const netId = traces[i].globalConnNetId + if (!netGroups.has(netId)) netGroups.set(netId, []) + netGroups.get(netId)!.push(i) + } + + // Work on a mutable copy with cloned paths + const result: SolvedTracePath[] = traces.map((t) => ({ + ...t, + tracePath: t.tracePath.map((p) => ({ ...p })), + })) + + for (const [, traceIndices] of netGroups) { + if (traceIndices.length < 2) continue + + const netTraces = traceIndices.map((i) => result[i]) + + let changed = true + while (changed) { + changed = false + const segs = getSegments(netTraces) + + for (let a = 0; a < segs.length; a++) { + for (let b = a + 1; b < segs.length; b++) { + const sa = segs[a] + const sb = segs[b] + + // Must be same orientation + if (sa.isHorizontal !== sb.isHorizontal) continue + // Must be different traces + if (sa.traceIdx === sb.traceIdx) continue + + const dist = Math.abs(sa.coord - sb.coord) + if (dist < 1e-9 || dist > SNAP_THRESHOLD) continue + if (!rangesOverlap(sa.rangeMin, sa.rangeMax, sb.rangeMin, sb.rangeMax)) continue + + // Snap both to midpoint + const mid = (sa.coord + sb.coord) / 2 + snapCoord(netTraces, sa, mid) + snapCoord(netTraces, sb, mid) + changed = true + break + } + if (changed) break + } + } + } + + return result +} diff --git a/tests/solvers/TraceCleanupSolver/snapSameNetParallelTraces.test.ts b/tests/solvers/TraceCleanupSolver/snapSameNetParallelTraces.test.ts new file mode 100644 index 00000000..57f4ef63 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/snapSameNetParallelTraces.test.ts @@ -0,0 +1,154 @@ +import { test, expect } from "bun:test" +import { snapSameNetParallelTraces } from "lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +function makeTrace( + id: string, + netId: string, + path: Array<{ x: number; y: number }>, +): SolvedTracePath { + return { + mspPairId: id, + dcConnNetId: netId, + globalConnNetId: netId, + tracePath: path, + mspConnectionPairIds: [id], + pinIds: [], + pins: [] as any, + } +} + +test("snaps two close horizontal segments on the same net to the same Y", () => { + // Trace A: horizontal at y=0, from x=0 to x=2 (internal segment) + const traceA = makeTrace("a", "net1", [ + { x: -1, y: 0 }, // anchor + { x: 0, y: 0 }, // internal start + { x: 2, y: 0 }, // internal end + { x: 3, y: 0 }, // anchor + ]) + // Trace B: horizontal at y=0.1 (close!), from x=0.5 to x=2.5 + const traceB = makeTrace("b", "net1", [ + { x: -1, y: 0.1 }, + { x: 0.5, y: 0.1 }, + { x: 2.5, y: 0.1 }, + { x: 3, y: 0.1 }, + ]) + + const result = snapSameNetParallelTraces([traceA, traceB]) + + // Both internal segments should be snapped to midpoint y=0.05 + const mid = 0.05 + expect(result[0].tracePath[1].y).toBeCloseTo(mid) + expect(result[0].tracePath[2].y).toBeCloseTo(mid) + expect(result[1].tracePath[1].y).toBeCloseTo(mid) + expect(result[1].tracePath[2].y).toBeCloseTo(mid) + // Anchors should be unchanged + expect(result[0].tracePath[0].y).toBe(0) + expect(result[0].tracePath[3].y).toBe(0) + expect(result[1].tracePath[0].y).toBe(0.1) + expect(result[1].tracePath[3].y).toBe(0.1) +}) + +test("snaps two close vertical segments on the same net to the same X", () => { + const traceA = makeTrace("a", "net1", [ + { x: 0, y: -1 }, + { x: 0, y: 0 }, + { x: 0, y: 2 }, + { x: 0, y: 3 }, + ]) + const traceB = makeTrace("b", "net1", [ + { x: 0.1, y: -1 }, + { x: 0.1, y: 0.5 }, + { x: 0.1, y: 2.5 }, + { x: 0.1, y: 3 }, + ]) + + const result = snapSameNetParallelTraces([traceA, traceB]) + + const mid = 0.05 + expect(result[0].tracePath[1].x).toBeCloseTo(mid) + expect(result[0].tracePath[2].x).toBeCloseTo(mid) + expect(result[1].tracePath[1].x).toBeCloseTo(mid) + expect(result[1].tracePath[2].x).toBeCloseTo(mid) +}) + +test("does not snap segments on different nets", () => { + const traceA = makeTrace("a", "net1", [ + { x: -1, y: 0 }, + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ]) + const traceB = makeTrace("b", "net2", [ + { x: -1, y: 0.05 }, + { x: 0, y: 0.05 }, + { x: 2, y: 0.05 }, + { x: 3, y: 0.05 }, + ]) + + const result = snapSameNetParallelTraces([traceA, traceB]) + + expect(result[0].tracePath[1].y).toBe(0) + expect(result[1].tracePath[1].y).toBe(0.05) +}) + +test("does not snap segments farther apart than threshold", () => { + const traceA = makeTrace("a", "net1", [ + { x: -1, y: 0 }, + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ]) + const traceB = makeTrace("b", "net1", [ + { x: -1, y: 0.5 }, + { x: 0, y: 0.5 }, + { x: 2, y: 0.5 }, + { x: 3, y: 0.5 }, + ]) + + const result = snapSameNetParallelTraces([traceA, traceB]) + + expect(result[0].tracePath[1].y).toBe(0) + expect(result[1].tracePath[1].y).toBe(0.5) +}) + +test("does not snap segments with non-overlapping ranges", () => { + const traceA = makeTrace("a", "net1", [ + { x: -1, y: 0 }, + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]) + const traceB = makeTrace("b", "net1", [ + { x: 3, y: 0.1 }, + { x: 4, y: 0.1 }, + { x: 5, y: 0.1 }, + { x: 6, y: 0.1 }, + ]) + + const result = snapSameNetParallelTraces([traceA, traceB]) + + expect(result[0].tracePath[1].y).toBe(0) + expect(result[1].tracePath[1].y).toBe(0.1) +}) + +test("does not modify anchor (first and last) segments", () => { + // Trace with only 3 points: first-to-second is an anchor segment (si starts at 1) + // With path length 3, internal segments are si in [1, length-2) = [1, 1) => empty + const traceA = makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ]) + const traceB = makeTrace("b", "net1", [ + { x: 0, y: 0.1 }, + { x: 2, y: 0.1 }, + { x: 3, y: 0.1 }, + ]) + + const result = snapSameNetParallelTraces([traceA, traceB]) + + // No internal segments, nothing should change + expect(result[0].tracePath[1].y).toBe(0) + expect(result[1].tracePath[1].y).toBe(0.1) +}) From 276e2ae0f26e3790aab4ad7ce0ca025c29f09220 Mon Sep 17 00:00:00 2001 From: tungnguyentu Date: Fri, 15 May 2026 11:24:13 +0700 Subject: [PATCH 2/2] fix(format): apply biome formatting to snap-same-net-parallel-traces files Co-Authored-By: Claude Sonnet 4.6 --- lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts | 4 +++- .../TraceCleanupSolver/snapSameNetParallelTraces.ts | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index ed20fc23..d9d29ee8 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -100,7 +100,9 @@ export class TraceCleanupSolver extends BaseSolver { } private _runSnapSameNetParallelTracesStep() { - const snapped = snapSameNetParallelTraces(Array.from(this.tracesMap.values())) + const snapped = snapSameNetParallelTraces( + Array.from(this.tracesMap.values()), + ) this.outputTraces = snapped this.tracesMap = new Map(snapped.map((t) => [t.mspPairId, t])) this.traceIdQueue = Array.from(this.input.allTraces.map((e) => e.mspPairId)) diff --git a/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts b/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts index 131ec4a8..b607c140 100644 --- a/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts +++ b/lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts @@ -11,9 +11,7 @@ interface Segment { rangeMax: number // x-max for horizontal, y-max for vertical } -function getSegments( - traces: SolvedTracePath[], -): Segment[] { +function getSegments(traces: SolvedTracePath[]): Segment[] { const segments: Segment[] = [] for (let ti = 0; ti < traces.length; ti++) { const { tracePath } = traces[ti] @@ -124,7 +122,10 @@ export function snapSameNetParallelTraces( const dist = Math.abs(sa.coord - sb.coord) if (dist < 1e-9 || dist > SNAP_THRESHOLD) continue - if (!rangesOverlap(sa.rangeMin, sa.rangeMax, sb.rangeMin, sb.rangeMax)) continue + if ( + !rangesOverlap(sa.rangeMin, sa.rangeMax, sb.rangeMin, sb.rangeMax) + ) + continue // Snap both to midpoint const mid = (sa.coord + sb.coord) / 2