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);
});
});
158 changes: 158 additions & 0 deletions lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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 {
private readonly inputTraces: SolvedTracePath[];
outputTraces: SolvedTracePath[];
mergeCount = 0;

constructor({
traces,
mergeThreshold = MERGE_THRESHOLD,
}: {
traces: SolvedTracePath[];
mergeThreshold?: number;
}) {
super();
this.inputTraces = [...traces];
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 @@ -74,8 +75,9 @@ export class SchematicTracePipelineSolver extends BaseSolver {
netLabelPlacementSolver?: NetLabelPlacementSolver
labelMergingSolver?: MergedNetLabelObstacleSolver
traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver
traceCleanupSolver?: TraceCleanupSolver
example28Solver?: Example28Solver
traceCleanupSolver?: TraceCleanupSolver
sameNetTraceMergeSolver?: SameNetTraceMergeSolver
example28Solver?: Example28Solver
availableNetOrientationSolver?: AvailableNetOrientationSolver
vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver
traceAnchoredNetLabelOverlapSolver?: TraceAnchoredNetLabelOverlapSolver
Expand Down Expand Up @@ -216,14 +218,25 @@ export class SchematicTracePipelineSolver extends BaseSolver {
paddingBuffer: 0.1,
},
]
}),
definePipelineStep(
"netLabelPlacementSolver",
NetLabelPlacementSolver,
(instance) => {
const traces =
instance.traceCleanupSolver?.getOutput().traces ??
instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces
}),
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

return [
{
Expand Down
Loading