Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -94,6 +99,16 @@ 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"
Expand Down
143 changes: 143 additions & 0 deletions lib/solvers/TraceCleanupSolver/snapSameNetParallelTraces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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<string, number[]>()
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
}
154 changes: 154 additions & 0 deletions tests/solvers/TraceCleanupSolver/snapSameNetParallelTraces.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading