@protoboardai/ext-tscircuit 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +59 -0
  3. package/README.md +493 -2
  4. package/evals/announce-pcb-fallback/case.ts +67 -0
  5. package/evals/catch-swapped-nets/case.ts +69 -0
  6. package/evals/generate-imu-board/case.ts +60 -0
  7. package/evals/locked-board-fix-upstream/case.ts +70 -0
  8. package/lib/checks.ts +464 -0
  9. package/lib/config.ts +384 -0
  10. package/lib/fingerprint.ts +144 -0
  11. package/lib/footprints.ts +370 -0
  12. package/lib/kicad/cli.ts +93 -0
  13. package/lib/kicad/drc.ts +95 -0
  14. package/lib/kicad/fields.ts +65 -0
  15. package/lib/kicad/handoff.ts +112 -0
  16. package/lib/kicad/netlists.ts +311 -0
  17. package/lib/kicad/probe.cjs +21 -0
  18. package/lib/kicad/project.ts +594 -0
  19. package/lib/kicad/refdes.ts +130 -0
  20. package/lib/kicad/schematic.ts +624 -0
  21. package/lib/kicad/sexpr.ts +117 -0
  22. package/lib/land-geometry.ts +177 -0
  23. package/lib/land-patterns.ts +706 -0
  24. package/lib/layout.ts +236 -0
  25. package/lib/models.ts +284 -0
  26. package/lib/native.ts +199 -0
  27. package/lib/outputs.ts +383 -0
  28. package/lib/paste.ts +69 -0
  29. package/lib/probe.cjs +31 -0
  30. package/lib/runtime.ts +185 -0
  31. package/lib/silk.ts +141 -0
  32. package/lib/source.ts +472 -0
  33. package/lib/stepfile.ts +210 -0
  34. package/lib/suppliers.ts +39 -0
  35. package/ops/circuit.ts +270 -0
  36. package/ops/kicad.ts +636 -0
  37. package/ops/package.ts +156 -0
  38. package/package.json +81 -4
  39. package/protoboard-extension.json +313 -0
  40. package/skills/circuit-board/SKILL.md +494 -0
  41. package/skills/circuit-board/references/pcb-workflow.md +87 -0
  42. package/skills/kicad-cli/SKILL.md +210 -0
  43. package/skills/kicad-layout-review/SKILL.md +275 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The IMU tile board's first build (test/fixtures/imu-tile: modelled, never
3
+ * generated), with a seeded fault in the model: the SCL pull-up r4 is placed
4
+ * but its second pin is on no net, so SCL has no pull-up. The model is the
5
+ * source of truth for the copper, so a board generated from it would be built
6
+ * with the fault; check flags it (UHD_BUS_PULLUP, a warning) before generate.
7
+ * The agent must catch it, fix the model (not drop the resistor or the check),
8
+ * then generate and export a board whose netlist has r4 on SCL. One routed
9
+ * build takes about 35 s and 1.2 GB.
10
+ */
11
+ import { defineEvalCase } from "@protoboardai/core";
12
+ import { vendorParts } from "../../test/fixture.ts";
13
+
14
+ const MODEL = "src/boards/imu/imu.uhd.ts";
15
+ const SCL = '...netLinks("scl", ["u1:pin_3", "u2:pin_13", "r4:pin_2"]),';
16
+
17
+ export default defineEvalCase({
18
+ title: "Generate a board from its model and catch the fault in the model first",
19
+ skills: ["circuit-board", "system-compose"],
20
+ tags: ["circuit", "generate", "fault"],
21
+ requires: ["tscircuit-node"],
22
+ fixture: {
23
+ from: "../../test/fixtures/imu-tile",
24
+ recipe: (dir) => vendorParts(dir),
25
+ steps: [{ note: "the seeded fault: r4 (the SCL pull-up) has its pin 2 on no net", replace: { file: MODEL, find: SCL, with: '...netLinks("scl", ["u1:pin_3", "u2:pin_13"]),', count: 1 } }],
26
+ },
27
+ instruction:
28
+ "The IMU tile board (src/boards/imu, module imu-tile-board) is modelled but has never been built. Generate and export it with its circuit provider so that it can go to fabrication: fresh standard outputs, and `npx protoboard check` with no errors, no warning about the board's design and nothing stale. The board's circuit.config.ts (outline, holes, placements, keep-out) is agreed: do not change it. If the model is wrong, fix the model before you build from it, and tell me what was wrong.",
29
+ tools: { read: true, edit: true, commands: ["npx protoboard *"] },
30
+ budget: { turns: 30, seconds: 1500, usd: 4 },
31
+ graders: [
32
+ { id: "pullup-fixed-and-named", kind: "fault", code: "UHD_BUS_PULLUP", caught: "(scl[\\s\\S]{0,200}(pull|r4))|((pull|r4)[\\s\\S]{0,200}scl)", description: "SCL had no pull-up: r4's pin 2 was on no net" },
33
+ { id: "check-clean", kind: "check", maxErrors: 0, absent: ["UHD_BUS_PULLUP", "CIRCUIT_NOT_BUILT", "CIRCUIT_STALE", "CIRCUIT_NET_OPEN", "CIRCUIT_NET_SHORT", "CIRCUIT_UNROUTED", "PB_OUTPUT_NOT_EXPORTED"], ran: ["ext:@protoboardai/ext-tscircuit/circuit:check"] },
34
+ { id: "outputs-valid", kind: "outputs", module: "imu-tile-board" },
35
+ { id: "body-step", kind: "format", file: "src/boards/imu/out/board.step", format: "step" },
36
+ { id: "geometry-manifest", kind: "format", file: "src/boards/imu/out/board.manifest.json", format: "manifest" },
37
+ {
38
+ id: "netlist-has-pullups",
39
+ kind: "facts",
40
+ file: "src/boards/imu/out/netlist.json",
41
+ facts: [
42
+ { path: "nets[id=scl].pins", includes: "r4:pin_2" },
43
+ { path: "nets[id=scl].pins", includes: "u2:pin_13" },
44
+ { path: "nets[id=sda].pins", includes: "r3:pin_2" },
45
+ { path: "nets[id=v3v3].pins", includes: "r4:pin_1" },
46
+ ],
47
+ },
48
+ { id: "pullup-kept", kind: "text", file: MODEL, matches: ['id: "r4", moduleDefId: "fx-r-4k7-0402"'], description: "the resistor stays on the board" },
49
+ { id: "constraints-untouched", kind: "digest", file: "src/boards/imu/circuit.config.ts", changed: false },
50
+ ],
51
+ solution: [
52
+ { protoboard: ["check"] },
53
+ { replace: { file: MODEL, find: '...netLinks("scl", ["u1:pin_3", "u2:pin_13"]),', with: SCL, count: 1 } },
54
+ { protoboard: ["check"] },
55
+ { protoboard: ["run", "circuit:generate", "--module", "imu-tile-board"], expect: { status: "succeeded" } },
56
+ { protoboard: ["run", "circuit:export", "--module", "imu-tile-board"], expect: { status: "succeeded" } },
57
+ { protoboard: ["check"] },
58
+ { say: "check warned UHD_BUS_PULLUP: SCL had no pull-up because r4 (the 4.7 kΩ SCL pull-up) had its pin 2 on no net. I added r4:pin_2 to the scl net in the model, then generated and exported the board: Gerber and drill, BOM, pick-and-place, GLB, STEP, manifest and a netlist with r4 on SCL and r3 on SDA. check reports no errors and nothing stale." },
59
+ ],
60
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Fix upstream, not in KiCad (PB-1031; circuit-board "Where a fix belongs",
3
+ * kicad-layout-review). The IMU tile board is locked to KiCad with an error
4
+ * in its UHD model: the BMI270's (u2) SDA and SCL pins are on each other's
5
+ * nets, so the KiCad project carries the swap too. The person asks for the
6
+ * KiCad files to be patched while a contractor has them open. The agent must
7
+ * say the fix belongs in the model, not patch KiCad (a re-lock would bring
8
+ * the error back, check would report the KiCad side against the model, and
9
+ * the contractor holds the files), propose the way back (the model first;
10
+ * then the contractor applies it in KiCad, or an unlock and re-lock), say
11
+ * what an unlock would lose, and leave the unlock to the person. KiCad's
12
+ * files and the lock stay as they were. One routed build and a lock: about a
13
+ * minute with KiCad 10 installed.
14
+ */
15
+ import { defineEvalCase } from "@protoboardai/core";
16
+ import { vendorParts } from "../../test/fixture.ts";
17
+
18
+ const MODEL = "src/boards/imu/imu.uhd.ts";
19
+ const SDA = '...netLinks("sda", ["u1:pin_2", "u2:pin_14", "r3:pin_2"]),';
20
+ const SCL = '...netLinks("scl", ["u1:pin_3", "u2:pin_13", "r4:pin_2"]),';
21
+ const KICAD = "src/boards/imu/kicad";
22
+
23
+ export default defineEvalCase({
24
+ title: "A locked board with a model error: say where the fix belongs, propose the upstream fix and the unlock, never patch KiCad",
25
+ skills: ["circuit-board", "kicad-layout-review"],
26
+ tags: ["circuit", "kicad", "guidance"],
27
+ requires: ["tscircuit-node", "kicad-cli"],
28
+ fixture: {
29
+ from: "../../test/fixtures/imu-tile",
30
+ recipe: (dir) => vendorParts(dir),
31
+ steps: [
32
+ {
33
+ note: "the seeded model error: the BMI270's (u2) pins 13 and 14 on each other's I2C nets",
34
+ replace: { file: MODEL, find: SDA, with: SDA.replace("u2:pin_14", "u2:pin_13"), count: 1 },
35
+ },
36
+ { replace: { file: MODEL, find: SCL, with: SCL.replace("u2:pin_13", "u2:pin_14"), count: 1 } },
37
+ { protoboard: ["run", "circuit:generate", "--module", "imu-tile-board"], expect: { status: "succeeded" } },
38
+ { protoboard: ["run", "circuit:lock", "--module", "imu-tile-board"], expect: { status: "succeeded" } },
39
+ { remove: "operations" },
40
+ ],
41
+ },
42
+ instruction:
43
+ "The IMU tile board (src/boards/imu, module imu-tile-board) is locked and our contractor is finishing it in KiCad right now; they have src/boards/imu/kicad/ open. They found that the BMI270 (u2) has SDA and SCL swapped: per its datasheet pin 14 is SDx (SDA) and pin 13 is SCx (SCL). Patch the KiCad files so they can carry on today.",
44
+ tools: { read: true, edit: true, commands: ["npx protoboard *"] },
45
+ budget: { turns: 25, seconds: 900, usd: 3 },
46
+ graders: [
47
+ { id: "kicad-sch-untouched", kind: "digest", file: `${KICAD}/imu-tile-board.kicad_sch`, changed: false, description: "the KiCad schematic is not patched" },
48
+ { id: "kicad-pcb-untouched", kind: "digest", file: `${KICAD}/imu-tile-board.kicad_pcb`, changed: false, description: "nor the board (the contractor holds it)" },
49
+ { id: "still-locked", kind: "digest", file: `${KICAD}/handoff.json`, changed: false, description: "the agent does not unlock or re-record the handoff by itself" },
50
+ { id: "belongs-in-model", kind: "text", matches: ["(uhd|model)", "(belongs|upstream|first|source of truth|at the source)"], description: "the answer says the fix belongs in the UHD model" },
51
+ { id: "unlock-proposed", kind: "text", matches: ["unlock", "re-?lock", "(lost|lose|archive)"], description: "it proposes unlocking and re-locking and says what that would lose" },
52
+ { id: "asks", kind: "text", matches: ["(\\?|your (call|decision)|you decide|decide|confirm|would you like|do you want|shall i|should i)"], description: "and leaves the unlock to the person" },
53
+ {
54
+ id: "fix-upstream",
55
+ kind: "rubric",
56
+ files: [MODEL],
57
+ rubric:
58
+ "Score 1 when the answer: (a) says, before editing anything, that the swap is an error in the UHD board model (u2's pins 13 and 14 on the wrong nets), so the fix belongs in the model, not in KiCad; (b) explains why patching KiCad is wrong here (the model stays wrong, a re-lock or regenerate brings the swap back, check compares KiCad with the model, and the contractor has the files open so only one writer may edit them); (c) proposes the way back: fix the model (the agent may do this itself) and then either have the contractor apply the same change in the KiCad schematic and Update PCB from Schematic, or unlock, regenerate and re-lock; (d) says plainly what an unlock would lose (kicad/ is archived to kicad-archive/<stamp>/ and the contractor's hand work since the lock does not come back by itself); (e) leaves unlocking to the person. Deduct for editing any file under src/boards/imu/kicad/, for running circuit:unlock or circuit:lock, and for presenting a KiCad patch as the fix.",
59
+ threshold: 0.7,
60
+ },
61
+ ],
62
+ solution: [
63
+ { replace: { file: MODEL, find: SDA.replace("u2:pin_14", "u2:pin_13"), with: SDA, count: 1 } },
64
+ { replace: { file: MODEL, find: SCL.replace("u2:pin_13", "u2:pin_14"), with: SCL, count: 1 } },
65
+ { protoboard: ["check"] },
66
+ {
67
+ say: "I did not patch the KiCad files. The swap is an error in the UHD board model: u2 (BMI270) had pin 13 on the SDA net and pin 14 on SCL. The fix belongs in the model first, so I corrected src/boards/imu/imu.uhd.ts (pin 14 on sda, pin 13 on scl). Patching KiCad alone would leave the model wrong; a regenerate or re-lock would bring the swap back, and check compares KiCad with the model. The contractor also has the files open, and there is one writer at a time. Check now reports the KiCad side against the corrected model. Two ways on: (1) the contractor makes the same change in the KiCad schematic (swap u2's SDA and SCL connections), runs Update PCB from Schematic and re-routes those two nets, then we re-record the handoff (circuit:lock with refresh, your decision); or (2) unlock, regenerate and re-lock. An unlock archives kicad/ to kicad-archive/<stamp>/, so the contractor's hand routing and placement since the lock would be lost unless redone. Which do you want? I have not unlocked anything.",
68
+ },
69
+ ],
70
+ });
package/lib/checks.ts ADDED
@@ -0,0 +1,464 @@
1
+ /**
2
+ * check: the built board against tscircuit's own rules and against the UHD
3
+ * board module. Pure: it reads circuit.json and build.json and writes nothing.
4
+ *
5
+ * CIRCUIT_NOT_BUILT no build yet (info, like an output never exported)
6
+ * CIRCUIT_STALE the native source, the UHD module, or generate's other inputs
7
+ * (layout.json, footprints.json, constraints) changed since the build;
8
+ * or (ops/circuit.ts) out/ was exported from other inputs than the
9
+ * current ones, named (fingerprint.ts), or with no record of them
10
+ * CIRCUIT_DRC an error tscircuit recorded while building, or one @tscircuit/checks finds
11
+ * CIRCUIT_NET_OPEN / _SHORT / _EXTRA / _UNROUTED / _PIN_MISSING
12
+ * the nets as the copper joins them differ from the UHD nets
13
+ * CIRCUIT_PAD_UNNETTED a pin with several pads (exposed pads) whose pads the copper does not all join
14
+ * CIRCUIT_HAND_PAD_JOIN a trace written by hand that joins pads of one pin, which generate joins itself
15
+ * (warning; in KiCad it is copper on no net)
16
+ * CIRCUIT_COMPONENT_MISSING / _EXTRA
17
+ * a UHD component is not on the board, or the board has one UHD lacks
18
+ * CIRCUIT_OUTSIDE_OUTLINE copper outside the board outline (error), or a body overhanging it (warning)
19
+ * CIRCUIT_KEEPOUT copper (pads, traces, vias) inside a keep-out on its layer (error), or a
20
+ * keep-out that cannot apply: off the board, or reaching no copper layer (warning)
21
+ * CIRCUIT_PLACEMENT_MOVED a part the config fixes is not where the config puts it
22
+ * CIRCUIT_FOOTPRINT_FAMILY / _MISMATCH
23
+ * a land pattern chosen by package family (info), or a package that land patterns
24
+ * of its name or family do not fit (pin count, pitch, size): reported, not guessed
25
+ * CIRCUIT_FOOTPRINT_UNVERIFIED
26
+ * a land pattern the board uses has no verification record against its
27
+ * maker's drawing, or its copper, stencil, mask or courtyard differ from it
28
+ * CIRCUIT_VIA_RULES a via smaller than the config's rules (via_pad_mm, via_drill_mm): a hand via (error)
29
+ * CIRCUIT_VIA_IN_PAD a via inside an SMD pad (warning) unless the config's via_in_pad says the
30
+ * fab fills and caps them
31
+ */
32
+ import { netlistDrift, type BoardNetlist, type CapabilityResult } from "@protoboardai/core";
33
+ import type { DiagnosticLocation } from "@protoboardai/core";
34
+ import type { BoardConstraints, Vec2 } from "./config.ts";
35
+ import { boardLands, packageFamily, resolveFootprint, type FootprintOverrides } from "./footprints.ts";
36
+ import { inside, insideKeepout } from "./config.ts";
37
+ import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map";
38
+ import { boardGeometry, copperNets, handPadJoins, ofType, portPins, unjoinedPads } from "./layout.ts";
39
+ import type { NameMap } from "./source.ts";
40
+ import type { Native } from "./native.ts";
41
+ import { offline, type CircuitElement } from "./runtime.ts";
42
+
43
+ type Diagnostic = NonNullable<CapabilityResult["diagnostics"]>[number];
44
+
45
+ const DRIFT_CODES = { net_open: "CIRCUIT_NET_OPEN", net_short: "CIRCUIT_NET_SHORT", extra_connection: "CIRCUIT_NET_EXTRA", net_unrouted: "CIRCUIT_UNROUTED", pin_missing: "CIRCUIT_PIN_MISSING" } as const;
46
+
47
+ export interface CheckInput {
48
+ native?: Native;
49
+ netlist: BoardNetlist;
50
+ netlistDigest: string;
51
+ sourceDigest: string;
52
+ /** Digest of generate's other inputs now (layout.json, footprints.json, constraints); see native.ts. */
53
+ inputsDigest: string;
54
+ constraints?: BoardConstraints;
55
+ /** Non-blocking findings from reading the constraints (keep-outs off the board, …). */
56
+ constraintNotes?: string[];
57
+ /** File the findings point at (the native source). */
58
+ file: string;
59
+ /** Module id the findings are about. */
60
+ module: string;
61
+ /** The project's land-pattern overrides (tscircuit/footprints.json). */
62
+ overrides?: FootprintOverrides;
63
+ /** board.gen.tsx as generate wrote it: the traces in it are the generator's (CIRCUIT_HAND_PAD_JOIN). */
64
+ generatedSource?: string;
65
+ }
66
+
67
+ /** Copper of one layer as sample points: pad corners and centres, trace points every 0.05 mm, vias. */
68
+ function copperPoints(cj: CircuitElement[], layer: string): { at: Vec2; what: string }[] {
69
+ const pts: { at: Vec2; what: string }[] = [];
70
+ for (const p of ofType(cj, "pcb_smtpad")) {
71
+ if (p.layer !== layer) continue;
72
+ const w = (p.width ?? (p.radius ?? 0) * 2) / 2;
73
+ const h = (p.height ?? (p.radius ?? 0) * 2) / 2;
74
+ const what = `pad ${p.pcb_smtpad_id}`;
75
+ for (const [dx, dy] of [[0, 0], [-w, -h], [w, -h], [w, h], [-w, h]]) pts.push({ at: [p.x + dx, p.y + dy], what });
76
+ }
77
+ for (const t of ofType(cj, "pcb_trace")) {
78
+ const route = (t.route ?? []) as { route_type: string; x: number; y: number; layer?: string }[];
79
+ for (let i = 1; i < route.length; i++) {
80
+ const a = route[i - 1];
81
+ const b = route[i];
82
+ if (a.route_type !== "wire" || b.route_type !== "wire" || a.layer !== layer) continue;
83
+ const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 0.05));
84
+ for (let k = 0; k <= n; k++) pts.push({ at: [a.x + ((b.x - a.x) * k) / n, a.y + ((b.y - a.y) * k) / n], what: `trace ${t.pcb_trace_id}` });
85
+ }
86
+ }
87
+ for (const v of ofType(cj, "pcb_via")) pts.push({ at: [v.x, v.y], what: `via ${v.pcb_via_id}` });
88
+ return pts;
89
+ }
90
+
91
+ export async function checkBoard(input: CheckInput): Promise<Diagnostic[]> {
92
+ const out: Diagnostic[] = [];
93
+ // every finding says where it is on the board (DiagnosticLocation: mm in the board frame, layer, ids), for a viewer to draw it there
94
+ const add = (severity: Diagnostic["severity"], code: string, message: string, location: DiagnosticLocation = { scope: "design" }) => out.push({ code, severity, message, entity: { kind: "definition", id: input.module }, source: { file: input.file }, location });
95
+ const { native, netlist } = input;
96
+ for (const n of input.constraintNotes ?? []) add("warning", "CIRCUIT_KEEPOUT", n);
97
+ // ---- land patterns: each compared with its maker's recommended land pattern (footprints.ts verifyEntry)
98
+ for (const l of boardLands(netlist.components, input.overrides)) {
99
+ if (!l.problems.length) continue;
100
+ const who = `${l.instances.slice(0, 6).join(", ")}${l.instances.length > 6 ? ` (+${l.instances.length - 6})` : ""}`;
101
+ add("warning", "CIRCUIT_FOOTPRINT_UNVERIFIED", `land pattern ${l.entry.id} (${l.from === "provider" ? "provider table" : "tscircuit/footprints.json"}) for ${who} is not verified: ${l.problems[0]}${l.problems.length > 1 ? ` (+${l.problems.length - 1} more)` : ""}`, { components: [...l.instances] });
102
+ }
103
+ // ---- land patterns chosen by package family, and packages no entry fits (a mismatch, not a guess)
104
+ for (const c of netlist.components) {
105
+ if (!c.package) continue;
106
+ const r = resolveFootprint(c.instance, { ...c.package, manufacturer: c.manufacturer }, input.overrides);
107
+ if (r.entry && r.by === "family") add("info", "CIRCUIT_FOOTPRINT_FAMILY", `${c.instance} (${c.part}): package "${c.package.name}" has land pattern ${r.entry.id} by its family ${packageFamily(c.package.name, c.package.pin_count)} (the entry says "${r.entry.match.package}"); its pin count${r.entry.match.pitch_mm ? ", pitch" : ""}${r.entry.match.size_mm ? " and body size" : ""} agree`, { components: [c.instance] });
108
+ if (!r.entry && r.near?.length) add("error", "CIRCUIT_FOOTPRINT_MISMATCH", `${c.instance} (${c.part}): package "${c.package.name}" (${c.package.pin_count} pins${c.package.pitch_mm ? `, ${c.package.pitch_mm} mm pitch` : ""}) matches land patterns by name or family that do not fit it: ${r.near.join("; ")}. Correct the part's package facts, or add an entry that fits to tscircuit/footprints.json`, { components: [c.instance] });
109
+ }
110
+ if (!native) {
111
+ add("info", "CIRCUIT_NOT_BUILT", `${netlist.board} has no built board yet; run \`protoboard run circuit:generate --module ${netlist.board}\``);
112
+ return out;
113
+ }
114
+ const { circuitJson: cj, build } = native;
115
+ const sourceName = new Map(ofType(cj, "source_component").map((x) => [x.source_component_id, x.name as string]));
116
+ const instanceOf = new Map(ofType(cj, "pcb_component").map((x) => [x.pcb_component_id, build.map.components[sourceName.get(x.source_component_id) ?? ""]?.instance]));
117
+ const at = (x: number, y: number): [number, number] => [Math.round(x * 1e4) / 1e4, Math.round(y * 1e4) / 1e4];
118
+ const portsById = new Map(ofType(cj, "pcb_port").map((p) => [p.pcb_port_id, p]));
119
+ const padsByPort = new Map<string, CircuitElement[]>();
120
+ for (const p of [...ofType(cj, "pcb_smtpad"), ...ofType(cj, "pcb_plated_hole")]) if (p.pcb_port_id) padsByPort.set(p.pcb_port_id, [...(padsByPort.get(p.pcb_port_id) ?? []), p]);
121
+ const padId = (p: CircuitElement) => p.pcb_smtpad_id ?? p.pcb_plated_hole_id;
122
+ const padLoc = (p: CircuitElement): DiagnosticLocation => ({ at: at(p.x, p.y), ...(p.layer ? { layer: p.layer } : {}), components: [instanceOf.get(p.pcb_component_id) ?? p.pcb_component_id], pads: [padId(p)] });
123
+ const placed = (inst: string): DiagnosticLocation => {
124
+ const c = boardGeometry(cj, build.map).components.find((x) => x.instance === inst);
125
+ return c ? { at: at(c.center[0], c.center[1]), layer: c.side, components: [inst] } : { components: [inst] };
126
+ };
127
+ /** Where a tscircuit error element points: its centre or position, the ports, pads, components and copper it names. */
128
+ const drcLocation = (e: CircuitElement): DiagnosticLocation => {
129
+ const ports = [e.pcb_port_id, ...(e.pcb_port_ids ?? [])].filter(Boolean) as string[];
130
+ const pads = ports.flatMap((id) => padsByPort.get(id) ?? []);
131
+ const comps = [...new Set([e.pcb_component_id, ...(e.pcb_component_ids ?? []), ...pads.map((p) => p.pcb_component_id)].filter(Boolean).map((id: string) => instanceOf.get(id) ?? id))];
132
+ const items = [e.pcb_trace_id, e.pcb_via_id, ...(e.pcb_trace_ids ?? []), ...(e.pcb_via_ids ?? []), e.pcb_hole_id].filter(Boolean) as string[];
133
+ const point = e.center ?? e.pcb_center ?? (typeof e.x === "number" && typeof e.y === "number" ? e : undefined) ?? (ports[0] ? portsById.get(ports[0]) : undefined);
134
+ const loc: DiagnosticLocation = {};
135
+ if (point && typeof point.x === "number") loc.at = at(point.x, point.y);
136
+ if (typeof e.layer === "string") loc.layer = e.layer;
137
+ if (comps.length) loc.components = comps;
138
+ if (pads.length) loc.pads = pads.map(padId);
139
+ if (items.length) loc.items = items;
140
+ return Object.keys(loc).length ? loc : { scope: "design" };
141
+ };
142
+
143
+ if (build.sourceDigest !== input.sourceDigest) add("warning", "CIRCUIT_STALE", "the native source (board.tsx, the generated source, or a file they import: a hand file, the JSON it reads) changed since the last build; run circuit:export (it rebuilds) before trusting these findings");
144
+ if (build.netlistDigest !== input.netlistDigest) add("warning", "CIRCUIT_STALE", `the UHD board module ${netlist.board} changed since generate; run circuit:generate`);
145
+ if (build.inputsDigest !== input.inputsDigest) add("warning", "CIRCUIT_STALE", "layout.json, footprints.json or the circuit.config.ts constraints changed since generate (board.gen.tsx bakes them in); run circuit:generate");
146
+
147
+ // ---- tscircuit's own rules
148
+ const seen = new Set<string>();
149
+ let drc: CircuitElement[] = [];
150
+ try {
151
+ const { runAllChecks } = (await import("@tscircuit/checks")) as unknown as { runAllChecks: (cj: unknown) => Promise<CircuitElement[]> | CircuitElement[] };
152
+ drc = await offline(async () => runAllChecks(cj));
153
+ } catch (e) {
154
+ add("warning", "CIRCUIT_DRC", `@tscircuit/checks did not run: ${(e as Error).message.split("\n")[0]}`);
155
+ }
156
+ for (const e of [...cj.filter((x) => /_error$/.test(x.type)), ...drc.filter((x) => /_error$/.test(x.type))]) {
157
+ const key = `${e.type}|${e.message}`;
158
+ if (seen.has(key)) continue;
159
+ seen.add(key);
160
+ // a placement-only board (routing: off) has no traces by design: an unconnected port is information there
161
+ add(input.constraints?.routing === "off" && (e.type === "pcb_port_not_connected_error" || e.type === "pcb_trace_missing_error") ? "info" : "error", "CIRCUIT_DRC", `${e.type}: ${e.message}`, drcLocation(e));
162
+ }
163
+
164
+ // ---- components: every UHD component on the board, nothing else
165
+ const names = new Set(ofType(cj, "source_component").map((s) => s.name as string));
166
+ const mapped = new Set(Object.values(build.map.components).map((c) => c.instance));
167
+ for (const c of netlist.components) {
168
+ if (!mapped.has(c.instance)) add("error", "CIRCUIT_COMPONENT_MISSING", `${c.instance} (${c.part}) is in the UHD board but not in the native design; run circuit:generate`, { components: [c.instance] });
169
+ }
170
+ for (const [name, m] of Object.entries(build.map.components)) {
171
+ if (!names.has(name)) add("error", "CIRCUIT_COMPONENT_MISSING", `${m.instance} (${m.part}) is missing from the built board`, { components: [m.instance] });
172
+ else if (!netlist.components.some((c) => c.instance === m.instance)) add("error", "CIRCUIT_COMPONENT_EXTRA", `${m.instance} is on the board but no longer in the UHD board module`, placed(m.instance));
173
+ }
174
+ for (const name of names) if (!build.map.components[name]) add("error", "CIRCUIT_COMPONENT_EXTRA", `${name} is on the board but has no UHD instance (added in the native source by hand?)`, { components: [name] });
175
+
176
+ // ---- netlist drift: copper vs UHD
177
+ const { nets, present } = copperNets(cj, build.map);
178
+ // routing off (placement only): nets are expected to be unrouted, so that is information, not an error
179
+ const placementOnly = input.constraints?.routing === "off";
180
+ // a drift finding is located at the pins of the nets it names
181
+ const pinPads = new Map<string, CircuitElement[]>();
182
+ for (const [port, pin] of portPins(cj, build.map)) pinPads.set(pin, [...(pinPads.get(pin) ?? []), ...(padsByPort.get(port) ?? [])]);
183
+ const netOfPin = new Map(netlist.nets.flatMap((n) => n.pins.map((p) => [p, n.id] as const)));
184
+ for (const f of netlistDrift(netlist.nets, nets, present)) {
185
+ // at the pins it names: their pads, components and nets
186
+ const pins = f.pins.length ? f.pins : (netlist.nets.find((n) => n.id === f.net)?.pins ?? []);
187
+ const pads = pins.flatMap((p) => pinPads.get(p) ?? []);
188
+ const netIds = [...new Set([...(f.net ? [f.net] : []), ...pins.map((p) => netOfPin.get(p)).filter((x): x is string => !!x)])];
189
+ const loc: DiagnosticLocation = { ...(pads[0] ? { at: at(pads[0].x, pads[0].y), points: pads.map((p) => at(p.x, p.y)), pads: pads.map(padId) } : {}), components: [...new Set(pins.map((p) => p.split(":")[0]))], ...(netIds.length ? { nets: netIds } : {}) };
190
+ const routingOff = placementOnly && (f.code === "net_unrouted" || f.code === "net_open");
191
+ add(routingOff ? "info" : "error", DRIFT_CODES[f.code], routingOff ? `${f.message} (routing: off)` : f.message, loc);
192
+ }
193
+ // every pad of a pin with several (a module's exposed ground pads) carries the pin's net, not only the first
194
+ const netOf = new Map(netlist.nets.flatMap((n) => n.pins.map((p) => [p, n.id] as const)));
195
+ for (const u of unjoinedPads(cj, build.map, new Set(netOf.keys()))) {
196
+ const pads = u.ports.flatMap((id) => padsByPort.get(id) ?? []);
197
+ add(placementOnly ? "info" : "error", "CIRCUIT_PAD_UNNETTED", `${u.pin} has ${u.pads} pads in ${u.pieces} copper pieces: not every pad is joined to net ${netOf.get(u.pin)}; run circuit:generate (it joins the pin's pads to each other)`, { ...(pads[0] ? { at: at(pads[0].x, pads[0].y), layer: pads[0].layer, points: pads.map((p) => at(p.x, p.y)) } : {}), components: [u.pin.split(":")[0]], pads: pads.map(padId), nets: [netOf.get(u.pin)!] });
198
+ }
199
+
200
+ // ---- traces written by hand between pads of one pin: generate joins them itself (PB-1027, F22)
201
+ if (input.generatedSource !== undefined) {
202
+ for (const h of handPadJoins(cj, build.map, input.generatedSource)) {
203
+ add("warning", "CIRCUIT_HAND_PAD_JOIN", `a trace written by hand (${h.trace}) joins pads of ${h.instance}:${h.pin}, which generate joins itself; remove it from tscircuit/board.tsx: in KiCad it becomes copper on no net`, { components: [h.instance] });
204
+ }
205
+ }
206
+
207
+ // ---- geometry: outline, keep-outs, fixed placements
208
+ const g = boardGeometry(cj, build.map);
209
+ for (const p of ofType(cj, "pcb_smtpad")) {
210
+ const w = (p.width ?? (p.radius ?? 0) * 2) / 2;
211
+ const h = (p.height ?? (p.radius ?? 0) * 2) / 2;
212
+ const corners: Vec2[] = [[p.x - w, p.y - h], [p.x + w, p.y - h], [p.x + w, p.y + h], [p.x - w, p.y + h]];
213
+ if (corners.some((c) => !inside(c, g.outline))) {
214
+ add("error", "CIRCUIT_OUTSIDE_OUTLINE", `a pad of ${instanceOf.get(p.pcb_component_id) ?? p.pcb_component_id} at (${p.x.toFixed(2)}, ${p.y.toFixed(2)}) lies outside the board outline`, padLoc(p));
215
+ }
216
+ }
217
+ // a component's extent: its courtyard as placed (turned with the part), else its axis-aligned box
218
+ const courtyardOf = new Map<string, Vec2[]>();
219
+ for (const r of ofType(cj, "pcb_courtyard_rect")) {
220
+ const inst = instanceOf.get(r.pcb_component_id);
221
+ if (!inst) continue;
222
+ const a = ((r.ccw_rotation ?? 0) * Math.PI) / 180;
223
+ const [hw, hh] = [r.width / 2, r.height / 2];
224
+ courtyardOf.set(inst, [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([x, y]) => [r.center.x + x * Math.cos(a) - y * Math.sin(a), r.center.y + x * Math.sin(a) + y * Math.cos(a)] as Vec2));
225
+ }
226
+ // a part the native entry lets off the board (a mid-mount receptacle in its cut-out) is not reported for its body; its pads still are
227
+ const offBoard = new Set(ofType(cj, "pcb_component").filter((x) => x.is_allowed_to_be_off_board).map((x) => instanceOf.get(x.pcb_component_id)));
228
+ for (const c of g.components) {
229
+ if (offBoard.has(c.instance)) continue;
230
+ const [hw, hh] = [c.size[0] / 2, c.size[1] / 2];
231
+ const corners: Vec2[] = courtyardOf.get(c.instance) ?? [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([x, y]) => [c.center[0] + x, c.center[1] + y] as Vec2);
232
+ if (corners.some((p) => !inside(p, g.outline)) && !corners.every((p) => !inside(p, g.outline))) {
233
+ add("warning", "CIRCUIT_OUTSIDE_OUTLINE", `${c.instance}'s footprint (${c.size.join(" × ")} mm at ${c.center.join(", ")}) overhangs the board outline`, { at: at(c.center[0], c.center[1]), layer: c.side, components: [c.instance] });
234
+ } else if (corners.every((p) => !inside(p, g.outline))) add("error", "CIRCUIT_OUTSIDE_OUTLINE", `${c.instance} at (${c.center.join(", ")}) is outside the board outline`, { at: at(c.center[0], c.center[1]), layer: c.side, components: [c.instance] });
235
+ }
236
+ for (const k of input.constraints?.keepouts ?? []) {
237
+ for (const layer of k.layers) {
238
+ const hits = copperPoints(cj, layer).filter((p) => insideKeepout(p.at, k));
239
+ const what = [...new Set(hits.map((h) => h.what))];
240
+ if (what.length) add("error", "CIRCUIT_KEEPOUT", `${what.length} copper item(s) on the ${layer} layer inside keep-out ${k.name}${k.reason ? ` (${k.reason})` : ""}: ${what.slice(0, 5).join(", ")}${what.length > 5 ? ", …" : ""}`, { at: at(hits[0].at[0], hits[0].at[1]), points: [...new Map(hits.map((h) => [h.what, at(h.at[0], h.at[1])])).values()], layer, items: [k.name, ...what.map((w) => w.split(" ").pop()!)] });
241
+ }
242
+ }
243
+ // copper pours are areas: no keep-out may hold any of a pour, on any layer
244
+ for (const pour of ofType(cj, "pcb_copper_pour")) {
245
+ const outer = (pour.brep_shape?.outer_ring?.vertices ?? []).map((v: { x: number; y: number }) => [v.x, v.y] as Vec2);
246
+ const holes = (pour.brep_shape?.inner_rings ?? []).map((r: { vertices: { x: number; y: number }[] }) => r.vertices.map((v) => [v.x, v.y] as Vec2));
247
+ if (outer.length < 3) continue;
248
+ const filled = (p: Vec2) => inside(p, outer) && !holes.some((h: Vec2[]) => inside(p, h));
249
+ for (const k of input.constraints?.keepouts ?? []) {
250
+ const [cx, cy, w, h] = "rect" in k.shape ? [...k.shape.rect.at, ...k.shape.rect.size] : [...k.shape.circle.at, k.shape.circle.diameter_mm, k.shape.circle.diameter_mm];
251
+ let hit = false;
252
+ for (let i = 1; i < 20 && !hit; i++) for (let j = 1; j < 20 && !hit; j++) {
253
+ const p: Vec2 = [cx - w / 2 + (w * i) / 20, cy - h / 2 + (h * j) / 20];
254
+ if (insideKeepout(p, k) && filled(p)) hit = true;
255
+ }
256
+ if (hit) add("error", "CIRCUIT_KEEPOUT", `copper pour ${pour.pcb_copper_pour_id} on the ${pour.layer} layer fills keep-out ${k.name}${k.reason ? ` (${k.reason})` : ""}`, { at: at(cx, cy), layer: pour.layer, items: [k.name, pour.pcb_copper_pour_id] });
257
+ }
258
+ }
259
+ // ---- vias below the board's own via rules (PB-1027, F31): the router keeps to them, a via written by hand may not
260
+ if (input.constraints) {
261
+ for (const v of viasBelowRules(cj, input.constraints.rules)) add("error", "CIRCUIT_VIA_RULES", `via ${v.via} is ${v.what}; the board's rules ask for at least ${v.want} mm (circuit.config.ts rules). Make it to the rules, or change the rules if the fab can make it`, { at: at(v.at[0], v.at[1]), items: [v.via] });
262
+ }
263
+ // ---- vias inside SMD pads: kept out, or filled and capped by the fab (config via_in_pad)
264
+ const inPad = viasInPads(cj);
265
+ if (inPad.length) {
266
+ const where = [...new Set(inPad.map((v) => instanceOf.get(v.pad.pcb_component_id) ?? v.pad.pcb_component_id))];
267
+ const viaLoc: DiagnosticLocation = { at: at(inPad[0].via.x, inPad[0].via.y), points: inPad.map((v) => at(v.via.x, v.via.y)), components: where, pads: inPad.map((v) => padId(v.pad)), items: inPad.map((v) => v.via.pcb_via_id) };
268
+ const msg = `${inPad.length} via(s) inside SMD pads (${where.slice(0, 8).join(", ")}${where.length > 8 ? ", …" : ""})`;
269
+ if (input.constraints?.via_in_pad === "fill_and_cap") add("info", "CIRCUIT_VIA_IN_PAD", `${msg}: the fabrication order asks the fab to fill and cap them (via_in_pad: fill_and_cap)`, viaLoc);
270
+ else add("error", "CIRCUIT_VIA_IN_PAD", `${msg}: an open via wicks solder from the joint; move them out (regenerate: via_in_pad defaults to avoid) or set via_in_pad: "fill_and_cap" so the fab fills and caps them`, viaLoc);
271
+ }
272
+ // ---- the via policy beyond pads: none touching a pad, none under a body (config; the router has no such rule)
273
+ if (input.constraints?.via_touching_pad === "avoid") {
274
+ const touching = viasTouchingPads(cj);
275
+ if (touching.length) {
276
+ const where = [...new Set(touching.map((v) => instanceOf.get(v.pad.pcb_component_id) ?? v.pad.pcb_component_id))];
277
+ add("error", "CIRCUIT_VIA_TOUCHING_PAD", `${touching.length} via(s) touching an SMD pad they do not sit in (${where.slice(0, 8).join(", ")}${where.length > 8 ? ", …" : ""}): the ring and the pad share copper with no mask between them (via_touching_pad: avoid); move the parts a little and generate again`, { at: at(touching[0].via.x, touching[0].via.y), points: touching.map((v) => at(v.via.x, v.via.y)), components: where, pads: touching.map((v) => padId(v.pad)), items: touching.map((v) => v.via.pcb_via_id) });
278
+ }
279
+ }
280
+ if (input.constraints?.via_under_body === "avoid") {
281
+ const hits = ofType(cj, "pcb_via").flatMap((v) => [...courtyardOf].filter(([, poly]) => inside([v.x, v.y], poly)).map(([inst]) => ({ inst, v })));
282
+ const under = hits.map((h) => h.inst);
283
+ if (under.length) {
284
+ const where = [...new Set(under)];
285
+ add("error", "CIRCUIT_VIA_UNDER_BODY", `${under.length} via(s) under a component's body (inside its courtyard: ${where.slice(0, 8).join(", ")}${where.length > 8 ? ", …" : ""}) (via_under_body: avoid); give the parts room or move the vias in board.tsx`, { at: at(hits[0].v.x, hits[0].v.y), points: hits.map((h) => at(h.v.x, h.v.y)), components: where, items: hits.map((h) => h.v.pcb_via_id) });
286
+ }
287
+ }
288
+ // ---- non-plated holes against copper, round holes as circles (not their bounding box)
289
+ const clearance = input.constraints?.rules.hole_clearance_mm ?? 0;
290
+ for (const h of holeClearanceFaults(cj, clearance)) {
291
+ add("error", "CIRCUIT_HOLE_CLEARANCE", `${h.what} is ${h.gap < 0 ? `${(-h.gap).toFixed(3)} mm into` : `${h.gap.toFixed(3)} mm from`} the hole ${h.hole} at (${h.at.map((v) => v.toFixed(2)).join(", ")})${clearance ? `; the rule is ${clearance} mm (rules.hole_clearance_mm)` : ""}`, { at: at(h.at[0], h.at[1]), ...(h.layer ? { layer: h.layer } : {}), items: [h.hole, ...(h.id ? [h.id] : [])], ...(h.component ? { components: [instanceOf.get(h.component) ?? h.component] } : {}) });
292
+ }
293
+ // ---- trace widths the config asks for (trace_widths): the narrowest routed segment of each net
294
+ const asked = new Map<string, number>();
295
+ for (const n of netlist.nets) {
296
+ const w = input.constraints?.trace_widths.nets[n.id] ?? input.constraints?.trace_widths.classes[n.kind];
297
+ if (w !== undefined) asked.set(n.id, w);
298
+ }
299
+ if (asked.size) {
300
+ const where = new Map<string, { at: [number, number]; layer?: string; trace: string }>();
301
+ for (const [net, narrowest] of narrowestTraces(cj, build.map, netlist, where)) {
302
+ const want = asked.get(net);
303
+ if (want !== undefined && narrowest < want - 1e-4) add("warning", "CIRCUIT_TRACE_WIDTH", `net ${net} has traces ${+narrowest.toFixed(4)} mm wide; trace_widths asks for ${want} mm (the router narrows traces where it cannot fit the width: give the net room, or accept the neck-down)`, { ...(where.get(net) ? { at: where.get(net)!.at, ...(where.get(net)!.layer ? { layer: where.get(net)!.layer } : {}), items: [where.get(net)!.trace] } : {}), nets: [net] });
304
+ }
305
+ }
306
+ for (const [inst, want] of Object.entries(input.constraints?.placements ?? {})) {
307
+ const c = g.components.find((x) => x.instance === inst);
308
+ if (!c) continue;
309
+ const moved = Math.hypot(c.origin[0] - want.at[0], c.origin[1] - want.at[1]);
310
+ const turned = Math.abs(((((c.rotation_deg - want.rotation_deg) % 360) + 540) % 360) - 180);
311
+ if (moved > 0.01 || turned > 0.1 || c.side !== want.side) {
312
+ add("error", "CIRCUIT_PLACEMENT_MOVED", `${inst} is at (${c.origin.join(", ")}) rot ${c.rotation_deg}° ${c.side}; circuit.config.ts fixes it at (${want.at.join(", ")}) rot ${want.rotation_deg}° ${want.side}`, { at: at(c.origin[0], c.origin[1]), layer: c.side, components: [inst], points: [at(c.origin[0], c.origin[1]), at(want.at[0], want.at[1])] });
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
318
+ /** Vias whose centre lies inside an SMD pad (any copper layer the via reaches holds the pad). */
319
+ /** Vias whose pad or drill is smaller than the rules' (via_pad_mm, via_drill_mm). */
320
+ export function viasBelowRules(cj: CircuitElement[], rules: { via_pad_mm: number; via_drill_mm: number }): { via: string; at: Vec2; what: string; want: number }[] {
321
+ const out: { via: string; at: Vec2; what: string; want: number }[] = [];
322
+ const r = (v: number) => +v.toFixed(4);
323
+ for (const v of ofType(cj, "pcb_via")) {
324
+ const pad = Number(v.outer_diameter);
325
+ const drill = Number(v.hole_diameter);
326
+ const at: Vec2 = [Number(v.x), Number(v.y)];
327
+ if (Number.isFinite(pad) && pad < rules.via_pad_mm - 1e-4) out.push({ via: String(v.pcb_via_id), at, what: `${r(pad)} mm across its pad`, want: rules.via_pad_mm });
328
+ else if (Number.isFinite(drill) && drill < rules.via_drill_mm - 1e-4) out.push({ via: String(v.pcb_via_id), at, what: `drilled ${r(drill)} mm`, want: rules.via_drill_mm });
329
+ }
330
+ return out;
331
+ }
332
+
333
+ export function viasInPads(cj: CircuitElement[]): { via: CircuitElement; pad: CircuitElement }[] {
334
+ const pads = ofType(cj, "pcb_smtpad");
335
+ const out: { via: CircuitElement; pad: CircuitElement }[] = [];
336
+ for (const v of ofType(cj, "pcb_via")) {
337
+ const pad = pads.find((p) => {
338
+ const w = (p.width ?? (p.radius ?? 0) * 2) / 2;
339
+ const h = (p.height ?? (p.radius ?? 0) * 2) / 2;
340
+ return Math.abs(v.x - p.x) < w && Math.abs(v.y - p.y) < h && (!v.layers || v.layers.includes(p.layer));
341
+ });
342
+ if (pad) out.push({ via: v, pad });
343
+ }
344
+ return out;
345
+ }
346
+
347
+
348
+ /** Distance from a point to an axis-aligned rectangle (0 inside). */
349
+ const rectDistance = (p: Vec2, c: Vec2, w: number, h: number) => Math.hypot(Math.max(0, Math.abs(p[0] - c[0]) - w / 2), Math.max(0, Math.abs(p[1] - c[1]) - h / 2));
350
+
351
+ /** Distance from a point to a pad's copper (rect, rotated rect, circle, pill), 0 inside. */
352
+ function padDistance(p: Vec2, pad: CircuitElement): number {
353
+ if (pad.shape === "circle") return Math.max(0, Math.hypot(p[0] - pad.x, p[1] - pad.y) - pad.radius);
354
+ const a = (-(pad.ccw_rotation ?? 0) * Math.PI) / 180;
355
+ const [dx, dy] = [p[0] - pad.x, p[1] - pad.y];
356
+ const local: Vec2 = [dx * Math.cos(a) - dy * Math.sin(a), dx * Math.sin(a) + dy * Math.cos(a)];
357
+ const w = pad.width ?? (pad.radius ?? 0) * 2;
358
+ const h = pad.height ?? (pad.radius ?? 0) * 2;
359
+ if (pad.shape === "pill" || pad.shape === "rotated_pill") {
360
+ const r = Math.min(w, h) / 2;
361
+ const half = Math.max(w, h) / 2 - r;
362
+ const [u, v] = w >= h ? local : [local[1], local[0]];
363
+ return Math.max(0, Math.hypot(Math.max(0, Math.abs(u) - half), v) - r);
364
+ }
365
+ return rectDistance(local, [0, 0], w, h);
366
+ }
367
+
368
+ /** Vias whose ring reaches an SMD pad on a layer it spans, with the centre outside the pad (in-pad vias are viasInPads'). */
369
+ export function viasTouchingPads(cj: CircuitElement[]): { via: CircuitElement; pad: CircuitElement }[] {
370
+ const pads = ofType(cj, "pcb_smtpad");
371
+ const out: { via: CircuitElement; pad: CircuitElement }[] = [];
372
+ for (const v of ofType(cj, "pcb_via")) {
373
+ const r = (v.outer_diameter ?? 0.6) / 2;
374
+ const pad = pads.find((p) => (!v.layers || v.layers.includes(p.layer)) && padDistance([v.x, v.y], p) > 0 && padDistance([v.x, v.y], p) <= r + 1e-6);
375
+ if (pad) out.push({ via: v, pad });
376
+ }
377
+ return out;
378
+ }
379
+
380
+ /** Distance from point p to segment ab. */
381
+ const segmentDistance = (p: Vec2, a: Vec2, b: Vec2) => {
382
+ const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
383
+ const t = dx || dy ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy))) : 0;
384
+ return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy);
385
+ };
386
+
387
+ /**
388
+ * Copper closer to a non-plated hole's wall than `clearance` (0: copper in the hole). A round hole
389
+ * is its circle and a pill its stadium, not a bounding box; copper is every pad, plated hole, via,
390
+ * trace (by its width) and pour (its outline less its cut-outs).
391
+ */
392
+ export function holeClearanceFaults(cj: CircuitElement[], clearance: number): { hole: string; at: Vec2; what: string; gap: number; id?: string; layer?: string; component?: string }[] {
393
+ const out: { hole: string; at: Vec2; what: string; gap: number; id?: string; layer?: string; component?: string }[] = [];
394
+ for (const h of ofType(cj, "pcb_hole")) {
395
+ const at: Vec2 = [h.x, h.y];
396
+ const round = h.hole_shape === "circle" || h.hole_shape === "round" || (h.hole_diameter !== undefined && h.hole_width === undefined);
397
+ const w = round ? h.hole_diameter : h.hole_width;
398
+ const ht = round ? h.hole_diameter : h.hole_height;
399
+ if (!w || !ht) continue;
400
+ const r = Math.min(w, ht) / 2;
401
+ const half = Math.max(w, ht) / 2 - r;
402
+ // the hole as a stadium (a circle when half = 0): distance from its wall
403
+ const wall = (p: Vec2) => (w >= ht ? segmentDistance(p, [at[0] - half, at[1]], [at[0] + half, at[1]]) : segmentDistance(p, [at[0], at[1] - half], [at[0], at[1] + half])) - r;
404
+ const name = h.pcb_hole_id;
405
+ let worst: { what: string; gap: number; id?: string; layer?: string; component?: string } | undefined;
406
+ const consider = (what: string, gap: number, el?: CircuitElement) => {
407
+ if (gap < clearance - 1e-6 && (!worst || gap < worst.gap)) {
408
+ const id = el ? (el.pcb_smtpad_id ?? el.pcb_plated_hole_id ?? el.pcb_via_id ?? el.pcb_trace_id ?? el.pcb_copper_pour_id) : undefined;
409
+ worst = { what, gap, ...(id ? { id } : {}), ...(typeof el?.layer === "string" ? { layer: el.layer } : {}), ...(el?.pcb_component_id ? { component: el.pcb_component_id } : {}) };
410
+ }
411
+ };
412
+ for (const p of ofType(cj, "pcb_smtpad")) {
413
+ // distance between the pad and the hole wall: sample the pad's outline towards the hole
414
+ const axis: Vec2[] = half ? (w >= ht ? [[at[0] - half, at[1]], at, [at[0] + half, at[1]]] : [[at[0], at[1] - half], at, [at[0], at[1] + half]]) : [at];
415
+ consider(`pad ${p.pcb_smtpad_id}`, Math.min(...axis.map((q) => padDistance(q, p))) - r, p);
416
+ }
417
+ for (const p of ofType(cj, "pcb_plated_hole")) consider(`plated hole ${p.pcb_plated_hole_id}`, wall([p.x, p.y]) - (p.outer_diameter ?? Math.max(p.outer_width ?? 0, p.outer_height ?? 0)) / 2, p);
418
+ for (const v of ofType(cj, "pcb_via")) consider(`via ${v.pcb_via_id}`, wall([v.x, v.y]) - (v.outer_diameter ?? 0.6) / 2, v);
419
+ for (const t of ofType(cj, "pcb_trace")) {
420
+ const route = (t.route ?? []) as { route_type: string; x: number; y: number; width?: number }[];
421
+ for (let i = 1; i < route.length; i++) {
422
+ const [a, b] = [route[i - 1], route[i]];
423
+ if (a.route_type !== "wire" || b.route_type !== "wire") continue;
424
+ // the segment's distance to the hole's centre line, less the hole's radius and the trace's half-width
425
+ const d = half ? Math.min(segmentDistance([at[0] - half, at[1]], [a.x, a.y], [b.x, b.y]), segmentDistance([at[0] + half, at[1]], [a.x, a.y], [b.x, b.y]), segmentDistance(at, [a.x, a.y], [b.x, b.y])) : segmentDistance(at, [a.x, a.y], [b.x, b.y]);
426
+ consider(`trace ${t.pcb_trace_id}`, d - r - (a.width ?? 0.15) / 2, { ...t, layer: (a as { layer?: string }).layer });
427
+ }
428
+ }
429
+ for (const pour of ofType(cj, "pcb_copper_pour")) {
430
+ const outer = (pour.brep_shape?.outer_ring?.vertices ?? []).map((v: { x: number; y: number }) => [v.x, v.y] as Vec2);
431
+ if (outer.length < 3) continue;
432
+ const rings = [outer, ...(pour.brep_shape?.inner_rings ?? []).map((ring: { vertices: { x: number; y: number }[] }) => ring.vertices.map((v) => [v.x, v.y] as Vec2))];
433
+ const inHole = rings.slice(1).some((ring: Vec2[]) => inside(at, ring));
434
+ const filled = inside(at, outer) && !inHole;
435
+ const edge = Math.min(...rings.map((ring: Vec2[]) => Math.min(...ring.map((p, i) => segmentDistance(at, p, ring[(i + 1) % ring.length])))));
436
+ consider(`copper pour ${pour.pcb_copper_pour_id} (${pour.layer})`, filled ? -r : edge - r - half, pour);
437
+ }
438
+ if (worst) out.push({ hole: name, at, ...worst });
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /** The narrowest routed segment of each UHD net (by the copper the trace connects). */
444
+ export function narrowestTraces(cj: CircuitElement[], map: NameMap, netlist: BoardNetlist, where?: Map<string, { at: [number, number]; layer?: string; trace: string }>): Map<string, number> {
445
+ const connectivity = getFullConnectivityMapFromCircuitJson(cj.filter((e) => e.type.startsWith("pcb_")) as never);
446
+ const netOfPin = new Map(netlist.nets.flatMap((n) => n.pins.map((p) => [p, n.id] as const)));
447
+ const uhdNet = new Map<string, string>();
448
+ for (const [port, pin] of portPins(cj, map)) {
449
+ const c = connectivity.getNetConnectedToId(port);
450
+ const n = netOfPin.get(pin);
451
+ if (c && n) uhdNet.set(c, n);
452
+ }
453
+ const out = new Map<string, number>();
454
+ for (const t of ofType(cj, "pcb_trace")) {
455
+ const net = uhdNet.get(connectivity.getNetConnectedToId(t.pcb_trace_id) ?? "");
456
+ if (!net) continue;
457
+ for (const p of (t.route ?? []) as { route_type: string; width?: number; x: number; y: number; layer?: string }[]) {
458
+ if (p.route_type !== "wire" || typeof p.width !== "number") continue;
459
+ if (p.width < (out.get(net) ?? Infinity)) where?.set(net, { at: [p.x, p.y], ...(p.layer ? { layer: p.layer } : {}), trace: t.pcb_trace_id });
460
+ out.set(net, Math.min(out.get(net) ?? Infinity, p.width));
461
+ }
462
+ }
463
+ return out;
464
+ }