@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,624 @@
1
+ /**
2
+ * The KiCad schematic of a board, written by the provider (PB-1032), not
3
+ * converted from tscircuit's schematic: a person reads it, so it is laid out
4
+ * the way one would draw it.
5
+ *
6
+ * symbols one per part (and pin set), pins named as the model names them
7
+ * (`IN+`, `D-`, `A`/`K`; a long note in brackets is dropped, and two
8
+ * different pins of one name become `GND`, `GND_2`), electrical types
9
+ * from the UHD leaf types (power in/out, input, output, open
10
+ * collector, bidirectional, passive), every pin on the 1.27 mm grid
11
+ * blocks one labelled block per sub-module (UHD_Group, the instance path)
12
+ * and one for the board's own parts; in a block each IC comes first
13
+ * with the passives it connects to in columns beside it
14
+ * nets a short stub and a global label at every connected pin, named by
15
+ * the board's net (no long wires, nothing through a symbol body); a
16
+ * pin on no net gets a no-connect flag
17
+ * flags a PWR_FLAG on every net with a power input and no power output,
18
+ * so ERC checks what matters
19
+ * sheet the smallest ISO sheet the blocks fit on (A4 to A0), else a user size
20
+ *
21
+ * The connections are the board's own (the pads' nets in the exported
22
+ * .kicad_pcb), so Update PCB from Schematic changes nothing.
23
+ */
24
+ import type { BoardComponent, BoardNetlist, BoardPinKind } from "@protoboardai/core";
25
+ import type { ProtocolDef } from "@deltarobotics/uhd";
26
+ import { list, str, type List, type Node } from "./sexpr.ts";
27
+
28
+ /** KiCad's electrical pin types (those used here). */
29
+ export type KicadPinType = "input" | "output" | "bidirectional" | "open_collector" | "passive" | "power_in" | "power_out" | "no_connect";
30
+
31
+ /** What a leaf interface is to KiCad: its BoardPin kind refined by its protocols' roles and capabilities. */
32
+ export function kicadPinType(kind: BoardPinKind, leaf?: { protocols?: Pick<ProtocolDef, "type" | "roles">[]; capabilities?: string[] }): KicadPinType {
33
+ if (kind === "ground" || kind === "power_in") return "power_in";
34
+ if (kind === "power_out") return "power_out";
35
+ if (kind === "passive") return "passive";
36
+ if (kind === "nc") return "no_connect";
37
+ const roles = new Set((leaf?.protocols ?? []).filter((p) => p.type !== "passive").flatMap((p) => p.roles ?? []));
38
+ const caps = leaf?.capabilities ?? [];
39
+ const input = roles.has("input") || roles.has("sink");
40
+ const output = roles.has("output") || roles.has("source");
41
+ if (roles.has("bidirectional") || (input && output)) return "bidirectional";
42
+ if (output) return caps.some((c) => c === "open_drain" || c === "open_collector") ? "open_collector" : "output";
43
+ if (input) return "input";
44
+ return "passive";
45
+ }
46
+
47
+ export interface SchematicInput {
48
+ board: string;
49
+ /** The project's library name (`<lib>:<symbol>`). */
50
+ lib: string;
51
+ netlist: BoardNetlist;
52
+ /** instance → its pads: pad number → UHD leaf interface (the build's name map; tscircuit's pad numbers are KiCad's). */
53
+ pads: Map<string, Map<string, string>>;
54
+ /** instance → designator */
55
+ refdes: Record<string, string>;
56
+ /** instance → fields as name/value pairs, in order (Value first). */
57
+ fields: Record<string, [string, string][]>;
58
+ /** instance → footprint library id */
59
+ footprint: (instance: string) => string;
60
+ /** designator → pad → net (the exported board's copper). */
61
+ padNets: Map<string, Map<string, string>>;
62
+ /** instance → leaf interface → KiCad type (absent: from the pin's kind). */
63
+ pinTypes?: Record<string, Record<string, KicadPinType>>;
64
+ /** instance → symbol UUID */
65
+ symbolUuid: Record<string, string>;
66
+ uuid: (key: string) => string;
67
+ }
68
+
69
+ export interface Schematic {
70
+ sch: List;
71
+ /** The library symbols, named `<lib>:<name>` (the project's .kicad_sym holds them without the prefix). */
72
+ libSymbols: List[];
73
+ /** instance → its symbol's library id */
74
+ symbolOf: Record<string, string>;
75
+ /** library id → pad → pin name */
76
+ pinNames: Map<string, Map<string, string>>;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Geometry
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const G = 2.54; // the grid symbols and pins sit on (a multiple of KiCad's 1.27 mm)
84
+ const FONT = 1.27;
85
+ /** Width of a string in KiCad's stroke font at 1.27 mm (a little generous). */
86
+ const textW = (s: string, size = FONT) => s.length * size * 0.92;
87
+ const up = (v: number) => Math.ceil(v / G - 1e-9) * G;
88
+ const r = (v: number) => String(Math.round(v * 1e4) / 1e4 + 0);
89
+ const at = (x: number, y: number, a = 0) => list("at", r(x), r(y), r(a));
90
+ const effects = (size = FONT, justify?: string[], hide = false) => {
91
+ const e = list("effects", list("font", list("size", r(size), r(size))));
92
+ if (justify?.length) e.items.push(list("justify", ...justify));
93
+ if (hide) e.items.push(list("hide", "yes"));
94
+ return e;
95
+ };
96
+ const stroke = (width = 0.254, type = "default") => list("stroke", list("width", r(width)), list("type", type));
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Symbols
100
+ // ---------------------------------------------------------------------------
101
+
102
+ interface PinSpec {
103
+ pad: string;
104
+ name: string;
105
+ type: KicadPinType;
106
+ }
107
+
108
+ interface SymbolSpec {
109
+ /** The library item name. */
110
+ name: string;
111
+ prefix: string;
112
+ kind: "two" | "box";
113
+ passive?: BoardComponent["kind"];
114
+ pins: PinSpec[];
115
+ /** box: pins on each side, top to bottom */
116
+ left: PinSpec[];
117
+ right: PinSpec[];
118
+ /** box: half the body's width; both: rows of pins */
119
+ hw: number;
120
+ rows: number;
121
+ }
122
+
123
+ const PASSIVES = new Set(["resistor", "capacitor", "inductor", "ferrite_bead"]);
124
+
125
+ /** A pin name KiCad shows as written: the model's name, a note in brackets dropped, spaces joined. */
126
+ export function pinName(name: string): string {
127
+ let n = name.trim();
128
+ const bracket = n.indexOf(" (");
129
+ if (bracket > 0) n = n.slice(0, bracket);
130
+ n = n.replace(/\s+/g, "_");
131
+ // KiCad's text markup (~{…}, _{…}, ^{…}) and a lone ~ (no name) are not the model's
132
+ if (/^~/.test(n) || /[~^_]\{/.test(n)) n = n.replace(/([~^_])\{/g, "$1(").replace(/^~/, "-");
133
+ return n || "~";
134
+ }
135
+
136
+ /** Side of a box symbol a pin goes on: supplies and inputs left, outputs right, ground at the bottom of the left. */
137
+ function sides(pins: PinSpec[], kindOf: (p: PinSpec) => BoardPinKind): { left: PinSpec[]; right: PinSpec[] } {
138
+ const supply = pins.filter((p) => p.type === "power_in" && kindOf(p) !== "ground");
139
+ const out = pins.filter((p) => p.type === "power_out");
140
+ const ins = pins.filter((p) => p.type === "input");
141
+ const outs = pins.filter((p) => p.type === "output" || p.type === "open_collector");
142
+ const ground = pins.filter((p) => kindOf(p) === "ground");
143
+ const rest = pins.filter((p) => ![supply, out, ins, outs, ground].some((s) => s.includes(p)));
144
+ const left = [...supply, ...ins];
145
+ const right = [...out, ...outs];
146
+ // the rest (bidirectional, passive, no-connect) fill the shorter side, in pad order
147
+ for (const p of rest) (left.length + ground.length <= right.length ? left : right).push(p);
148
+ left.push(...ground);
149
+ // a lopsided symbol is hard to read: move pins from the longer side's end until the two are within two
150
+ while (left.length - right.length > 2 && left.length > ground.length) right.push(left.splice(left.length - ground.length - 1, 1)[0]);
151
+ while (right.length - left.length > 2) left.splice(left.length - ground.length, 0, right.pop()!);
152
+ return { left, right };
153
+ }
154
+
155
+ function symbolSpec(c: BoardComponent, pads: Map<string, string>, typeOf: (pad: string, iface: string) => KicadPinType, netOf: (pad: string) => string | undefined, prefix: string): Omit<SymbolSpec, "name"> {
156
+ const byIface = new Map(c.pins.map((p) => [p.interface, p]));
157
+ const order = [...pads.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
158
+ // one name per interface, as the model names it; pins of one name stay one name while they are on one net
159
+ // (a module's ground pads), and one on another net is told apart visibly (GND, GND_2)
160
+ const nameOf = new Map<string, string>();
161
+ const firstNet = new Map<string, string | undefined>(); // shown name → the net of its first pin
162
+ const count = new Map<string, number>();
163
+ for (const [pad, iface] of order) {
164
+ if (nameOf.has(iface)) continue;
165
+ const base = pinName(byIface.get(iface)?.name ?? iface);
166
+ const net = netOf(pad);
167
+ if (!count.has(base) || firstNet.get(base) === net) {
168
+ if (!count.has(base)) (count.set(base, 1), firstNet.set(base, net));
169
+ nameOf.set(iface, base);
170
+ continue;
171
+ }
172
+ let n = count.get(base)! + 1;
173
+ while (count.has(`${base}_${n}`)) n++;
174
+ count.set(base, n);
175
+ count.set(`${base}_${n}`, 1);
176
+ firstNet.set(`${base}_${n}`, net);
177
+ nameOf.set(iface, `${base}_${n}`);
178
+ }
179
+ const pins: PinSpec[] = order.map(([pad, iface]) => ({ pad, name: nameOf.get(iface)!, type: PASSIVES.has(c.kind) ? "passive" : typeOf(pad, iface) }));
180
+ const kindByPad = new Map(order.map(([pad, iface]) => [pad, byIface.get(iface)?.kind ?? "passive"]));
181
+ if (PASSIVES.has(c.kind) && pins.length === 2) return { prefix, kind: "two", passive: c.kind, pins, left: [pins[0]], right: [pins[1]], hw: G, rows: 1 };
182
+ const { left, right } = sides(pins, (p) => kindByPad.get(p.pad)!);
183
+ const rows = Math.max(left.length, right.length, 1);
184
+ const nameW = Math.max(0, ...left.map((p) => textW(p.name))) + Math.max(0, ...right.map((p) => textW(p.name)));
185
+ const hw = Math.max(2 * G, up((nameW + 2 * G) / 2));
186
+ return { prefix, kind: "box", pins, left, right, hw, rows };
187
+ }
188
+
189
+ /** The pin's connection point in library coordinates (Y up) and its angle. */
190
+ function pinPlaces(s: SymbolSpec): Map<string, { x: number; y: number; angle: number }> {
191
+ const out = new Map<string, { x: number; y: number; angle: number }>();
192
+ if (s.kind === "two") {
193
+ out.set(s.left[0].pad, { x: -2 * G, y: 0, angle: 0 });
194
+ out.set(s.right[0].pad, { x: 2 * G, y: 0, angle: 180 });
195
+ return out;
196
+ }
197
+ s.left.forEach((p, i) => out.set(p.pad, { x: -(s.hw + G), y: -i * G, angle: 0 }));
198
+ s.right.forEach((p, i) => out.set(p.pad, { x: s.hw + G, y: -i * G, angle: 180 }));
199
+ return out;
200
+ }
201
+
202
+ function libSymbol(s: SymbolSpec, lib: string): List {
203
+ const name = s.name;
204
+ const props = [
205
+ list("property", str("Reference"), str(s.prefix), at(0, s.kind === "two" ? G : 2 * G), effects()),
206
+ list("property", str("Value"), str(name), at(0, s.kind === "two" ? -G : -(s.rows * G + G)), effects()),
207
+ list("property", str("Footprint"), str(""), at(0, 0), effects(FONT, undefined, true)),
208
+ list("property", str("Datasheet"), str(""), at(0, 0), effects(FONT, undefined, true)),
209
+ list("property", str("Description"), str(""), at(0, 0), effects(FONT, undefined, true)),
210
+ ];
211
+ const body: Node[] = [];
212
+ if (s.kind === "two") {
213
+ if (s.passive === "capacitor") {
214
+ body.push(
215
+ list("polyline", list("pts", list("xy", "-0.635", "-1.524"), list("xy", "-0.635", "1.524")), stroke(0.3048), list("fill", list("type", "none"))),
216
+ list("polyline", list("pts", list("xy", "0.635", "-1.524"), list("xy", "0.635", "1.524")), stroke(0.3048), list("fill", list("type", "none"))),
217
+ list("polyline", list("pts", list("xy", "-2.54", "0"), list("xy", "-0.635", "0")), stroke(0.254), list("fill", list("type", "none"))),
218
+ list("polyline", list("pts", list("xy", "0.635", "0"), list("xy", "2.54", "0")), stroke(0.254), list("fill", list("type", "none"))),
219
+ );
220
+ } else if (s.passive === "inductor") {
221
+ for (let i = 0; i < 4; i++) {
222
+ const x0 = -2.54 + i * 1.27;
223
+ body.push(list("arc", list("start", r(x0), "0"), list("mid", r(x0 + 0.635), "0.635"), list("end", r(x0 + 1.27), "0"), stroke(0.254), list("fill", list("type", "none"))));
224
+ }
225
+ } else {
226
+ body.push(list("rectangle", list("start", "-2.54", "1.016"), list("end", "2.54", "-1.016"), stroke(0.254), list("fill", list("type", s.passive === "ferrite_bead" ? "outline" : "none"))));
227
+ }
228
+ } else {
229
+ body.push(list("rectangle", list("start", r(-s.hw), r(G)), list("end", r(s.hw), r(-(s.rows * G))), stroke(0.254), list("fill", list("type", "background"))));
230
+ }
231
+ const places = pinPlaces(s);
232
+ const pins = s.pins.map((p) => {
233
+ const pl = places.get(p.pad)!;
234
+ return list("pin", p.type, "line", at(pl.x, pl.y, pl.angle), list("length", r(G)), list("name", str(p.name), effects()), list("number", str(p.pad), effects()));
235
+ });
236
+ return list(
237
+ "symbol",
238
+ str(`${lib}:${name}`),
239
+ ...(s.kind === "two" ? [list("pin_numbers", list("hide", "yes")), list("pin_names", list("offset", "0"), list("hide", "yes"))] : [list("pin_names", list("offset", "0.762"))]),
240
+ list("exclude_from_sim", "no"),
241
+ list("in_bom", "yes"),
242
+ list("on_board", "yes"),
243
+ ...props,
244
+ list("symbol", str(`${name}_0_1`), ...body),
245
+ list("symbol", str(`${name}_1_1`), ...pins),
246
+ list("embedded_fonts", "no"),
247
+ );
248
+ }
249
+
250
+ /** KiCad's PWR_FLAG, in the project's library. */
251
+ function pwrFlag(lib: string): List {
252
+ return list(
253
+ "symbol",
254
+ str(`${lib}:PWR_FLAG`),
255
+ list("power"),
256
+ list("pin_numbers", list("hide", "yes")),
257
+ list("pin_names", list("offset", "0"), list("hide", "yes")),
258
+ list("exclude_from_sim", "no"),
259
+ list("in_bom", "yes"),
260
+ list("on_board", "yes"),
261
+ list("property", str("Reference"), str("#FLG"), at(0, 1.905), effects(FONT, undefined, true)),
262
+ list("property", str("Value"), str("PWR_FLAG"), at(0, 3.81), effects()),
263
+ list("property", str("Footprint"), str(""), at(0, 0), effects(FONT, undefined, true)),
264
+ list("property", str("Datasheet"), str("~"), at(0, 0), effects(FONT, undefined, true)),
265
+ list("property", str("Description"), str("Special symbol for telling ERC where power comes from"), at(0, 0), effects(FONT, undefined, true)),
266
+ list("symbol", str("PWR_FLAG_0_0"), list("pin", "power_out", "line", at(0, 0, 90), list("length", "0"), list("name", str("~"), effects()), list("number", str("1"), effects()))),
267
+ list("symbol", str("PWR_FLAG_0_1"), list("polyline", list("pts", list("xy", "0", "0"), list("xy", "0", "1.27"), list("xy", "-1.016", "1.905"), list("xy", "0", "2.54"), list("xy", "1.016", "1.905"), list("xy", "0", "1.27")), stroke(0), list("fill", list("type", "none")))),
268
+ list("embedded_fonts", "no"),
269
+ );
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Layout
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /** A label's extent from its pin: the stub and the label's own width. */
277
+ const STUB = 2 * G;
278
+ const labelW = (net: string) => STUB + textW(net) + 2.6;
279
+
280
+ interface Placed {
281
+ inst: string;
282
+ spec: SymbolSpec;
283
+ /** Symbol origin, relative to its cluster (then its block, then the sheet). */
284
+ x: number;
285
+ y: number;
286
+ }
287
+
288
+ interface Box {
289
+ w: number;
290
+ h: number;
291
+ items: Placed[];
292
+ }
293
+
294
+ /** Extents of a symbol with its labels around its origin: left, right, top, bottom (sheet orientation, positive outward). */
295
+ function extent(spec: SymbolSpec, nets: Map<string, string> | undefined): { l: number; r: number; t: number; b: number } {
296
+ const lw = Math.max(0, ...spec.left.map((p) => (nets?.get(p.pad) ? labelW(nets.get(p.pad)!) : G)));
297
+ const rw = Math.max(0, ...spec.right.map((p) => (nets?.get(p.pad) ? labelW(nets.get(p.pad)!) : G)));
298
+ if (spec.kind === "two") return { l: 2 * G + lw, r: 2 * G + rw, t: 2 * G, b: 2 * G };
299
+ return { l: spec.hw + G + lw, r: spec.hw + G + rw, t: 3 * G, b: spec.rows * G + 2 * G };
300
+ }
301
+
302
+ /** Lay boxes in rows no wider than `width` (each at least its own width), with `gap` between them. */
303
+ function shelf<T extends { w: number; h: number }>(boxes: T[], width: number, gap: number): { box: T; x: number; y: number }[] {
304
+ const out: { box: T; x: number; y: number }[] = [];
305
+ let x = 0;
306
+ let y = 0;
307
+ let rowH = 0;
308
+ for (const b of boxes) {
309
+ if (x > 0 && x + b.w > width) {
310
+ x = 0;
311
+ y += rowH + gap;
312
+ rowH = 0;
313
+ }
314
+ out.push({ box: b, x, y });
315
+ x += up(b.w) + gap;
316
+ rowH = Math.max(rowH, up(b.h));
317
+ }
318
+ return out;
319
+ }
320
+
321
+ const PAPERS: [string, number, number][] = [
322
+ ["A4", 297, 210],
323
+ ["A3", 420, 297],
324
+ ["A2", 594, 420],
325
+ ["A1", 841, 594],
326
+ ["A0", 1189, 841],
327
+ ];
328
+
329
+ export function buildSchematic(input: SchematicInput): Schematic {
330
+ const { board, lib, netlist, uuid } = input;
331
+ const comps = netlist.components.filter((c) => input.refdes[c.instance] && input.pads.has(c.instance));
332
+ const netsOf = (inst: string) => input.padNets.get(input.refdes[inst]);
333
+
334
+ // ---- symbols: one per part and pin set
335
+ const specs = new Map<string, SymbolSpec>(); // instance → spec
336
+ const byKey = new Map<string, SymbolSpec>();
337
+ const names = new Set<string>(["PWR_FLAG"]);
338
+ // electrical types: the leaf's, then made to say what ERC should check
339
+ const baseType = (c: BoardComponent, pad: string, iface: string): KicadPinType => {
340
+ const kind = c.pins.find((p) => p.interface === iface)?.kind ?? "passive";
341
+ let type = input.pinTypes?.[c.instance]?.[iface] ?? kicadPinType(kind);
342
+ // a no-connect pin that is wired (tied to ground on purpose) is passive, or ERC calls the net an error
343
+ if (type === "no_connect" && netsOf(c.instance)?.get(pad)) type = "passive";
344
+ return type;
345
+ };
346
+ const demoted = new Set<string>(); // `${instance}:${pad}`: a power output that is not the net's source
347
+ const sources = new Map<string, { c: BoardComponent; pad: string }[]>(); // net → its power outputs
348
+ for (const c of comps) {
349
+ const seen = new Set<string>();
350
+ for (const [pad, iface] of [...input.pads.get(c.instance)!].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))) {
351
+ const net = netsOf(c.instance)?.get(pad);
352
+ if (!net || PASSIVES.has(c.kind) || baseType(c, pad, iface) !== "power_out") continue;
353
+ // one part's several output pins on one rail (VOUT on two pads) are one source
354
+ if (seen.has(net)) demoted.add(`${c.instance}:${pad}`);
355
+ else (seen.add(net), sources.set(net, [...(sources.get(net) ?? []), { c, pad }]));
356
+ }
357
+ }
358
+ // a connector carries the power a part on the board makes (a battery or speaker lead): the part is the source
359
+ for (const list of sources.values()) if (list.some((x) => x.c.kind !== "connector")) for (const x of list) if (x.c.kind === "connector") demoted.add(`${x.c.instance}:${x.pad}`);
360
+ for (const c of comps) {
361
+ const pads = input.pads.get(c.instance)!;
362
+ const prefix = (input.refdes[c.instance].match(/^[A-Z#]+/)?.[0] ?? "U") || "U";
363
+ const s = symbolSpec(c, pads, (pad, iface) => (demoted.has(`${c.instance}:${pad}`) ? "passive" : baseType(c, pad, iface)), (pad) => netsOf(c.instance)?.get(pad), prefix);
364
+ const key = JSON.stringify([c.part, prefix, s.kind, s.passive, s.pins, s.left.map((p) => p.pad)]);
365
+ let spec = byKey.get(key);
366
+ if (!spec) {
367
+ const base = c.part.replace(/[:/\\\s"]+/g, "_");
368
+ let name = base;
369
+ for (let n = 2; names.has(name); n++) name = `${base}_${n}`;
370
+ names.add(name);
371
+ spec = { ...s, name };
372
+ byKey.set(key, spec);
373
+ }
374
+ specs.set(c.instance, spec);
375
+ }
376
+
377
+ // ---- blocks: the board's own parts, then each sub-module in tree order
378
+ const groups = [{ path: "", name: board }, ...(netlist.groups ?? []).map((g) => ({ path: g.path, name: g.name }))];
379
+ const isIc = (c: BoardComponent) => specs.get(c.instance)!.kind === "box";
380
+ const netKind = new Map(netlist.nets.map((n) => [n.name, n.kind]));
381
+ const netKindOf = (name: string) => netKind.get(name) ?? (/gnd|ground/i.test(name) ? "ground" : "signal");
382
+ const blocks: { path: string; title: string; subtitle: string; box: Box }[] = [];
383
+ for (const g of groups) {
384
+ const members = comps.filter((c) => (c.group ?? "") === g.path);
385
+ if (!members.length) continue;
386
+ const ics = members.filter(isIc);
387
+ const passives = members.filter((c) => !isIc(c));
388
+ // each passive beside the IC it shares the most with: a signal net counts most, a supply some, ground not at all
389
+ const beside = new Map<string, BoardComponent[]>(ics.map((c) => [c.instance, []]));
390
+ const loose: BoardComponent[] = [];
391
+ for (const p of passives) {
392
+ const mine = [...(netsOf(p.instance)?.values() ?? [])];
393
+ let best: BoardComponent | undefined;
394
+ let score = 0;
395
+ for (const ic of ics) {
396
+ const theirs = new Set(netsOf(ic.instance)?.values() ?? []);
397
+ const s = mine.filter((n) => theirs.has(n)).reduce((a, n) => a + (netKindOf(n) === "signal" ? 3 : netKindOf(n) === "power" ? 1 : 0), 0);
398
+ if (s > score) (score = s), (best = ic);
399
+ }
400
+ if (best) beside.get(best.instance)!.push(p);
401
+ else loose.push(p);
402
+ }
403
+ // clusters: an IC and its passives in columns to its right; the loose passives in columns of their own
404
+ const clusters: Box[] = [];
405
+ const column = (parts: BoardComponent[], perColumn: number, x0: number, y0: number, items: Placed[]): { w: number; h: number } => {
406
+ let x = x0;
407
+ let h = 0;
408
+ for (let i = 0; i < parts.length; i += perColumn) {
409
+ const col = parts.slice(i, i + perColumn);
410
+ const ex = col.map((c) => extent(specs.get(c.instance)!, netsOf(c.instance)));
411
+ const l = up(Math.max(...ex.map((e) => e.l)));
412
+ const rr = up(Math.max(...ex.map((e) => e.r)));
413
+ col.forEach((c, k) => items.push({ inst: c.instance, spec: specs.get(c.instance)!, x: x + l, y: y0 + 2 * G + k * 3 * G }));
414
+ h = Math.max(h, col.length * 3 * G + G);
415
+ x += l + rr + G;
416
+ }
417
+ return { w: x - x0, h };
418
+ };
419
+ for (const ic of ics) {
420
+ const spec = specs.get(ic.instance)!;
421
+ const e = extent(spec, netsOf(ic.instance));
422
+ const items: Placed[] = [{ inst: ic.instance, spec, x: up(e.l), y: up(e.t) }];
423
+ const icW = up(e.l) + up(e.r);
424
+ const icH = up(e.t) + up(e.b);
425
+ const mine = beside.get(ic.instance)!;
426
+ const per = Math.max(4, Math.floor(icH / (3 * G)));
427
+ const col = mine.length ? column(mine, per, icW + G, 0, items) : { w: 0, h: 0 };
428
+ clusters.push({ w: icW + (mine.length ? G + col.w : 0), h: Math.max(icH, col.h), items });
429
+ }
430
+ if (loose.length) {
431
+ const items: Placed[] = [];
432
+ const per = Math.max(4, Math.ceil(Math.sqrt(loose.length * 3)));
433
+ const col = column(loose, per, 0, 0, items);
434
+ clusters.push({ w: col.w, h: col.h, items });
435
+ }
436
+ const area = clusters.reduce((a, c) => a + c.w * c.h, 0);
437
+ const width = Math.max(...clusters.map((c) => c.w), Math.sqrt(area) * 1.4);
438
+ const laid = shelf(clusters, width, 2 * G);
439
+ const items: Placed[] = [];
440
+ let w = 0;
441
+ let h = 0;
442
+ for (const { box, x, y } of laid) {
443
+ for (const it of box.items) items.push({ ...it, x: it.x + x, y: it.y + y });
444
+ w = Math.max(w, x + box.w);
445
+ h = Math.max(h, y + box.h);
446
+ }
447
+ blocks.push({ path: g.path, title: g.path || board, subtitle: g.path ? g.name : "the board's own parts", box: { w: up(w), h: up(h), items } });
448
+ }
449
+
450
+ // ---- the nets ERC should see as powered: a power input and no power output on them
451
+ const typeAt = new Map<string, KicadPinType[]>(); // net → pin types on it
452
+ for (const c of comps) {
453
+ const nets = netsOf(c.instance);
454
+ for (const p of specs.get(c.instance)!.pins) {
455
+ const n = nets?.get(p.pad);
456
+ if (n) typeAt.set(n, [...(typeAt.get(n) ?? []), p.type]);
457
+ }
458
+ }
459
+ const flagged = [...typeAt.entries()].filter(([, ts]) => ts.includes("power_in") && !ts.includes("power_out")).map(([n]) => n).sort((a, b) => a.localeCompare(b));
460
+ if (flagged.length) {
461
+ const items: Placed[] = [];
462
+ const per = Math.max(4, Math.ceil(Math.sqrt(flagged.length * 2)));
463
+ const colW = up(Math.max(...flagged.map((n) => 2 * G + labelW(n)))) + 2 * G;
464
+ flagged.forEach((n, i) => items.push({ inst: `#flag:${n}`, spec: { name: "PWR_FLAG", prefix: "#FLG", kind: "two", pins: [], left: [], right: [], hw: 0, rows: 0 }, x: G + Math.floor(i / per) * colW, y: 4 * G + (i % per) * 4 * G }));
465
+ const cols = Math.ceil(flagged.length / per);
466
+ blocks.push({ path: "#flags", title: "Power flags", subtitle: "nets with a power input and no power output", box: { w: cols * colW, h: Math.min(per, flagged.length) * 4 * G + 2 * G, items } });
467
+ }
468
+
469
+ // ---- the sheet: blocks in rows, each framed with its title
470
+ const PAD = 2 * G; // inside a block's frame
471
+ const TITLE = 4 * G; // the title (sub-module path) and subtitle (its name) above a block's contents
472
+ const titleW = (t: string) => t.length * 2.54 * 0.95;
473
+ const framed = blocks.map((b) => ({ ...b, w: up(Math.max(b.box.w + 2 * PAD, titleW(b.title) + 2 * PAD, textW(b.subtitle) + 2 * PAD)), h: b.box.h + 2 * PAD + TITLE }));
474
+ const MARGIN = 10 * G; // the drawing frame
475
+ const TITLE_BLOCK = 12 * G; // KiCad's title block, bottom right
476
+ const GAP = 4 * G;
477
+ // the smallest sheet the blocks fit on, packed to its width; past A0, a user size of A0's shape
478
+ const pack = (width: number) => {
479
+ const laid = shelf(framed, width, GAP);
480
+ return { laid, w: Math.max(...laid.map((l) => l.x + l.box.w)), h: Math.max(...laid.map((l) => l.y + l.box.h)) };
481
+ };
482
+ let chosen: { laid: ReturnType<typeof shelf<(typeof framed)[number]>>; w: number; h: number; paper: List } | undefined;
483
+ for (const [name, pw, ph] of PAPERS) {
484
+ const p = pack(pw - 2 * MARGIN);
485
+ if (p.w + 2 * MARGIN <= pw && p.h + 2 * MARGIN + TITLE_BLOCK <= ph) {
486
+ chosen = { ...p, paper: list("paper", str(name)) };
487
+ break;
488
+ }
489
+ }
490
+ if (!chosen) {
491
+ const area = framed.reduce((a, b) => a + (b.w + GAP) * (b.h + GAP), 0);
492
+ const p = pack(Math.max(...framed.map((b) => b.w), Math.sqrt(area * 1.414) * 1.1));
493
+ chosen = { ...p, paper: list("paper", str("User"), r(up(Math.max(p.w + 2 * MARGIN, (p.h + 2 * MARGIN + TITLE_BLOCK) * 1.414))), r(up(Math.max(p.h + 2 * MARGIN + TITLE_BLOCK, (p.w + 2 * MARGIN) / 1.414)))) };
494
+ }
495
+ const laid = chosen.laid;
496
+ const paperNode = chosen.paper;
497
+
498
+ // ---- the document
499
+ const libSymbols = [...new Map([...specs.values()].map((s) => [s.name, s])).values()].sort((a, b) => a.name.localeCompare(b.name)).map((s) => libSymbol(s, lib));
500
+ if (flagged.length) libSymbols.push(pwrFlag(lib));
501
+ const root = uuid("schematic");
502
+ const sch = list(
503
+ "kicad_sch",
504
+ list("version", "20250114"),
505
+ list("generator", str("protoboard")),
506
+ list("generator_version", str("0.1")),
507
+ list("uuid", root),
508
+ paperNode,
509
+ list("title_block", list("title", str(board)), list("comment", "1", str("From the UHD board (@protoboardai/ext-tscircuit)"))),
510
+ list("lib_symbols", ...libSymbols),
511
+ );
512
+ const drawn: List[] = [];
513
+ const labels: List[] = [];
514
+ const symbols: List[] = [];
515
+ const symbolOf: Record<string, string> = {};
516
+ const pinNames = new Map<string, Map<string, string>>();
517
+ let flagN = 0;
518
+ for (const { box: b, x: bx, y: by } of laid) {
519
+ const ox = MARGIN + bx;
520
+ const oy = MARGIN + by;
521
+ drawn.push(list("rectangle", list("start", r(ox), r(oy)), list("end", r(ox + b.w), r(oy + b.h)), stroke(0.254, "dash"), list("fill", list("type", "none")), list("uuid", uuid(`block:${b.path}`))));
522
+ drawn.push(list("text", str(b.title), list("exclude_from_sim", "no"), at(ox + PAD, oy + PAD + G), list("effects", list("font", list("size", "2.54", "2.54"), list("thickness", "0.508"), list("bold", "yes")), list("justify", "left", "bottom")), list("uuid", uuid(`title:${b.path}`))));
523
+ drawn.push(list("text", str(b.subtitle), list("exclude_from_sim", "no"), at(ox + PAD, oy + PAD + 2 * G), list("effects", list("font", list("size", r(FONT), r(FONT)), list("italic", "yes")), list("justify", "left", "bottom")), list("uuid", uuid(`subtitle:${b.path}`))));
524
+ const cx = ox + PAD;
525
+ const cy = oy + PAD + TITLE;
526
+ for (const it of b.box.items) {
527
+ const X = cx + it.x;
528
+ const Y = cy + it.y;
529
+ if (it.inst.startsWith("#flag:")) {
530
+ const net = it.inst.slice("#flag:".length);
531
+ const ref = `#FLG${String(++flagN).padStart(2, "0")}`;
532
+ const u = uuid(`flag:${net}`);
533
+ symbols.push(
534
+ list(
535
+ "symbol",
536
+ list("lib_id", str(`${lib}:PWR_FLAG`)),
537
+ at(X, Y),
538
+ list("unit", "1"),
539
+ list("exclude_from_sim", "no"),
540
+ list("in_bom", "yes"),
541
+ list("on_board", "yes"),
542
+ list("dnp", "no"),
543
+ list("uuid", u),
544
+ list("property", str("Reference"), str(ref), at(X, Y - 1.905), effects(FONT, undefined, true)),
545
+ list("property", str("Value"), str("PWR_FLAG"), at(X, Y - 3.81), effects()),
546
+ list("property", str("Footprint"), str(""), at(X, Y), effects(FONT, undefined, true)),
547
+ list("property", str("Datasheet"), str("~"), at(X, Y), effects(FONT, undefined, true)),
548
+ list("property", str("Description"), str(""), at(X, Y), effects(FONT, undefined, true)),
549
+ list("pin", str("1"), list("uuid", uuid(`flag-pin:${net}`))),
550
+ list("instances", list("project", str(board), list("path", str(`/${root}`), list("reference", str(ref)), list("unit", "1")))),
551
+ ),
552
+ );
553
+ labels.push(...stubAndLabel(X, Y, 0, net, `flag:${net}`, uuid));
554
+ continue;
555
+ }
556
+ const c = comps.find((x) => x.instance === it.inst)!;
557
+ const ref = input.refdes[c.instance];
558
+ const libId = `${lib}:${it.spec.name}`;
559
+ symbolOf[c.instance] = libId;
560
+ if (!pinNames.has(libId)) pinNames.set(libId, new Map(it.spec.pins.map((p) => [p.pad, p.name])));
561
+ const nets = netsOf(c.instance);
562
+ const places = pinPlaces(it.spec);
563
+ for (const p of it.spec.pins) {
564
+ const pl = places.get(p.pad)!;
565
+ const [px, py] = [X + pl.x, Y - pl.y];
566
+ const net = nets?.get(p.pad);
567
+ if (!net) {
568
+ labels.push(list("no_connect", list("at", r(px), r(py)), list("uuid", uuid(`nc:${ref}:${p.pad}`))));
569
+ continue;
570
+ }
571
+ // the label points away from the body: left pins to the left, right pins to the right
572
+ labels.push(...stubAndLabel(px, py, pl.angle === 0 ? 180 : 0, net, `${ref}:${p.pad}`, uuid));
573
+ }
574
+ const two = it.spec.kind === "two";
575
+ const refAt = two ? at(X, Y - 1.905) : at(X - it.spec.hw, Y - G - 0.635);
576
+ const valAt = two ? at(X, Y + 1.905) : at(X - it.spec.hw, Y + it.spec.rows * G + 0.635);
577
+ const fields = input.fields[c.instance] ?? [["Value", c.name]];
578
+ const value = fields.find(([k]) => k === "Value")?.[1] ?? c.name;
579
+ symbols.push(
580
+ list(
581
+ "symbol",
582
+ list("lib_id", str(libId)),
583
+ at(X, Y),
584
+ list("unit", "1"),
585
+ list("exclude_from_sim", "no"),
586
+ list("in_bom", "yes"),
587
+ list("on_board", "yes"),
588
+ list("dnp", "no"),
589
+ list("uuid", input.symbolUuid[c.instance]),
590
+ list("property", str("Reference"), str(ref), refAt, effects(FONT, two ? ["bottom"] : ["left", "bottom"])),
591
+ list("property", str("Value"), str(value), valAt, effects(FONT, two ? ["top"] : ["left", "top"])),
592
+ list("property", str("Footprint"), str(input.footprint(c.instance)), at(X, Y), effects(FONT, undefined, true)),
593
+ list("property", str("Datasheet"), str(""), at(X, Y), effects(FONT, undefined, true)),
594
+ list("property", str("Description"), str(c.name), at(X, Y), effects(FONT, undefined, true)),
595
+ ...fields.filter(([k]) => k !== "Value").map(([k, v]) => list("property", str(k), str(v), at(X, Y), effects(FONT, undefined, true))),
596
+ ...it.spec.pins.map((p) => list("pin", str(p.pad), list("uuid", uuid(`pin:${c.instance}:${p.pad}`)))),
597
+ list("instances", list("project", str(board), list("path", str(`/${root}`), list("reference", str(ref)), list("unit", "1")))),
598
+ ),
599
+ );
600
+ }
601
+ }
602
+ sch.items.push(...drawn, ...labels, ...symbols, list("sheet_instances", list("path", str("/"), list("page", str("1")))), list("embedded_fonts", "no"));
603
+ return { sch, libSymbols, symbolOf, pinNames };
604
+ }
605
+
606
+ /** A short wire from a pin outward and a global label at its end, named by the net. */
607
+ function stubAndLabel(x: number, y: number, angle: 0 | 180, net: string, key: string, uuid: (k: string) => string): List[] {
608
+ const dx = angle === 0 ? STUB : -STUB;
609
+ const lx = x + dx;
610
+ return [
611
+ list("wire", list("pts", list("xy", r(x), r(y)), list("xy", r(lx), r(y))), stroke(0.1524), list("uuid", uuid(`stub:${key}`))),
612
+ list(
613
+ "global_label",
614
+ str(net),
615
+ list("shape", "passive"),
616
+ at(lx, y, angle),
617
+ list("fields_autoplaced", "yes"),
618
+ effects(FONT, [angle === 0 ? "left" : "right"]),
619
+ list("uuid", uuid(`label:${key}`)),
620
+ list("property", str("Intersheetrefs"), str("${INTERSHEET_REFS}"), at(lx, y, 0), effects(FONT, undefined, true)),
621
+ ),
622
+ ];
623
+ }
624
+