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
94 changes: 94 additions & 0 deletions tests/different-net-trace-crossings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expect, test } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver"
import {
getTraceCrossings,
getTraceOverlaps,
} from "tests/fixtures/traceCrossings"

/**
* Different-net trace crossings and collinear overlaps, measured across every
* imported bug report.
*
* These are the current values, not a target — several of these boards are
* open bugs. The point is that the numbers are pinned: a routing or cleanup
* change that makes any board worse fails here instead of sliding into a
* regenerated SVG snapshot where a crossing is easy to miss.
*
* Both numbers are pinned because either one alone is gameable. Two nets that
* cross can always be "fixed" by making them run along each other instead, and
* an overlap can always be "fixed" by introducing a crossing. Neither is
* strictly better, so a change that trades one for the other should be visible
* and deliberate rather than scored as an improvement by a one-sided count.
*
* If a change legitimately improves a board, lower the number in the same PR.
*/
const EXPECTED: Record<string, { crossings: number; overlaps: number }> = {
"bug-report-20260706T213649Z": { crossings: 11, overlaps: 0 },
"bug-report-20260706T220324Z": { crossings: 6, overlaps: 0 },
"bug-report-20260721T221026Z": { crossings: 5, overlaps: 0 },
"bug-report-20260716T144856Z": { crossings: 4, overlaps: 0 },
"bug-report-20260717T022934Z": { crossings: 3, overlaps: 0 },
"bug-report-20260707T134549Z": { crossings: 3, overlaps: 0 },
"bug-report-20260708T055430Z": { crossings: 2, overlaps: 0 },
// #727 moved this board from 2 crossings / 1 overlap to 3 crossings / 0
// overlaps. Both numbers involve the same pair of segments
// (`C_SENSE.2-C1.2` and `available-net-orientation-13-BAT_POS`), so that
// change separated two nets that had been drawn on top of each other over a
// 0.141-length span and left them crossing at a point instead. That is a
// deliberate trade in the right direction, not a regression — which is only
// visible because both quantities are pinned here.
"bug-report-20260707T092615Z": { crossings: 3, overlaps: 0 },
"bug-report-20260707T230831Z": { crossings: 2, overlaps: 0 },
"bug-report-20260707T140410Z": { crossings: 1, overlaps: 0 },
"bug-report-20260707T020342Z": { crossings: 1, overlaps: 0 },
"bug-report-20260717T031704Z": { crossings: 0, overlaps: 0 },
"bug-report-20260708T053736Z": { crossings: 0, overlaps: 0 },
"bug-report-20260707T134722Z": { crossings: 0, overlaps: 0 },
"bug-report-20260707T141025Z": { crossings: 0, overlaps: 0 },
"bug-report-20260708T095725Z": { crossings: 0, overlaps: 0 },
"bug-report-20260707T141421Z": { crossings: 0, overlaps: 0 },
"bug-report-20260717T042845Z": { crossings: 0, overlaps: 0 },
"bug-report-20260724T175257Z": { crossings: 0, overlaps: 0 },
}

const BUG_REPORTS_DIR = path.join(import.meta.dir, "bug-reports")

const measure = (board: string) => {
const inputPath = path.join(BUG_REPORTS_DIR, board, `${board}.json`)
const inputProblem = JSON.parse(fs.readFileSync(inputPath, "utf8"))

const solver = new SchematicTracePipelineSolver(inputProblem as any)
solver.solve()

// `netLabelNetLabelCollisionSolver` is the last pipeline step but only
// adjusts labels, so the final trace geometry comes from the cleanup pass
// feeding it.
const traces =
solver.traceCleanupSolver2?.getOutput().traces ??
solver.traceLabelOverlapAvoidanceSolver?.getOutput().traces ??
[]

return {
crossings: getTraceCrossings(traces).length,
overlaps: getTraceOverlaps(traces).length,
}
}

test("every pinned board still exists", () => {
const onDisk = fs
.readdirSync(BUG_REPORTS_DIR)
.filter((d) => fs.existsSync(path.join(BUG_REPORTS_DIR, d, `${d}.json`)))
.sort()

// A newly imported bug report should be added above rather than silently
// going unmeasured.
expect(onDisk).toEqual(Object.keys(EXPECTED).sort())
})

for (const [board, expected] of Object.entries(EXPECTED)) {
test(`${board} has ${expected.crossings} different-net trace crossings and ${expected.overlaps} overlaps`, () => {
expect(measure(board)).toEqual(expected)
})
}
194 changes: 194 additions & 0 deletions tests/fixtures/traceCrossings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"

const EPS = 1e-9

const isHorizontal = (
a: { x: number; y: number },
b: { x: number; y: number },
) => Math.abs(a.y - b.y) < EPS

const isVertical = (a: { x: number; y: number }, b: { x: number; y: number }) =>
Math.abs(a.x - b.x) < EPS

type Segment = {
a: { x: number; y: number }
b: { x: number; y: number }
traceId: string
netId: string
segmentIndex: number
}

/**
* True when a horizontal and a vertical segment properly cross — they share an
* interior point. Touching at an endpoint is excluded, since traces on the same
* net legitimately meet there.
*/
const properlyCrosses = (h: Segment, v: Segment) => {
const y = h.a.y
const x = v.a.x

const withinH =
x > Math.min(h.a.x, h.b.x) + EPS && x < Math.max(h.a.x, h.b.x) - EPS
const withinV =
y > Math.min(v.a.y, v.b.y) + EPS && y < Math.max(v.a.y, v.b.y) - EPS

return withinH && withinV
}

/**
* Counts places where traces belonging to *different* nets cross each other.
*
* A schematic can't always avoid these, but each one is a readability cost, so
* this is useful as a regression guard: a change to the routing or cleanup
* solvers should not silently increase the count on a known board.
*/
export const getTraceCrossings = (traces: SolvedTracePath[]) => {
const segments: Segment[] = []

for (const trace of traces) {
for (let i = 0; i < trace.tracePath.length - 1; i++) {
segments.push({
a: trace.tracePath[i]!,
b: trace.tracePath[i + 1]!,
traceId: trace.mspPairId,
netId: trace.globalConnNetId,
segmentIndex: i,
})
}
}

const crossings: Array<{
aTraceId: string
aNetId: string
aSegmentIndex: number
bTraceId: string
bNetId: string
bSegmentIndex: number
at: { x: number; y: number }
}> = []

for (let i = 0; i < segments.length; i++) {
for (let k = i + 1; k < segments.length; k++) {
const s1 = segments[i]!
const s2 = segments[k]!
if (s1.netId === s2.netId) continue

let h: Segment | null = null
let v: Segment | null = null
if (isHorizontal(s1.a, s1.b) && isVertical(s2.a, s2.b)) {
h = s1
v = s2
} else if (isVertical(s1.a, s1.b) && isHorizontal(s2.a, s2.b)) {
h = s2
v = s1
}
if (!h || !v || !properlyCrosses(h, v)) continue

crossings.push({
aTraceId: s1.traceId,
aNetId: s1.netId,
aSegmentIndex: s1.segmentIndex,
bTraceId: s2.traceId,
bNetId: s2.netId,
bSegmentIndex: s2.segmentIndex,
at: { x: v.a.x, y: h.a.y },
})
}
}

return crossings
}

/**
* Counts places where segments of *different* nets run along each other and
* share more than a single point — a collinear overlap.
*
* This is the companion measurement to {@link getTraceCrossings}, and the two
* are needed together. A solver can always drive the crossing count down by
* letting two nets lie on top of one another instead, which reads as one line
* and hides a connection the schematic is supposed to show. Counting only
* crossings would score that as an improvement.
*/
export const getTraceOverlaps = (traces: SolvedTracePath[]) => {
const segments: Segment[] = []

for (const trace of traces) {
for (let i = 0; i < trace.tracePath.length - 1; i++) {
segments.push({
a: trace.tracePath[i]!,
b: trace.tracePath[i + 1]!,
traceId: trace.mspPairId,
netId: trace.globalConnNetId,
segmentIndex: i,
})
}
}

const overlaps: Array<{
aTraceId: string
aNetId: string
aSegmentIndex: number
bTraceId: string
bNetId: string
bSegmentIndex: number
axis: "horizontal" | "vertical"
length: number
}> = []

for (let i = 0; i < segments.length; i++) {
for (let k = i + 1; k < segments.length; k++) {
const s1 = segments[i]!
const s2 = segments[k]!
if (s1.netId === s2.netId) continue

let axis: "horizontal" | "vertical" | null = null
let length = 0

if (
isHorizontal(s1.a, s1.b) &&
isHorizontal(s2.a, s2.b) &&
Math.abs(s1.a.y - s2.a.y) < EPS
) {
const low = Math.max(Math.min(s1.a.x, s1.b.x), Math.min(s2.a.x, s2.b.x))
const high = Math.min(
Math.max(s1.a.x, s1.b.x),
Math.max(s2.a.x, s2.b.x),
)
// A shared endpoint is a touch, not an overlap.
if (high - low > EPS) {
axis = "horizontal"
length = high - low
}
} else if (
isVertical(s1.a, s1.b) &&
isVertical(s2.a, s2.b) &&
Math.abs(s1.a.x - s2.a.x) < EPS
) {
const low = Math.max(Math.min(s1.a.y, s1.b.y), Math.min(s2.a.y, s2.b.y))
const high = Math.min(
Math.max(s1.a.y, s1.b.y),
Math.max(s2.a.y, s2.b.y),
)
if (high - low > EPS) {
axis = "vertical"
length = high - low
}
}

if (!axis) continue

overlaps.push({
aTraceId: s1.traceId,
aNetId: s1.netId,
aSegmentIndex: s1.segmentIndex,
bTraceId: s2.traceId,
bNetId: s2.netId,
bSegmentIndex: s2.segmentIndex,
axis,
length,
})
}
}

return overlaps
}
Loading