@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
package/lib/source.ts ADDED
@@ -0,0 +1,472 @@
1
+ /**
2
+ * generate's writer: board netlist + land patterns + constraints → tscircuit
3
+ * source (TSX), plus the map from tscircuit names back to UHD instance paths
4
+ * and interfaces.
5
+ *
6
+ * The generated file is the provider's native design. Hand work goes in
7
+ * `layout.json` (positions for parts the enclosure does not fix), in
8
+ * `footprints.json` (land-pattern overrides) or in `board.tsx`, which imports
9
+ * the generated board and is never overwritten.
10
+ */
11
+ import type { BoardComponent, BoardNetlist } from "@protoboardai/core";
12
+ import { traceWidthOf, type BoardConstraints, type Placement } from "./config.ts";
13
+ import polygonClipping from "polygon-clipping";
14
+ import type { CadModelProp } from "./models.ts";
15
+ import type { SupplierNumbers } from "./suppliers.ts";
16
+ import { builtinPads, resolveFootprint, type FootprintEntry, type FootprintOverrides, type LandPad } from "./footprints.ts";
17
+
18
+ /** One stencil aperture in the land pattern's frame (mm); `pill` rounds its short ends. */
19
+ export interface PasteAperture {
20
+ x: number;
21
+ y: number;
22
+ w: number;
23
+ h: number;
24
+ pill?: boolean;
25
+ }
26
+
27
+ export const MAP_SCHEMA = "protoboard.tscircuit-map/v0";
28
+
29
+ /** How the native design's names relate to the UHD board (written as `tscircuit/build.json`). */
30
+ export interface NameMap {
31
+ schema: typeof MAP_SCHEMA;
32
+ board: string;
33
+ components: Record<
34
+ string,
35
+ {
36
+ /** UHD instance path. */
37
+ instance: string;
38
+ part: string;
39
+ /** tscircuit pad number (`pinN`) → UHD leaf interface id; null for a mechanical pad with no pin. */
40
+ ports: Record<string, string | null>;
41
+ footprint: string;
42
+ footprintFrom: "instance" | "project" | "provider";
43
+ placement: "fixed" | "layout" | "auto";
44
+ /**
45
+ * Centre of the land pattern's pad extents in its own frame (mm), when it is not the
46
+ * origin. tscircuit centres a component on its pads, so the body's origin (the pattern's)
47
+ * is this far from the component's centre (models.ts).
48
+ */
49
+ padCentre?: [number, number];
50
+ /**
51
+ * The stencil this land pattern asks for (authored entries): every aperture in the
52
+ * pattern's frame, and a pad with a port hint no other pad has, whose position ties the
53
+ * frame to the board (paste.ts). Absent for built-in footprint strings.
54
+ */
55
+ paste?: { apertures: PasteAperture[]; anchor: { hint: string; x: number; y: number } };
56
+ /**
57
+ * Straight copper between the pads of a pin with several (tscircuit port names: `pin49` and its
58
+ * internal ports `pin49_internal_k`), laid on the built board after routing (native.ts joinPads):
59
+ * the router never sees them, so they cannot move the rest of the routing.
60
+ */
61
+ padJoins?: { a: string; b: string; width: number }[];
62
+ }
63
+ >;
64
+ /** tscircuit net name → UHD net id. */
65
+ nets: Record<string, string>;
66
+ }
67
+
68
+ export interface GenerateInput {
69
+ netlist: BoardNetlist;
70
+ constraints: BoardConstraints;
71
+ overrides?: FootprintOverrides;
72
+ /** Positions from `layout.json`, by instance path. */
73
+ layout?: Record<string, Placement>;
74
+ /** Supplier part numbers by instance (suppliers.ts), written as `supplierPartNumbers`. */
75
+ suppliers?: Record<string, SupplierNumbers>;
76
+ /** Body models by instance (models.ts cadModels), written as `cadModel`. */
77
+ models?: Record<string, CadModelProp>;
78
+ }
79
+
80
+ export interface GenerateOutput {
81
+ source: string;
82
+ map: NameMap;
83
+ problems: string[];
84
+ /** Warnings that do not stop generate (a layout.json key that names nothing on the board). */
85
+ notes: string[];
86
+ }
87
+
88
+ const r4 = (v: number) => Math.round(v * 1e4) / 1e4 + 0;
89
+
90
+ /**
91
+ * A part's placement in a sub-module's frame, carried to the board (docs/specs/nested-boards.md §7):
92
+ * a frame on the bottom turns the sub-module over about its own Y axis (x mirrored, rotation negated,
93
+ * side swapped), then the frame's rotation and position apply.
94
+ */
95
+ export function inFrame(frame: Placement, local: Placement): Placement {
96
+ const bottom = frame.side === "bottom";
97
+ const [x0, y0] = [bottom ? -local.at[0] : local.at[0], local.at[1]];
98
+ const r0 = bottom ? -local.rotation_deg : local.rotation_deg;
99
+ const a = (frame.rotation_deg * Math.PI) / 180;
100
+ const at: [number, number] = [r4(frame.at[0] + x0 * Math.cos(a) - y0 * Math.sin(a)), r4(frame.at[1] + x0 * Math.sin(a) + y0 * Math.cos(a))];
101
+ const rotation_deg = r4((((r0 + frame.rotation_deg) % 360) + 360) % 360);
102
+ const side = bottom ? (local.side === "bottom" ? "top" : "bottom") : local.side;
103
+ return { at, rotation_deg, side };
104
+ }
105
+
106
+ /**
107
+ * Where a component goes: its own placement (the config's, else layout.json's), carried through the
108
+ * frame of every enclosing sub-module that has one (`groups`: the sub-module paths). Undefined when
109
+ * the component has no placement of its own (tscircuit places it).
110
+ */
111
+ export function placementOf(instance: string, groups: Set<string>, own: (key: string) => Placement | undefined): Placement | undefined {
112
+ let p = own(instance);
113
+ if (!p) return undefined;
114
+ const segs = instance.split("/");
115
+ for (let k = segs.length - 1; k >= 1; k--) {
116
+ const path = segs.slice(0, k).join("/");
117
+ const frame = groups.has(path) ? own(path) : undefined;
118
+ if (frame) p = inFrame(frame, p);
119
+ }
120
+ return p;
121
+ }
122
+
123
+ /** The component or sub-module a stale key (a part since moved into a group) most likely means. */
124
+ const movedTo = (key: string, paths: string[]) => {
125
+ const hits = paths.filter((p) => p.endsWith(`/${key}`));
126
+ return hits.length === 1 ? hits[0] : undefined;
127
+ };
128
+
129
+ /** A tscircuit-safe identifier (component and net names are CSS-like selectors). */
130
+ export function safeName(s: string, prefix: string): string {
131
+ const n = s.replace(/[^A-Za-z0-9_]/g, "_");
132
+ return /^[A-Za-z_]/.test(n) ? n : `${prefix}${n}`;
133
+ }
134
+
135
+ const js = (v: unknown) => JSON.stringify(v);
136
+ const mm = (v: number) => `"${+v.toFixed(4)}mm"`;
137
+
138
+ /** Pad numbers: numeric designators keep their number; others ("EP", "A7") follow the highest one. */
139
+ function padNumbers(component: BoardComponent, entry: FootprintEntry): Map<string, string> {
140
+ const designators = new Set([...component.pins.map((p) => p.pin), ...(entry.pads ?? []).map((p) => p.pin).filter(Boolean)]);
141
+ const out = new Map<string, string>();
142
+ if (entry.footprinter) {
143
+ for (const d of designators) out.set(d, entry.padMap?.[d] ?? d);
144
+ return out;
145
+ }
146
+ let next = Math.max(0, ...[...designators].filter((d) => /^\d+$/.test(d)).map(Number)) + 1;
147
+ for (const d of [...designators].sort()) out.set(d, /^\d+$/.test(d) ? d : String(next++));
148
+ return out;
149
+ }
150
+
151
+ function courtyard(pads: LandPad[], component: BoardComponent, entry: FootprintEntry): [number, number] {
152
+ if (entry.courtyard_mm) return entry.courtyard_mm;
153
+ const ext = (k: "x" | "y", s: "w" | "h") => Math.max(...pads.map((p) => Math.abs(p[k]) + p[s] / 2));
154
+ const size = component.package?.size_mm;
155
+ const w = Math.max(2 * ext("x", "w"), size?.length ?? 0) + 0.5;
156
+ const h = Math.max(2 * ext("y", "h"), size?.width ?? 0) + 0.5;
157
+ return [+w.toFixed(3), +h.toFixed(3)];
158
+ }
159
+
160
+ /** Centre of the pads' extents (pad size included), in the pattern's frame. */
161
+ function padsCentre(pads: LandPad[]): [number, number] {
162
+ const xs = pads.flatMap((p) => [p.x - p.w / 2, p.x + p.w / 2]);
163
+ const ys = pads.flatMap((p) => [p.y - p.h / 2, p.y + p.h / 2]);
164
+ const r = (v: number) => Math.round(v * 1e4) / 1e4;
165
+ return [r((Math.min(...xs) + Math.max(...xs)) / 2), r((Math.min(...ys) + Math.max(...ys)) / 2)];
166
+ }
167
+
168
+ function footprintJsx(entry: FootprintEntry, numbers: Map<string, string>, component: BoardComponent): string {
169
+ const insertion = entry.insertion ? ` insertionDirection=${js(entry.insertion)}` : "";
170
+ if (entry.footprinter) return entry.insertion ? `<footprint src=${js(entry.footprinter)}${insertion} />` : js(entry.footprinter);
171
+ const pads = entry.pads!;
172
+ const mask = entry.mask ? ` solderMaskMargin=${mm(entry.mask.margin_mm)}` : "";
173
+ let mech = 0;
174
+ const lines = pads.map((p) => {
175
+ const hint = p.pin ? `pin${numbers.get(p.pin)}` : `mp${++mech}`;
176
+ if (p.drill) {
177
+ const hole = p.drill.w === p.drill.h && p.w === p.h
178
+ ? `shape="circle" outerDiameter=${mm(p.w)} holeDiameter=${mm(p.drill.w)}`
179
+ : `shape="pill" outerWidth=${mm(p.w)} outerHeight=${mm(p.h)} holeWidth=${mm(p.drill.w)} holeHeight=${mm(p.drill.h)}`;
180
+ return ` <platedhole ${hole} pcbX=${mm(p.x)} pcbY=${mm(p.y)} portHints={[${js(hint)}]}${mask} />`;
181
+ }
182
+ const shape = p.shape === "pill" ? `shape="pill" width=${mm(p.w)} height=${mm(p.h)} radius=${mm(Math.min(p.w, p.h) / 2)}` : `shape="rect" width=${mm(p.w)} height=${mm(p.h)}`;
183
+ return ` <smtpad ${shape} pcbX=${mm(p.x)} pcbY=${mm(p.y)} portHints={[${js(hint)}]}${mask} />`;
184
+ });
185
+ for (const h of entry.holes ?? []) lines.push(` <hole diameter=${mm(h.diameter_mm)} pcbX=${mm(h.x)} pcbY=${mm(h.y)} />`);
186
+ const [cw, ch] = courtyard(pads, component, entry);
187
+ const at = entry.courtyard_mm && entry.courtyard_at ? ` pcbX=${mm(entry.courtyard_at[0])} pcbY=${mm(entry.courtyard_at[1])}` : "";
188
+ lines.push(` <courtyardrect width=${mm(cw)} height=${mm(ch)}${at} />`);
189
+ return `{\n <footprint${insertion}>\n${lines.join("\n")}\n </footprint>\n }`;
190
+ }
191
+
192
+ /**
193
+ * The stencil of an authored land pattern (footprints.ts PasteSpec): each SMD pad's aperture
194
+ * (the pad grown by the margin, the pin's own margin, its windows, or none), and plated holes'
195
+ * rings when the pattern prints pin-in-paste. The anchor is a pad whose port hint is unique.
196
+ */
197
+ export function pastePlan(entry: FootprintEntry, numbers: Map<string, string>): { apertures: PasteAperture[]; anchor: { hint: string; x: number; y: number } } {
198
+ const spec = entry.paste ?? {};
199
+ const apertures: PasteAperture[] = [];
200
+ for (const p of entry.pads ?? []) {
201
+ if (p.drill) {
202
+ // the ring: round for a round hole, a pill for a slot
203
+ if (spec.plated) apertures.push({ x: p.x, y: p.y, w: p.w, h: p.h, pill: true });
204
+ continue;
205
+ }
206
+ const own = p.pin ? spec.pins?.[p.pin] : undefined;
207
+ if (own === "none") continue;
208
+ if (own && "windows" in own) {
209
+ apertures.push(...own.windows.map((w) => ({ ...w })));
210
+ continue;
211
+ }
212
+ const m = own && "margin_mm" in own ? own.margin_mm : (spec.margin_mm ?? 0);
213
+ const [w, h] = [p.w + 2 * m, p.h + 2 * m];
214
+ if (w > 0 && h > 0) apertures.push({ x: p.x, y: p.y, w: +w.toFixed(4), h: +h.toFixed(4), ...(p.shape === "pill" ? { pill: true } : {}) });
215
+ }
216
+ const hints = (entry.pads ?? []).map((p) => (p.pin ? `pin${numbers.get(p.pin)}` : ""));
217
+ const i = hints.findIndex((h, k) => h && hints.indexOf(h) === k && hints.lastIndexOf(h) === k);
218
+ const a = (entry.pads ?? [])[Math.max(0, i)];
219
+ return { apertures, anchor: { hint: i >= 0 ? hints[i] : "", x: a.x, y: a.y } };
220
+ }
221
+
222
+ /** Write the tscircuit source for a board netlist. */
223
+ export function generateSource(input: GenerateInput): GenerateOutput {
224
+ const { netlist, constraints: c } = input;
225
+ const problems: string[] = [];
226
+ const map: NameMap = { schema: MAP_SCHEMA, board: netlist.board, components: {}, nets: {} };
227
+ const notes: string[] = [];
228
+ const parts: string[] = [];
229
+ const portOf = new Map<string, string>(); // "<instance>:<interface>" → ".name > .pinN"
230
+ const groupPaths = new Set((netlist.groups ?? []).map((g) => g.path));
231
+ const own = (key: string) => c.placements[key] ?? input.layout?.[key];
232
+ const names = new Set<string>();
233
+ const padJoins = new Map<string, [string, string, boolean, string, string][]>(); // "<instance>:<interface>" → joins of the pin's pads (straight when clear)
234
+
235
+ for (const comp of netlist.components) {
236
+ // a sub-module path is not a tscircuit name: `led1/r1` is `led1_r1`, and a collision gets `_` appended
237
+ let name = safeName(comp.instance, "c_");
238
+ while (names.has(name)) name += "_";
239
+ names.add(name);
240
+ const { entry, from, near } = resolveFootprint(comp.instance, comp.package ? { ...comp.package, name: comp.package.name, manufacturer: comp.manufacturer } : undefined, input.overrides);
241
+ if (!entry) {
242
+ problems.push(
243
+ comp.package
244
+ ? `${comp.instance} (${comp.part}): no land pattern for package ${comp.package.name} (${comp.package.pin_count} pins${comp.package.pitch_mm ? `, ${comp.package.pitch_mm} mm pitch` : ""})${near?.length ? `; entries of that package do not fit its facts: ${near.join("; ")}` : ""}; add one to tscircuit/footprints.json`
245
+ : `${comp.instance} (${comp.part}): the part states no package (UHD mechanical domain \`package\`), so no land pattern can be chosen; add the package fact to the part, or an instance entry to tscircuit/footprints.json`,
246
+ );
247
+ continue;
248
+ }
249
+ const numbers = padNumbers(comp, entry);
250
+ const builtin = entry.footprinter ? builtinPads(entry.footprinter) : undefined;
251
+ const ports: Record<string, string | null> = {};
252
+ for (const p of comp.pins) {
253
+ const n = numbers.get(p.pin)!;
254
+ if (entry.pads && !entry.pads.some((x) => x.pin === p.pin)) problems.push(`${comp.instance}: land pattern ${entry.id} has no pad for pin ${p.pin} (${p.name})`);
255
+ if (entry.footprinter && !builtin!.has(n)) problems.push(`${comp.instance}: land pattern ${entry.id} (built-in "${entry.footprinter}", pads ${[...builtin!].join(", ") || "none"}) has no pad ${n} for pin ${p.pin} (${p.name})`);
256
+ ports[n] = p.interface;
257
+ portOf.set(`${comp.instance}:${p.interface}`, `.${name} > .pin${n}`);
258
+ }
259
+ for (const p of entry.pads ?? []) if (p.pin && !comp.pins.some((x) => x.pin === p.pin)) ports[numbers.get(p.pin)!] = null;
260
+ // a pin with several pads (a module's grid of exposed ground pads): tscircuit binds the pin's port to its
261
+ // first pad and gives each other pad an internal port, pinN_internal_1…, in pad order. The pads are joined to
262
+ // each other by the shortest tree over their centres (each pad to its nearest already joined one). A join
263
+ // clear of every other pad is straight copper laid on the built board after routing (NameMap padJoins,
264
+ // native.ts): the router never sees it, so the rest of the board routes as before; the pin's own port
265
+ // carries the net
266
+ // Only SMD pads: a pin's plated holes (a receptacle's shell stakes) are joined by the part's own metal.
267
+ // A join whose straight line would pass another pin's pad is left to the router instead.
268
+ for (const p of comp.pins) {
269
+ const all = (entry.pads ?? []).filter((x) => x.pin === p.pin);
270
+ if (all.length < 2 || !portOf.has(`${comp.instance}:${p.interface}`)) continue;
271
+ const n = numbers.get(p.pin)!;
272
+ const index = new Map(all.map((x, k) => [x, k]));
273
+ const pads = all.filter((x) => !x.drill);
274
+ if (pads.length < 2) continue;
275
+ const port = (k: number) => (k === 0 ? `pin${n}` : `pin${n}_internal_${k}`);
276
+ const sel = (k: number) => `.${name} > .${port(k)}`;
277
+ const others = (entry.pads ?? []).filter((x) => x.pin !== p.pin);
278
+ const clear = (a: LandPad, b: LandPad) => {
279
+ for (let t = 0; t <= 20; t++) {
280
+ const [x, y] = [a.x + ((b.x - a.x) * t) / 20, a.y + ((b.y - a.y) * t) / 20];
281
+ if (others.some((o) => Math.abs(x - o.x) < o.w / 2 + 0.3 && Math.abs(y - o.y) < o.h / 2 + 0.3)) return false;
282
+ }
283
+ return true;
284
+ };
285
+ const joined = [0];
286
+ const joins: [string, string, boolean, string, string][] = [];
287
+ while (joined.length < pads.length) {
288
+ let best: [number, number, number] | undefined;
289
+ for (let k = 0; k < pads.length; k++) {
290
+ if (joined.includes(k)) continue;
291
+ for (const j of joined) {
292
+ const d = Math.hypot(pads[k].x - pads[j].x, pads[k].y - pads[j].y);
293
+ if (!best || d < best[2] - 1e-9) best = [k, j, d];
294
+ }
295
+ }
296
+ joined.push(best![0]);
297
+ const [a, b] = [pads[best![0]], pads[best![1]]];
298
+ joins.push([sel(index.get(a)!), sel(index.get(b)!), clear(a, b), port(index.get(a)!), port(index.get(b)!)]);
299
+ }
300
+ padJoins.set(`${comp.instance}:${p.interface}`, joins);
301
+ }
302
+
303
+ const fixed = c.placements[comp.instance];
304
+ const place = groupPaths.size ? placementOf(comp.instance, groupPaths, own) : (fixed ?? input.layout?.[comp.instance]);
305
+ const padCentre = entry.pads?.length ? padsCentre(entry.pads) : undefined;
306
+ map.components[name] = {
307
+ instance: comp.instance,
308
+ part: comp.part,
309
+ ports,
310
+ footprint: entry.id,
311
+ footprintFrom: from!,
312
+ placement: fixed ? "fixed" : place ? "layout" : "auto",
313
+ ...(padCentre && (Math.abs(padCentre[0]) > 1e-6 || Math.abs(padCentre[1]) > 1e-6) ? { padCentre } : {}),
314
+ ...(entry.pads?.length ? { paste: pastePlan(entry, numbers) } : {}),
315
+ };
316
+
317
+ const props: string[] = [`name=${js(name)}`];
318
+ if (place) props.push(`pcbX={${place.at[0]}}`, `pcbY={${place.at[1]}}`, `pcbRotation={${place.rotation_deg}}`, ...(place.side === "bottom" ? [`layer="bottom"`] : []));
319
+ if (comp.mpn) props.push(`manufacturerPartNumber=${js(comp.mpn)}`);
320
+ const suppliers = input.suppliers?.[comp.instance];
321
+ if (suppliers && Object.keys(suppliers).length) props.push(`supplierPartNumbers={${js(suppliers)}}`);
322
+ const model = input.models?.[comp.instance];
323
+ if (model) props.push(`cadModel={${js(model)}}`);
324
+ const passive = comp.value && ["resistor", "capacitor", "inductor"].includes(comp.kind);
325
+ if (passive) {
326
+ const key = { resistor: "resistance", capacitor: "capacitance", inductor: "inductance" }[comp.kind as "resistor"];
327
+ props.push(`${key}={${comp.value!.value}}`);
328
+ } else {
329
+ const labels: Record<string, string> = {};
330
+ const attrs: Record<string, Record<string, boolean | undefined>> = {};
331
+ const used = new Set<string>();
332
+ for (const p of comp.pins) {
333
+ const n = numbers.get(p.pin)!;
334
+ let label = safeName(p.name, "P_");
335
+ if (used.has(label) || /^pin\d+$/.test(label)) label = `${label}_${safeName(p.pin, "")}`;
336
+ used.add(label);
337
+ labels[`pin${n}`] = label;
338
+ const a = { power_in: { requiresPower: true }, power_out: { providesPower: true }, ground: { requiresGround: true }, nc: { doNotConnect: true }, passive: { isPassive: true }, signal: undefined }[p.kind];
339
+ if (a) attrs[`pin${n}`] = a;
340
+ }
341
+ props.push(`pinLabels={${js(labels)}}`);
342
+ if (Object.keys(attrs).length) props.push(`pinAttributes={${js(attrs)}}`);
343
+ }
344
+ const tag = passive ? comp.kind : comp.kind === "connector" ? "connector" : "chip";
345
+ parts.push(` {/* ${comp.instance}: ${comp.part}${comp.package ? `, ${comp.package.name}` : ""}; land pattern ${entry.id} (${from}) */}\n <${tag}\n ${props.join("\n ")}\n footprint=${footprintJsx(entry, numbers, comp)}\n />`);
346
+ }
347
+
348
+ const paths = [...netlist.components.map((x) => x.instance), ...groupPaths];
349
+ for (const inst of Object.keys(c.placements)) {
350
+ if (netlist.components.some((x) => x.instance === inst) || groupPaths.has(inst)) continue;
351
+ const moved = movedTo(inst, paths);
352
+ problems.push(`placements.${inst}: the board has no component ${inst}${moved ? ` (it is ${moved} now: a part in a sub-module is keyed by its path)` : ""}`);
353
+ }
354
+ for (const key of Object.keys(input.layout ?? {})) {
355
+ if (paths.includes(key)) continue;
356
+ const moved = movedTo(key, paths);
357
+ notes.push(`layout.json: ${key} names no component or sub-module of the board${moved ? `; it is ${moved} now (a part in a sub-module is keyed by its path)` : ""}, so it places nothing`);
358
+ }
359
+
360
+ // ---- nets: every pin joins its net by name, so the native netlist stays keyed to UHD
361
+ const nets: string[] = [];
362
+ const traces: string[] = [];
363
+ const used = new Set<string>();
364
+ for (const net of netlist.nets) {
365
+ let name = safeName(net.id, "N_");
366
+ while (used.has(name)) name += "_";
367
+ used.add(name);
368
+ map.nets[name] = net.id;
369
+ const width = traceWidthOf(c, net);
370
+ const attrs = [net.kind === "ground" ? "isGroundNet" : net.kind === "power" ? "isPowerNet" : "", width !== undefined ? `nominalTraceWidth=${mm(width)}` : ""].filter(Boolean);
371
+ nets.push(` <net name=${js(name)}${attrs.length ? " " + attrs.join(" ") : ""} />`);
372
+ for (const pin of net.pins) {
373
+ const sel = portOf.get(pin);
374
+ if (sel) traces.push(` <trace from=${js(sel)} to=${js(`net.${name}`)} />`);
375
+ // straight copper between neighbouring pads of the pin, laid by core and never handed to the router
376
+ for (const [a, b, straight, pa, pb] of padJoins.get(pin) ?? []) {
377
+ if (!straight) traces.push(` <trace from=${js(a)} to=${js(b)}${width !== undefined ? ` thickness=${mm(width)}` : ""} />`);
378
+ else {
379
+ const comp = map.components[a.split(" > ")[0].slice(1)];
380
+ (comp.padJoins ??= []).push({ a: pa, b: pb, width: width ?? c.rules.trace_width_mm });
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ for (const id of Object.keys(c.trace_widths.nets)) if (!netlist.nets.some((n) => n.id === id)) problems.push(`trace_widths.nets.${id}: the board has no net ${id}`);
387
+
388
+ // ---- copper pours: filled after routing around the other nets' copper, inside the outline less every
389
+ // keep-out (a keep-out is no copper on any layer, so an antenna clearance holds on the planes too)
390
+ const pours: string[] = [];
391
+ const pourAreas = pourOutlines(c);
392
+ if (c.pours?.length && pourAreas.holes) problems.push("pours: a keep-out lies wholly inside the board, which a pour outline cannot leave out; move it to the edge or drop the pour");
393
+ for (const [i, pour] of (c.pours ?? []).entries()) {
394
+ const name = Object.entries(map.nets).find(([, id]) => id === pour.net)?.[0];
395
+ if (!name) {
396
+ problems.push(`pours[${i}]: the board has no net ${pour.net}`);
397
+ continue;
398
+ }
399
+ for (const layer of pour.layers)
400
+ pourAreas.outlines.forEach((ring, k) =>
401
+ pours.push(
402
+ ` <copperpour name=${js(`pour_${name}_${layer}${pourAreas.outlines.length > 1 ? `_${k + 1}` : ""}`)} layer=${js(layer)} connectsTo=${js(`net.${name}`)} boardEdgeMargin=${mm(c.rules.edge_clearance_mm + 0.02)}${pour.clearance_mm ? ` clearance=${mm(pour.clearance_mm)}` : ""} outline={${js(ring.map(([x, y]) => ({ x, y })))}} />`,
403
+ ),
404
+ );
405
+ }
406
+
407
+ const holes = c.holes.map((h) => ` <hole name=${js(safeName(h.id, "H_"))} diameter=${mm(h.diameter_mm)} pcbX={${h.at[0]}} pcbY={${h.at[1]}} />`);
408
+ const keepouts = c.keepouts.flatMap((k) => {
409
+ const exclude = k.allow_bodies?.length ? ` excludeRefs={${js(k.allow_bodies.map((i) => `.${safeName(i, "c_")}`))}}` : "";
410
+ return k.layers.map((layer) =>
411
+ "rect" in k.shape
412
+ ? ` <keepout shape="rect" width=${mm(k.shape.rect.size[0])} height=${mm(k.shape.rect.size[1])} pcbX={${k.shape.rect.at[0]}} pcbY={${k.shape.rect.at[1]}} layer=${js(layer)}${exclude} />`
413
+ : ` <keepout shape="circle" radius=${mm(k.shape.circle.diameter_mm / 2)} pcbX={${k.shape.circle.at[0]}} pcbY={${k.shape.circle.at[1]}} layer=${js(layer)}${exclude} />`,
414
+ );
415
+ });
416
+
417
+ const outline = c.outline.map(([x, y]) => ({ x, y }));
418
+ const source = `// @ts-nocheck
419
+ // GENERATED by @protoboardai/ext-tscircuit (circuit:generate) from the UHD board module ${netlist.board}.
420
+ // Do not edit: it is rewritten on every generate. Change the board module, circuit.config.ts,
421
+ // tscircuit/layout.json or tscircuit/footprints.json instead, or hand-edit tscircuit/board.tsx.
422
+ // Board frame: mm, +X right, +Y up (top view), origin at the outline's origin.
423
+
424
+ export const OUTLINE = ${js(outline)}
425
+
426
+ export default function Board() {
427
+ return (
428
+ <board
429
+ outline={OUTLINE}
430
+ layers={${c.layers}}
431
+ thickness=${mm(c.thickness_mm)}
432
+ minTraceWidth=${mm(c.rules.trace_width_mm)}
433
+ minViaHoleDiameter=${mm(c.rules.via_drill_mm)}
434
+ minViaPadDiameter=${mm(c.rules.via_pad_mm)}
435
+ minBoardEdgeClearance=${mm(c.rules.edge_clearance_mm)}
436
+ autorouter=${js(c.autorouter)}${c.via_in_pad === "fill_and_cap" ? "\n isViaInPadAllowed" : ""}${c.routing === "off" ? "\n routingDisabled" : ""}
437
+ >
438
+ ${[...holes, ...keepouts, ...parts, ...nets, ...traces, ...pours].join("\n")}
439
+ </board>
440
+ )
441
+ }
442
+ `;
443
+ return { source, map, problems, notes };
444
+ }
445
+
446
+ /** The entry file written once next to the generated source; hand edits go here. */
447
+ export const ENTRY_SOURCE = `// @ts-nocheck
448
+ // The board's tscircuit entry. circuit:generate writes this file once and never overwrites it:
449
+ // edit it to wrap or replace the generated board (board.gen.tsx), which is rewritten on every generate.
450
+ export { default } from "./board.gen.tsx"
451
+ `;
452
+
453
+ /** The areas a pour may fill: the board outline less every keep-out, as simple rings (`holes` when a keep-out is enclosed). */
454
+ export function pourOutlines(c: Pick<BoardConstraints, "outline" | "keepouts">): { outlines: [number, number][][]; holes: boolean } {
455
+ const ring = (pts: [number, number][]) => [...pts, pts[0]];
456
+ const cut = c.keepouts.map((k) => {
457
+ if ("rect" in k.shape) {
458
+ const { at, size } = k.shape.rect;
459
+ const [x0, x1, y0, y1] = [at[0] - size[0] / 2, at[0] + size[0] / 2, at[1] - size[1] / 2, at[1] + size[1] / 2];
460
+ return [ring([[x0, y0], [x1, y0], [x1, y1], [x0, y1]])];
461
+ }
462
+ const { at, diameter_mm } = k.shape.circle;
463
+ return [ring(Array.from({ length: 48 }, (_, i) => [at[0] + (diameter_mm / 2) * Math.cos((2 * Math.PI * i) / 48), at[1] + (diameter_mm / 2) * Math.sin((2 * Math.PI * i) / 48)] as [number, number]))];
464
+ });
465
+ const outline = [ring(c.outline as [number, number][])];
466
+ const result = cut.length ? polygonClipping.difference(outline as never, ...(cut as never[])) : [outline];
467
+ const r4 = (v: number) => Math.round(v * 1e4) / 1e4;
468
+ return {
469
+ outlines: (result as [number, number][][][]).map((poly) => poly[0].slice(0, -1).map(([x, y]) => [r4(x), r4(y)] as [number, number])),
470
+ holes: (result as unknown[][]).some((poly) => poly.length > 1),
471
+ };
472
+ }