diff --git a/tests/different-net-trace-crossings.test.ts b/tests/different-net-trace-crossings.test.ts new file mode 100644 index 000000000..96bf0afb4 --- /dev/null +++ b/tests/different-net-trace-crossings.test.ts @@ -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 = { + "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) + }) +} diff --git a/tests/fixtures/traceCrossings.ts b/tests/fixtures/traceCrossings.ts new file mode 100644 index 000000000..b1d64761a --- /dev/null +++ b/tests/fixtures/traceCrossings.ts @@ -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 +}