diff --git a/tests/fixtures/labelPlacementDefects.ts b/tests/fixtures/labelPlacementDefects.ts new file mode 100644 index 000000000..f3c7fa5f2 --- /dev/null +++ b/tests/fixtures/labelPlacementDefects.ts @@ -0,0 +1,108 @@ +import type { NetLabelPlacement } from "lib/solvers/NetLabelPlacementSolver/NetLabelPlacementSolver" +import type { InputProblem } from "lib/types/InputProblem" + +const EPS = 1e-9 + +type Bounds = { minX: number; maxX: number; minY: number; maxY: number } + +const boundsOf = ( + center: { x: number; y: number }, + width: number, + height: number, +): Bounds => ({ + minX: center.x - width / 2, + maxX: center.x + width / 2, + minY: center.y - height / 2, + maxY: center.y + height / 2, +}) + +/** Interior overlap: touching edges are not an overlap. */ +const overlaps = (a: Bounds, b: Bounds) => + a.minX < b.maxX - EPS && + a.maxX > b.minX + EPS && + a.minY < b.maxY - EPS && + a.maxY > b.minY + EPS + +/** + * Fraction of `inner`'s area covered by `outer`. + */ +const coverageFraction = (inner: Bounds, outer: Bounds) => { + const overlapWidth = Math.max( + 0, + Math.min(inner.maxX, outer.maxX) - Math.max(inner.minX, outer.minX), + ) + const overlapHeight = Math.max( + 0, + Math.min(inner.maxY, outer.maxY) - Math.max(inner.minY, outer.minY), + ) + const area = (inner.maxX - inner.minX) * (inner.maxY - inner.minY) + + return area > 0 ? (overlapWidth * overlapHeight) / area : 0 +} + +/** + * Mirrors `CHIP_COLLISION_MIN_OVERLAP_FRACTION` in + * `NetLabelNetLabelCollisionSolver`: a label counts as "inside" a chip only + * once the chip covers at least half of it. Smaller incidental overlaps are + * tolerated there deliberately, so counting them would report defects the + * solver is intentionally leaving alone. + */ +const INSIDE_CHIP_MIN_COVERAGE = 0.5 + +/** + * Net labels substantially covered by a chip body. These render behind the + * component and are illegible — see #655. + */ +export const getLabelsInsideChips = ( + labels: NetLabelPlacement[], + inputProblem: InputProblem, +) => { + const offenders: Array<{ netId: string; chipId: string }> = [] + + for (const label of labels) { + const labelBounds = boundsOf(label.center, label.width, label.height) + + for (const chip of inputProblem.chips) { + const chipBounds = boundsOf(chip.center, chip.width, chip.height) + if ( + !overlaps(labelBounds, chipBounds) || + coverageFraction(labelBounds, chipBounds) < INSIDE_CHIP_MIN_COVERAGE + ) { + continue + } + offenders.push({ + netId: label.netId ?? label.globalConnNetId, + chipId: chip.chipId, + }) + break + } + } + + return offenders +} + +/** Pairs of net labels whose boxes overlap each other. */ +export const getOverlappingLabelPairs = (labels: NetLabelPlacement[]) => { + const pairs: Array<{ a: string; b: string }> = [] + + for (let i = 0; i < labels.length; i++) { + for (let k = i + 1; k < labels.length; k++) { + const a = labels[i]! + const b = labels[k]! + if ( + !overlaps( + boundsOf(a.center, a.width, a.height), + boundsOf(b.center, b.width, b.height), + ) + ) { + continue + } + pairs.push({ + a: a.netId ?? a.globalConnNetId, + b: b.netId ?? b.globalConnNetId, + }) + } + } + + return pairs +} diff --git a/tests/net-label-placement-defects.test.ts b/tests/net-label-placement-defects.test.ts new file mode 100644 index 000000000..47d129180 --- /dev/null +++ b/tests/net-label-placement-defects.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import { + getLabelsInsideChips, + getOverlappingLabelPairs, +} from "tests/fixtures/labelPlacementDefects" + +/** + * Net-label placement defects per board: labels rendered inside a chip body + * (illegible — see #655) and labels overlapping each other. + * + * These are current values, not targets. Several are open bugs. Pinning them + * means a placement change that makes a board worse fails a named assertion + * instead of disappearing into a regenerated SVG snapshot. + * + * If a change legitimately improves a board, lower the number in the same PR. + */ +const EXPECTED: Record< + string, + { insideChips: number; overlappingPairs: number } +> = { + "bug-report-20260706T213649Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260706T220324Z": { insideChips: 0, overlappingPairs: 1 }, + "bug-report-20260707T020342Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260707T092615Z": { insideChips: 1, overlappingPairs: 1 }, + "bug-report-20260707T134549Z": { insideChips: 0, overlappingPairs: 1 }, + "bug-report-20260707T134722Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260707T140410Z": { insideChips: 0, overlappingPairs: 1 }, + "bug-report-20260707T141025Z": { insideChips: 1, overlappingPairs: 0 }, + "bug-report-20260707T141421Z": { insideChips: 1, overlappingPairs: 1 }, + "bug-report-20260707T230831Z": { insideChips: 4, overlappingPairs: 0 }, + "bug-report-20260708T053736Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260708T055430Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260708T095725Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260716T144856Z": { insideChips: 1, overlappingPairs: 0 }, + "bug-report-20260717T022934Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260717T031704Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260717T042845Z": { insideChips: 0, overlappingPairs: 0 }, + "bug-report-20260721T221026Z": { insideChips: 0, overlappingPairs: 2 }, + "bug-report-20260724T175257Z": { insideChips: 0, overlappingPairs: 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() + + // The *output* copy — `netLabelPlacements` on the solver is its input. + const labels = + solver.netLabelNetLabelCollisionSolver?.getOutput().netLabelPlacements ?? [] + + return { + insideChips: getLabelsInsideChips(labels, solver.inputProblem).length, + overlappingPairs: getOverlappingLabelPairs(labels).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() + + expect(onDisk).toEqual(Object.keys(EXPECTED).sort()) +}) + +for (const [board, expected] of Object.entries(EXPECTED)) { + test(`${board} label placement defects`, () => { + expect(measure(board)).toEqual(expected) + }) +}