@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.
- package/LICENSE +202 -0
- package/NOTICE +59 -0
- package/README.md +493 -2
- package/evals/announce-pcb-fallback/case.ts +67 -0
- package/evals/catch-swapped-nets/case.ts +69 -0
- package/evals/generate-imu-board/case.ts +60 -0
- package/evals/locked-board-fix-upstream/case.ts +70 -0
- package/lib/checks.ts +464 -0
- package/lib/config.ts +384 -0
- package/lib/fingerprint.ts +144 -0
- package/lib/footprints.ts +370 -0
- package/lib/kicad/cli.ts +93 -0
- package/lib/kicad/drc.ts +95 -0
- package/lib/kicad/fields.ts +65 -0
- package/lib/kicad/handoff.ts +112 -0
- package/lib/kicad/netlists.ts +311 -0
- package/lib/kicad/probe.cjs +21 -0
- package/lib/kicad/project.ts +594 -0
- package/lib/kicad/refdes.ts +130 -0
- package/lib/kicad/schematic.ts +624 -0
- package/lib/kicad/sexpr.ts +117 -0
- package/lib/land-geometry.ts +177 -0
- package/lib/land-patterns.ts +706 -0
- package/lib/layout.ts +236 -0
- package/lib/models.ts +284 -0
- package/lib/native.ts +199 -0
- package/lib/outputs.ts +383 -0
- package/lib/paste.ts +69 -0
- package/lib/probe.cjs +31 -0
- package/lib/runtime.ts +185 -0
- package/lib/silk.ts +141 -0
- package/lib/source.ts +472 -0
- package/lib/stepfile.ts +210 -0
- package/lib/suppliers.ts +39 -0
- package/ops/circuit.ts +270 -0
- package/ops/kicad.ts +636 -0
- package/ops/package.ts +156 -0
- package/package.json +81 -4
- package/protoboard-extension.json +313 -0
- package/skills/circuit-board/SKILL.md +494 -0
- package/skills/circuit-board/references/pcb-workflow.md +87 -0
- package/skills/kicad-cli/SKILL.md +210 -0
- package/skills/kicad-layout-review/SKILL.md +275 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider's package → land-pattern table (tool-integrations.md §4).
|
|
3
|
+
*
|
|
4
|
+
* UHD parts state their package as the manufacturer prints it ("QFN-56",
|
|
5
|
+
* pitch 0.4 mm, 7 × 7 mm, exposed pad 57). Which copper pattern fits that
|
|
6
|
+
* package is this provider's knowledge, kept here and never written into a
|
|
7
|
+
* part. Two kinds of entry:
|
|
8
|
+
*
|
|
9
|
+
* footprinter a tscircuit built-in footprint string ("0402", "jst4_p1mm_sh"),
|
|
10
|
+
* generated locally by @tscircuit/footprinter (MIT)
|
|
11
|
+
* pads a land pattern authored here from the manufacturer's
|
|
12
|
+
* recommended land pattern, with the drawing cited
|
|
13
|
+
*
|
|
14
|
+
* A project overrides or extends the table with
|
|
15
|
+
* `src/boards/<module>/tscircuit/footprints.json` ({ packages: [...],
|
|
16
|
+
* instances: { <instance>: entry } }). KiCad footprint libraries are not
|
|
17
|
+
* bundled: they are CC-BY-SA 4.0 (see README, "Licensing").
|
|
18
|
+
*/
|
|
19
|
+
import { fp } from "@tscircuit/footprinter";
|
|
20
|
+
import type { Dim, FootprintEntry, LandPad, LandVerification } from "./land-geometry.ts";
|
|
21
|
+
import { FOOTPRINTS } from "./land-patterns.ts";
|
|
22
|
+
|
|
23
|
+
export * from "./land-geometry.ts";
|
|
24
|
+
export { FOOTPRINTS } from "./land-patterns.ts";
|
|
25
|
+
|
|
26
|
+
/** Table overrides from the project: `tscircuit/footprints.json`. */
|
|
27
|
+
export interface FootprintOverrides {
|
|
28
|
+
packages?: FootprintEntry[];
|
|
29
|
+
instances?: Record<string, FootprintEntry>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Package names compared without case, spaces, underscores or dashes; `×` reads as `x` ("3 × 3" = "3x3"). */
|
|
33
|
+
const norm = (s: string) => s.toLowerCase().replace(/×/g, "x").replace(/[\s_\-–]/g, "");
|
|
34
|
+
|
|
35
|
+
export interface PackageFacts {
|
|
36
|
+
name: string;
|
|
37
|
+
pin_count: number;
|
|
38
|
+
pitch_mm?: number;
|
|
39
|
+
size_mm?: { length?: number; width?: number };
|
|
40
|
+
/** The part's manufacturer, for entries one maker recommends for its own parts. */
|
|
41
|
+
manufacturer?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Chip-passive metric codes (the "1005" of "0402 (1005 metric)") to the imperial ones the table uses. */
|
|
45
|
+
const METRIC: Record<string, string> = { "0603": "0201", "1005": "0402", "1608": "0603", "2012": "0805", "3216": "1206", "3225": "1210", "5025": "2010", "6332": "2512" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A package name's family, for names that say the same package in different words: "SOT-23-5",
|
|
49
|
+
* "SOT23-5" and "SOT23-5L" are `sot23-5`; "SOT23 leads (SMD)" with 3 pins is `sot23`; "0402 (1005
|
|
50
|
+
* metric) chip" is `chip-0402`; "QFN-32-EP", "VQFN-32" and "WQFN32" are `qfn-32`; "VSSOP-10" and
|
|
51
|
+
* "MSOP-10" are `msop-10`. Undefined for a name with no family (a module, a connector series):
|
|
52
|
+
* those match by name only. The family says nothing of pitch or size, so an entry that states them
|
|
53
|
+
* must still agree with the part's (fits).
|
|
54
|
+
*/
|
|
55
|
+
export function packageFamily(name: string, pinCount?: number): string | undefined {
|
|
56
|
+
const n = name.toLowerCase().replace(/×/g, "x");
|
|
57
|
+
const metric = /\b(\d{4})\s*metric\b/.exec(n) ?? /\(\s*(\d{4})\s*metric\s*\)/.exec(n);
|
|
58
|
+
if (metric && METRIC[metric[1]]) return `chip-${METRIC[metric[1]]}`;
|
|
59
|
+
const chip = /^(?:[a-z ]*\b)?(0201|0402|0603|0805|1206|1210|2010|2512)\b/.exec(n.trim());
|
|
60
|
+
if (chip && !/sot|qfn|dfn|son|soic|ssop|lga|bga/.test(n)) return `chip-${chip[1]}`;
|
|
61
|
+
const sot = /\b(t?sot)[\s-]?(23|223|323|353|363|563|89)(?:[\s-]?(\d{1,2})\s*l?\b)?/.exec(n);
|
|
62
|
+
if (sot) {
|
|
63
|
+
const pins = sot[3] ? Number(sot[3]) : pinCount;
|
|
64
|
+
return `${sot[1]}${sot[2]}${pins && !(sot[2] === "23" && pins === 3) ? `-${pins}` : ""}`;
|
|
65
|
+
}
|
|
66
|
+
const sc70 = /\bsc[\s-]?70(?:[\s-]?(\d))?/.exec(n);
|
|
67
|
+
if (sc70) return `sc70-${sc70[1] ?? pinCount ?? ""}`;
|
|
68
|
+
const sod = /\bsod[\s-]?(\d{3})/.exec(n);
|
|
69
|
+
if (sod) return `sod-${sod[1]}`;
|
|
70
|
+
const flat = /\b(?:[vwux]|t)?(qfn|dfn|son|lga|bga)[\s-]?(\d+)/.exec(n);
|
|
71
|
+
if (flat) return `${flat[1]}-${flat[2]}`;
|
|
72
|
+
const small = /\b(soic|so|tssop|ssop|msop|vssop|umax|µmax)[\s-]?(\d+)/.exec(n);
|
|
73
|
+
if (small) return `${{ so: "soic", vssop: "msop", umax: "msop", "µmax": "msop" }[small[1]] ?? small[1]}-${small[2]}`;
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Why an entry does not fit a part's package facts, or undefined when it fits; `by` says whether the name or the family matched. */
|
|
78
|
+
function misfit(e: FootprintEntry, pkg: PackageFacts): { by?: "name" | "family"; why?: string } {
|
|
79
|
+
const named = norm(e.match.package) === norm(pkg.name);
|
|
80
|
+
const family = !named && packageFamily(e.match.package, e.match.pin_count) !== undefined && packageFamily(e.match.package, e.match.pin_count) === packageFamily(pkg.name, pkg.pin_count);
|
|
81
|
+
if (!named && !family) return {};
|
|
82
|
+
const by = named ? ("name" as const) : ("family" as const);
|
|
83
|
+
if (e.match.manufacturer !== undefined && !(pkg.manufacturer ?? "").toLowerCase().startsWith(e.match.manufacturer.toLowerCase())) return { by, why: "maker" };
|
|
84
|
+
if (e.match.pin_count !== undefined && e.match.pin_count !== pkg.pin_count) return { by, why: `${e.match.pin_count} pins, the part ${pkg.pin_count}` };
|
|
85
|
+
if (e.match.pitch_mm !== undefined && (pkg.pitch_mm === undefined || Math.abs(e.match.pitch_mm - pkg.pitch_mm) > 1e-6)) return { by, why: pkg.pitch_mm === undefined ? `pitch ${e.match.pitch_mm} mm, the part states none` : `pitch ${e.match.pitch_mm} mm, the part ${pkg.pitch_mm} mm` };
|
|
86
|
+
if (e.match.size_mm) {
|
|
87
|
+
const l = pkg.size_mm?.length;
|
|
88
|
+
const w = pkg.size_mm?.width;
|
|
89
|
+
if (l === undefined || w === undefined) return { by, why: `body ${e.match.size_mm.join(" × ")} mm, the part states no size` };
|
|
90
|
+
const [a, b] = e.match.size_mm;
|
|
91
|
+
const close = (p: number, q: number) => Math.abs(p - q) <= 0.15;
|
|
92
|
+
if (!((close(a, l) && close(b, w)) || (close(a, w) && close(b, l)))) return { by, why: `body ${a} × ${b} mm, the part ${l} × ${w} mm` };
|
|
93
|
+
}
|
|
94
|
+
return { by };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const specificity = (e: FootprintEntry) => Object.keys(e.match).length;
|
|
98
|
+
|
|
99
|
+
export interface ResolvedFootprint {
|
|
100
|
+
entry?: FootprintEntry;
|
|
101
|
+
from?: "instance" | "project" | "provider";
|
|
102
|
+
/** How a package entry matched: the package name (normalised), or only its family. */
|
|
103
|
+
by?: "name" | "family";
|
|
104
|
+
/** Entries of the same name or family that do not fit the part's facts, with why (when nothing fits). */
|
|
105
|
+
near?: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The land pattern for one component: the project's instance override, then
|
|
110
|
+
* the project's package entries, then the provider's table. A package entry
|
|
111
|
+
* matches by the package name, else by the package family (packageFamily),
|
|
112
|
+
* and then must agree with every fact it states (pin count, pitch, body size,
|
|
113
|
+
* maker). Among those the most specific wins, a name match before a family
|
|
114
|
+
* match. When none fits, `near` says which entries of that name or family
|
|
115
|
+
* were refused and why, so a mismatch is reported instead of a guess.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveFootprint(instance: string, pkg: PackageFacts | undefined, overrides: FootprintOverrides = {}): ResolvedFootprint {
|
|
118
|
+
const inst = overrides.instances?.[instance];
|
|
119
|
+
if (inst) return { entry: inst, from: "instance" };
|
|
120
|
+
if (!pkg) return {};
|
|
121
|
+
const near: string[] = [];
|
|
122
|
+
const pick = (list: FootprintEntry[]) => {
|
|
123
|
+
const ok: { e: FootprintEntry; by: "name" | "family" }[] = [];
|
|
124
|
+
for (const e of list) {
|
|
125
|
+
const m = misfit(e, pkg);
|
|
126
|
+
if (!m.by) continue;
|
|
127
|
+
if (m.why === undefined) ok.push({ e, by: m.by });
|
|
128
|
+
else if (m.why !== "maker") near.push(`${e.id} (${m.by === "family" ? `family ${packageFamily(e.match.package, e.match.pin_count)}` : e.match.package}): ${m.why}`);
|
|
129
|
+
}
|
|
130
|
+
return ok.sort((a, b) => (a.by === b.by ? specificity(b.e) - specificity(a.e) : a.by === "name" ? -1 : 1))[0];
|
|
131
|
+
};
|
|
132
|
+
const project = pick(overrides.packages ?? []);
|
|
133
|
+
if (project) return { entry: project.e, from: "project", by: project.by };
|
|
134
|
+
const provider = pick(FOOTPRINTS);
|
|
135
|
+
if (provider) return { entry: provider.e, from: "provider", by: provider.by };
|
|
136
|
+
return near.length ? { near } : {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const builtinCache = new Map<string, Set<string>>();
|
|
140
|
+
|
|
141
|
+
/** The pad numbers a built-in footprint string generates (each pad's numeric port hint), locally. */
|
|
142
|
+
export function builtinPads(footprinter: string): Set<string> {
|
|
143
|
+
let pads = builtinCache.get(footprinter);
|
|
144
|
+
if (!pads) {
|
|
145
|
+
let cj: { type: string; port_hints?: string[] }[] = [];
|
|
146
|
+
try {
|
|
147
|
+
cj = fp.string(footprinter).circuitJson() as typeof cj;
|
|
148
|
+
} catch {
|
|
149
|
+
// an unknown string: no pads, so every pin is reported
|
|
150
|
+
}
|
|
151
|
+
pads = new Set(cj.filter((e) => e.type === "pcb_smtpad" || e.type === "pcb_plated_hole").flatMap((e) => (e.port_hints ?? []).filter((h) => /^\d+$/.test(h))));
|
|
152
|
+
builtinCache.set(footprinter, pads);
|
|
153
|
+
}
|
|
154
|
+
return pads;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Problems with an entry from a project file (it is hand-written JSON). */
|
|
158
|
+
export function entryProblems(e: FootprintEntry): string[] {
|
|
159
|
+
const out: string[] = [];
|
|
160
|
+
if (!e || typeof e !== "object") return ["is not an object"];
|
|
161
|
+
if (!e.footprinter === !e.pads) out.push("needs exactly one of footprinter or pads");
|
|
162
|
+
if (typeof e.source !== "string" || !e.source) out.push("needs a source (where the land pattern comes from)");
|
|
163
|
+
for (const p of e.pads ?? []) {
|
|
164
|
+
if (typeof p.pin !== "string" || ![p.x, p.y, p.w, p.h].every((v) => typeof v === "number" && Number.isFinite(v)) || p.w <= 0 || p.h <= 0) {
|
|
165
|
+
out.push(`pad ${JSON.stringify(p)} needs pin (string), x, y and positive w, h in mm`);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (p.drill && !(p.drill.w > 0 && p.drill.h > 0 && p.drill.w < p.w && p.drill.h < p.h)) out.push(`pad ${JSON.stringify(p)}: drill needs positive w, h smaller than the pad`);
|
|
169
|
+
}
|
|
170
|
+
if (e.holes && e.footprinter) out.push("holes are for authored pads entries only");
|
|
171
|
+
for (const h of e.holes ?? []) {
|
|
172
|
+
if (![h.x, h.y, h.diameter_mm].every((v) => typeof v === "number" && Number.isFinite(v)) || h.diameter_mm <= 0) out.push(`hole ${JSON.stringify(h)} needs x, y and a positive diameter_mm`);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- verification against the manufacturer's drawing
|
|
178
|
+
|
|
179
|
+
const TOL = 0.011;
|
|
180
|
+
const within = (v: number, d: Dim) => (Array.isArray(d) ? v >= d[0] - TOL && v <= d[1] + TOL : Math.abs(v - d) <= TOL);
|
|
181
|
+
const show = (d: Dim) => (Array.isArray(d) ? `${d[0]}–${d[1]}` : String(d));
|
|
182
|
+
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
183
|
+
/** Either way round: [w, h] matches [a, b] or [b, a]. */
|
|
184
|
+
const sizeFits = (w: number, h: number, [a, b]: [Dim, Dim]) => (within(w, a) && within(h, b)) || (within(w, b) && within(h, a));
|
|
185
|
+
|
|
186
|
+
/** Pads on one line: grouped by a shared y (a row along X) or a shared x (a column along Y), two or more pads each. */
|
|
187
|
+
function lines(pads: { x: number; y: number }[]): { axis: "x" | "y"; at: number; along: number[] }[] {
|
|
188
|
+
const out: { axis: "x" | "y"; at: number; along: number[] }[] = [];
|
|
189
|
+
for (const axis of ["x", "y"] as const) {
|
|
190
|
+
const across = axis === "x" ? "y" : "x";
|
|
191
|
+
const groups = new Map<number, number[]>();
|
|
192
|
+
for (const p of pads) {
|
|
193
|
+
const key = [...groups.keys()].find((k) => Math.abs(k - p[across]) < 0.002) ?? p[across];
|
|
194
|
+
groups.set(key, [...(groups.get(key) ?? []), p[axis]]);
|
|
195
|
+
}
|
|
196
|
+
for (const [at, along] of groups) if (along.length >= 2) out.push({ axis, at, along: along.sort((a, b) => a - b) });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Compare an entry with its verification record. Returns the problems; an
|
|
203
|
+
* entry is verified when it has a record and this returns none. Built-in
|
|
204
|
+
* (footprinter) entries cannot be verified: their copper is not stated here.
|
|
205
|
+
*/
|
|
206
|
+
export function verifyEntry(e: FootprintEntry): string[] {
|
|
207
|
+
const v: LandVerification | undefined = e.verification;
|
|
208
|
+
if (!v) return ["no verification record: not compared with the manufacturer's recommended land pattern"];
|
|
209
|
+
const out: string[] = [];
|
|
210
|
+
if (e.footprinter || !e.pads) return ["a built-in footprint string: its copper is not stated in the entry, so it cannot be compared with a drawing; author the pads"];
|
|
211
|
+
if (!v.document || !v.figure || v.page === undefined || v.page === "" || !v.checked) out.push("the record needs the document, page, figure and the date it was checked");
|
|
212
|
+
|
|
213
|
+
// pads by group
|
|
214
|
+
const smd = e.pads.filter((p) => !p.drill);
|
|
215
|
+
const grouped = new Map<string, number>();
|
|
216
|
+
v.pads.forEach((g, i) => g.pins.forEach((pin) => grouped.set(pin, i)));
|
|
217
|
+
for (const p of smd) if (!grouped.has(p.pin)) out.push(`pad ${p.pin || "(mechanical)"} at (${p.x}, ${p.y}) is in no group of the record`);
|
|
218
|
+
for (const [i, g] of v.pads.entries()) {
|
|
219
|
+
const pads = smd.filter((p) => g.pins.includes(p.pin));
|
|
220
|
+
const label = `group ${i + 1} (${g.pins.slice(0, 4).join(", ")}${g.pins.length > 4 ? ", …" : ""})`;
|
|
221
|
+
if (!pads.length) {
|
|
222
|
+
out.push(`${label}: the entry has no such pads`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const pin of g.pins) if (pin && !pads.some((p) => p.pin === pin)) out.push(`${label}: no pad for pin ${pin}`);
|
|
226
|
+
let centres: { x: number; y: number }[];
|
|
227
|
+
if (g.merged) {
|
|
228
|
+
const xs = pads.flatMap((p) => [p.x - p.w / 2, p.x + p.w / 2]);
|
|
229
|
+
const ys = pads.flatMap((p) => [p.y - p.h / 2, p.y + p.h / 2]);
|
|
230
|
+
const [w, h] = [Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)];
|
|
231
|
+
const area = pads.reduce((a, p) => a + p.w * p.h, 0);
|
|
232
|
+
if (!sizeFits(w, h, g.size)) out.push(`${label}: merged land ${r3(w)} × ${r3(h)} mm, the drawing ${show(g.size[0])} × ${show(g.size[1])}`);
|
|
233
|
+
if (Math.abs(area - w * h) > 1e-4) out.push(`${label}: the merged pads leave a gap or overlap (${r3(area)} of ${r3(w * h)} mm²)`);
|
|
234
|
+
centres = [{ x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 }];
|
|
235
|
+
} else {
|
|
236
|
+
for (const p of pads) if (!sizeFits(p.w, p.h, g.size)) out.push(`${label}: pad ${p.pin || "(mechanical)"} is ${p.w} × ${p.h} mm, the drawing ${show(g.size[0])} × ${show(g.size[1])}`);
|
|
237
|
+
centres = pads;
|
|
238
|
+
}
|
|
239
|
+
const ls = lines(centres);
|
|
240
|
+
if (g.pitch_mm !== undefined) {
|
|
241
|
+
const steps = ls.flatMap((l) => l.along.slice(1).map((a, k) => a - l.along[k]));
|
|
242
|
+
const min = steps.length ? Math.min(...steps) : undefined;
|
|
243
|
+
if (min === undefined) out.push(`${label}: pitch ${show(g.pitch_mm)} stated but no two pads share a row`);
|
|
244
|
+
else if (!within(min, g.pitch_mm)) out.push(`${label}: pitch ${r3(min)} mm, the drawing ${show(g.pitch_mm)}`);
|
|
245
|
+
}
|
|
246
|
+
if (g.row_mm !== undefined) {
|
|
247
|
+
// opposite rows: a row is a line of pads side by side, each longer across the line than
|
|
248
|
+
// along it (a row along X has pads taller than wide); every direction with two such rows
|
|
249
|
+
// must be row_mm apart (a quad's rows both ways). Without rows (square pads, a chip's two
|
|
250
|
+
// lands, merged lands) the centres' extent in X or in Y must be.
|
|
251
|
+
const rowLines = g.merged ? [] : lines(pads).filter((l) => l.along.length >= 2 && pads.filter((p) => Math.abs((l.axis === "x" ? p.y : p.x) - l.at) < 0.002).every((p) => (l.axis === "x" ? p.h > p.w : p.w > p.h)));
|
|
252
|
+
const spans = (["x", "y"] as const).map((axis) => rowLines.filter((l) => l.axis === axis).map((l) => l.at)).filter((at) => at.length >= 2).map((at) => Math.max(...at) - Math.min(...at));
|
|
253
|
+
if (spans.length) {
|
|
254
|
+
if (spans.some((sp) => !within(sp, g.row_mm!))) out.push(`${label}: rows ${spans.map(r3).join(" and ")} mm apart, the drawing ${show(g.row_mm)}`);
|
|
255
|
+
} else {
|
|
256
|
+
const ext = (["x", "y"] as const).map((k) => Math.max(...centres.map((c) => c[k])) - Math.min(...centres.map((c) => c[k])));
|
|
257
|
+
if (!ext.some((sp) => within(sp, g.row_mm!))) out.push(`${label}: pad centres span ${ext.map(r3).join(" × ")} mm, the drawing's rows ${show(g.row_mm)} apart`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// every pad centre the drawing dimensions
|
|
263
|
+
if (v.centres) {
|
|
264
|
+
const [ox, oy] = v.centres.origin;
|
|
265
|
+
const want = v.centres.points.map(([x, y]) => [r3(x - ox), r3(y - oy)]);
|
|
266
|
+
const have = smd.map((p) => [r3(p.x), r3(p.y)]);
|
|
267
|
+
const missing = want.filter(([x, y]) => !have.some(([a, b]) => Math.abs(a - x) < TOL && Math.abs(b - y) < TOL));
|
|
268
|
+
const extra = have.filter(([x, y]) => !want.some(([a, b]) => Math.abs(a - x) < TOL && Math.abs(b - y) < TOL));
|
|
269
|
+
if (missing.length) out.push(`${missing.length} pad centre(s) of the drawing have no pad: ${missing.slice(0, 4).map((c) => `(${c.join(", ")})`).join(", ")}`);
|
|
270
|
+
if (extra.length) out.push(`${extra.length} pad(s) are not at a centre the drawing gives: ${extra.slice(0, 4).map((c) => `(${c.join(", ")})`).join(", ")}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// slots and holes
|
|
274
|
+
const plated = e.pads.filter((p) => p.drill);
|
|
275
|
+
const slots = plated.filter((p) => Math.abs(p.drill!.w - p.drill!.h) > 1e-6);
|
|
276
|
+
const rounds = plated.filter((p) => Math.abs(p.drill!.w - p.drill!.h) <= 1e-6);
|
|
277
|
+
const slotWant = v.slots ?? [];
|
|
278
|
+
const slotCount = slotWant.reduce((a, s) => a + s.count, 0);
|
|
279
|
+
if (slots.length !== slotCount) out.push(`${slots.length} plated slot(s), the drawing ${slotCount}`);
|
|
280
|
+
for (const p of slots) {
|
|
281
|
+
if (!slotWant.some((s) => sizeFits(p.drill!.w, p.drill!.h, s.drill) && sizeFits(p.w, p.h, s.pad))) out.push(`slot at (${p.x}, ${p.y}): drill ${p.drill!.w} × ${p.drill!.h}, ring ${p.w} × ${p.h} mm match no slot of the drawing`);
|
|
282
|
+
}
|
|
283
|
+
const holesWant = v.holes ?? [];
|
|
284
|
+
const pth = holesWant.filter((h) => h.plated);
|
|
285
|
+
const npth = holesWant.filter((h) => !h.plated);
|
|
286
|
+
const pthCount = pth.reduce((a, h) => a + h.count, 0);
|
|
287
|
+
if (rounds.length !== pthCount) out.push(`${rounds.length} plated round hole(s), the drawing ${pthCount}`);
|
|
288
|
+
for (const p of rounds) if (!pth.some((h) => within(p.drill!.w, h.diameter_mm) && (h.pad_mm === undefined || (within(p.w, h.pad_mm) && within(p.h, h.pad_mm))))) out.push(`plated hole at (${p.x}, ${p.y}): Ø${p.drill!.w} in a ${p.w} mm pad matches no hole of the drawing`);
|
|
289
|
+
const npthCount = npth.reduce((a, h) => a + h.count, 0);
|
|
290
|
+
if ((e.holes ?? []).length !== npthCount) out.push(`${(e.holes ?? []).length} non-plated hole(s), the drawing ${npthCount}`);
|
|
291
|
+
for (const h of e.holes ?? []) if (!npth.some((n) => within(h.diameter_mm, n.diameter_mm))) out.push(`non-plated hole at (${h.x}, ${h.y}): Ø${h.diameter_mm} matches no hole of the drawing`);
|
|
292
|
+
|
|
293
|
+
// where the drawing puts the slots and holes: each stated centre has one of that kind there
|
|
294
|
+
const placed = (what: string, want: [number, number][] | undefined, count: number, have: { x: number; y: number }[]) => {
|
|
295
|
+
if (!want) return;
|
|
296
|
+
if (want.length !== count) out.push(`${what}: the record gives ${want.length} centre(s) for ${count}`);
|
|
297
|
+
const missing = want.filter(([x, y]) => !have.some((h) => Math.abs(h.x - x) < TOL && Math.abs(h.y - y) < TOL));
|
|
298
|
+
if (missing.length) out.push(`${what}: none at ${missing.slice(0, 4).map((c) => `(${c.map(r3).join(", ")})`).join(", ")}, where the drawing puts ${missing.length === 1 ? "one" : "them"}`);
|
|
299
|
+
};
|
|
300
|
+
for (const s of slotWant) placed(`slots ${s.drill.join(" × ")}`, s.at, s.count, slots.filter((p) => sizeFits(p.drill!.w, p.drill!.h, s.drill) && sizeFits(p.w, p.h, s.pad)));
|
|
301
|
+
for (const h of pth) placed(`plated holes Ø${h.diameter_mm}`, h.at, h.count, rounds.filter((p) => within(p.drill!.w, h.diameter_mm)));
|
|
302
|
+
for (const h of npth) placed(`non-plated holes Ø${h.diameter_mm}`, h.at, h.count, (e.holes ?? []).filter((x) => within(x.diameter_mm, h.diameter_mm)));
|
|
303
|
+
|
|
304
|
+
// courtyard: the recorded size, and it holds every pad and hole
|
|
305
|
+
if (!e.courtyard_mm) out.push("no courtyard_mm (the record states one)");
|
|
306
|
+
else {
|
|
307
|
+
if (!sizeFits(e.courtyard_mm[0], e.courtyard_mm[1], v.courtyard.size_mm) || !within(e.courtyard_mm[0], v.courtyard.size_mm[0])) out.push(`courtyard ${e.courtyard_mm.join(" × ")} mm, the record ${v.courtyard.size_mm.join(" × ")}`);
|
|
308
|
+
const [cx, cy] = e.courtyard_at ?? [0, 0];
|
|
309
|
+
const [hw, hh] = [e.courtyard_mm[0] / 2 + 1e-6, e.courtyard_mm[1] / 2 + 1e-6];
|
|
310
|
+
const outside = [...e.pads.map((p) => ({ x: p.x, y: p.y, w: p.w / 2, h: p.h / 2 })), ...(e.holes ?? []).map((h) => ({ x: h.x, y: h.y, w: h.diameter_mm / 2, h: h.diameter_mm / 2 }))].filter((p) => Math.abs(p.x - cx) + p.w > hw || Math.abs(p.y - cy) + p.h > hh);
|
|
311
|
+
if (outside.length) out.push(`${outside.length} pad(s) or hole(s) reach outside the courtyard`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// stencil and mask: the entry applies what the record states
|
|
315
|
+
const paste = e.paste ?? {};
|
|
316
|
+
if (Math.abs((paste.margin_mm ?? 0) - v.paste.margin_mm) > 1e-6) out.push(`paste margin ${paste.margin_mm ?? 0} mm, the record ${v.paste.margin_mm}`);
|
|
317
|
+
for (const [pin, w] of Object.entries(v.paste.windows ?? {})) {
|
|
318
|
+
const spec = paste.pins?.[pin];
|
|
319
|
+
const windows = spec && typeof spec === "object" && "windows" in spec ? spec.windows : undefined;
|
|
320
|
+
if (!windows) {
|
|
321
|
+
out.push(`pin ${pin}: the record gives ${w.count} paste window(s), the entry none`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (windows.length !== w.count) out.push(`pin ${pin}: ${windows.length} paste window(s), the drawing ${w.count}`);
|
|
325
|
+
for (const win of windows) if (!sizeFits(win.w, win.h, w.size)) out.push(`pin ${pin}: paste window ${win.w} × ${win.h} mm, the drawing ${w.size.join(" × ")}`);
|
|
326
|
+
const pad = smd.find((p) => p.pin === pin);
|
|
327
|
+
if (pad) {
|
|
328
|
+
const outside = windows.filter((win) => Math.abs(win.x - pad.x) + win.w / 2 > pad.w / 2 + 1e-6 || Math.abs(win.y - pad.y) + win.h / 2 > pad.h / 2 + 1e-6);
|
|
329
|
+
if (outside.length) out.push(`pin ${pin}: ${outside.length} paste window(s) reach outside the pad`);
|
|
330
|
+
if (w.coverage !== undefined) {
|
|
331
|
+
const cov = windows.reduce((a, win) => a + win.w * win.h, 0) / (pad.w * pad.h);
|
|
332
|
+
if (!within(cov, Array.isArray(w.coverage) ? w.coverage : [w.coverage - 0.02, w.coverage + 0.02])) out.push(`pin ${pin}: paste covers ${Math.round(cov * 100)} % of the pad, the drawing ${show(w.coverage)}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const pin of v.paste.none ?? []) if (paste.pins?.[pin] !== "none") out.push(`pin ${pin}: the record prints no paste there, the entry does`);
|
|
337
|
+
if (!!paste.plated !== !!v.paste.plated) out.push(`paste on plated holes: the entry ${paste.plated ? "prints" : "prints none"}, the record ${v.paste.plated ? "prints it" : "none"}`);
|
|
338
|
+
if (!e.mask) out.push("no mask spec (the record states the opening)");
|
|
339
|
+
else if (Math.abs(e.mask.margin_mm - v.mask.margin_mm) > 1e-6) out.push(`mask margin ${e.mask.margin_mm} mm, the record ${v.mask.margin_mm}`);
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** An entry's verification status: verified, or the first problems. */
|
|
344
|
+
export function footprintStatus(e: FootprintEntry): { verified: boolean; problems: string[] } {
|
|
345
|
+
const problems = verifyEntry(e);
|
|
346
|
+
return { verified: problems.length === 0, problems };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
/** One land pattern a board uses: the entry, where it came from, the instances on it, and its verification problems. */
|
|
351
|
+
export interface BoardLand {
|
|
352
|
+
entry: FootprintEntry;
|
|
353
|
+
from: "instance" | "project" | "provider";
|
|
354
|
+
instances: string[];
|
|
355
|
+
problems: string[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Every land pattern a board's components resolve to, with its verification status (unresolved components are generate's to report). */
|
|
359
|
+
export function boardLands(components: { instance: string; manufacturer?: string; package?: PackageFacts & { name: string } }[], overrides: FootprintOverrides = {}): BoardLand[] {
|
|
360
|
+
const out = new Map<string, BoardLand>();
|
|
361
|
+
for (const c of components) {
|
|
362
|
+
const { entry, from } = resolveFootprint(c.instance, c.package ? { ...c.package, manufacturer: c.manufacturer } : undefined, overrides);
|
|
363
|
+
if (!entry) continue;
|
|
364
|
+
const key = `${from}:${entry.id}:${from === "instance" ? c.instance : ""}`;
|
|
365
|
+
const hit = out.get(key);
|
|
366
|
+
if (hit) hit.instances.push(c.instance);
|
|
367
|
+
else out.set(key, { entry, from: from!, instances: [c.instance], problems: verifyEntry(entry) });
|
|
368
|
+
}
|
|
369
|
+
return [...out.values()];
|
|
370
|
+
}
|
package/lib/kicad/cli.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kicad-cli (KiCad 10): found on PATH, at KiCad's usual install places, or
|
|
3
|
+
* where PROTOBOARD_KICAD_CLI points. Every call is synchronous, has a timeout
|
|
4
|
+
* and reports KiCad's own words when it fails.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const PLACES = [
|
|
10
|
+
"/usr/bin/kicad-cli",
|
|
11
|
+
"/usr/local/bin/kicad-cli",
|
|
12
|
+
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
|
13
|
+
"C:\\Program Files\\KiCad\\10.0\\bin\\kicad-cli.exe",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
let found: string | null | undefined;
|
|
17
|
+
|
|
18
|
+
/** The kicad-cli to run, or undefined when KiCad is not installed. */
|
|
19
|
+
export function kicadCli(): string | undefined {
|
|
20
|
+
if (found !== undefined) return found ?? undefined;
|
|
21
|
+
const env = process.env.PROTOBOARD_KICAD_CLI;
|
|
22
|
+
const candidates = [...(env ? [env] : []), "kicad-cli", ...PLACES];
|
|
23
|
+
for (const c of candidates) {
|
|
24
|
+
if (c !== "kicad-cli" && !existsSync(c)) continue;
|
|
25
|
+
const r = spawnSync(c, ["version"], { encoding: "utf8", timeout: 20_000 });
|
|
26
|
+
if (r.status === 0 && /\d+\.\d+/.test(r.stdout)) {
|
|
27
|
+
found = c;
|
|
28
|
+
return c;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
found = null;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `kicad-cli version`, e.g. "10.0.6". */
|
|
36
|
+
export function kicadVersion(): string | undefined {
|
|
37
|
+
const cli = kicadCli();
|
|
38
|
+
if (!cli) return undefined;
|
|
39
|
+
return spawnSync(cli, ["version"], { encoding: "utf8", timeout: 20_000 }).stdout.trim() || undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CliRun {
|
|
43
|
+
status: number;
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Run kicad-cli; throws when it is not installed. A non-zero exit is returned, not thrown (DRC exits 5 on violations with --exit-code-violations). */
|
|
49
|
+
export function runKicad(args: string[], options: { cwd?: string; timeoutMs?: number } = {}): CliRun {
|
|
50
|
+
const cli = kicadCli();
|
|
51
|
+
if (!cli) throw new Error("kicad-cli (KiCad 10) is not installed or not on PATH; install KiCad 10, or set PROTOBOARD_KICAD_CLI to its kicad-cli");
|
|
52
|
+
const r = spawnSync(cli, args, { encoding: "utf8", cwd: options.cwd, timeout: options.timeoutMs ?? 300_000, maxBuffer: 64 * 1024 * 1024 });
|
|
53
|
+
if (r.error) throw new Error(`kicad-cli ${args.slice(0, 3).join(" ")}: ${r.error.message}`);
|
|
54
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Run kicad-cli and throw with its output when it exits non-zero. */
|
|
58
|
+
export function mustKicad(args: string[], options: { cwd?: string; timeoutMs?: number } = {}): CliRun {
|
|
59
|
+
const r = runKicad(args, options);
|
|
60
|
+
if (r.status !== 0) throw new Error(`kicad-cli ${args.slice(0, 3).join(" ")} failed (exit ${r.status}): ${(r.stderr || r.stdout).trim().split("\n").slice(-3).join(" ")}`);
|
|
61
|
+
return r;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A GLB exported by kicad-cli in the stack's board frame: KiCad writes glTF's
|
|
66
|
+
* own axes (metres, +Y up, (x, z, −y) of the board) with the board's bottom
|
|
67
|
+
* face at height 0; the stack's frame puts the mid-plane there (layout.ts), so
|
|
68
|
+
* one root node lowers everything by half the thickness. Nodes named by
|
|
69
|
+
* designator are renamed by instance path (as the tscircuit board.glb names
|
|
70
|
+
* them), and the export date is dropped so an unchanged board writes the same
|
|
71
|
+
* file.
|
|
72
|
+
*/
|
|
73
|
+
export function reframeKicadGlb(glb: Buffer, thicknessMm: number, instanceOf: (ref: string) => string | undefined): Buffer {
|
|
74
|
+
const jsonLength = glb.readUInt32LE(12);
|
|
75
|
+
const json = JSON.parse(glb.toString("utf8", 20, 20 + jsonLength));
|
|
76
|
+
const rest = glb.subarray(20 + jsonLength);
|
|
77
|
+
for (const n of json.nodes ?? []) if (typeof n.name === "string" && instanceOf(n.name)) n.name = instanceOf(n.name);
|
|
78
|
+
const scene = json.scenes[json.scene ?? 0];
|
|
79
|
+
json.nodes.push({ name: "board", translation: [0, -thicknessMm / 2000, 0], children: scene.nodes });
|
|
80
|
+
scene.nodes = [json.nodes.length - 1];
|
|
81
|
+
if (json.asset?.extras) delete json.asset.extras.generated_at;
|
|
82
|
+
json.asset = { ...json.asset, extras: { ...(json.asset?.extras ?? {}), frame: "glTF: metres, +Y up; board frame (mm, Z up) = (x, -z, y) × 1000, z = 0 on the mid-plane" } };
|
|
83
|
+
let text = JSON.stringify(json);
|
|
84
|
+
while (Buffer.byteLength(text) % 4) text += " ";
|
|
85
|
+
const body = Buffer.from(text);
|
|
86
|
+
const head = Buffer.alloc(20);
|
|
87
|
+
head.write("glTF", 0, "latin1");
|
|
88
|
+
head.writeUInt32LE(2, 4);
|
|
89
|
+
head.writeUInt32LE(20 + body.length + rest.length, 8);
|
|
90
|
+
head.writeUInt32LE(body.length, 12);
|
|
91
|
+
head.writeUInt32LE(0x4e4f534a, 16);
|
|
92
|
+
return Buffer.concat([head, body, rest]);
|
|
93
|
+
}
|
package/lib/kicad/drc.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KiCad's DRC of a locked board (PB-1027): one way of running it and one way
|
|
3
|
+
* of counting, for circuit:export (which stops on errors) and circuit:check
|
|
4
|
+
* (CIRCUIT_KICAD_DRC). Before, export counted one way and a person running
|
|
5
|
+
* kicad-cli by hand another (201 against 209 for one large board), and
|
|
6
|
+
* check did not run it at all, so a forced export passed every stack check.
|
|
7
|
+
* The command is always
|
|
8
|
+
*
|
|
9
|
+
* kicad-cli pcb drc --format json --severity-all --schematic-parity
|
|
10
|
+
*
|
|
11
|
+
* on a copy of the KiCad folder (kicad-cli writes its settings beside the
|
|
12
|
+
* project), and the summary names it, so the same numbers can be had by hand.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { kicadVersion, runKicad } from "./cli.ts";
|
|
19
|
+
|
|
20
|
+
export const DRC_ARGS = ["pcb", "drc", "--format", "json", "--severity-all", "--schematic-parity"] as const;
|
|
21
|
+
export const DRC_COMMAND = `kicad-cli ${DRC_ARGS.join(" ")}`;
|
|
22
|
+
|
|
23
|
+
export interface DrcReport {
|
|
24
|
+
violations?: { severity: string; type: string; description: string; items?: { description?: string }[] }[];
|
|
25
|
+
unconnected_items?: unknown[];
|
|
26
|
+
schematic_parity?: unknown[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DrcSummary {
|
|
30
|
+
errors: number;
|
|
31
|
+
warnings: number;
|
|
32
|
+
unconnected: number;
|
|
33
|
+
parity: number;
|
|
34
|
+
/** Error types, most frequent first. */
|
|
35
|
+
byType: [string, number][];
|
|
36
|
+
/** The first few errors, as "type: description". */
|
|
37
|
+
first: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The counts every report of a KiCad DRC gives: errors and warnings (KiCad's severities), unconnected items, schematic parity. */
|
|
41
|
+
export function summariseDrc(r: DrcReport): DrcSummary {
|
|
42
|
+
const v = r.violations ?? [];
|
|
43
|
+
const errors = v.filter((x) => x.severity === "error");
|
|
44
|
+
const byType = Object.entries(errors.reduce((m, x) => ({ ...m, [x.type]: (m[x.type] ?? 0) + 1 }), {} as Record<string, number>)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
45
|
+
return { errors: errors.length, warnings: v.length - errors.length, unconnected: r.unconnected_items?.length ?? 0, parity: r.schematic_parity?.length ?? 0, byType, first: errors.slice(0, 3).map((x) => `${x.type}: ${x.description}${x.items?.length ? ` (${x.items.map((i) => i.description).filter(Boolean).slice(0, 2).join(", ")})` : ""}`) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One line: "KiCad DRC: 15 error(s) (13 courtyards_overlap, 2 hole_clearance), 156 warning(s), 4 unconnected, 0 schematic parity". */
|
|
49
|
+
export const drcLine = (s: DrcSummary) =>
|
|
50
|
+
`KiCad DRC: ${s.errors} error(s)${s.byType.length ? ` (${s.byType.slice(0, 4).map(([t, n]) => `${n} ${t}`).join(", ")})` : ""}, ${s.warnings} warning(s), ${s.unconnected} unconnected, ${s.parity} schematic parity`;
|
|
51
|
+
|
|
52
|
+
/** The digest of what DRC reads in a KiCad folder: the board, its rules, the project and the schematic. */
|
|
53
|
+
export function drcInputsDigest(folder: string, board: string): string {
|
|
54
|
+
const h = createHash("sha256");
|
|
55
|
+
for (const f of [`${board}.kicad_pcb`, `${board}.kicad_dru`, `${board}.kicad_pro`, `${board}.kicad_sch`]) {
|
|
56
|
+
h.update(`${f}\0`);
|
|
57
|
+
if (existsSync(join(folder, f))) h.update(readFileSync(join(folder, f)));
|
|
58
|
+
h.update("\0");
|
|
59
|
+
}
|
|
60
|
+
h.update(kicadVersion() ?? "");
|
|
61
|
+
return h.digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run KiCad's DRC on a KiCad folder (copied first) and return its report.
|
|
66
|
+
* With `cache`, a report for the same files and KiCad version is reused from
|
|
67
|
+
* the system's temporary folder (check runs often; nothing is written into the
|
|
68
|
+
* project).
|
|
69
|
+
*/
|
|
70
|
+
export function runDrc(folder: string, board: string, options: { cache?: boolean } = {}): { report: DrcReport; raw: Buffer } {
|
|
71
|
+
const key = options.cache ? drcInputsDigest(folder, board) : undefined;
|
|
72
|
+
const cacheDir = join(tmpdir(), "protoboard-kicad-drc");
|
|
73
|
+
if (key && existsSync(join(cacheDir, `${key}.json`))) {
|
|
74
|
+
const raw = readFileSync(join(cacheDir, `${key}.json`));
|
|
75
|
+
return { report: JSON.parse(raw.toString("utf8")) as DrcReport, raw };
|
|
76
|
+
}
|
|
77
|
+
const tmp = mkdtempSync(join(tmpdir(), "pb-kicad-drc-"));
|
|
78
|
+
try {
|
|
79
|
+
cpSync(folder, join(tmp, "project"), { recursive: true, filter: (src) => !/[\\/]3d([\\/]|$)/.test(src.slice(folder.length)) || statSync(src).isDirectory() });
|
|
80
|
+
const out = join(tmp, "drc.json");
|
|
81
|
+
const r = runKicad([...DRC_ARGS, "--output", out, join(tmp, "project", `${board}.kicad_pcb`)]);
|
|
82
|
+
if (!existsSync(out)) throw new Error(`kicad-cli pcb drc wrote no report (exit ${r.status}): ${(r.stderr || r.stdout).trim().split("\n").slice(-2).join(" ")}`);
|
|
83
|
+
const raw = readFileSync(out);
|
|
84
|
+
if (key) {
|
|
85
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
86
|
+
writeFileSync(join(cacheDir, `${key}.json`), raw);
|
|
87
|
+
// a small cache: the newest reports only
|
|
88
|
+
const old = readdirSync(cacheDir).map((f) => ({ f, t: statSync(join(cacheDir, f)).mtimeMs })).sort((a, b) => b.t - a.t).slice(50);
|
|
89
|
+
for (const o of old) rmSync(join(cacheDir, o.f), { force: true });
|
|
90
|
+
}
|
|
91
|
+
return { report: JSON.parse(raw.toString("utf8")) as DrcReport, raw };
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fields each KiCad symbol and footprint carries for a component: its
|
|
3
|
+
* value, MPN, manufacturer, LCSC number and UHD identity
|
|
4
|
+
* (docs/specs/pcb-kicad-handoff.md §2). LCSC numbers come from the offers the
|
|
5
|
+
* project holds (`parts/offers.json` and `parts/evidence/<part>/sourcing.json`).
|
|
6
|
+
*/
|
|
7
|
+
import type { BoardComponent } from "@protoboardai/core";
|
|
8
|
+
import { lcscNumberOf, readProjectOffers, type ProjectOffers } from "@protoboardai/core/offers";
|
|
9
|
+
|
|
10
|
+
export interface ComponentFields {
|
|
11
|
+
Value: string;
|
|
12
|
+
MPN?: string;
|
|
13
|
+
Manufacturer?: string;
|
|
14
|
+
LCSC?: string;
|
|
15
|
+
UHD_Instance: string;
|
|
16
|
+
UHD_Part: string;
|
|
17
|
+
/** The sub-module the part sits in (its path), when it is not a child of the board itself. */
|
|
18
|
+
UHD_Group?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The field names written beside Reference, Value and Footprint, in order. */
|
|
22
|
+
export const EXTRA_FIELDS = ["MPN", "Manufacturer", "LCSC", "UHD_Instance", "UHD_Part", "UHD_Group"] as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* part id → LCSC code for a placement with this MPN, through core's one offer
|
|
26
|
+
* reader (PB-1033): the part's own sourcing evidence first for a project part,
|
|
27
|
+
* its library offers first for a library part; an offer for another MPN does
|
|
28
|
+
* not count (a series passive's offers are per value).
|
|
29
|
+
*/
|
|
30
|
+
export function lcscNumbers(root: string, offers: ProjectOffers = readProjectOffers(root)): (part: string, mpn?: string) => string | undefined {
|
|
31
|
+
return (part, mpn) => lcscNumberOf(offers, part, mpn);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SI: [number, string][] = [
|
|
35
|
+
[1e9, "G"],
|
|
36
|
+
[1e6, "M"],
|
|
37
|
+
[1e3, "k"],
|
|
38
|
+
[1, ""],
|
|
39
|
+
[1e-3, "m"],
|
|
40
|
+
[1e-6, "µ"],
|
|
41
|
+
[1e-9, "n"],
|
|
42
|
+
[1e-12, "p"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** A passive value as engineers write it: 10k, 4.7µF, 100nF, 2.2µH. */
|
|
46
|
+
export function formatValue(v: { value: number; unit: string }): string {
|
|
47
|
+
const unit = v.unit === "Ω" || /^ohm/i.test(v.unit) ? "" : v.unit;
|
|
48
|
+
const abs = Math.abs(v.value);
|
|
49
|
+
const [scale, prefix] = SI.find(([s]) => abs >= s * 0.999) ?? SI[SI.length - 1];
|
|
50
|
+
const n = Math.round((v.value / scale) * 1000) / 1000;
|
|
51
|
+
return `${n}${prefix}${unit}` || "0";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function componentFields(c: BoardComponent, lcsc: (part: string, mpn?: string) => string | undefined): ComponentFields {
|
|
55
|
+
const code = lcsc(c.part, c.mpn);
|
|
56
|
+
return {
|
|
57
|
+
Value: c.value ? formatValue(c.value) : c.mpn ?? c.name,
|
|
58
|
+
...(c.mpn ? { MPN: c.mpn } : {}),
|
|
59
|
+
...(c.manufacturer ? { Manufacturer: c.manufacturer } : {}),
|
|
60
|
+
...(code ? { LCSC: code } : {}),
|
|
61
|
+
UHD_Instance: c.instance,
|
|
62
|
+
UHD_Part: c.part,
|
|
63
|
+
...(c.group ? { UHD_Group: c.group } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|