@protoboardai/ext-tscircuit 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +59 -0
  3. package/README.md +493 -2
  4. package/evals/announce-pcb-fallback/case.ts +67 -0
  5. package/evals/catch-swapped-nets/case.ts +69 -0
  6. package/evals/generate-imu-board/case.ts +60 -0
  7. package/evals/locked-board-fix-upstream/case.ts +70 -0
  8. package/lib/checks.ts +464 -0
  9. package/lib/config.ts +384 -0
  10. package/lib/fingerprint.ts +144 -0
  11. package/lib/footprints.ts +370 -0
  12. package/lib/kicad/cli.ts +93 -0
  13. package/lib/kicad/drc.ts +95 -0
  14. package/lib/kicad/fields.ts +65 -0
  15. package/lib/kicad/handoff.ts +112 -0
  16. package/lib/kicad/netlists.ts +311 -0
  17. package/lib/kicad/probe.cjs +21 -0
  18. package/lib/kicad/project.ts +594 -0
  19. package/lib/kicad/refdes.ts +130 -0
  20. package/lib/kicad/schematic.ts +624 -0
  21. package/lib/kicad/sexpr.ts +117 -0
  22. package/lib/land-geometry.ts +177 -0
  23. package/lib/land-patterns.ts +706 -0
  24. package/lib/layout.ts +236 -0
  25. package/lib/models.ts +284 -0
  26. package/lib/native.ts +199 -0
  27. package/lib/outputs.ts +383 -0
  28. package/lib/paste.ts +69 -0
  29. package/lib/probe.cjs +31 -0
  30. package/lib/runtime.ts +185 -0
  31. package/lib/silk.ts +141 -0
  32. package/lib/source.ts +472 -0
  33. package/lib/stepfile.ts +210 -0
  34. package/lib/suppliers.ts +39 -0
  35. package/ops/circuit.ts +270 -0
  36. package/ops/kicad.ts +636 -0
  37. package/ops/package.ts +156 -0
  38. package/package.json +81 -4
  39. package/protoboard-extension.json +313 -0
  40. package/skills/circuit-board/SKILL.md +494 -0
  41. package/skills/circuit-board/references/pcb-workflow.md +87 -0
  42. package/skills/kicad-cli/SKILL.md +210 -0
  43. package/skills/kicad-layout-review/SKILL.md +275 -0
package/lib/native.ts ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The provider directory `src/boards/<module>/tscircuit/`:
3
+ *
4
+ * board.gen.tsx generated source, rewritten by every generate
5
+ * board.tsx entry, written once, never overwritten (hand edits)
6
+ * layout.json positions for parts the config does not fix (hand-owned)
7
+ * footprints.json land-pattern overrides (hand-owned, optional)
8
+ * circuit.json the built board (circuit JSON), with the source digest in build.json
9
+ * build.json name map (tscircuit names ↔ UHD), digests (source, netlist,
10
+ * generate inputs), versions, routing totals
11
+ * models/ box bodies for parts without a body artifact, and part bodies
12
+ * without their interface feature shapes
13
+ * export.json what the last export read (built board, UHD module, constraints,
14
+ * body files, provider code, converter versions): fingerprint.ts
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
18
+ import { dirname, join, relative, sep } from "node:path";
19
+ import type { CapabilityContext } from "@protoboardai/core";
20
+ import type { Placement } from "./config.ts";
21
+ import { entryProblems, type FootprintOverrides } from "./footprints.ts";
22
+ import { applyPaste } from "./paste.ts";
23
+ import { addSilkscreen } from "./silk.ts";
24
+ import { buildCircuit, coreVersion, type CircuitElement } from "./runtime.ts";
25
+ import type { NameMap } from "./source.ts";
26
+
27
+ export const BUILD_SCHEMA = "protoboard.tscircuit-build/v0";
28
+
29
+ export interface BuildRecord {
30
+ schema: typeof BUILD_SCHEMA;
31
+ map: NameMap;
32
+ /** Digest of the native source: the folder's .tsx/.ts/.jsx/.js files and every file the build read (sourceFiles). */
33
+ sourceDigest: string;
34
+ /**
35
+ * Project-relative paths of the files the build read besides the folder's own sources: JSON a hand
36
+ * file imports, a hand file shared from another board's folder. Absent in builds before 0.1.7.
37
+ */
38
+ sourceFiles?: string[];
39
+ /** Digest of the board netlist generate started from (detects a changed UHD module). */
40
+ netlistDigest: string;
41
+ /** Digest of what else generate bakes into board.gen.tsx: layout.json, footprints.json and the config's constraints. */
42
+ inputsDigest: string;
43
+ tscircuit: { core: string };
44
+ build_ms: number;
45
+ /** The router's own progress notes, last lines only. */
46
+ log: string[];
47
+ }
48
+
49
+ export const files = (native: string) => ({
50
+ gen: `${native}/board.gen.tsx`,
51
+ entry: `${native}/board.tsx`,
52
+ layout: `${native}/layout.json`,
53
+ footprints: `${native}/footprints.json`,
54
+ circuit: `${native}/circuit.json`,
55
+ build: `${native}/build.json`,
56
+ });
57
+
58
+ export const digestOf = (s: string | Buffer) => "sha256:" + createHash("sha256").update(s).digest("hex");
59
+
60
+ /**
61
+ * Digest of the native source: every .tsx/.ts/.jsx/.js file in the native folder, and each of
62
+ * `loaded` (project-relative paths the last build read: JSON a hand file imports, a hand file in
63
+ * another folder). A file that is gone digests as missing, so deleting one is a change too.
64
+ */
65
+ export function sourceDigest(root: string, native: string, loaded: string[] = []): string {
66
+ const dir = join(root, native);
67
+ const own = existsSync(dir) ? readdirSync(dir).filter((f) => /\.(tsx|ts|jsx|js)$/.test(f)).map((f) => `${native}/${f}`) : [];
68
+ // when the build read nothing else, the digest is the one builds before 0.1.7 recorded (folder sources by file name)
69
+ if (!loaded.length) return digestOf(own.sort().map((f) => `${f.slice(native.length + 1)}\n${readFileSync(join(root, f), "utf8")}`).join("\n\0"));
70
+ const names = [...new Set([...own, ...loaded])].sort();
71
+ return digestOf(names.map((f) => `${f}\n${existsSync(join(root, f)) ? readFileSync(join(root, f), "utf8") : "\0missing"}`).join("\n\0"));
72
+ }
73
+
74
+ /** The files a build read, project-relative with forward slashes, less the native folder's own sources (always digested). */
75
+ export function loadedFiles(root: string, native: string, files: string[]): string[] {
76
+ const rel = files.map((f) => relative(root, f).split(sep).join("/"));
77
+ return rel.filter((f) => !(dirname(f) === native && /\.(tsx|ts|jsx|js)$/.test(f))).sort();
78
+ }
79
+
80
+ /**
81
+ * Digest of generate's other inputs: layout.json, footprints.json and the
82
+ * circuit.config.ts constraints. Generate writes them into board.gen.tsx, so
83
+ * after a change only generate (not a rebuild) brings the board up to date.
84
+ */
85
+ export function inputsDigest(root: string, native: string, constraints: Record<string, unknown>, suppliers: Record<string, unknown> = {}): string {
86
+ const f = files(native);
87
+ const text = (path: string) => (existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : null);
88
+ // supplier part numbers (from the project's offers) are baked in too; a board with none digests as before 0.1.7
89
+ return digestOf(JSON.stringify({ constraints, layout: text(f.layout), footprints: text(f.footprints), ...(Object.keys(suppliers).length ? { suppliers } : {}) }));
90
+ }
91
+
92
+ const readJson = <T>(root: string, path: string): T | undefined => (existsSync(join(root, path)) ? (JSON.parse(readFileSync(join(root, path), "utf8")) as T) : undefined);
93
+
94
+ /** `layout.json`: { <instance>: { at, rotation_deg?, side? } }; keys starting with `$` are comments. */
95
+ export function readLayout(root: string, native: string): { layout: Record<string, Placement>; problems: string[] } {
96
+ const raw = readJson<Record<string, unknown>>(root, files(native).layout) ?? {};
97
+ const layout: Record<string, Placement> = {};
98
+ const problems: string[] = [];
99
+ for (const [k, v] of Object.entries(raw)) {
100
+ if (k.startsWith("$")) continue;
101
+ const p = v as { at?: unknown; rotation_deg?: unknown; side?: unknown };
102
+ const ok = Array.isArray(p?.at) && p.at.length === 2 && p.at.every((n) => typeof n === "number") && (p.rotation_deg === undefined || typeof p.rotation_deg === "number") && (p.side === undefined || p.side === "top" || p.side === "bottom");
103
+ if (!ok) problems.push(`layout.json: ${k} needs at: [x, y] (mm), optional rotation_deg and side (top|bottom)`);
104
+ else layout[k] = { at: p.at as [number, number], rotation_deg: (p.rotation_deg as number | undefined) ?? 0, side: (p.side as "top" | "bottom" | undefined) ?? "top" };
105
+ }
106
+ return { layout, problems };
107
+ }
108
+
109
+ /** `footprints.json`: project overrides of the land-pattern table. */
110
+ export function readOverrides(root: string, native: string): { overrides: FootprintOverrides; problems: string[] } {
111
+ const raw = readJson<FootprintOverrides>(root, files(native).footprints) ?? {};
112
+ const problems: string[] = [];
113
+ for (const e of raw.packages ?? []) for (const p of entryProblems(e)) problems.push(`footprints.json packages ${e?.id ?? "?"}: ${p}`);
114
+ for (const [k, e] of Object.entries(raw.instances ?? {})) for (const p of entryProblems(e)) problems.push(`footprints.json instances.${k}: ${p}`);
115
+ return { overrides: { packages: raw.packages ?? [], instances: raw.instances ?? {} }, problems };
116
+ }
117
+
118
+ export interface Native {
119
+ circuitJson: CircuitElement[];
120
+ build: BuildRecord;
121
+ }
122
+
123
+ /** The last build, if there is one. */
124
+ export function readNative(root: string, native: string): Native | undefined {
125
+ const f = files(native);
126
+ const build = readJson<BuildRecord>(root, f.build);
127
+ const circuitJson = readJson<CircuitElement[]>(root, f.circuit);
128
+ return build && circuitJson ? { build, circuitJson } : undefined;
129
+ }
130
+
131
+ /** Build the native source and record it (circuit.json + build.json). */
132
+ export async function buildAndRecord(ctx: CapabilityContext, map: NameMap, netlistDigest: string, generateInputs: string, designators: Record<string, string> = {}): Promise<Native> {
133
+ const root = ctx.project.root;
134
+ const f = files(ctx.module.native);
135
+ const result = await buildCircuit(join(root, f.entry));
136
+ // the stencil from the land patterns, not tscircuit's 70 % default (paste.ts)
137
+ const paste = applyPaste(result.circuitJson, map);
138
+ // references (designators) and pin-1 marks for authored land patterns (tscircuit draws silkscreen only for its own strings)
139
+ const silk = addSilkscreen(paste.circuitJson, map, designators);
140
+ // the straight joins between a multi-pad pin's pads, after routing (source.ts padJoins)
141
+ result.circuitJson = joinPads(silk.circuitJson, map);
142
+ for (const p of paste.problems) ctx.log(`warning: ${p}`);
143
+ if (silk.omitted.length) ctx.log(`silkscreen: no room for the reference of ${silk.omitted.join(", ")}`);
144
+ const sourceFiles = loadedFiles(root, ctx.module.native, result.files);
145
+ const build: BuildRecord = {
146
+ schema: BUILD_SCHEMA,
147
+ map,
148
+ sourceDigest: sourceDigest(root, ctx.module.native, sourceFiles),
149
+ ...(sourceFiles.length ? { sourceFiles } : {}),
150
+ netlistDigest,
151
+ inputsDigest: generateInputs,
152
+ tscircuit: { core: coreVersion() },
153
+ build_ms: result.ms,
154
+ log: result.log.slice(-20),
155
+ };
156
+ ctx.writeOutput(f.circuit, JSON.stringify(result.circuitJson) + "\n", "built board (tscircuit circuit JSON)");
157
+ ctx.writeOutput(f.build, JSON.stringify(build, null, 2) + "\n", "build record: name map, digests, versions");
158
+ return { circuitJson: result.circuitJson, build };
159
+ }
160
+
161
+ /** Errors tscircuit wrote into the circuit JSON while building (placement, routing, connectivity). */
162
+ export const buildErrors = (cj: CircuitElement[]): { type: string; message: string }[] => cj.filter((e) => /_error$/.test(e.type)).map((e) => ({ type: e.type, message: String(e.message ?? "") }));
163
+
164
+ /**
165
+ * Lay the straight joins generate planned between the pads of a pin with several (NameMap padJoins):
166
+ * a pcb_trace from pad centre to pad centre on the pads' layer, tied to both ports. Added after
167
+ * routing, so the router never saw them; generate keeps them clear of the component's other pads.
168
+ */
169
+ export function joinPads(cj: CircuitElement[], map: NameMap): CircuitElement[] {
170
+ const sources = new Map(cj.filter((e) => e.type === "source_component").map((s) => [s.source_component_id, s.name as string]));
171
+ const sourcePorts = new Map(cj.filter((e) => e.type === "source_port").map((p) => [p.source_port_id, p]));
172
+ const pads = new Map(cj.filter((e) => e.type === "pcb_smtpad" && e.pcb_port_id).map((p) => [p.pcb_port_id, p]));
173
+ const portOf = new Map<string, CircuitElement>(); // "<component name>/<port name>" → pcb_port
174
+ for (const p of cj.filter((e) => e.type === "pcb_port")) {
175
+ const sp = sourcePorts.get(p.source_port_id);
176
+ const comp = sp && sources.get(sp.source_component_id);
177
+ if (comp) portOf.set(`${comp}/${sp.name}`, p);
178
+ }
179
+ const added: CircuitElement[] = [];
180
+ for (const [name, c] of Object.entries(map.components)) {
181
+ for (const j of c.padJoins ?? []) {
182
+ // a labelled pin's port is named by its label; its pads still carry the hint pinN
183
+ const find = (port: string) => portOf.get(`${name}/${port}`) ?? [...portOf].find(([k, p]) => k.startsWith(`${name}/`) && sourcePorts.get(p.source_port_id)?.port_hints?.[0] === port)?.[1] ?? [...portOf].find(([k, p]) => k.startsWith(`${name}/`) && !/_internal_/.test(k) && sourcePorts.get(p.source_port_id)?.port_hints?.includes(port))?.[1];
184
+ const [a, b] = [find(j.a), find(j.b)];
185
+ const [pa, pb] = [a && pads.get(a.pcb_port_id), b && pads.get(b.pcb_port_id)];
186
+ if (!a || !b || !pa || !pb) continue;
187
+ added.push({
188
+ type: "pcb_trace",
189
+ pcb_trace_id: `pcb_trace_pad_join_${added.length}`,
190
+ pcb_component_id: pa.pcb_component_id,
191
+ route: [
192
+ { route_type: "wire", x: pa.x, y: pa.y, width: j.width, layer: pa.layer, start_pcb_port_id: a.pcb_port_id },
193
+ { route_type: "wire", x: pb.x, y: pb.y, width: j.width, layer: pb.layer, end_pcb_port_id: b.pcb_port_id },
194
+ ],
195
+ });
196
+ }
197
+ }
198
+ return added.length ? [...cj, ...added] : cj;
199
+ }
package/lib/outputs.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * export: the built board → the circuit capability's standard outputs
3
+ * (tool-integrations.md §3, §8), plus three renders the viewer shows:
4
+ *
5
+ * gerber/*.gbr, gerber/*.drl Gerber RS-274X (X2 attributes) and Excellon
6
+ * bom.csv, pnp.csv keyed by UHD instance path, with the part's MPN
7
+ * board.glb, board.step board and component bodies (see models.ts)
8
+ * board.manifest.json UHD geometry manifest: outline, holes, a feature
9
+ * per component instance and per board export
10
+ * netlist.json protoboard.netlist/v0, the nets as the copper joins them
11
+ * schematic.svg, pcb-top.svg, pcb-bottom.svg
12
+ *
13
+ * Everything runs locally from @tscircuit's converter libraries, with the
14
+ * offline fetch guard installed.
15
+ */
16
+ import { formatPassiveValue, NETLIST_SCHEMA, type BoardNetlist, type NetlistReport } from "@protoboardai/core";
17
+ import type { GeometryFrame } from "@deltarobotics/uhd";
18
+ import { convertCircuitJsonToExcellonDrillCommandLayers, convertCircuitJsonToGerberCommands, stringifyExcellonDrill, stringifyGerberCommandLayers } from "circuit-json-to-gerber";
19
+ import { convertCircuitJsonToGltf } from "circuit-json-to-gltf";
20
+ import { circuitJsonToStep } from "circuit-json-to-step";
21
+ import { convertCircuitJsonToPcbSvg, convertCircuitJsonToSchematicSvg } from "circuit-to-svg";
22
+ import { readFileSync } from "node:fs";
23
+ import { polygonArea, polygonCentroid, type BoardConstraints, type Vec2 } from "./config.ts";
24
+ import { boardGeometry, copperNets, ofType, portPins, type BoardGeometry } from "./layout.ts";
25
+ import { offline, quietly, type CircuitElement, type FileRequests } from "./runtime.ts";
26
+ import { stepEntities } from "./stepfile.ts";
27
+ import { supplierColumns, type SupplierNumbers } from "./suppliers.ts";
28
+ import type { NameMap } from "./source.ts";
29
+
30
+ type Vec3 = [number, number, number];
31
+ const r4 = (v: number) => Math.round(v * 1e4) / 1e4;
32
+
33
+ /** RFC 4180 field. */
34
+ export const csvField = (v: unknown) => {
35
+ const s = v === undefined || v === null ? "" : String(v);
36
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
37
+ };
38
+ const csv = (header: string[], rows: unknown[][]) => [header.join(","), ...rows.map((r) => r.map(csvField).join(","))].join("\n") + "\n";
39
+
40
+ /** Engineering notation for a passive value: 4.7 kΩ, 100 nF, 1.5 µH (core's formatter). */
41
+ export const formatValue = formatPassiveValue;
42
+
43
+ /**
44
+ * Gerber and Excellon files with their creation timestamps removed, so an
45
+ * unchanged board exports byte-identical files (X2 attributes are optional).
46
+ */
47
+ export function fabricationFiles(cj: CircuitElement[]): Record<string, string> {
48
+ const out: Record<string, string> = {};
49
+ // circuit-json-to-gerber flashes a rotated paste aperture unrotated: those are written here as regions
50
+ const rotated = cj.filter((e) => e.type === "pcb_solder_paste" && e.shape === "rotated_rect" && Math.abs(((e.ccw_rotation ?? 0) % 90)) > 1e-6);
51
+ const gerbers = stringifyGerberCommandLayers(convertCircuitJsonToGerberCommands(cj.filter((e) => !rotated.includes(e)) as never)) as Record<string, string>;
52
+ for (const [name, text] of Object.entries(gerbers)) out[`gerber/${name}.gbr`] = withRotatedPaste(name, withPours(name, text, cj), rotated).replace(/^%TF\.CreationDate,[^*]*\*%\r?\n/gm, "").replace(/^(G04 Created by [^*]*?) date [^*]*\*/gm, "$1*");
53
+ const drills = convertCircuitJsonToExcellonDrillCommandLayers({ circuitJson: cj as never }) as Record<string, unknown>;
54
+ for (const [name, cmds] of Object.entries(drills)) {
55
+ const text = stringifyExcellonDrill(cmds as never)
56
+ .replace(/^; #@! TF\.CreationDate,.*\r?\n/gm, "")
57
+ .replace(/^(; DRILL file \{[^}]*\}) date .*$/gm, "$1");
58
+ out[`gerber/${name.endsWith(".drl") ? name : `${name}.drl`}`] = text;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ const PASTE_LAYER: Record<string, string> = { F_Paste: "top", B_Paste: "bottom" };
64
+
65
+ /** Paste apertures turned by other than a multiple of 90°, as Gerber regions on their paste layer. */
66
+ export function withRotatedPaste(layerName: string, text: string, rotated: CircuitElement[]): string {
67
+ const layer = PASTE_LAYER[layerName];
68
+ const mine = rotated.filter((e) => e.layer === layer);
69
+ if (!layer || !mine.length) return text;
70
+ const xy = (v: number) => String(Math.round(v * 1e6));
71
+ const block = [
72
+ "G04 rotated paste apertures (ext-tscircuit)*",
73
+ "%LPD*%",
74
+ ...mine.flatMap((e) => {
75
+ const a = ((e.ccw_rotation ?? 0) * Math.PI) / 180;
76
+ const [hw, hh] = [e.width / 2, e.height / 2];
77
+ const pts = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]].map(([x, y]) => [e.x + x * Math.cos(a) - y * Math.sin(a), e.y + x * Math.sin(a) + y * Math.cos(a)]);
78
+ return ["G36*", `X${xy(pts[0][0])}Y${xy(pts[0][1])}D02*`, ...pts.slice(1).map(([x, y]) => `X${xy(x)}Y${xy(y)}D01*`), "G37*"];
79
+ }),
80
+ ].join("\n");
81
+ return text.replace(/M02\*\s*$/, `${block}\nM02*\n`);
82
+ }
83
+
84
+ const POUR_LAYER: Record<string, string> = { F_Cu: "top", B_Cu: "bottom", In1_Cu: "inner1", In2_Cu: "inner2" };
85
+
86
+ /**
87
+ * Copper pours as Gerber regions. circuit-json-to-gerber leaves
88
+ * `pcb_copper_pour` out, so each pour on the file's layer is written here:
89
+ * its outer ring dark, its inner rings (the clearances around other nets)
90
+ * clear, before any pad or trace is drawn, so the clears never cut them.
91
+ */
92
+ export function withPours(layerName: string, text: string, cj: CircuitElement[]): string {
93
+ const layer = POUR_LAYER[layerName];
94
+ const pours = cj.filter((e) => e.type === "pcb_copper_pour" && e.layer === layer && e.shape === "brep");
95
+ if (!layer || !pours.length) return text;
96
+ const xy = (v: number) => String(Math.round(v * 1e6));
97
+ const region = (vs: { x: number; y: number }[]) => {
98
+ const pts = [...vs, vs[0]];
99
+ return ["G36*", `X${xy(pts[0].x)}Y${xy(pts[0].y)}D02*`, ...pts.slice(1).map((p) => `X${xy(p.x)}Y${xy(p.y)}D01*`), "G37*"].join("\n");
100
+ };
101
+ const block = [
102
+ "G04 copper pours (ext-tscircuit)*",
103
+ ...pours.flatMap((p) => ["%LPD*%", region(p.brep_shape.outer_ring.vertices), ...(p.brep_shape.inner_rings ?? []).flatMap((r: { vertices: { x: number; y: number }[] }) => ["%LPC*%", region(r.vertices)]), "%LPD*%"]),
104
+ ].join("\n");
105
+ const at = text.indexOf("G04 aperture END LIST*");
106
+ if (at < 0) return text.replace(/M02\*\s*$/, `${block}\nM02*\n`);
107
+ const end = text.indexOf("\n", at) + 1;
108
+ return text.slice(0, end) + block + "\n" + text.slice(end);
109
+ }
110
+
111
+ /** The bill of materials: one row per component, with its supplier part numbers (LCSC first) when the project holds them. */
112
+ export const bomCsv = (netlist: BoardNetlist, suppliers: Record<string, SupplierNumbers> = {}) =>
113
+ csv(
114
+ ["instance", "part", "name", "kind", "manufacturer", "mpn", "value", "package", "quantity", "lcsc", "supplier_part_numbers"],
115
+ netlist.components.map((c) => {
116
+ const s = supplierColumns(suppliers[c.instance]);
117
+ return [c.instance, c.part, c.name, c.kind, c.manufacturer, c.mpn, c.value ? formatValue(c.value) : "", c.package?.name, 1, s.lcsc, s.suppliers];
118
+ }),
119
+ );
120
+
121
+ /** Pick-and-place at the pad bounding-box centre (what assembly houses take), board frame, with the LCSC number an assembler places from. */
122
+ export const pnpCsv = (netlist: BoardNetlist, geometry: BoardGeometry, suppliers: Record<string, SupplierNumbers> = {}) =>
123
+ csv(
124
+ ["instance", "x_mm", "y_mm", "rotation_deg", "side", "part", "mpn", "package", "lcsc"],
125
+ geometry.components.map((g) => {
126
+ const c = netlist.components.find((x) => x.instance === g.instance);
127
+ return [g.instance, g.center[0], g.center[1], g.rotation_deg, g.side, g.part, c?.mpn, c?.package?.name, supplierColumns(suppliers[g.instance]).lcsc];
128
+ }),
129
+ );
130
+
131
+ /** The nets as the copper joins them, named by the UHD net each piece carries. */
132
+ export function laidOutNetlist(cj: CircuitElement[], map: NameMap, netlist: BoardNetlist): NetlistReport {
133
+ const { nets } = copperNets(cj, map);
134
+ const netOf = new Map<string, string>();
135
+ for (const n of netlist.nets) for (const p of n.pins) netOf.set(p, n.id);
136
+ const used = new Set<string>();
137
+ let unnamed = 0;
138
+ const out: NetlistReport["nets"] = [];
139
+ for (const g of nets) {
140
+ const ids = [...new Set(g.pins.map((p) => netOf.get(p)).filter((x): x is string => !!x))];
141
+ let id = ids.length === 1 ? ids[0] : ids.length ? ids.sort().join("+") : `copper_${++unnamed}`;
142
+ while (used.has(id)) id += "_piece";
143
+ used.add(id);
144
+ const intended = ids.length === 1 ? netlist.nets.find((n) => n.id === ids[0]) : undefined;
145
+ out.push({ id, ...(intended?.links.length === 1 ? { link: intended.links[0] } : {}), pins: g.pins });
146
+ }
147
+ return { schema: NETLIST_SCHEMA, nets: out.sort((a, b) => a.id.localeCompare(b.id)) };
148
+ }
149
+
150
+ const frameFeature = (area: number, frame: GeometryFrame) => ({ area_mm2: r4(area), centroid: frame.origin, normal: frame.normal, frame });
151
+
152
+ /**
153
+ * The UHD geometry manifest of the board. Features: `outline` (the bottom
154
+ * face: the board's mounting side), `board_top`, each mounting hole by its
155
+ * config id, each component by instance path, each board export by its id.
156
+ * Every feature also carries `frame` (origin, normal, xAxis): proposed for
157
+ * UHD's manifest so interface frames can be bound from it (spike §9).
158
+ */
159
+ /** A board export's mating frame as its part states it (part coordinates), and the part's mount origin. */
160
+ export interface PartEdgeFrame {
161
+ frame: GeometryFrame;
162
+ /** The part's mounting frame origin (where its body meets the board), part coordinates. */
163
+ mount?: [number, number, number];
164
+ }
165
+
166
+ /**
167
+ * A part-coordinates frame placed on the board: the part's mount origin goes
168
+ * to the footprint origin on the surface (as the bodies are placed), rotated
169
+ * by the placement; a bottom-side part is mirrored in X and Z.
170
+ */
171
+ export function placePartFrame(f: PartEdgeFrame, comp: { origin: Vec2; rotation_deg: number; side: string }, half: number): GeometryFrame {
172
+ const a = (comp.rotation_deg * Math.PI) / 180;
173
+ const flip = comp.side === "bottom" ? -1 : 1;
174
+ const m = f.mount ?? [0, 0, 0];
175
+ const turn = (v: number[]): Vec3 => {
176
+ const x = v[0] * Math.cos(a) - v[1] * Math.sin(a);
177
+ const y = v[0] * Math.sin(a) + v[1] * Math.cos(a);
178
+ return [r4(x * flip) + 0, r4(y) + 0, r4(v[2] * flip) + 0];
179
+ };
180
+ const o = turn([f.frame.origin[0] - m[0], f.frame.origin[1] - m[1], f.frame.origin[2] - m[2]]);
181
+ return {
182
+ origin: [r4(comp.origin[0] + o[0]), r4(comp.origin[1] + o[1]), r4(flip * half + o[2])],
183
+ normal: turn(f.frame.normal),
184
+ ...(f.frame.xAxis ? { xAxis: turn(f.frame.xAxis) } : {}),
185
+ };
186
+ }
187
+
188
+ export function boardManifest(cj: CircuitElement[], map: NameMap, netlist: BoardNetlist, constraints: BoardConstraints | undefined, sourceDigest: string, partFrames: Record<string, PartEdgeFrame> = {}) {
189
+ const g = boardGeometry(cj, map);
190
+ const half = g.thickness_mm / 2;
191
+ const area = Math.abs(polygonArea(g.outline));
192
+ const [cx, cy] = polygonCentroid(g.outline);
193
+ const features: Record<string, ReturnType<typeof frameFeature>> = {
194
+ outline: frameFeature(area, { origin: [cx, cy, -half], normal: [0, 0, -1], xAxis: [1, 0, 0] }),
195
+ board_top: frameFeature(area, { origin: [cx, cy, half], normal: [0, 0, 1], xAxis: [1, 0, 0] }),
196
+ };
197
+ for (const h of g.holes) {
198
+ const id = constraints?.holes.find((c) => Math.hypot(c.at[0] - h.at[0], c.at[1] - h.at[1]) < 0.01)?.id ?? h.name;
199
+ features[id] = frameFeature(Math.PI * (h.diameter_mm / 2) ** 2, { origin: [h.at[0], h.at[1], -half], normal: [0, 0, -1], xAxis: [1, 0, 0] });
200
+ }
201
+ for (const c of g.components) {
202
+ const { source: _source, ...frame } = c.frame;
203
+ features[c.instance] = frameFeature(c.size[0] * c.size[1], frame);
204
+ }
205
+ const pads = new Map<string, Vec2>();
206
+ for (const [portId, pin] of portPins(cj, map)) {
207
+ const p = ofType(cj, "pcb_port").find((x) => x.pcb_port_id === portId);
208
+ if (p) pads.set(pin, [r4(p.x), r4(p.y)]);
209
+ }
210
+ const notes: string[] = [];
211
+ for (const e of netlist.edge) {
212
+ if (features[e.id]) {
213
+ notes.push(`export ${e.id} shares its name with another feature; not listed`);
214
+ continue;
215
+ }
216
+ const comp = g.components.find((c) => c.instance === e.instance);
217
+ if (!comp) continue;
218
+ const leaf = e.pins.length === 1 && e.pins[0] === `${e.instance}:${e.interface}` ? pads.get(e.pins[0]) : undefined;
219
+ const z = comp.side === "bottom" ? -half : half;
220
+ const { source: _source, ...frame } = comp.frame;
221
+ // a composite or mechanical export takes the mating frame its part states (a connector's mouth at its axis height,
222
+ // a side-push switch's stem), placed like the body; else the component frame tscircuit gives
223
+ const stated = !leaf && partFrames[e.id] ? placePartFrame(partFrames[e.id], comp, half) : undefined;
224
+ features[e.id] = leaf
225
+ ? frameFeature(0, { origin: [leaf[0], leaf[1], z] as Vec3, normal: [0, 0, comp.side === "bottom" ? -1 : 1], xAxis: frame.xAxis })
226
+ : stated
227
+ ? frameFeature(comp.size[0] * comp.size[1], stated)
228
+ : frameFeature(comp.size[0] * comp.size[1], frame);
229
+ }
230
+ return {
231
+ manifest: {
232
+ artifact: "board.glb",
233
+ tool: "tscircuit",
234
+ ...(g.toolVersion ? { toolVersion: g.toolVersion } : {}),
235
+ sourceDigest,
236
+ units: "mm" as const,
237
+ frame: "board: mm; +X right and +Y up in the top view; +Z out of the top copper; z = 0 on the mid-plane",
238
+ features,
239
+ },
240
+ geometry: g,
241
+ notes,
242
+ };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // 3D
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * board.glb in glTF's own convention (metres, +Y up), so it loads like every
251
+ * other UHD body: the viewer maps it back to mm, Z-up board coordinates.
252
+ * circuit-json-to-gltf writes millimetres with (x, y, z)glb = (−x, z, y)board;
253
+ * one root node undoes that. Nodes keep the instance paths as names.
254
+ */
255
+ export function reframeGlb(glb: Uint8Array, map: NameMap): Buffer {
256
+ const b = Buffer.from(glb);
257
+ const jsonLength = b.readUInt32LE(12);
258
+ const json = JSON.parse(b.toString("utf8", 20, 20 + jsonLength));
259
+ const rest = b.subarray(20 + jsonLength);
260
+ const s = 0.001;
261
+ const scene = json.scenes[json.scene ?? 0];
262
+ for (const n of json.nodes ?? []) if (n.name && map.components[n.name]) n.name = map.components[n.name].instance;
263
+ json.nodes.push({ name: "board", matrix: [-s, 0, 0, 0, 0, s, 0, 0, 0, 0, -s, 0, 0, 0, 0, 1], children: scene.nodes });
264
+ scene.nodes = [json.nodes.length - 1];
265
+ json.asset = { ...json.asset, generator: `${json.asset?.generator ?? "circuit-json-to-gltf"} via @protoboardai/ext-tscircuit`, extras: { frame: "glTF: metres, +Y up; board frame (mm, Z up) = (x, -z, y) × 1000" } };
266
+ let text = JSON.stringify(json);
267
+ while (Buffer.byteLength(text) % 4) text += " ";
268
+ const head = Buffer.alloc(20);
269
+ const body = Buffer.from(text);
270
+ head.write("glTF", 0, "latin1");
271
+ head.writeUInt32LE(2, 4);
272
+ head.writeUInt32LE(20 + body.length + rest.length, 8);
273
+ head.writeUInt32LE(body.length, 12);
274
+ head.writeUInt32LE(0x4e4f534a, 16);
275
+ return Buffer.concat([head, body, rest]);
276
+ }
277
+
278
+ /**
279
+ * board.glb, and which body files the converter could not load. It falls back to a box for
280
+ * such a body and says so only on the console, so the console is read here: a body that
281
+ * was chosen is not a body that was loaded (on Windows every one failed, PB-1013).
282
+ */
283
+ export async function boardGlb(cj: CircuitElement[], map: NameMap, platform?: NodeJS.Platform): Promise<{ glb: Buffer; failed: string[] }> {
284
+ const files: FileRequests = { read: [], missing: [] };
285
+ const log: string[] = [];
286
+ const glb = await quietly(() => offline(() => convertCircuitJsonToGltf(cj as never, { format: "glb" }) as Promise<ArrayBuffer>, { files, ...(platform ? { platform } : {}) }), log, (line) => line.startsWith("Failed to load STEP from "));
287
+ const failed = new Set(files.missing);
288
+ for (const line of log) {
289
+ const m = /Failed to load STEP from (.+?): /.exec(line);
290
+ if (m) failed.add(m[1]);
291
+ }
292
+ return { glb: reframeGlb(new Uint8Array(glb), map), failed: [...failed].sort() };
293
+ }
294
+
295
+ /**
296
+ * The first entity id above every id in use. A loop, not Math.max(...ids): a large board's STEP has
297
+ * hundreds of thousands of entities, more arguments than the stack holds ("Maximum call stack size
298
+ * exceeded" on a 137-part board, PB-1013).
299
+ */
300
+ export function nextEntityId(ids: Iterable<number>): number {
301
+ let next = 1;
302
+ for (const id of ids) if (id >= next) next = id + 1;
303
+ return next;
304
+ }
305
+
306
+ const stepString = (s: string) => `'${s.replace(/'/g, "''")}'`;
307
+ /** A STEP REAL to 1 nm: always with a decimal point. */
308
+ const stepReal = (v: number) => {
309
+ const s = (Math.round(v * 1e6) / 1e6 || 0).toString();
310
+ return s.includes(".") ? s : `${s}.`;
311
+ };
312
+
313
+ /**
314
+ * board.step in the board frame (mm): the board solid and one mapped item per
315
+ * component body, named by instance path.
316
+ *
317
+ * circuit-json-to-step 0.0.43 places a model by recentring it on the bounding
318
+ * box of every CARTESIAN_POINT in its file (axis placements included, so not
319
+ * the solid's own box) and lifts it by half the board thickness on top of a
320
+ * position that is already the board surface. Both move bodies off their
321
+ * pads. Each mapped item's target origin is therefore set back to the
322
+ * cad_component position afterwards (where the model's origin belongs, as in
323
+ * board.glb); its axes, which the converter gets right, are kept.
324
+ *
325
+ * The converter names each shape representation after the key it read the
326
+ * model under; models are passed under `labels` (the artifact, or the box's
327
+ * project-relative path), so no local path reaches the file, and the header
328
+ * date is fixed so an unchanged board writes the same file.
329
+ */
330
+ export async function boardStep(cj: CircuitElement[], map: NameMap, labels: Record<string, string> = {}): Promise<string> {
331
+ const fsMap: Record<string, string> = {};
332
+ const patched = cj.map((e) => {
333
+ if (e.type !== "cad_component" || typeof e.model_step_url !== "string") return e;
334
+ const key = labels[e.model_step_url] ?? e.model_step_url;
335
+ fsMap[key] ??= readFileSync(e.model_step_url, "utf8");
336
+ return { ...e, model_step_url: key };
337
+ });
338
+ const raw = await offline(() => circuitJsonToStep(patched as never, { includeComponents: true, includeExternalMeshes: true, fsMap } as never) as Promise<string>);
339
+
340
+ // each body's cad_components in circuit order, by the model they use
341
+ const sources = new Map(ofType(cj, "source_component").map((s) => [s.source_component_id, s.name as string]));
342
+ const queue = new Map<string, CircuitElement[]>();
343
+ for (const c of ofType(patched, "cad_component").filter((c) => typeof c.model_step_url === "string")) {
344
+ queue.set(c.model_step_url, [...(queue.get(c.model_step_url) ?? []), c]);
345
+ }
346
+ const entities = stepEntities(raw);
347
+ const refs = new Map<number, number>();
348
+ for (const body of entities.values()) for (const m of body.matchAll(/#(\d+)/g)) refs.set(Number(m[1]), (refs.get(Number(m[1])) ?? 0) + 1);
349
+ const rewrite = new Map<number, string>();
350
+ let next = nextEntityId(entities.keys());
351
+ const added: string[] = [];
352
+ for (const [id, body] of entities) {
353
+ const item = /^MAPPED_ITEM\('[^']*',#(\d+),#(\d+)\)$/.exec(body);
354
+ if (!item) continue;
355
+ const rep = /^REPRESENTATION_MAP\(#\d+,#(\d+)\)$/.exec(entities.get(Number(item[1])) ?? "");
356
+ const model = /^ADVANCED_BREP_SHAPE_REPRESENTATION\('((?:[^']|'')*)'/.exec(entities.get(Number(rep?.[1])) ?? "")?.[1].replace(/''/g, "'");
357
+ const cad = model !== undefined ? queue.get(model)?.shift() : undefined;
358
+ if (!cad) continue;
359
+ const instance = map.components[sources.get(cad.source_component_id) ?? ""]?.instance ?? "";
360
+ rewrite.set(id, `MAPPED_ITEM(${stepString(instance)},#${item[1]},#${item[2]})`);
361
+ const target = /^AXIS2_PLACEMENT_3D\('[^']*',#(\d+),(#\d+),(#\d+)\)$/.exec(entities.get(Number(item[2])) ?? "");
362
+ if (!target) continue;
363
+ const { x = 0, y = 0, z = 0 } = cad.position ?? {};
364
+ const point = `CARTESIAN_POINT('',(${[x, y, z].map(stepReal).join(",")}))`;
365
+ if (refs.get(Number(target[1])) === 1) rewrite.set(Number(target[1]), point);
366
+ else {
367
+ added.push(`#${next} = ${point};`);
368
+ rewrite.set(Number(item[2]), `AXIS2_PLACEMENT_3D('',#${next++},${target[2]},${target[3]})`);
369
+ }
370
+ }
371
+ const text = raw.replace(/^#(\d+) = [^\n]*;$/gm, (line, id) => (rewrite.has(Number(id)) ? `#${id} = ${rewrite.get(Number(id))};` : line)).replace(/^(FILE_NAME\('[^']*',)'[^']*'/m, "$1'1970-01-01T00:00:00'");
372
+ if (!added.length) return text;
373
+ const end = text.lastIndexOf("ENDSEC;");
374
+ return text.slice(0, end) + added.join("\n") + "\n" + text.slice(end);
375
+ }
376
+
377
+ export function renders(cj: CircuitElement[]): Record<string, string> {
378
+ return {
379
+ "schematic.svg": convertCircuitJsonToSchematicSvg(cj as never),
380
+ "pcb-top.svg": convertCircuitJsonToPcbSvg(cj as never, { layer: "top" } as never),
381
+ "pcb-bottom.svg": convertCircuitJsonToPcbSvg(cj as never, { layer: "bottom" } as never),
382
+ };
383
+ }
package/lib/paste.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The stencil, written from the land patterns rather than left to tscircuit.
3
+ *
4
+ * tscircuit gives every SMD pad a paste aperture of 70 % of the pad in each
5
+ * direction (49 % of its area), none at all to a pill-shaped pad, and one on
6
+ * both sides of every plated hole. None of that is what a manufacturer's
7
+ * land pattern asks for. After each build, every component with an authored
8
+ * land pattern gets exactly the apertures of its paste plan (source.ts
9
+ * `pastePlan`): placed with the component (rotation, and mirrored in X on the
10
+ * bottom side), on the component's own side. Built-in footprint strings keep
11
+ * tscircuit's apertures.
12
+ */
13
+ import type { CircuitElement } from "./runtime.ts";
14
+ import type { NameMap } from "./source.ts";
15
+
16
+ const r4 = (v: number) => Math.round(v * 1e4) / 1e4;
17
+
18
+ /** Pad centre of a copper element, or undefined. */
19
+ const at = (e: CircuitElement): [number, number] | undefined => (typeof e.x === "number" && typeof e.y === "number" ? [e.x, e.y] : undefined);
20
+
21
+ export function applyPaste(cj: CircuitElement[], map: NameMap): { circuitJson: CircuitElement[]; components: number; apertures: number; problems: string[] } {
22
+ const problems: string[] = [];
23
+ const nameOf = new Map(cj.filter((e) => e.type === "source_component").map((s) => [s.source_component_id, s.name as string]));
24
+ const drop = new Set<CircuitElement>();
25
+ const add: CircuitElement[] = [];
26
+ let components = 0;
27
+ let n = 0;
28
+ for (const pc of cj.filter((e) => e.type === "pcb_component")) {
29
+ const plan = map.components[nameOf.get(pc.source_component_id) ?? ""]?.paste;
30
+ if (!plan) continue;
31
+ const copper = cj.filter((e) => (e.type === "pcb_smtpad" || e.type === "pcb_plated_hole") && e.pcb_component_id === pc.pcb_component_id);
32
+ const anchor = copper.find((e) => (e.port_hints ?? []).includes(plan.anchor.hint));
33
+ const a = anchor && at(anchor);
34
+ if (!a) {
35
+ problems.push(`${map.components[nameOf.get(pc.source_component_id)!].instance}: no pad ${plan.anchor.hint} to place the stencil by; tscircuit's apertures kept`);
36
+ continue;
37
+ }
38
+ const bottom = pc.layer === "bottom";
39
+ const deg = ((((pc.rotation as number) ?? 0) % 360) + 360) % 360;
40
+ const t = (deg * Math.PI) / 180;
41
+ const [c, s] = [Math.cos(t), Math.sin(t)];
42
+ const place = (x: number, y: number): [number, number] => {
43
+ const fx = bottom ? -x : x;
44
+ return [fx * c - y * s, fx * s + y * c];
45
+ };
46
+ const [ax, ay] = place(plan.anchor.x, plan.anchor.y);
47
+ const origin: [number, number] = [a[0] - ax, a[1] - ay];
48
+ // this component's apertures: tied to its pads, or (plated holes) at a hole's centre with no component id
49
+ const holes = copper.filter((e) => e.type === "pcb_plated_hole").map(at).filter(Boolean) as [number, number][];
50
+ for (const p of cj) {
51
+ if (p.type !== "pcb_solder_paste") continue;
52
+ if (p.pcb_component_id === pc.pcb_component_id || (!p.pcb_component_id && holes.some(([hx, hy]) => Math.abs(hx - p.x) < 1e-4 && Math.abs(hy - p.y) < 1e-4))) drop.add(p);
53
+ }
54
+ const quarter = Math.abs(deg % 90) < 1e-6 || Math.abs((deg % 90) - 90) < 1e-6;
55
+ const turned = quarter && Math.round(deg / 90) % 2 === 1;
56
+ for (const ap of plan.apertures) {
57
+ const [dx, dy] = place(ap.x, ap.y);
58
+ const base = { type: "pcb_solder_paste", pcb_solder_paste_id: `pcb_solder_paste_land_${pc.pcb_component_id}_${n++}`, layer: bottom ? "bottom" : "top", pcb_component_id: pc.pcb_component_id, x: r4(origin[0] + dx), y: r4(origin[1] + dy) };
59
+ if (!quarter) {
60
+ add.push({ ...base, shape: "rotated_rect", width: ap.w, height: ap.h, ccw_rotation: bottom ? -deg : deg });
61
+ continue;
62
+ }
63
+ const [w, h] = turned ? [ap.h, ap.w] : [ap.w, ap.h];
64
+ add.push(ap.pill ? { ...base, shape: "pill", width: w, height: h, radius: Math.min(w, h) / 2 } : { ...base, shape: "rect", width: w, height: h });
65
+ }
66
+ components++;
67
+ }
68
+ return { circuitJson: [...cj.filter((e) => !drop.has(e)), ...add], components, apertures: add.length, problems };
69
+ }