@protoboardai/ext-docs 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 (55) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -2
  4. package/evals/build-datasheet/case.json +147 -0
  5. package/fonts/KodeMono-Bold.ttf +0 -0
  6. package/fonts/KodeMono-Medium.ttf +0 -0
  7. package/fonts/KodeMono-Regular.ttf +0 -0
  8. package/fonts/KodeMono-SemiBold.ttf +0 -0
  9. package/fonts/OFL.txt +93 -0
  10. package/lib/blocks.ts +268 -0
  11. package/lib/bom.ts +126 -0
  12. package/lib/build.ts +202 -0
  13. package/lib/charts.ts +241 -0
  14. package/lib/context.ts +122 -0
  15. package/lib/derived.ts +181 -0
  16. package/lib/docs/assembly.ts +674 -0
  17. package/lib/docs/common.ts +81 -0
  18. package/lib/docs/datasheet.ts +875 -0
  19. package/lib/docs/product.ts +45 -0
  20. package/lib/figures.ts +195 -0
  21. package/lib/format.ts +221 -0
  22. package/lib/html.ts +135 -0
  23. package/lib/model.ts +312 -0
  24. package/lib/package/assembly.ts +268 -0
  25. package/lib/package/common.ts +109 -0
  26. package/lib/package/plan.ts +432 -0
  27. package/lib/package/spec.ts +203 -0
  28. package/lib/png.ts +91 -0
  29. package/lib/render/cdp.ts +5 -0
  30. package/lib/render/page/render.html +17 -0
  31. package/lib/render/page/render.js +390 -0
  32. package/lib/render/renderer.ts +161 -0
  33. package/lib/render/server.ts +105 -0
  34. package/lib/standin.ts +36 -0
  35. package/lib/standins/multirotor.ts +283 -0
  36. package/lib/templates/doc.css +247 -0
  37. package/lib/templates/flow.js +131 -0
  38. package/lib/testdata.ts +85 -0
  39. package/lib/types.ts +120 -0
  40. package/lib/values.ts +309 -0
  41. package/lib/verify.ts +276 -0
  42. package/ops/docs.ts +52 -0
  43. package/ops/package.ts +9 -0
  44. package/package.json +62 -4
  45. package/protoboard-extension.json +94 -0
  46. package/skills/uhd-tech-docs/SKILL.md +234 -0
  47. package/skills/uhd-tech-docs/references/docspec.md +56 -0
  48. package/skills/uhd-tech-docs/references/house-style.md +50 -0
  49. package/skills/uhd-tech-docs/references/style-study.md +270 -0
  50. package/skills/uhd-tech-docs/references/test-data.md +49 -0
  51. package/skills/uhd-tech-docs/references/value-queries.md +67 -0
  52. package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
  53. package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
  54. package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
  55. package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The spec sheet of one Manufacturing Package unit (`package:spec`): what is
3
+ * made for the unit, from the capability providers' standard outputs and
4
+ * the model. One part per custom module the unit builds:
5
+ *
6
+ * - a board (circuit): layers, thickness, size, components by side, nets
7
+ * and rails, mounting holes, files;
8
+ * - a mechanical part (cad): process, material, size, volume, printed or
9
+ * cut bodies, the features that carry its interfaces, files;
10
+ * - a cable (cad on a harness): conductors, gauge, length and routed
11
+ * length, bundle, bend radius, terminations at each end.
12
+ *
13
+ * Each has a line-art figure of its GLB standard output. The release's own
14
+ * spec (identity, contents, interfaces) comes before it.
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import type { ModuleDef } from "@deltarobotics/uhd";
19
+ import { IDENTITY, netVoltageRange } from "@deltarobotics/uhd";
20
+ import { parseCsv, type CapabilityBinding, type Project } from "@protoboardai/core";
21
+ import type { Block, SectionContext, SectionContribution, SectionFn } from "@protoboardai/ext-release";
22
+ import { classify, cutList, fileGroup, glbBox, includedWith, inUnit, moduleFiles, perBuild, readOutput, unitModules } from "@protoboardai/ext-release/helpers";
23
+ import { projectPath } from "../render/server.ts";
24
+ import { slug } from "./common.ts";
25
+
26
+ const n = (v: number, d = 1) => String(Number(v.toFixed(d)));
27
+ const size3 = (s: number[]) => `${n(s[0])} × ${n(s[1])} × ${n(s[2])} mm`;
28
+ const params = (def: ModuleDef, type: string, kind?: string) =>
29
+ (def.traits ?? []).find((t) => t.type === type && (!kind || (t.params as Record<string, unknown> | undefined)?.kind === kind))?.params as Record<string, unknown> | undefined;
30
+
31
+ interface CadManifest {
32
+ volume_mm3?: number;
33
+ bodies?: Record<string, number>;
34
+ features?: Record<string, unknown>;
35
+ material?: string;
36
+ process?: string;
37
+ manufacturing?: { thickness_mm?: Record<string, number>; quantities?: Record<string, number>; layers?: string[] };
38
+ length_mm?: number;
39
+ route_length_mm?: number;
40
+ conductor_lengths_mm?: number[];
41
+ bundle?: { kind?: string; conductors?: number; conductor_od_mm?: number; bundle_od_mm?: number; twist_pitch_mm?: number };
42
+ bend_radius_mm?: { target?: number; minimum?: number; centreline?: number };
43
+ ends?: Record<string, { counterpart?: string; termination?: string; link?: string }>;
44
+ }
45
+
46
+ export const spec: SectionFn = async (s) => {
47
+ const { project: p, unit: u } = s;
48
+ const out: SectionContribution[] = [];
49
+ const bound = unitModules(p, u);
50
+ if (!bound.length) return undefined;
51
+ const made = bound.filter((b) => classify(p, p.definitions.get(b.module!)!.def).action === "fabricate").length;
52
+ const blocks: Block[] = [
53
+ {
54
+ kind: "paragraph",
55
+ text: `${bound.length} part${bound.length === 1 ? " of this unit is" : "s of this unit are"} designed in the project${made < bound.length ? ` (${made} made, ${bound.length - made} bought or included, routed and cut to length here)` : ""}. Facts below are read from each provider's standard outputs (out/) and the model; figures are drawn from the GLB output. Manufacturing files are in files/ of this unit's folder.`,
56
+ },
57
+ ];
58
+ const cables: CapabilityBinding[] = [];
59
+ for (const b of bound) {
60
+ const def = p.definitions.get(b.module!)?.def;
61
+ if (!def) continue;
62
+ const group = fileGroup(p, b);
63
+ if (group === "harnesses") {
64
+ cables.push(b);
65
+ continue;
66
+ }
67
+ if (group === "firmware") {
68
+ const files = moduleFiles(s.files, u, def.id).filter((f) => f.role === "firmware-config");
69
+ blocks.push({ kind: "heading", text: `${def.name ?? def.id}: firmware configuration` });
70
+ blocks.push({
71
+ kind: "keyvalues",
72
+ rows: [
73
+ { label: "Designed by", value: `${b.provider} (${b.capability})`, note: b.config },
74
+ ...(files.length ? [{ label: "Files", value: [...new Set(files.map((f) => inUnit(u, f.path).split("/").slice(0, 3).join("/") + "/"))].join(", "), note: `${files.length} file(s): the board definition and configuration the firmware builds against` }] : []),
75
+ ],
76
+ });
77
+ continue;
78
+ }
79
+ blocks.push({ kind: "heading", text: `${def.name ?? def.id}` });
80
+ const fig = await figureOf(p, s, b, group);
81
+ if (fig) blocks.push(fig);
82
+ const rows = [
83
+ { label: "Module", value: def.id, note: `${perBuild(u, def.id)} per build` },
84
+ { label: "Supply", value: supply(p, def) },
85
+ { label: "Designed by", value: `${b.provider} (${b.capability}${b.process ? `, ${b.process}` : ""})`, note: b.config },
86
+ ...(group === "boards" ? boardFacts(p, b, def) : mechanicalFacts(p, b, def)),
87
+ ];
88
+ const files = [...new Set(moduleFiles(s.files, u, def.id).map((f) => inUnit(u, f.path).split("/").slice(0, 3).join("/") + "/"))];
89
+ if (files.length) rows.push({ label: "Files", value: files.join(", "), note: `${moduleFiles(s.files, u, def.id).length} file(s) from the standard outputs` });
90
+ blocks.push({ kind: "keyvalues", rows });
91
+ }
92
+ if (cables.length) {
93
+ // cables: one table, and their routed shapes as a strip of thumbnails
94
+ blocks.push({ kind: "heading", text: `Cables (${cables.length})` });
95
+ const items = [];
96
+ for (const b of cables) {
97
+ const def = p.definitions.get(b.module!)!.def;
98
+ const th = existsSync(join(p.root, b.out, "body.glb"))
99
+ ? await s.figure(`made-${slug(b.module!)}`, { width: 480, height: 360, mode: "lineart", lineScale: 1.25, bodies: [{ key: b.module!, glb: projectPath(`${b.out}/body.glb`), matrix: IDENTITY, style: "accent", category: "generic" }], camera: { dir: [0.5, -0.8, 1.2], margin: 0.08 } })
100
+ : undefined;
101
+ items.push({ name: def.name ?? def.id, quantity: perBuild(u, def.id), ref: def.id, ...(th ? { image: th.file } : { placeholder: "not routed" }) });
102
+ }
103
+ blocks.push({ kind: "kit", items, note: "Each cable as routed in the assembly (ext-harness-cad's body.glb), not to scale." });
104
+ blocks.push({
105
+ kind: "table",
106
+ columns: [{ key: "cable", label: "Cable" }, { key: "supply", label: "Supply" }, { key: "cond", label: "Conductors" }, { key: "len", label: "Length" }, { key: "route", label: "Routed" }, { key: "bend", label: "Min bend" }, { key: "ends", label: "Ends" }],
107
+ rows: cables.map((b) => cableRow(p, b, p.definitions.get(b.module!)!.def)),
108
+ note: "Length: the installed length the model states (supplied length where it is cut down); routed: the centreline of the routed CAD; min bend: the smallest allowed bend radius. Cut lists are in files/harnesses/<cable>/cut-list.csv.",
109
+ });
110
+ }
111
+ out.push({ title: "Designed for this unit", order: 10, blocks });
112
+ return out;
113
+ };
114
+
115
+ function supply(p: Project, def: ModuleDef): string {
116
+ const c = classify(p, def);
117
+ if (c.action === "fabricate") return `made: ${c.process}`;
118
+ if (c.action === "included") return `ships with ${includedWith(def)}; the model gives its route and cut length`;
119
+ return "bought";
120
+ }
121
+
122
+ async function figureOf(p: Project, s: SectionContext, b: CapabilityBinding, group: string): Promise<Block | undefined> {
123
+ const file = group === "boards" ? "board.glb" : "body.glb";
124
+ if (!existsSync(join(p.root, b.out, file))) return undefined;
125
+ const cable = group === "harnesses";
126
+ const r = await s.figure(`made-${slug(b.module!)}`, {
127
+ width: 1600,
128
+ height: 900,
129
+ mode: "lineart",
130
+ bodies: [{ key: b.module!, glb: projectPath(`${b.out}/${file}`), matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], style: cable ? "accent" : "normal", category: group === "boards" ? "fc" : "generic" }],
131
+ camera: { dir: group === "boards" ? [0.6, -0.9, 1.6] : cable ? [0.5, -0.8, 1.2] : [0.8, -1, 1.4], margin: 0.08 },
132
+ });
133
+ return { kind: "image", file: r.file, width: r.width, height: r.height, size: "small", caption: `${b.module}: ${b.out}/${file}${cable ? " (routed in the assembly)" : ""}.` };
134
+ }
135
+
136
+ function boardFacts(p: Project, b: CapabilityBinding, def: ModuleDef) {
137
+ const c = b.constraints as { layers?: number; thickness_mm?: number; outline?: { circle?: { diameter_mm: number }; rect?: { width_mm: number; height_mm: number } }; mounting_holes?: unknown[] };
138
+ const rows: { label: string; value: string; note?: string }[] = [];
139
+ const box = glbBox(join(p.root, b.out, "board.glb"));
140
+ if (c.layers || c.thickness_mm) rows.push({ label: "Board", value: `${c.layers ?? "?"} copper layers, ${c.thickness_mm ?? "?"} mm`, note: "circuit.config.ts constraints" });
141
+ const outline = c.outline?.circle ? `round, Ø${c.outline.circle.diameter_mm} mm` : c.outline?.rect ? `${c.outline.rect.width_mm} × ${c.outline.rect.height_mm} mm` : undefined;
142
+ if (outline || box) rows.push({ label: "Outline", value: outline ?? `${n(box!.size[0])} × ${n(box!.size[1])} mm`, ...(box ? { note: `with components: ${size3(box.size)} (board.glb)` } : {}) });
143
+ const pnp = readCsv(p, b, "pnp.csv");
144
+ if (pnp.length) {
145
+ const sides = new Map<string, number>();
146
+ for (const r of pnp) sides.set(r.side || "top", (sides.get(r.side || "top") ?? 0) + 1);
147
+ rows.push({ label: "Components", value: `${pnp.length} placed`, note: [...sides].map(([k, v]) => `${v} ${k}`).join(", ") + " (pnp.csv)" });
148
+ } else rows.push({ label: "Components", value: `${def.children?.length ?? 0} in the model` });
149
+ const nets = def.interfaces.filter((i) => i.protocols.some((x) => x.type === "net"));
150
+ const rails = nets.map((i) => ({ i, r: netVoltageRange(i) })).filter((x) => x.r && x.r[1] > 0);
151
+ if (nets.length) rows.push({ label: "Nets", value: `${nets.length}`, note: rails.length ? `rails ${rails.map((x) => `${x.i.name ?? x.i.id} ${x.r![0] === x.r![1] ? `${x.r![0]} V` : `${x.r![0]}–${x.r![1]} V`}`).join(", ")}` : undefined });
152
+ if (c.mounting_holes?.length) rows.push({ label: "Mounting holes", value: String(c.mounting_holes.length) });
153
+ const edge = (def.exports ?? []).map((e) => e.name ?? e.id);
154
+ if (edge.length) rows.push({ label: "Board edge", value: edge.join(", ") });
155
+ return rows;
156
+ }
157
+
158
+ function mechanicalFacts(p: Project, b: CapabilityBinding, def: ModuleDef) {
159
+ const m = readOutput<CadManifest>(p, b, "body.manifest.json") ?? {};
160
+ const rows: { label: string; value: string; note?: string }[] = [];
161
+ const mech = def.domains?.find((d) => d.domain === "mechanical") as { material?: string | { name?: string; density_g_cm3?: number } } | undefined;
162
+ const mat = (b.constraints as { material?: string }).material ?? m.material ?? mech?.material;
163
+ const material = typeof mat === "object" ? mat?.name : mat;
164
+ if (material) rows.push({ label: "Material", value: String(material), ...(typeof mat === "object" && mat?.density_g_cm3 ? { note: `${mat.density_g_cm3} g/cm³` } : {}) });
165
+ const box = glbBox(join(p.root, b.out, "body.glb"));
166
+ if (box) rows.push({ label: "Overall size", value: size3(box.size), note: "bounding box of body.glb" });
167
+ const t = m.manufacturing?.thickness_mm;
168
+ if (t) rows.push({ label: "Thickness", value: Object.entries(t).map(([k, v]) => `${k} ${v} mm`).join(", ") });
169
+ const density = typeof mat === "object" ? mat?.density_g_cm3 : undefined;
170
+ if (m.volume_mm3) rows.push({ label: "Volume", value: `${n(m.volume_mm3 / 1000, 2)} cm³${density ? ` (${n((m.volume_mm3 / 1000) * density, 1)} g)` : ""}`, ...(m.bodies ? { note: Object.entries(m.bodies).map(([k, v]) => `${k} ${n(v / 1000, 2)} cm³`).join(", ") } : {}) });
171
+ const q = m.manufacturing?.quantities;
172
+ if (q) rows.push({ label: "Pieces", value: Object.entries(q).map(([k, v]) => `${v} × ${k}`).join(", ") });
173
+ const features = Object.keys(m.features ?? {});
174
+ if (features.length) {
175
+ const named = features.map((f) => def.interfaces.find((i) => i.id === f)?.name ?? f);
176
+ rows.push({ label: "Interface features", value: String(features.length), note: named.join(", ") });
177
+ }
178
+ return rows;
179
+ }
180
+
181
+ function cableRow(p: Project, b: CapabilityBinding, def: ModuleDef) {
182
+ const m = readOutput<CadManifest>(p, b, "body.manifest.json") ?? {};
183
+ const cable = params(def, "performance", "cable") ?? {};
184
+ const cut = cutList(def);
185
+ const c = classify(p, def);
186
+ return {
187
+ cable: def.name ?? def.id,
188
+ supply: c.action === "fabricate" ? "made" : c.action === "included" ? `with ${includedWith(def)}` : "bought",
189
+ cond: [String(cut.length || m.bundle?.conductors || "?"), cable.wire_gauge_awg ? `${cable.wire_gauge_awg} AWG` : "", m.bundle?.kind ?? ""].filter(Boolean).join(" · "),
190
+ len: typeof cable.length_mm === "number" ? `${cable.length_mm} mm${typeof cable.supplied_length_mm === "number" && cable.supplied_length_mm !== cable.length_mm ? ` (of ${cable.supplied_length_mm})` : ""}` : "",
191
+ route: m.route_length_mm ? `${n(m.route_length_mm)} mm` : "",
192
+ bend: m.bend_radius_mm?.minimum ? `${n(m.bend_radius_mm.minimum, 1)} mm` : "",
193
+ ends: Object.entries(m.ends ?? {}).map(([, e]) => `${e.termination ?? "?"} to ${e.counterpart ?? "?"}`).join("; "),
194
+ };
195
+ }
196
+
197
+ function readCsv(p: Project, b: CapabilityBinding, file: string): Record<string, string>[] {
198
+ const f = join(p.root, b.out, file);
199
+ if (!existsSync(f)) return [];
200
+ const [header = [], ...rows] = parseCsv(readFileSync(f, "utf8")).rows;
201
+ return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""])));
202
+ }
203
+
package/lib/png.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Minimal PNG decoder (8-bit, non-interlaced, grey/RGB/RGBA) and image
3
+ * statistics for the verifier's "not blank" check. No dependencies.
4
+ */
5
+ import { readFileSync } from "fs";
6
+ import { inflateSync } from "zlib";
7
+
8
+ export interface Png {
9
+ width: number;
10
+ height: number;
11
+ channels: number;
12
+ data: Uint8Array;
13
+ }
14
+
15
+ export function decodePng(file: string): Png {
16
+ const buf = readFileSync(file);
17
+ if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error(`${file}: not a PNG`);
18
+ let off = 8;
19
+ let width = 0;
20
+ let height = 0;
21
+ let depth = 0;
22
+ let type = 0;
23
+ let interlace = 0;
24
+ const idat: Buffer[] = [];
25
+ while (off < buf.length) {
26
+ const len = buf.readUInt32BE(off);
27
+ const kind = buf.toString("ascii", off + 4, off + 8);
28
+ const body = buf.subarray(off + 8, off + 8 + len);
29
+ if (kind === "IHDR") {
30
+ width = body.readUInt32BE(0);
31
+ height = body.readUInt32BE(4);
32
+ depth = body[8];
33
+ type = body[9];
34
+ interlace = body[12];
35
+ } else if (kind === "IDAT") idat.push(body);
36
+ else if (kind === "IEND") break;
37
+ off += 12 + len;
38
+ }
39
+ const channels = ({ 0: 1, 2: 3, 4: 2, 6: 4 } as Record<number, number>)[type];
40
+ if (depth !== 8 || !channels || interlace) throw new Error(`${file}: unsupported PNG (depth ${depth}, type ${type}, interlace ${interlace})`);
41
+ const raw = inflateSync(Buffer.concat(idat));
42
+ const stride = width * channels;
43
+ const out = new Uint8Array(height * stride);
44
+ for (let y = 0; y < height; y++) {
45
+ const f = raw[y * (stride + 1)];
46
+ const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
47
+ const row = out.subarray(y * stride, (y + 1) * stride);
48
+ const up = y ? out.subarray((y - 1) * stride, y * stride) : undefined;
49
+ for (let x = 0; x < stride; x++) {
50
+ const a = x >= channels ? row[x - channels] : 0;
51
+ const b = up ? up[x] : 0;
52
+ const c = up && x >= channels ? up[x - channels] : 0;
53
+ let v = src[x];
54
+ if (f === 1) v += a;
55
+ else if (f === 2) v += b;
56
+ else if (f === 3) v += (a + b) >> 1;
57
+ else if (f === 4) {
58
+ const p = a + b - c;
59
+ const pa = Math.abs(p - a);
60
+ const pb = Math.abs(p - b);
61
+ const pc = Math.abs(p - c);
62
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
63
+ }
64
+ row[x] = v & 255;
65
+ }
66
+ }
67
+ return { width, height, channels, data: out };
68
+ }
69
+
70
+ /** Share of pixels that carry ink (opaque and not near-white), and luminance spread. */
71
+ export function inkStats(png: Png): { ink: number; spread: number } {
72
+ const { data, channels, width, height } = png;
73
+ let ink = 0;
74
+ let sum = 0;
75
+ let sum2 = 0;
76
+ const n = width * height;
77
+ const step = Math.max(1, Math.floor(n / 200000));
78
+ let count = 0;
79
+ for (let i = 0; i < n; i += step) {
80
+ const o = i * channels;
81
+ const alpha = channels === 4 ? data[o + 3] : channels === 2 ? data[o + 1] : 255;
82
+ const lum = channels >= 3 ? 0.2126 * data[o] + 0.7152 * data[o + 1] + 0.0722 * data[o + 2] : data[o];
83
+ const l = alpha < 16 ? 255 : lum;
84
+ if (alpha >= 16 && lum < 235) ink++;
85
+ sum += l;
86
+ sum2 += l * l;
87
+ count++;
88
+ }
89
+ const mean = sum / count;
90
+ return { ink: ink / count, spread: Math.sqrt(Math.max(0, sum2 / count - mean * mean)) };
91
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The headless browser the documents are printed with: core's CDP client
3
+ * (one Chrome, one page; core/src/cdp.ts, shared with `protoboard views check`).
4
+ */
5
+ export { launchBrowser, type Browser, type LaunchOptions } from "@protoboardai/core/cdp";
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>uhd-docs renderer</title>
6
+ <script type="importmap">
7
+ { "imports": { "three": "/three/build/three.module.js", "three/addons/": "/three/examples/jsm/" } }
8
+ </script>
9
+ <style>
10
+ html, body { margin: 0; background: transparent; }
11
+ canvas { display: block; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <script type="module" src="/page/render.js"></script>
16
+ </body>
17
+ </html>