@protoboardai/ext-tscircuit 0.0.0-stage → 0.2.0

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 +70 -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,594 @@
1
+ /**
2
+ * The KiCad project of a built board (docs/specs/pcb-kicad-handoff.md §2).
3
+ *
4
+ * tscircuit's own circuit-json-to-kicad writes the board, the schematic and
5
+ * the project file from circuit.json. This module wraps it:
6
+ *
7
+ * before components are renamed to their reference designators (refdes.ts),
8
+ * and their bodies are set on the circuit JSON as circuit:export
9
+ * sets them (models.ts), so the converter places each 3D model
10
+ * after the S-expressions are edited: footprints and symbols move to the
11
+ * project's own libraries (<b>.pretty, <b>.kicad_sym), 3D models
12
+ * to ${KIPRJMOD}/3d/, fields (MPN, manufacturer, LCSC, UHD identity)
13
+ * are set, each footprint is linked to its symbol, UUIDs are derived
14
+ * from the board and the instance paths, the copper layers, board
15
+ * thickness and stack-up follow the config, and the board frame's
16
+ * origin is KiCad's drill/place origin
17
+ *
18
+ * `kicad-cli pcb upgrade` / `sch upgrade` then save both in KiCad 10's own
19
+ * format (upgrade.ts); without KiCad the converter's format is kept.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { basename, isAbsolute, resolve } from "node:path";
23
+ import type { BoardNetlist } from "@protoboardai/core";
24
+ import { CircuitJsonToKicadPcbConverter, CircuitJsonToKicadProConverter } from "circuit-json-to-kicad";
25
+ import type { BoardConstraints, Keepout } from "../config.ts";
26
+ import type { CircuitElement } from "../runtime.ts";
27
+ import type { NameMap } from "../source.ts";
28
+ import { EXTRA_FIELDS, type ComponentFields } from "./fields.ts";
29
+ import { buildSchematic, type KicadPinType } from "./schematic.ts";
30
+ import { child, children, head, isList, isStr, list, parse, print, printDocument, propertyNode, str, text, type List, type Node } from "./sexpr.ts";
31
+
32
+ /** The converter places the board frame's origin here, with Y down (its c2kMatPcb). */
33
+ export const KICAD_ORIGIN: [number, number] = [100, 100];
34
+
35
+ export interface KicadProjectInput {
36
+ /** Board module id: the file stem and the library name. */
37
+ board: string;
38
+ /** The built board, with bodies applied (cad_component.model_step_url: absolute STEP paths). */
39
+ circuitJson: CircuitElement[];
40
+ map: NameMap;
41
+ netlist: BoardNetlist;
42
+ /** instance → designator */
43
+ refdes: Record<string, string>;
44
+ /** instance → fields */
45
+ fields: Record<string, ComponentFields>;
46
+ constraints?: BoardConstraints;
47
+ /** Absolute folder a relative model path is read from (the native folder: a cadModel in board.tsx). */
48
+ modelBase?: string;
49
+ /** Whether a model file can be used (exists, is a STEP file); a model that cannot is left off its footprint. */
50
+ modelOk?: (abs: string) => boolean;
51
+ /** Outer copper in oz (stack-up; default 1). */
52
+ copperOz?: number;
53
+ /** instance → leaf interface → KiCad electrical type, from the UHD leaf types (schematic.ts `kicadPinType`). */
54
+ pinTypes?: Record<string, Record<string, KicadPinType>>;
55
+ }
56
+
57
+ /**
58
+ * UUIDs renumbered in document order (the texts in the order given), except
59
+ * those in `keep`: what KiCad or the converter made up becomes the same on
60
+ * every export of the same board. References between the texts stay
61
+ * consistent (the schematic's root UUID in the project file).
62
+ */
63
+ export function stabiliseUuids(board: string, texts: string[], keep: Set<string>, salt = "n"): string[] {
64
+ const renumber = new Map<string, string>();
65
+ let n = 0;
66
+ return texts.map((t) =>
67
+ t.replace(UUID, (u) => {
68
+ if (keep.has(u)) return u;
69
+ if (!renumber.has(u)) renumber.set(u, stableUuid(board, `${salt}:${n++}`));
70
+ return renumber.get(u)!;
71
+ }),
72
+ );
73
+ }
74
+
75
+ export interface KicadProject {
76
+ /** Files relative to the KiCad folder. */
77
+ files: Record<string, string>;
78
+ /** 3D models to copy: path relative to the KiCad folder → absolute source. */
79
+ models: Record<string, string>;
80
+ /** Symbol UUID per instance (footprints link to it: Update PCB from Schematic). */
81
+ symbols: Record<string, string>;
82
+ /** The UUIDs derived from instance paths (symbols, footprints): kept when the files are renumbered again. */
83
+ keep: Set<string>;
84
+ notes: string[];
85
+ }
86
+
87
+ /** A UUID derived from the board and a key: the same board exports the same UUIDs. */
88
+ export function stableUuid(board: string, key: string): string {
89
+ const h = createHash("sha256").update(`${board}\0${key}`).digest("hex");
90
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 3) | 8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
91
+ }
92
+
93
+ /** A library item name KiCad accepts (no colon or slash). */
94
+ export const libItemName = (s: string) => s.replace(/[:/\\\s"]+/g, "_");
95
+
96
+ /** The library (footprint and symbol) of a board's project. */
97
+ export const libraryName = (board: string) => libItemName(board);
98
+
99
+ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
100
+
101
+ /** The circuit JSON as the converter should see it: references for names, the part numbers on each component. */
102
+ function prepare(input: KicadProjectInput): { cj: CircuitElement[]; nameOf: Map<string, string> } {
103
+ const nameOf = new Map<string, string>(); // refdes → tscircuit name
104
+ const renamed = new Map<string, string>(); // tscircuit name → refdes
105
+ for (const [name, m] of Object.entries(input.map.components)) {
106
+ const r = input.refdes[m.instance];
107
+ if (r) {
108
+ renamed.set(name, r);
109
+ nameOf.set(r, name);
110
+ }
111
+ }
112
+ const pcbOfSource = new Map<string, string>();
113
+ for (const e of input.circuitJson) if (e.type === "pcb_component") pcbOfSource.set(e.pcb_component_id, e.source_component_id);
114
+ const sourceName = new Map(input.circuitJson.filter((e) => e.type === "source_component").map((s) => [s.source_component_id, s.name as string]));
115
+ const cj = input.circuitJson.map((e) => {
116
+ if (e.type === "source_component" && renamed.has(e.name)) {
117
+ const inst = input.map.components[e.name].instance;
118
+ const f = input.fields[inst];
119
+ return { ...e, name: renamed.get(e.name), ...(f?.MPN ? { manufacturer_part_number: f.MPN } : {}) };
120
+ }
121
+ // the reference silk.ts printed for an authored land pattern
122
+ if (e.type === "pcb_silkscreen_text" && e.pcb_component_id) {
123
+ const old = sourceName.get(pcbOfSource.get(e.pcb_component_id) ?? "");
124
+ if (old && e.text === old && renamed.has(old)) return { ...e, text: renamed.get(old) };
125
+ }
126
+ return e;
127
+ });
128
+ return { cj, nameOf };
129
+ }
130
+
131
+ const hidden = (at: Node[]) => list("effects", list("font", list("size", "1.27", "1.27")), list("hide", "yes"));
132
+
133
+ /** Set (or add) a hidden property on a footprint or symbol. */
134
+ function setProperty(n: List, name: string, value: string, layer?: string) {
135
+ const p = propertyNode(n, name);
136
+ if (p) {
137
+ p.items[2] = str(value);
138
+ return;
139
+ }
140
+ const at = child(n, "at");
141
+ const pos = at ? [at.items[1] ?? "0", at.items[2] ?? "0", "0"] : ["0", "0", "0"];
142
+ const prop = list("property", str(name), str(value), list("at", ...(layer ? ["0", "0", "0"] : pos)), ...(layer ? [list("layer", str(layer)), list("hide", "yes")] : []), hidden([]));
143
+ // after the last property
144
+ const last = n.items.reduce((k, c, i) => (head(c) === "property" ? i : k), -1);
145
+ n.items.splice(last >= 0 ? last + 1 : n.items.length, 0, prop);
146
+ }
147
+
148
+ const fieldsOf = (f: ComponentFields): [string, string][] => EXTRA_FIELDS.filter((k) => f[k]).map((k) => [k, f[k] as string]);
149
+
150
+ /** KiCad's layer list and stack-up for 2 or 4 copper layers. */
151
+ function stackup(layers: 2 | 4, thickness: number, oz: number): List {
152
+ const cu = Math.round(0.035 * oz * 10000) / 10000;
153
+ const mask = 0.01;
154
+ const copper = layers === 4 ? ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"] : ["F.Cu", "B.Cu"];
155
+ const dielectric = Math.max(0.05, (thickness - copper.length * cu - 2 * mask) / (copper.length - 1));
156
+ const r = (v: number) => String(Math.round(v * 10000) / 10000);
157
+ const items: Node[] = [
158
+ list("layer", str("F.SilkS"), list("type", str("Top Silk Screen"))),
159
+ list("layer", str("F.Paste"), list("type", str("Top Solder Paste"))),
160
+ list("layer", str("F.Mask"), list("type", str("Top Solder Mask")), list("thickness", r(mask))),
161
+ ];
162
+ copper.forEach((l, i) => {
163
+ items.push(list("layer", str(l), list("type", str("copper")), list("thickness", r(cu))));
164
+ if (i < copper.length - 1) items.push(list("layer", str(`dielectric ${i + 1}`), list("type", str(i === 1 || layers === 2 ? "core" : "prepreg")), list("thickness", r(dielectric)), list("material", str("FR4")), list("epsilon_r", "4.5"), list("loss_tangent", "0.02")));
165
+ });
166
+ items.push(list("layer", str("B.Mask"), list("type", str("Bottom Solder Mask")), list("thickness", r(mask))), list("layer", str("B.Paste"), list("type", str("Bottom Solder Paste"))), list("layer", str("B.SilkS"), list("type", str("Bottom Silk Screen"))));
167
+ return list("stackup", ...items, list("copper_finish", str("None")), list("dielectric_constraints", "no"));
168
+ }
169
+
170
+ /**
171
+ * A footprint as its library holds it: placed geometry and nets removed. KiCad
172
+ * stores pad and text angles relative to the footprint, so they stay; a
173
+ * bottom-side copy is flipped back to the top.
174
+ */
175
+ function libraryFootprint(fp: List, name: string): List {
176
+ const out = list("footprint", str(name), list("version", "20241229"), list("generator", str("protoboard")));
177
+ const bottom = text(child(fp, "layer")?.items[1]) === "B.Cu";
178
+ const rot = Number(text(child(fp, "at")?.items[3]) ?? 0) || 0;
179
+ const flipLayer = (s: string) => (s.startsWith("F.") ? `B.${s.slice(2)}` : s.startsWith("B.") ? `F.${s.slice(2)}` : s);
180
+ const unflip = (n: Node): Node => {
181
+ if (!isList(n)) return n;
182
+ const h = head(n);
183
+ if (h === "layer" || h === "layers") return { items: n.items.map((c, i) => (i === 0 ? c : isStr(c) ? str(flipLayer(c.str)) : typeof c === "string" ? flipLayer(c) : c)) };
184
+ if (h === "at" || h === "start" || h === "end" || h === "mid" || h === "center" || h === "xy") {
185
+ const items = [...n.items];
186
+ if (items[1] !== undefined) items[1] = String(-Number(text(items[1])));
187
+ if (h === "at" && items[3] !== undefined) items[3] = String(-Number(text(items[3])));
188
+ return { items };
189
+ }
190
+ if (h === "justify") return { items: n.items.filter((c) => text(c) !== "mirror") };
191
+ return { items: n.items.map(unflip) };
192
+ };
193
+ for (const c of fp.items.slice(2)) {
194
+ if (!isList(c)) continue;
195
+ const h = head(c);
196
+ if (h === "at" || h === "uuid" || h === "path" || h === "sheetname" || h === "sheetfile" || h === "locked") continue;
197
+ if (h === "property" && (EXTRA_FIELDS as readonly string[]).includes(text(c.items[1]) ?? "")) continue;
198
+ if (h === "layer") {
199
+ out.items.push(list("layer", str("F.Cu")));
200
+ continue;
201
+ }
202
+ const strip = (n: Node): Node => (isList(n) ? { items: n.items.filter((x) => !(isList(x) && (head(x) === "net" || head(x) === "uuid"))).map(strip) } : n);
203
+ let item = strip(c) as List;
204
+ // board files hold pad and text angles absolute; a library's are the footprint's own
205
+ if (rot && (h === "pad" || h === "property" || h === "fp_text")) {
206
+ const at = child(item, "at");
207
+ if (at && at.items[3] !== undefined) {
208
+ const a = (((Number(text(at.items[3])) - rot) % 360) + 360) % 360;
209
+ at.items[3] = String(Math.round(a * 1e6) / 1e6);
210
+ }
211
+ }
212
+ if (bottom) item = unflip(item) as List;
213
+ if (h === "property" && text(item.items[1]) === "Reference") item.items[2] = str("REF**");
214
+ out.items.push(item);
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** A symbol library from the schematic's embedded symbols, names without the library prefix. */
220
+ function symbolLibrary(libSymbols: List[]): string {
221
+ const lib = list("kicad_symbol_lib", list("version", "20241209"), list("generator", str("protoboard")), list("generator_version", str("0.1")));
222
+ const seen = new Set<string>();
223
+ for (const s of libSymbols) {
224
+ if (seen.has(text(s.items[1]) ?? "")) continue;
225
+ seen.add(text(s.items[1]) ?? "");
226
+ const copy = JSON.parse(JSON.stringify(s)) as List;
227
+ const name = text(copy.items[1]) ?? "";
228
+ copy.items[1] = str(name.includes(":") ? name.slice(name.indexOf(":") + 1) : name);
229
+ lib.items.push(copy);
230
+ }
231
+ return printDocument(lib);
232
+ }
233
+
234
+ /** The footprint's reference node: a Reference property, or (older files) `(fp_text reference …)`; its value is item 2. */
235
+ const referenceNode = (fp: List): List | undefined => propertyNode(fp, "Reference") ?? children(fp, "fp_text").find((t) => text(t.items[1]) === "reference");
236
+
237
+ /** Set a footprint's attributes (`(attr smd)`, `(attr board_only exclude_from_bom …)`). */
238
+ function setAttr(fp: List, flags: string[]) {
239
+ const i = fp.items.findIndex((c) => head(c) === "attr");
240
+ const node = list("attr", ...flags);
241
+ if (i >= 0) fp.items[i] = node;
242
+ else fp.items.splice(fp.items.findIndex((c) => head(c) === "at") + 1, 0, node);
243
+ }
244
+
245
+ const num = (n: Node | undefined) => Number(text(n) ?? 0);
246
+ const fmt = (v: number) => String(Math.round(v * 1e6) / 1e6);
247
+
248
+ /**
249
+ * Pads turned with their footprint. In a board file KiCad reads a pad's angle
250
+ * as absolute; the converter writes 0 and gives the board-aligned size, which
251
+ * is the right copper but not the library's pad turned (so KiCad reports the
252
+ * footprint as differing from its library copy). For a footprint turned by a
253
+ * right angle the pad gets the footprint's angle and its own size back.
254
+ */
255
+ function normalisePads(fp: List) {
256
+ const at = child(fp, "at");
257
+ const rot = (((num(at?.items[3]) % 360) + 360) % 360);
258
+ if (!rot || rot % 90) return;
259
+ for (const p of children(fp, "pad")) {
260
+ if (child(p, "primitives")) continue;
261
+ const pat = child(p, "at");
262
+ if (!pat || num(pat.items[3])) continue;
263
+ if (rot % 180) {
264
+ const size = child(p, "size");
265
+ if (size) [size.items[1], size.items[2]] = [size.items[2], size.items[1]];
266
+ const drill = child(p, "drill");
267
+ if (drill && text(drill.items[1]) === "oval" && drill.items.length >= 4) [drill.items[2], drill.items[3]] = [drill.items[3], drill.items[2]];
268
+ }
269
+ pat.items[3] = fmt(rot);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The file a part body is copied to under `3d/` (PB-1027): each source file
275
+ * gets its own name. Bodies are often all called `body.step` (a project's
276
+ * parts/<id>/cad/body.step), so the name is the part id and the file's name;
277
+ * a name already taken by another file gets the source path's digest too.
278
+ * `taken` maps the names given so far to their sources.
279
+ */
280
+ export function modelFileName(src: string, part: string | undefined, taken: Map<string, string>): string {
281
+ for (const [name, from] of taken) if (from === src) return name;
282
+ const base = libItemName(basename(src));
283
+ const stem = part && !base.startsWith(`${libItemName(part)}-`) && base !== `${libItemName(part)}.step` ? `${libItemName(part)}-${base}` : base;
284
+ let name = `3d/${stem}`;
285
+ if (taken.has(name)) {
286
+ const dot = stem.lastIndexOf(".");
287
+ const tag = createHash("sha256").update(src).digest("hex").slice(0, 8);
288
+ name = `3d/${dot > 0 ? `${stem.slice(0, dot)}-${tag}${stem.slice(dot)}` : `${stem}-${tag}`}`;
289
+ }
290
+ taken.set(name, src);
291
+ return name;
292
+ }
293
+
294
+ /** A footprint with nothing in it: no pad, no drawing, no model (the converter's "Unknown" for a pcb_component with no source). */
295
+ export function emptyFootprint(fp: List): boolean {
296
+ return !fp.items.some((c) => isList(c) && /^(pad|fp_line|fp_rect|fp_circle|fp_arc|fp_poly|fp_curve|model|zone)$/.test(head(c) ?? ""));
297
+ }
298
+
299
+ /** A keep-out's corners in KiCad's frame (origin KICAD_ORIGIN, Y down), as a bounding box. */
300
+ function keepoutBox(k: Keepout): [number, number, number, number] {
301
+ const [cx, cy, w, h] = "rect" in k.shape ? [k.shape.rect.at[0], k.shape.rect.at[1], k.shape.rect.size[0], k.shape.rect.size[1]] : [k.shape.circle.at[0], k.shape.circle.at[1], k.shape.circle.diameter_mm, k.shape.circle.diameter_mm];
302
+ return [KICAD_ORIGIN[0] + cx - w / 2, KICAD_ORIGIN[1] - cy - h / 2, KICAD_ORIGIN[0] + cx + w / 2, KICAD_ORIGIN[1] - cy + h / 2];
303
+ }
304
+
305
+ /**
306
+ * Keep-outs that let some components' bodies over them (`allow_bodies`: the
307
+ * module whose own antenna the keep-out clears) in KiCad (PB-1027, F23).
308
+ * KiCad's keep-out areas forbid every footprint or none, so each such zone is
309
+ * named after its keep-out and allows footprints, and a custom rule in the
310
+ * project's `<b>.kicad_dru` forbids every footprint but the allowed ones
311
+ * there. Tracks, vias, pads and pours stay forbidden. Returns the rules file
312
+ * (undefined when no keep-out has exceptions).
313
+ */
314
+ export function keepoutExceptions(pcb: List, keepouts: Keepout[], refdes: Record<string, string>): { rules?: string; notes: string[] } {
315
+ const notes: string[] = [];
316
+ const rules: string[] = [];
317
+ const zones = children(pcb, "zone").filter((z) => child(z, "keepout"));
318
+ for (const k of keepouts) {
319
+ if (!k.allow_bodies?.length) continue;
320
+ const [x0, y0, x1, y1] = keepoutBox(k);
321
+ const near = (a: number, b: number) => Math.abs(a - b) < 0.02;
322
+ const mine = zones.filter((z) => {
323
+ const pts = children(child(child(z, "polygon") ?? list("polygon"), "pts") ?? list("pts"), "xy").map((p) => [num(p.items[1]), num(p.items[2])] as const);
324
+ if (!pts.length) return false;
325
+ const xs = pts.map((p) => p[0]);
326
+ const ys = pts.map((p) => p[1]);
327
+ return near(Math.min(...xs), x0) && near(Math.max(...xs), x1) && near(Math.min(...ys), y0) && near(Math.max(...ys), y1);
328
+ });
329
+ if (!mine.length) {
330
+ notes.push(`keep-out ${k.name}: no KiCad keep-out area was found for it, so its exception for ${k.allow_bodies.join(", ")} is not in KiCad`);
331
+ continue;
332
+ }
333
+ const name = libItemName(k.name);
334
+ for (const z of mine) {
335
+ const at = z.items.findIndex((c) => head(c) === "name");
336
+ if (at >= 0) z.items[at] = list("name", str(name));
337
+ else z.items.splice(z.items.findIndex((c) => head(c) === "uuid") + 1 || 1, 0, list("name", str(name)));
338
+ const fps = child(child(z, "keepout")!, "footprints");
339
+ if (fps) fps.items[1] = "allowed";
340
+ }
341
+ const refs = k.allow_bodies.map((i) => refdes[i] ?? i);
342
+ rules.push(`\t(rule "keepout ${name}: no footprint but ${refs.join(", ")}"\n\t\t(constraint disallow footprint)\n\t\t(condition "A.intersectsArea('${name}')${refs.map((r) => ` && A.Reference != '${r}'`).join("")}"))`);
343
+ }
344
+ return { ...(rules.length ? { rules: rules.join("\n") } : {}), notes };
345
+ }
346
+
347
+ /**
348
+ * The project's custom DRC rules (`<b>.kicad_dru`): the config's via size as a
349
+ * minimum for every via (PB-1027, F31: KiCad's net class via size is what new
350
+ * vias get, not a limit, so a via made smaller by hand passed), and the
351
+ * keep-out exceptions (keepoutExceptions).
352
+ */
353
+ export function customRules(constraints: BoardConstraints | undefined, keepoutRules?: string): string | undefined {
354
+ const r = constraints?.rules;
355
+ const lines: string[] = [];
356
+ if (r) lines.push(`\t(rule "via size (circuit.config.ts rules)"\n\t\t(condition "A.Type == 'Via'")\n\t\t(constraint via_diameter (min ${r.via_pad_mm}mm))\n\t\t(constraint hole_size (min ${r.via_drill_mm}mm)))`);
357
+ if (keepoutRules) lines.push(keepoutRules);
358
+ if (!lines.length) return undefined;
359
+ return `(version 1)\n# Written by @protoboardai/ext-tscircuit from circuit.config.ts: the via rules as minimums, and keep-outs that\n# let named bodies over them (allow_bodies; their KiCad areas allow footprints and a rule forbids all others).\n${lines.join("\n")}\n`;
360
+ }
361
+
362
+ export function kicadProject(input: KicadProjectInput): KicadProject {
363
+ const { board } = input;
364
+ const lib = libraryName(board);
365
+ const notes: string[] = [];
366
+ const { cj, nameOf } = prepare(input);
367
+ const instanceOf = (ref: string) => {
368
+ const name = nameOf.get(ref);
369
+ return name ? input.map.components[name] : undefined;
370
+ };
371
+
372
+ const pcbConv = new CircuitJsonToKicadPcbConverter(cj as never, { projectName: board });
373
+ pcbConv.runUntilFinished();
374
+ const proConv = new CircuitJsonToKicadProConverter(cj as never, { projectName: board, schematicFilename: `${board}.kicad_sch`, pcbFilename: `${board}.kicad_pcb` });
375
+ proConv.runUntilFinished();
376
+ const pcb = parse(pcbConv.getOutputString());
377
+
378
+ const keep = new Set<string>(); // UUIDs derived on purpose: not renumbered below
379
+ const symbols: Record<string, string> = {};
380
+ for (const c of input.netlist.components) {
381
+ symbols[c.instance] = stableUuid(board, `symbol:${c.instance}`);
382
+ keep.add(symbols[c.instance]);
383
+ }
384
+
385
+ const fpByInstance = new Map<string, string>();
386
+ for (const m of Object.values(input.map.components)) fpByInstance.set(m.instance, `${lib}:${libItemName(m.footprint)}`);
387
+ const footprintOf = (inst: string) => fpByInstance.get(inst) ?? `${lib}:${libItemName(inst)}`;
388
+ // the footprint carries the symbol's description (the part's name) and datasheet
389
+ const symbolText = new Map<string, Record<string, string>>(input.netlist.components.map((c) => [c.instance, { Description: c.name, Datasheet: "" }]));
390
+
391
+ // ---- board: footprints, fields, models, links to the symbols, layers, thickness, origin
392
+ const models: Record<string, string> = {};
393
+ const modelNames = new Map<string, string>(); // 3d/<file> → source
394
+ const library = new Map<string, List>();
395
+ let empty = 0;
396
+ const padNets = new Map<string, Map<string, string>>(); // refdes → pad → net
397
+ let holes = 0;
398
+ const dropped = new Set<string>();
399
+ const unnetted: { ref: string; fp: List }[] = [];
400
+ for (const fp of children(pcb, "footprint")) {
401
+ const refNode = referenceNode(fp);
402
+ let ref = text(refNode?.items[2]) ?? "";
403
+ // a footprint with nothing in it and no component (the converter's "Unknown" for a pcb_component without a
404
+ // source component): left out, not written as an empty "MountingHole" (PB-1027, F21)
405
+ if (!instanceOf(ref) && emptyFootprint(fp)) {
406
+ pcb.items = pcb.items.filter((c) => c !== fp);
407
+ empty++;
408
+ continue;
409
+ }
410
+ // a footprint with no component (a mounting hole): a designator, and nothing in the schematic, BOM or placement
411
+ if (!ref) {
412
+ ref = `H${++holes}`;
413
+ if (refNode) refNode.items[2] = str(ref);
414
+ else {
415
+ setProperty(fp, "Reference", ref, "F.Fab");
416
+ setProperty(fp, "Value", "MountingHole", "F.Fab");
417
+ }
418
+ setAttr(fp, ["board_only", "exclude_from_pos_files", "exclude_from_bom"]);
419
+ }
420
+ normalisePads(fp);
421
+ const m = instanceOf(ref);
422
+ const entry = m ? libItemName(m.footprint) : libItemName((text(fp.items[1]) ?? "footprint").replace(/^[^:]*:/, ""));
423
+ fp.items[1] = str(`${lib}:${entry}`);
424
+ if (m) {
425
+ const f = input.fields[m.instance];
426
+ if (f) {
427
+ setProperty(fp, "Value", f.Value);
428
+ for (const [k, v] of fieldsOf(f)) setProperty(fp, k, v, "F.Fab");
429
+ }
430
+ for (const [k, v] of Object.entries(symbolText.get(m.instance) ?? {})) setProperty(fp, k, v, "F.Fab");
431
+ const pads = children(fp, "pad");
432
+ setAttr(fp, [pads.length && pads.every((p) => text(p.items[2]) === "smd") ? "smd" : "through_hole"]);
433
+ const nets = new Map<string, string>();
434
+ for (const p of pads) {
435
+ const net = child(p, "net");
436
+ const name = net ? text(net.items[net.items.length - 1]) : undefined;
437
+ if (name && text(p.items[1]) && !nets.has(text(p.items[1])!)) nets.set(text(p.items[1])!, name);
438
+ }
439
+ padNets.set(ref, nets);
440
+ unnetted.push({ ref, fp });
441
+ const uuid = child(fp, "uuid");
442
+ const fpUuid = stableUuid(board, `footprint:${m.instance}`);
443
+ keep.add(fpUuid);
444
+ if (uuid) uuid.items[1] = fpUuid;
445
+ // the symbol this footprint belongs to (root sheet)
446
+ const at = fp.items.findIndex((c) => head(c) === "at");
447
+ fp.items.splice(at + 1, 0, list("path", str(`/${symbols[m.instance]}`)), list("sheetname", str("/")), list("sheetfile", str(`${board}.kicad_sch`)));
448
+ }
449
+ for (const model of children(fp, "model")) {
450
+ const raw = text(model.items[1]) ?? "";
451
+ if (!raw || raw.startsWith("${KIPRJMOD}")) continue;
452
+ const src = isAbsolute(raw) || !input.modelBase ? raw : resolve(input.modelBase, raw);
453
+ if (input.modelOk && !input.modelOk(src)) {
454
+ fp.items = fp.items.filter((c) => c !== model);
455
+ dropped.add(`${ref} (${basename(src)})`);
456
+ continue;
457
+ }
458
+ const file = modelFileName(src, m ? input.fields[m.instance]?.UHD_Part : undefined, modelNames);
459
+ models[file] = src;
460
+ model.items[1] = str(`\${KIPRJMOD}/${file}`);
461
+ }
462
+ // the library's copy: an unturned top-side instance when there is one, else a top-side one
463
+ const rank = (f: List) => (text(child(f, "layer")?.items[1]) === "B.Cu" ? 2 : num(child(f, "at")?.items[3]) % 360 ? 1 : 0);
464
+ const prev = library.get(entry);
465
+ if (!prev || rank(fp) < rank(prev)) library.set(entry, fp);
466
+ }
467
+
468
+ // ---- one KiCad group per sub-module, named by its path and nested as the sub-modules nest (docs/specs/nested-boards.md §8)
469
+ const placed = new Set(children(pcb, "footprint").map((fp) => instanceOf(text(referenceNode(fp)?.items[2]) ?? "")?.instance).filter(Boolean));
470
+ const made = new Set<string>();
471
+ const groupNodes: List[] = [];
472
+ // innermost first, so a group lists only the sub-groups that exist
473
+ for (const g of [...(input.netlist.groups ?? [])].reverse()) {
474
+ const members = [
475
+ ...input.netlist.components.filter((c) => c.group === g.path && placed.has(c.instance)).map((c) => stableUuid(board, `footprint:${c.instance}`)),
476
+ ...(input.netlist.groups ?? []).filter((x) => x.parent === g.path && made.has(x.path)).map((x) => stableUuid(board, `group:${x.path}`)),
477
+ ];
478
+ if (!members.length) continue;
479
+ made.add(g.path);
480
+ const uuid = stableUuid(board, `group:${g.path}`);
481
+ keep.add(uuid);
482
+ groupNodes.unshift(list("group", str(g.path), list("uuid", uuid), list("members", ...members)));
483
+ }
484
+ pcb.items.push(...groupNodes);
485
+
486
+ const general = child(pcb, "general");
487
+ const thickness = input.constraints?.thickness_mm;
488
+ if (general && thickness) {
489
+ const t = child(general, "thickness");
490
+ if (t) t.items[1] = String(thickness);
491
+ else general.items.push(list("thickness", String(thickness)));
492
+ }
493
+ const want = input.constraints?.layers;
494
+ const layersNode = child(pcb, "layers");
495
+ if (layersNode && want === 2) {
496
+ const before = layersNode.items.length;
497
+ layersNode.items = layersNode.items.filter((l) => !(isList(l) && /^In\d+\.Cu$/.test(text(l.items[1]) ?? "")));
498
+ if (layersNode.items.length !== before) notes.push("inner copper layers removed: the config states 2 layers");
499
+ }
500
+ const setup = child(pcb, "setup") ?? (pcb.items.splice(pcb.items.findIndex((c) => head(c) === "layers") + 1, 0, list("setup")), child(pcb, "setup")!);
501
+ setup.items = setup.items.filter((c) => !["stackup", "aux_axis_origin", "grid_origin"].includes(head(c) ?? ""));
502
+ const copperLayers = layersNode ? layersNode.items.filter((l) => isList(l) && /\.Cu$/.test(text(l.items[1]) ?? "")).length : 2;
503
+ setup.items.splice(1, 0, stackup(copperLayers >= 4 ? 4 : 2, thickness ?? 1.6, input.copperOz ?? 1));
504
+ setup.items.push(list("aux_axis_origin", String(KICAD_ORIGIN[0]), String(KICAD_ORIGIN[1])), list("grid_origin", String(KICAD_ORIGIN[0]), String(KICAD_ORIGIN[1])));
505
+
506
+ if (empty) notes.push(`${empty} empty footprint(s) with no component left out (the converter writes one for a tscircuit pcb_component with no source component)`);
507
+ // ---- keep-outs whose exceptions KiCad's areas cannot hold: a custom rule (PB-1027, F23)
508
+ const exceptions = keepoutExceptions(pcb, input.constraints?.keepouts ?? [], input.refdes);
509
+ notes.push(...exceptions.notes);
510
+ if (dropped.size) notes.push(`${dropped.size} footprint(s) have no 3D model: their body file is missing or not a STEP file (${[...dropped].slice(0, 4).join(", ")}${dropped.size > 4 ? ", …" : ""})`);
511
+
512
+ // ---- pads without a net: a pad numbered like a netted one is the same pin (several lands of one
513
+ // pin: a module's ground pads, a receptacle's shield) and takes its net; a pin on no net takes
514
+ // KiCad's own "unconnected-(…)" net, as Update PCB from Schematic would give it
515
+ // ---- the schematic (schematic.ts): written from the model and the board's pad nets, not converted
516
+ const pads = new Map<string, Map<string, string>>();
517
+ for (const m of Object.values(input.map.components)) {
518
+ const byPad = new Map<string, string>();
519
+ for (const [port, iface] of Object.entries(m.ports)) if (iface) byPad.set(port.replace(/^pin/, ""), iface);
520
+ pads.set(m.instance, byPad);
521
+ }
522
+ const schematic = buildSchematic({
523
+ board,
524
+ lib,
525
+ netlist: input.netlist,
526
+ pads,
527
+ refdes: input.refdes,
528
+ fields: Object.fromEntries(Object.entries(input.fields).map(([inst, f]) => [inst, [["Value", f.Value] as [string, string], ...fieldsOf(f)]])),
529
+ footprint: footprintOf,
530
+ padNets,
531
+ pinTypes: input.pinTypes,
532
+ symbolUuid: symbols,
533
+ uuid: (key) => stableUuid(board, `sch:${key}`),
534
+ });
535
+ const sch = schematic.sch;
536
+ const libSymbols = schematic.libSymbols;
537
+ const refOf = new Map(Object.entries(input.refdes).map(([i, r]) => [r, i]));
538
+ const pinNames = schematic.pinNames;
539
+ const symbolOf = new Map([...refOf].map(([r, i]) => [r, schematic.symbolOf[i] ?? ""]));
540
+ const header = children(pcb, "net");
541
+ const netNumber = new Map(header.map((n) => [text(n.items[2]) ?? "", text(n.items[1]) ?? ""]));
542
+ let next = Math.max(0, ...header.map((n) => Number(text(n.items[1]) ?? 0))) + 1;
543
+ const numbered = header.length > 0 && /^\d+$/.test(text(header[0].items[1]) ?? "");
544
+ const joined: string[] = [];
545
+ const added: List[] = [];
546
+ for (const { ref, fp } of unnetted) {
547
+ const nets = padNets.get(ref)!;
548
+ for (const p of children(fp, "pad")) {
549
+ const n = text(p.items[1]) ?? "";
550
+ if (!n || child(p, "net")) continue;
551
+ let name = nets.get(n);
552
+ if (name) joined.push(`${ref}.${n}`);
553
+ else {
554
+ const pin = pinNames.get(symbolOf.get(ref) ?? "")?.get(n);
555
+ if (pin === undefined) continue; // a mechanical pad with no pin
556
+ name = `unconnected-(${ref}${pin && pin !== "~" ? `-${pin}` : ""}-Pad${n})`;
557
+ }
558
+ if (numbered && !netNumber.has(name)) {
559
+ netNumber.set(name, String(next));
560
+ added.push(list("net", String(next++), str(name)));
561
+ }
562
+ const net = numbered ? list("net", netNumber.get(name)!, str(name)) : list("net", str(name));
563
+ const after = p.items.findIndex((c) => head(c) === "layers");
564
+ p.items.splice(after >= 0 ? after + 1 : p.items.length, 0, net);
565
+ }
566
+ }
567
+ if (added.length) pcb.items.splice(pcb.items.indexOf(header[header.length - 1]) + 1, 0, ...added);
568
+ if (joined.length) notes.push(`${joined.length} pad(s) sharing a pin number with a netted pad were given its net (${joined.slice(0, 4).join(", ")}${joined.length > 4 ? ", …" : ""}); tscircuit had left them off the net, so KiCad's DRC may report them unrouted`);
569
+
570
+ // ---- the project's libraries
571
+ const files: Record<string, string> = {};
572
+ for (const [entry, fp] of [...library.entries()].sort(([a], [b]) => a.localeCompare(b))) files[`${lib}.pretty/${entry}.kicad_mod`] = printDocument(libraryFootprint(fp, entry));
573
+ files[`${lib}.kicad_sym`] = symbolLibrary(libSymbols);
574
+ const dru = customRules(input.constraints, exceptions.rules);
575
+ if (dru) files[`${board}.kicad_dru`] = dru;
576
+ files["fp-lib-table"] = `(fp_lib_table\n\t(version 7)\n\t(lib (name "${lib}") (type "KiCad") (uri "\${KIPRJMOD}/${lib}.pretty") (options "") (descr "${board}: footprints exported by @protoboardai/ext-tscircuit"))\n)\n`;
577
+ files["sym-lib-table"] = `(sym_lib_table\n\t(version 7)\n\t(lib (name "${lib}") (type "KiCad") (uri "\${KIPRJMOD}/${lib}.kicad_sym") (options "") (descr "${board}: symbols exported by @protoboardai/ext-tscircuit"))\n)\n`;
578
+
579
+ // ---- stable UUIDs everywhere else: renumbered in document order (schematic, board, project)
580
+ const [schText, pcbText, proText] = stabiliseUuids(board, [printDocument(sch), printDocument(pcb), proConv.getOutputString()], keep);
581
+ const pro = JSON.parse(proText) as Record<string, unknown> & { head?: Record<string, unknown>; meta?: Record<string, unknown>; net_settings?: { classes?: Record<string, unknown>[] } };
582
+ // a project file without dates: the same board exports the same file
583
+ if (pro.head) for (const k of ["created", "modified"]) delete pro.head[k];
584
+ const rules = input.constraints?.rules;
585
+ if (rules && pro.net_settings?.classes?.[0]) Object.assign(pro.net_settings.classes[0], { track_width: rules.trace_width_mm, via_diameter: rules.via_pad_mm, via_drill: rules.via_drill_mm });
586
+ // the config's edge clearance is the board's rule (KiCad's default is 0.5 mm)
587
+ const board_ = (pro as { board?: { design_settings?: { rules?: Record<string, number> } } }).board;
588
+ if (rules && board_?.design_settings) board_.design_settings.rules = { ...(board_.design_settings.rules ?? {}), min_copper_edge_clearance: rules.edge_clearance_mm };
589
+ files[`${board}.kicad_sch`] = schText;
590
+ files[`${board}.kicad_pcb`] = pcbText;
591
+ files[`${board}.kicad_pro`] = JSON.stringify(pro, null, 2) + "\n";
592
+ void print;
593
+ return { files, models, symbols, keep, notes };
594
+ }