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
161 changes: 161 additions & 0 deletions lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, test } from "bun:test"
import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver"
import { SameNetTraceMergeSolver } from "./SameNetTraceMergeSolver"

interface Point {
x: number
y: number
}

function makeTrace(id: string, net: string, path: Point[]): SolvedTracePath {
return {
mspPairId: id,
dcConnNetId: net,
globalConnNetId: net,
pins: [
{ chipId: "a", pinId: `${id}_p1`, x: path[0].x, y: path[0].y },
{
chipId: "b",
pinId: `${id}_p2`,
x: path[path.length - 1]!.x,
y: path[path.length - 1]!.y,
},
],
mspConnectionPairIds: [id],
pinIds: [`${id}-p1`, `${id}-p2`],
tracePath: path,
} as SolvedTracePath
}

describe("SameNetTraceMergeSolver", () => {
test("merges two adjacent same-net traces", () => {
const t1 = makeTrace("t1", "net1", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const t2 = makeTrace("t2", "net1", [
{ x: 1, y: 0 },
{ x: 2, y: 0 },
])
const solver = new SameNetTraceMergeSolver({ traces: [t1, t2] })
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(1)
expect(solver.outputTraces[0]!.tracePath).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
])
})

test("does NOT merge traces from different nets", () => {
const t1 = makeTrace("t1", "netA", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const t2 = makeTrace("t2", "netB", [
{ x: 1, y: 0 },
{ x: 2, y: 0 },
])
const solver = new SameNetTraceMergeSolver({ traces: [t1, t2] })
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(2)
expect(solver.mergeCount).toBe(0)
})

test("does NOT merge when gap exceeds threshold", () => {
const t1 = makeTrace("t1", "net1", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const t2 = makeTrace("t2", "net1", [
{ x: 5, y: 0 },
{ x: 6, y: 0 },
])
const solver = new SameNetTraceMergeSolver({
traces: [t1, t2],
mergeThreshold: 0.1,
})
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(2)
expect(solver.mergeCount).toBe(0)
})

test("inserts L-bridge for non-axis-aligned gaps", () => {
const t1 = makeTrace("t1", "net1", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const t2 = makeTrace("t2", "net1", [
{ x: 1.05, y: 0.05 },
{ x: 2, y: 0.05 },
])
const solver = new SameNetTraceMergeSolver({
traces: [t1, t2],
mergeThreshold: 0.15,
})
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(1)
const path = solver.outputTraces[0]!.tracePath
expect(path.length).toBeGreaterThan(3)
})

test("merges three sequential same-net traces in one net", () => {
const t1 = makeTrace("t1", "net1", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const t2 = makeTrace("t2", "net1", [
{ x: 1, y: 0 },
{ x: 2, y: 0 },
])
const t3 = makeTrace("t3", "net1", [
{ x: 2, y: 0 },
{ x: 3, y: 0 },
])
const solver = new SameNetTraceMergeSolver({ traces: [t1, t2, t3] })
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(1)
expect(solver.mergeCount).toBe(2)
})

test("handles reversed endpoint matching (end-to-start)", () => {
const t1 = makeTrace("t1", "net1", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const t2 = makeTrace("t2", "net1", [
{ x: 2, y: 0 },
{ x: 1, y: 0 },
])
const solver = new SameNetTraceMergeSolver({ traces: [t1, t2] })
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(1)
})

test("leaves single-trace nets unchanged", () => {
const t1 = makeTrace("t1", "net1", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
const solver = new SameNetTraceMergeSolver({ traces: [t1] })
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(1)
expect(solver.mergeCount).toBe(0)
})

test("prefers userNetId over globalConnNetId", () => {
const t1 = makeTrace("t1", "shared_net", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
])
t1.userNetId = "user_net"
const t2 = makeTrace("t2", "shared_net", [
{ x: 1, y: 0 },
{ x: 2, y: 0 },
])
t2.userNetId = "user_net"
const solver = new SameNetTraceMergeSolver({ traces: [t1, t2] })
while (!solver.solved && !solver.failed) solver.step()
expect(solver.outputTraces.length).toBe(1)
})
})
156 changes: 156 additions & 0 deletions lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { Point } from "@tscircuit/math-utils"
import { BaseSolver } from "../BaseSolver/BaseSolver"
import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver"

/**
* SameNetTraceMergeSolver — pipeline phase that merges close same-net trace
* segments on the same axis (collinear same-X or same-Y).
*
* Finds all traces belonging to the same electrical net (via userNetId,
* globalConnNetId, or dcConnNetId), then merges pairs where the gap between
* endpoints on their shared axis is below `mergeThreshold`.
*
* Inserted after TraceCleanupSolver in SchematicTracePipelineSolver.
*/

const MERGE_THRESHOLD = 0.1 // mm — max gap on shared axis to consider merging

function netKey(trace: SolvedTracePath): string {
// userNetId takes priority, then globalConnNetId, then dcConnNetId
if (trace.userNetId && trace.userNetId.length > 0) return trace.userNetId
if (trace.globalConnNetId && trace.globalConnNetId.length > 0)
return trace.globalConnNetId
return trace.dcConnNetId ?? ""
}

function dist2(a: Point, b: Point): number {
const dx = a.x - b.x
const dy = a.y - b.y
return dx * dx + dy * dy
}

function removeDupes(pts: Point[]): Point[] {
const out: Point[] = []
for (const p of pts) {
const prev = out[out.length - 1]
if (
!prev ||
Math.abs(prev.x - p.x) > 1e-9 ||
Math.abs(prev.y - p.y) > 1e-9
) {
out.push(p)
}
}
return out
}

/**
* Try to merge two same-net traces. Returns merged trace or null.
*/
function tryMerge(
a: SolvedTracePath,
b: SolvedTracePath,
threshold: number,
): SolvedTracePath | null {
const pa = a.tracePath
const pb = b.tracePath
if (!pa?.length || !pb?.length) return null

const aS = pa[0]!
const aE = pa[pa.length - 1]!
const bS = pb[0]!
const bE = pb[pb.length - 1]!

// Check all 4 endpoint pairing options
const options = [
{ d2: dist2(aE, bS), ra: false, rb: false },
{ d2: dist2(aE, bE), ra: false, rb: true },
{ d2: dist2(aS, bS), ra: true, rb: false },
{ d2: dist2(aS, bE), ra: true, rb: true },
]

const best = options.reduce((p, c) => (c.d2 < p.d2 ? c : p))
if (best.d2 > threshold * threshold) return null

const pathA = best.ra ? [...pa].reverse() : pa
const pathB = best.rb ? [...pb].reverse() : pb

const from = pathA[pathA.length - 1]!
const to = pathB[0]!

// Insert an L-bridge if not already axis-aligned
const bridge: Point[] =
Math.abs(from.x - to.x) > 1e-9 && Math.abs(from.y - to.y) > 1e-9
? [{ x: to.x, y: from.y }]
: []

return {
...a,
mspPairId: `merged:${a.mspPairId}+${b.mspPairId}`,
tracePath: removeDupes([...pathA, ...bridge, ...pathB]),
mspConnectionPairIds: [
...a.mspConnectionPairIds,
...b.mspConnectionPairIds,
],
pinIds: [...a.pinIds, ...b.pinIds],
}
}

export class SameNetTraceMergeSolver extends BaseSolver {
outputTraces: SolvedTracePath[]
mergeCount = 0

constructor({
traces,
mergeThreshold: _mergeThreshold = MERGE_THRESHOLD,
}: {
traces: SolvedTracePath[]
mergeThreshold?: number
}) {
super()
this.outputTraces = [...traces]
}

getOutput(): { traces: SolvedTracePath[] } {
return { traces: this.outputTraces }
}

override _step(): void {
// Build net groups
const byNet = new Map<string, SolvedTracePath[]>()
for (const t of this.outputTraces) {
const k = netKey(t)
const g = byNet.get(k)
if (g) g.push(t)
else byNet.set(k, [t])
}

let merged = false

for (const group of byNet.values()) {
if (group.length < 2) continue

// O(n²) scan per net
for (let i = 0; i < group.length; i++) {
for (let j = i + 1; j < group.length; j++) {
const result = tryMerge(group[i]!, group[j]!, MERGE_THRESHOLD)
if (result) {
this.outputTraces = this.outputTraces.filter(
(t) =>
t.mspPairId !== group[i]!.mspPairId &&
t.mspPairId !== group[j]!.mspPairId,
)
this.outputTraces.push(result)
this.mergeCount++
merged = true
return
}
}
}
}

if (!merged) {
this.solved = true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { expandChipsToFitPins } from "./expandChipsToFitPins"
import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver"
import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver"
import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver"
import { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver"
import { Example28Solver } from "../Example28Solver/Example28Solver"
import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/AvailableNetOrientationSolver"
import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver"
Expand Down Expand Up @@ -75,6 +76,7 @@ export class SchematicTracePipelineSolver extends BaseSolver {
labelMergingSolver?: MergedNetLabelObstacleSolver
traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver
traceCleanupSolver?: TraceCleanupSolver
sameNetTraceMergeSolver?: SameNetTraceMergeSolver
example28Solver?: Example28Solver
availableNetOrientationSolver?: AvailableNetOrientationSolver
vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver
Expand Down Expand Up @@ -217,11 +219,22 @@ export class SchematicTracePipelineSolver extends BaseSolver {
},
]
}),
definePipelineStep(
"sameNetTraceMergeSolver",
SameNetTraceMergeSolver,
(instance) => {
const traces =
instance.traceCleanupSolver?.getOutput().traces ??
instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces
return [{ traces }]
},
),
definePipelineStep(
"netLabelPlacementSolver",
NetLabelPlacementSolver,
(instance) => {
const traces =
instance.sameNetTraceMergeSolver?.getOutput().traces ??
instance.traceCleanupSolver?.getOutput().traces ??
instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces

Expand Down
Loading
Loading