Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9da5d60
Create SameNetTraceMergerSolver.ts
kuaaq May 25, 2026
43b2f32
Update SameNetTraceMergerSolver.ts
kuaaq May 25, 2026
95860ba
Update SchematicTracePipelineSolver.ts
kuaaq May 25, 2026
1a04c08
Create same-net-trace-merger.test.ts
kuaaq May 25, 2026
64489ba
feat: add visualize() and getOutput() to SameNetTraceMergerSolver
kuaaq May 25, 2026
695f98e
test: add snapshot test and getOutput() usage for SameNetTraceMergerS…
kuaaq May 25, 2026
891aef9
test: add SameNetTraceMerger snapshot test input fixture
kuaaq May 25, 2026
fda5f62
fix: expand inputProblem to multiline to pass Biome format check
kuaaq May 25, 2026
153f7fe
fix: push properly formatted test file with newlines
kuaaq May 25, 2026
77784a9
fix: push solver with proper newlines (Biome format fix)
kuaaq May 25, 2026
1e13c34
style: apply biome format fixes
kuaaq May 25, 2026
551ed6f
fix: collapse single-element netConnections array to pass Biome forma…
kuaaq May 25, 2026
eee5373
feat: export SameNetTraceMergerSolver from index
kuaaq May 25, 2026
9e44bb8
style: apply biome format to test input JSON
kuaaq May 25, 2026
5cfea24
style: apply biome format to test file
kuaaq May 25, 2026
976f44d
fix: collapse all short arrays to one line for Biome JSON format
kuaaq May 25, 2026
ee32af4
style: biome format - inline short arrays in test input JSON
kuaaq May 25, 2026
d4c1e8c
style: biome format - inline single-element netConnections array
kuaaq May 25, 2026
96bde53
fix: pretty-print JSON fixture for Biome format check
kuaaq May 25, 2026
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
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver"
export * from "./types/InputProblem"
export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2"
export * from "./solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver"
132 changes: 132 additions & 0 deletions lib/solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { InputProblem } from "lib/types/InputProblem"
import type { GraphicsObject, Line } from "graphics-debug"
import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver"
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem"

interface SameNetTraceMergerSolverInput {
inputProblem: InputProblem
traces: SolvedTracePath[]
threshold?: number
}

/**
* SameNetTraceMergerSolver snaps nearly-collinear same-net trace segments
* to the same coordinate, eliminating slight offsets introduced by routing.
* Horizontal segments close in Y and vertical segments close in X are merged.
* Runs after traceCleanupSolver in the pipeline.
*/
export class SameNetTraceMergerSolver extends BaseSolver {
private input: SameNetTraceMergerSolverInput
outputTraces: SolvedTracePath[]
readonly threshold: number

constructor(input: SameNetTraceMergerSolverInput) {
super()
this.input = input
this.threshold = input.threshold ?? 0.15
this.outputTraces = input.traces.map((t) => ({
...t,
tracePath: t.tracePath.map((p) => ({ x: p.x, y: p.y })),
}))
}

override _step() {
this._mergeCollinearSegments()
this.solved = true
}

getOutput() {
return {
traces: this.outputTraces,
}
}

private _mergeCollinearSegments() {
const netGroups = new Map<string, number[]>()
for (let i = 0; i < this.outputTraces.length; i++) {
const netId = this.outputTraces[i]!.globalConnNetId
if (!netGroups.has(netId)) netGroups.set(netId, [])
netGroups.get(netId)!.push(i)
}
for (const traceIndices of netGroups.values()) {
if (traceIndices.length < 2) continue
for (let a = 0; a < traceIndices.length; a++) {
for (let b = a + 1; b < traceIndices.length; b++) {
this._trySnapTraces(traceIndices[a]!, traceIndices[b]!)
}
}
}
}

/**
* Compare every segment of trace A against every segment of trace B.
* Snap collinear overlapping segments to their average coordinate.
*/
private _trySnapTraces(idxA: number, idxB: number) {
const pathA = this.outputTraces[idxA]!.tracePath
const pathB = this.outputTraces[idxB]!.tracePath
for (let i = 0; i < pathA.length - 1; i++) {
const a1 = pathA[i]!
const a2 = pathA[i + 1]!
const isHorizA = Math.abs(a1.y - a2.y) < 1e-6
const isVertA = Math.abs(a1.x - a2.x) < 1e-6
if (!isHorizA && !isVertA) continue
for (let j = 0; j < pathB.length - 1; j++) {
const b1 = pathB[j]!
const b2 = pathB[j + 1]!
if (isHorizA && Math.abs(b1.y - b2.y) < 1e-6) {
const dy = Math.abs(a1.y - b1.y)
if (dy > 1e-6 && dy < this.threshold) {
const xMinA = Math.min(a1.x, a2.x)
const xMaxA = Math.max(a1.x, a2.x)
const xMinB = Math.min(b1.x, b2.x)
const xMaxB = Math.max(b1.x, b2.x)
if (xMinA < xMaxB && xMinB < xMaxA) {
const avgY = (a1.y + b1.y) / 2
a1.y = avgY
a2.y = avgY
b1.y = avgY
b2.y = avgY
}
}
} else if (isVertA && Math.abs(b1.x - b2.x) < 1e-6) {
const dx = Math.abs(a1.x - b1.x)
if (dx > 1e-6 && dx < this.threshold) {
const yMinA = Math.min(a1.y, a2.y)
const yMaxA = Math.max(a1.y, a2.y)
const yMinB = Math.min(b1.y, b2.y)
const yMaxB = Math.max(b1.y, b2.y)
if (yMinA < yMaxB && yMinB < yMaxA) {
const avgX = (a1.x + b1.x) / 2
a1.x = avgX
a2.x = avgX
b1.x = avgX
b2.x = avgX
}
}
}
}
}
}

override visualize(): GraphicsObject {
const graphics = visualizeInputProblem(this.input.inputProblem, {
chipAlpha: 0.1,
connectionAlpha: 0.1,
})
if (!graphics.lines) graphics.lines = []
if (!graphics.points) graphics.points = []
if (!graphics.rects) graphics.rects = []
if (!graphics.circles) graphics.circles = []
if (!graphics.texts) graphics.texts = []
for (const trace of this.outputTraces) {
const line: Line = {
points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })),
strokeColor: "blue",
}
graphics.lines!.push(line)
}
return graphics
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/
import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver"
import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver"
import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver"
import { SameNetTraceMergerSolver } from "../SameNetTraceMergerSolver/SameNetTraceMergerSolver"

type PipelineStep<T extends new (...args: any[]) => BaseSolver> = {
solverName: string
Expand Down Expand Up @@ -75,6 +76,7 @@ export class SchematicTracePipelineSolver extends BaseSolver {
labelMergingSolver?: MergedNetLabelObstacleSolver
traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver
traceCleanupSolver?: TraceCleanupSolver
sameNetTraceMergerSolver?: SameNetTraceMergerSolver
example28Solver?: Example28Solver
availableNetOrientationSolver?: AvailableNetOrientationSolver
vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver
Expand Down Expand Up @@ -217,11 +219,22 @@ export class SchematicTracePipelineSolver extends BaseSolver {
},
]
}),
definePipelineStep(
"sameNetTraceMergerSolver",
SameNetTraceMergerSolver,
(instance) => {
const traces =
instance.traceCleanupSolver?.getOutput().traces ??
instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces
return [{ inputProblem: instance.inputProblem, traces }]
},
),
definePipelineStep(
"netLabelPlacementSolver",
NetLabelPlacementSolver,
(instance) => {
const traces =
instance.sameNetTraceMergerSolver?.getOutput().traces ??
instance.traceCleanupSolver?.getOutput().traces ??
instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces

Expand All @@ -237,6 +250,7 @@ export class SchematicTracePipelineSolver extends BaseSolver {
),
definePipelineStep("example28Solver", Example28Solver, (instance) => {
const traces =
instance.sameNetTraceMergerSolver?.getOutput().traces ??
instance.traceCleanupSolver?.getOutput().traces ??
instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces

Expand Down Expand Up @@ -411,7 +425,6 @@ export class SchematicTracePipelineSolver extends BaseSolver {
return visualizations[0]!
}

// Simple combination of visualizations
const finalGraphics = {
points: visualizations.flatMap((v) => v.points || []),
rects: visualizations.flatMap((v) => v.rects || []),
Expand Down
79 changes: 79 additions & 0 deletions tests/assets/SameNetTraceMerger.test.input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"inputProblem": {
"chips": [
{
"chipId": "U1",
"center": { "x": 0, "y": 0 },
"width": 1.6,
"height": 1.0,
"pins": [
{ "pinId": "U1.1", "x": -0.8, "y": 0.3 },
{ "pinId": "U1.2", "x": -0.8, "y": 0.0 },
{ "pinId": "U1.3", "x": -0.8, "y": -0.3 }
]
},
{
"chipId": "C1",
"center": { "x": -2.5, "y": 0.3 },
"width": 0.5,
"height": 0.5,
"pins": [
{ "pinId": "C1.1", "x": -2.5, "y": 0.55 },
{ "pinId": "C1.2", "x": -2.5, "y": 0.05 }
]
},
{
"chipId": "C2",
"center": { "x": -2.5, "y": -0.3 },
"width": 0.5,
"height": 0.5,
"pins": [
{ "pinId": "C2.1", "x": -2.5, "y": -0.05 },
{ "pinId": "C2.2", "x": -2.5, "y": -0.55 }
]
}
],
"directConnections": [
{ "pinIds": ["U1.1", "C1.1"], "netId": "VCC" },
{ "pinIds": ["U1.2", "C2.1"], "netId": "EN" }
],
"netConnections": [{ "netId": "GND", "pinIds": ["U1.3", "C1.2", "C2.2"] }],
"availableNetLabelOrientations": {
"VCC": ["y+"],
"EN": ["x-"],
"GND": ["y-"]
}
},
"traces": [
{
"mspPairId": "U1.3-C1.2",
"dcConnNetId": "GND",
"globalConnNetId": "GND",
"userNetId": "GND",
"pins": [],
"mspConnectionPairIds": [],
"pinIds": ["U1.3", "C1.2"],
"tracePath": [
{ "x": -0.8, "y": -0.3 },
{ "x": -1.8, "y": -0.3 },
{ "x": -1.8, "y": 0.05 },
{ "x": -2.5, "y": 0.05 }
]
},
{
"mspPairId": "U1.3-C2.2",
"dcConnNetId": "GND",
"globalConnNetId": "GND",
"userNetId": "GND",
"pins": [],
"mspConnectionPairIds": [],
"pinIds": ["U1.3", "C2.2"],
"tracePath": [
{ "x": -0.8, "y": -0.3 },
{ "x": -1.82, "y": -0.3 },
{ "x": -1.82, "y": -0.55 },
{ "x": -2.5, "y": -0.55 }
]
}
]
}
116 changes: 116 additions & 0 deletions tests/same-net-trace-merger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { expect, test } from "bun:test"
import { SameNetTraceMergerSolver } from "lib/solvers/SameNetTraceMergerSolver/SameNetTraceMergerSolver"
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
import inputData from "./assets/SameNetTraceMerger.test.input.json"

const emptyProblem = {
chips: [],
directConnections: [],
netConnections: [],
availableNetLabelOrientations: {},
}

// Helper to build a minimal SolvedTracePath
const makeTrace = (
mspPairId: string,
globalConnNetId: string,
points: Array<{ x: number; y: number }>,
): SolvedTracePath =>
({
mspPairId,
globalConnNetId,
dcConnNetId: globalConnNetId,
pins: [] as any,
mspConnectionPairIds: [],
pinIds: [],
tracePath: points,
}) as SolvedTracePath

test("snaps two nearly-horizontal same-net segments to same Y", () => {
const traces = [
makeTrace("t1", "net.GND", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]),
makeTrace("t2", "net.GND", [
{ x: 0, y: 0.1 },
{ x: 1, y: 0.1 },
]),
]
const solver = new SameNetTraceMergerSolver({
inputProblem: emptyProblem,
traces,
})
solver.solve()
const out = solver.getOutput().traces
expect(out[0]!.tracePath[0]!.y).toBe(out[1]!.tracePath[0]!.y)
})

test("does NOT snap segments from different nets", () => {
const traces = [
makeTrace("t1", "net.VCC", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]),
makeTrace("t2", "net.GND", [
{ x: 0, y: 0.1 },
{ x: 1, y: 0.1 },
]),
]
const solver = new SameNetTraceMergerSolver({
inputProblem: emptyProblem,
traces,
})
solver.solve()
const out = solver.getOutput().traces
expect(out[0]!.tracePath[0]!.y).toBe(0)
expect(out[1]!.tracePath[0]!.y).toBe(0.1)
})

test("does NOT snap segments farther apart than threshold", () => {
const traces = [
makeTrace("t1", "net.GND", [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]),
makeTrace("t2", "net.GND", [
{ x: 0, y: 0.5 },
{ x: 1, y: 0.5 },
]),
]
const solver = new SameNetTraceMergerSolver({
inputProblem: emptyProblem,
traces,
threshold: 0.15,
})
solver.solve()
const out = solver.getOutput().traces
expect(out[0]!.tracePath[0]!.y).toBe(0)
expect(out[1]!.tracePath[0]!.y).toBe(0.5)
})

test("snaps two nearly-vertical same-net segments to same X", () => {
const traces = [
makeTrace("t1", "net.GND", [
{ x: 0, y: 0 },
{ x: 0, y: 1 },
]),
makeTrace("t2", "net.GND", [
{ x: 0.08, y: 0 },
{ x: 0.08, y: 1 },
]),
]
const solver = new SameNetTraceMergerSolver({
inputProblem: emptyProblem,
traces,
})
solver.solve()
const out = solver.getOutput().traces
expect(out[0]!.tracePath[0]!.x).toBe(out[1]!.tracePath[0]!.x)
})

test("SameNetTraceMergerSolver snapshot", async () => {
const solver = new SameNetTraceMergerSolver(inputData as any)
solver.solve()
await expect(solver).toMatchSolverSnapshot(import.meta.path)
})
Loading