@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
package/lib/model.ts ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Loading a Protoboard project's system for documentation, and turning its
3
+ * assembly into renderable bodies. Everything here is read from the model at
4
+ * run time: `assemble()` placements and hardware, artifacts, manifests.
5
+ * Artifact paths resolve against each definition's artifact base (the
6
+ * project, the parts/catalog/<id> directory a catalog revision was vendored
7
+ * into, or an older pin's package), never against a repository.
8
+ */
9
+ import { existsSync, readFileSync } from "fs";
10
+ import { dirname, join } from "path";
11
+ import { pathToFileURL } from "url";
12
+ import type { ArtifactDef, GeometryFrame, GeometryManifest, ModuleDef } from "@deltarobotics/uhd";
13
+ import type { ModuleLookup } from "@deltarobotics/uhd";
14
+ import { applyMat4, assemble, bodyKey, multiplyMat4, type Assembly, type Mat4 } from "@deltarobotics/uhd";
15
+ import { packageRoot, type Project } from "@protoboardai/core";
16
+ import { packagePath, projectPath, type ServerRoots } from "./render/server.ts";
17
+ import type { DocConfig } from "./types.ts";
18
+
19
+ /** Project-relative folder of the documents and their spec (`docs/docspec.ts`). */
20
+ export const DOCS_DIR = "docs";
21
+
22
+ export interface LoadedSystem {
23
+ /** Project name, shown in the documents. */
24
+ name: string;
25
+ /** Absolute project root. */
26
+ root: string;
27
+ /** The documents folder: project-relative and absolute. */
28
+ docsDir: string;
29
+ absDocs: string;
30
+ system: ModuleDef;
31
+ lookup: ModuleLookup;
32
+ scenarios: { id: string; label: string; description: string; system: ModuleDef; lookup: ModuleLookup }[];
33
+ config: DocConfig;
34
+ /** What the doc server serves: the project, and packages by name. */
35
+ roots: ServerRoots;
36
+ /** The absolute file of an artifact path `def` declares (resolved against its origin), if it can be placed. */
37
+ file: (def: ModuleDef, filePath: string) => string | undefined;
38
+ /** The same file as a served path (server.ts). */
39
+ served: (def: ModuleDef, filePath: string) => string | undefined;
40
+ /** Folders that may hold a definition's sources.json and verification.json. */
41
+ evidenceDirs: (id: string) => string[];
42
+ }
43
+
44
+ interface ProjectScenario {
45
+ id: string;
46
+ label: string;
47
+ description?: string;
48
+ system?: ModuleDef;
49
+ overrides?: ModuleDef[];
50
+ }
51
+
52
+ /** The project's root system, its scenarios and `docs/docspec.ts`, with artifact and evidence resolution. */
53
+ export async function loadSystem(p: Project, docsDir = DOCS_DIR): Promise<LoadedSystem> {
54
+ if (!p.rootDef) throw new Error("the project has no root system (protoboard.config.ts root)");
55
+ const system = p.rootDef;
56
+ const lookup: ModuleLookup = (id) => p.lookup(id);
57
+ const packages = new Map<string, string | undefined>();
58
+ const pkgRoot = (name: string) => {
59
+ if (!packages.has(name)) packages.set(name, packageRoot(p.root, name));
60
+ return packages.get(name);
61
+ };
62
+ // A definition's artifact base (core ArtifactBase): the project, a directory in it the
63
+ // catalog revision was vendored into (parts/catalog/<id>), or an installed package.
64
+ type Base = { kind: "project"; dir: string } | { kind: "package"; name: string; root: string };
65
+ const base = (def: ModuleDef): Base | undefined => {
66
+ const rec = p.definitions.get(def.id);
67
+ const ab = rec?.origin.artifactBase;
68
+ if (!ab || ab.kind === "project") return { kind: "project", dir: "" };
69
+ if (ab.kind === "vendored") return { kind: "project", dir: ab.dir };
70
+ const root = pkgRoot(ab.package);
71
+ return root ? { kind: "package", name: ab.package, root } : undefined;
72
+ };
73
+ const file = (def: ModuleDef, filePath: string) => {
74
+ const b = base(def);
75
+ return b ? join(b.kind === "project" ? join(p.root, b.dir) : b.root, filePath) : undefined;
76
+ };
77
+ const served = (def: ModuleDef, filePath: string) => {
78
+ const b = base(def);
79
+ return b ? (b.kind === "project" ? projectPath(b.dir ? `${b.dir}/${filePath}` : filePath) : packagePath(b.name, filePath)) : undefined;
80
+ };
81
+ const evidenceDirs = (id: string) => {
82
+ const rec = p.definitions.get(id);
83
+ const dirs = [join(p.root, "parts", "evidence", id)];
84
+ // evidence files vendored with the catalog revision (parts/catalog/<id>/…)
85
+ for (const e of rec?.origin.evidence ?? []) {
86
+ const dir = dirname(join(p.root, e.file));
87
+ if (!dirs.includes(dir)) dirs.push(dir);
88
+ }
89
+ // catalog parts vendored from a package keep their evidence at library/parts/<id> in it
90
+ if (rec?.origin.kind === "catalog" && rec.origin.package) {
91
+ const root = pkgRoot(rec.origin.package);
92
+ if (root) dirs.push(join(root, "library", "parts", id));
93
+ }
94
+ return dirs;
95
+ };
96
+
97
+ let scenarios: LoadedSystem["scenarios"] = [];
98
+ if (p.config.scenarios) {
99
+ const mod = (await import(pathToFileURL(join(p.root, p.config.scenarios.source)).href)) as Record<string, unknown>;
100
+ scenarios = ((mod[p.config.scenarios.export] as ProjectScenario[] | undefined) ?? []).map((s) => {
101
+ const overrides = new Map((s.overrides ?? []).map((d) => [d.id, d]));
102
+ return { id: s.id, label: s.label, description: s.description ?? "", system: s.system ?? system, lookup: (id: string) => overrides.get(id) ?? p.lookup(id) };
103
+ });
104
+ }
105
+ let config: DocConfig = {};
106
+ const spec = join(p.root, docsDir, "docspec.ts");
107
+ if (existsSync(spec)) config = ((await import(pathToFileURL(spec).href)) as { default?: DocConfig }).default ?? {};
108
+ return {
109
+ name: p.name,
110
+ root: p.root,
111
+ docsDir,
112
+ absDocs: join(p.root, docsDir),
113
+ system,
114
+ lookup,
115
+ scenarios,
116
+ config,
117
+ roots: { project: p.root, packageRoot: pkgRoot },
118
+ file,
119
+ served,
120
+ evidenceDirs,
121
+ };
122
+ }
123
+
124
+ /** The generator manifest for one of a module's CAD artifacts (the JSON artifact generated from it). */
125
+ export function manifestFor(sys: LoadedSystem, def: ModuleDef, artifactId: string): GeometryManifest | undefined {
126
+ const art = (def.artifacts ?? []).find((a) => a.format === "json" && a.provenance?.generatedFrom === artifactId && a.filePath);
127
+ const f = art?.filePath ? sys.file(def, art.filePath) : undefined;
128
+ if (!f || !existsSync(f)) return undefined;
129
+ try {
130
+ return JSON.parse(readFileSync(f, "utf8")) as GeometryManifest;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ /** Solid volume (mm³) of a module's generated CAD body: the `volume_mm3` its generator records (UHD systemMass input). */
137
+ export const cadVolume = (sys: LoadedSystem) => (def: ModuleDef): number | undefined => manifestFor(sys, def, "cad_step")?.volume_mm3;
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Classification (drawing palette only; never shown as data)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * UHD taxonomy paths (docs/taxonomy.md) and the drawing class of a part
145
+ * filed at or under them. Matched by whole segments, so
146
+ * `actuator.motor_controller` (an ESC) is not under `actuator.motor`.
147
+ */
148
+ const TAXONOMY_CLASSES: [string, string][] = [
149
+ ["actuator.motor", "motor"],
150
+ ["actuator.motor_controller", "esc"],
151
+ ["mechanical.propeller", "prop"],
152
+ ["microcontroller.flight_controller", "fc"],
153
+ ["connectivity.wireless.video", "video"],
154
+ ["power.battery", "battery"],
155
+ ["sensor.gnss", "gnss"],
156
+ ["component.passive.capacitor", "capacitor"],
157
+ ];
158
+
159
+ export function category(def: ModuleDef): string {
160
+ const id = def.id;
161
+ const tags = new Set(def.tags ?? []);
162
+ const paths = def.categories ?? [];
163
+ const cats = paths.join(" ");
164
+ if (tags.has("screw")) return "screw";
165
+ if (tags.has("nut")) return "nut";
166
+ if (tags.has("standoff")) return "standoff";
167
+ if (tags.has("spacer")) return "spacer";
168
+ if (id.includes("top-plate")) return "top_plate";
169
+ if (tags.has("frame") || cats.includes("structure") && id.includes("frame")) return "frame";
170
+ // taxonomy paths, in the definition's order: its first category is what it is
171
+ for (const path of paths) for (const [root, cls] of TAXONOMY_CLASSES) if (path === root || path.startsWith(`${root}.`)) return cls;
172
+ if (tags.has("receiver")) return "receiver";
173
+ // categories from before the UHD taxonomy (PB-849)
174
+ if (cats.includes("motor")) return "motor";
175
+ if (cats.includes("propeller") || tags.has("propeller")) return "prop";
176
+ if (cats.includes("esc")) return "esc";
177
+ if (cats.includes("flight_controller")) return "fc";
178
+ if (cats.includes("video")) return "video";
179
+ if (cats.includes("battery")) return "battery";
180
+ if (cats.includes("gnss")) return "gnss";
181
+ if (cats.includes("receiver")) return "receiver";
182
+ if (cats.includes("capacitor")) return "capacitor";
183
+ return "generic";
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Artifacts
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /** Body GLB for a module (or for one of its bodies, by the STEP/vendor artifact id). */
191
+ export function glbFor(def: ModuleDef, bodyArtifact?: string): ArtifactDef | undefined {
192
+ const arts = def.artifacts ?? [];
193
+ if (bodyArtifact) return arts.find((a) => a.format === "glb" && (a.id === bodyArtifact || a.provenance?.generatedFrom === bodyArtifact));
194
+ return arts.find((a) => a.role === "body" && a.format === "glb");
195
+ }
196
+
197
+ /** Feature names in the manifest of the STEP a GLB was generated from (sub-shapes to hide or call out). */
198
+ export function featureNames(sys: LoadedSystem, def: ModuleDef, glb?: ArtifactDef): string[] {
199
+ const src = glb?.provenance?.generatedFrom;
200
+ return src ? Object.keys(manifestFor(sys, def, src)?.features ?? {}) : [];
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Scene bodies from the assembly
205
+ // ---------------------------------------------------------------------------
206
+
207
+ export interface SceneBody {
208
+ key: string;
209
+ path: string[];
210
+ def: ModuleDef;
211
+ glb?: string;
212
+ matrix: Mat4;
213
+ category: string;
214
+ features: string[];
215
+ vendor: boolean;
216
+ /** Link that placed it; "hint:<note>" for doc-config placements. */
217
+ via?: string;
218
+ /** Hardware only. */
219
+ harness?: string;
220
+ child?: string;
221
+ linkId?: string;
222
+ }
223
+
224
+ export interface SceneModel {
225
+ assembly: Assembly;
226
+ bodies: SceneBody[];
227
+ hardware: SceneBody[];
228
+ byKey: Map<string, SceneBody>;
229
+ }
230
+
231
+ export function buildScene(sys: LoadedSystem): SceneModel {
232
+ const fixed: Record<string, Mat4> = {};
233
+ for (const h of sys.config.placementHints ?? []) fixed[bodyKey(h.path)] = h.matrix;
234
+ const assembly = assemble(sys.system, sys.lookup, { root: sys.config.root ?? [], fixed });
235
+ const defs = new Map(assembly.instances.map((i) => [bodyKey(i.path), i.def]));
236
+ // A module with children is flattened into its leaves by assemble(), but one with its own body
237
+ // (a circuit board: its GLB carries every component) is drawn whole when a hint places it.
238
+ const walk = (d: ModuleDef, prefix: string[]) => {
239
+ for (const ref of d.children ?? []) {
240
+ const child = sys.lookup(ref.moduleDefId);
241
+ if (!child?.children?.length || child.kind === "harness") continue;
242
+ const path = [...prefix, ref.id];
243
+ if (glbFor(child) && fixed[bodyKey(path)]) defs.set(bodyKey(path), child);
244
+ walk(child, path);
245
+ }
246
+ };
247
+ walk(sys.system, []);
248
+ const bodies: SceneBody[] = [];
249
+ // a placed module with children (a designed board carrying its components) is not a leaf instance; it is a body when it has its own GLB
250
+ const defAt = (path: string[]): ModuleDef | undefined => {
251
+ let d: ModuleDef | undefined = sys.system;
252
+ for (const id of path) {
253
+ const ref: { moduleDefId: string } | undefined = d?.children?.find((c) => c.id === id);
254
+ d = ref ? sys.lookup(ref.moduleDefId) : undefined;
255
+ }
256
+ return d;
257
+ };
258
+ for (const p of assembly.placements) {
259
+ const leaf = defs.get(bodyKey(p.path));
260
+ const inner = leaf ? undefined : defAt(p.path);
261
+ const def = leaf ?? (inner && glbFor(inner, p.body) ? inner : undefined);
262
+ if (!def) continue;
263
+ const glb = glbFor(def, p.body);
264
+ const hint = (sys.config.placementHints ?? []).find((h) => bodyKey(h.path) === p.key);
265
+ bodies.push({
266
+ key: p.key,
267
+ path: p.path,
268
+ def,
269
+ glb: glb?.filePath ? sys.served(def, glb.filePath) : undefined,
270
+ matrix: p.matrix,
271
+ category: category(def),
272
+ features: featureNames(sys, def, glb),
273
+ vendor: Boolean(glb?.id.startsWith("cad_vendor")),
274
+ via: p.via ?? (hint ? `hint:${hint.note}` : undefined),
275
+ });
276
+ }
277
+ const counters = new Map<string, number>();
278
+ const hardware: SceneBody[] = assembly.hardware.map((h) => {
279
+ const base = `${h.harness}/${h.child}`;
280
+ const n = (counters.get(base) ?? 0) + 1;
281
+ counters.set(base, n);
282
+ const glb = glbFor(h.def);
283
+ return {
284
+ key: `${base}#${n}`,
285
+ path: [...h.harness.split("/"), h.child],
286
+ def: h.def,
287
+ glb: glb?.filePath ? sys.served(h.def, glb.filePath) : undefined,
288
+ matrix: h.matrix,
289
+ category: category(h.def),
290
+ features: featureNames(sys, h.def, glb),
291
+ vendor: false,
292
+ harness: h.harness,
293
+ child: h.child,
294
+ linkId: h.linkId,
295
+ };
296
+ });
297
+ const byKey = new Map([...bodies, ...hardware].map((b) => [b.key, b]));
298
+ return { assembly, bodies, hardware, byKey };
299
+ }
300
+
301
+ /** World position and direction of an interface frame on a placed body. */
302
+ export function worldFrame(matrix: Mat4, frame: GeometryFrame): { origin: number[]; normal: number[] } {
303
+ const o = applyMat4(matrix, frame.origin);
304
+ const tip = applyMat4(matrix, [frame.origin[0] + frame.normal[0], frame.origin[1] + frame.normal[1], frame.origin[2] + frame.normal[2]]);
305
+ const n = [tip[0] - o[0], tip[1] - o[1], tip[2] - o[2]];
306
+ const len = Math.hypot(n[0], n[1], n[2]) || 1;
307
+ return { origin: o, normal: n.map((x) => x / len) };
308
+ }
309
+
310
+ export const translate = (v: number[]): Mat4 => [1, 0, 0, v[0], 0, 1, 0, v[1], 0, 0, 1, v[2], 0, 0, 0, 1];
311
+ export const offsetMatrix = (m: Mat4, v: number[]): Mat4 => multiplyMat4(translate(v), m);
312
+ export const matTranslation = (m: Mat4): number[] => [m[3], m[7], m[11]];
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The assembly guide of one Manufacturing Package unit (`package:assembly`):
3
+ * before you begin (sub-units, tools, materials), the kit with thumbnails,
4
+ * then one illustrated step per planned step (./plan.ts): the parts strip,
5
+ * an exploded line-art figure lettered like the strip (or, for connections,
6
+ * the routed cables drawn in the accent colour), actions, connections,
7
+ * torque, tools and notes, and a coverage table of every link the unit makes.
8
+ * Figures are rendered by the release (SectionContext.figure).
9
+ */
10
+ import type { ModuleDef } from "@deltarobotics/uhd";
11
+ import { applyMat4 } from "@deltarobotics/uhd";
12
+ import type { Block, Callout, KitItem, SectionContext, SectionFn, Step } from "@protoboardai/ext-release";
13
+ import { unitBom } from "@protoboardai/ext-release/helpers";
14
+ import type { BuiltContext } from "../context.ts";
15
+ import { centerOf, shortName } from "../docs/assembly.ts";
16
+ import { explode, ISO, renderBody, withOffset } from "../figures.ts";
17
+ import { matTranslation, type SceneBody } from "../model.ts";
18
+ import type { RenderBody } from "../render/renderer.ts";
19
+ import { bodyGlb, noImage, slug, thumbnail, type UnitScope } from "./common.ts";
20
+ import { unitPlan, type PlannedStep } from "./plan.ts";
21
+
22
+ export const assembly: SectionFn = async (s) => {
23
+ const { project: p, unit: u, release } = s;
24
+ if (!p.rootDef) return undefined;
25
+ const { ctx, scope, steps, builds: buildList, variants } = await unitPlan(p, u);
26
+ const blocks: Block[] = [];
27
+ const builds = u.perProduct * release.build;
28
+ const subs = [...new Set(u.members.filter((m) => m.unit).map((m) => m.unit!))];
29
+ blocks.push({
30
+ kind: "paragraph",
31
+ text: `${u.kind === "root" ? `Final assembly of ${u.name}` : `One build of ${u.name}`}, in ${steps.length} step${steps.length === 1 ? "" : "s"}. Build ${builds} (${u.perProduct} per product × ${release.build}). Steps, parts, hardware and torques come from the model: mates and fastener stacks from assemble(), connections from the wiring checklist.${steps.some((x) => x.split) ? " A step marked as split is the part of a longer step that this unit does; the rest is in the unit named in it." : ""}`,
32
+ });
33
+
34
+ if (variants.length)
35
+ blocks.push({
36
+ kind: "note",
37
+ level: "caution",
38
+ text: `The ${buildList.length} builds are not all the same: ${variants.map((x) => `${x.field} ${x.values.map((y) => `${y.value} for ${y.build}`).join(", ")}`).join("; ")}. ${variants.every((x) => x.handled) ? "The steps that depend on it list each build." : "Not every step says which build gets what: check each build against the position it goes to."}`,
39
+ });
40
+ const later = steps.filter((x) => x.afterTest).map((x) => String(x.n));
41
+ if (later.length)
42
+ blocks.push({ kind: "paragraph", text: `${later.length === 1 ? `Step ${later[0]} comes` : `Steps ${later.slice(0, -1).join(", ")} and ${later[later.length - 1]} come`} after the bench test and configuration in the work order; the steps before ${later.length === 1 ? "it" : "them"} are done first.` });
43
+
44
+ // ---- before you begin
45
+ const rank = (t: string) => [/hex key/, /nut driver/, /spanner/].findIndex((r) => r.test(t)) >>> 0;
46
+ const tools = [...new Set(steps.flatMap((x) => x.tools))].sort((a, b) => rank(a) - rank(b) || parseFloat(a) - parseFloat(b) || (a < b ? -1 : a > b ? 1 : 0));
47
+ const materials = [...new Set(steps.flatMap((x) => x.materials))];
48
+ const before: string[] = [];
49
+ if (subs.length) before.push(`Bring the finished sub-units, each built and tested in its own folder: ${subs.map((id) => `${id} (units/${id}/)`).join(", ")}.`);
50
+ before.push(`Tools: ${tools.length ? tools.join(", ") : "none beyond hands"}${tools.length ? " (hex keys from each screw's socket, drivers and spanners from each nut's and standoff's across-flats)" : ""}.`);
51
+ if (materials.length) before.push(`Materials: ${materials.join(", ")}.`);
52
+ before.push("Figures: parts already assembled are drawn light; parts fitted in the step dark, exploded along each joint's axis in stacking order; hardware in the accent colour. Letters match the parts strip.");
53
+ blocks.push({ kind: "heading", text: "Before you begin" }, { kind: "list", items: before });
54
+
55
+ // ---- kit
56
+ blocks.push({ kind: "heading", text: "Kit" }, { kind: "kit", items: await kit(ctx, s), note: "Quantities per build, from the unit's bill of materials (bom.csv). Drawings are the parts' CAD, not to scale." });
57
+
58
+ // ---- steps
59
+ const out: Step[] = [];
60
+ const state = initialState(ctx, scope, steps);
61
+ for (const step of steps) out.push(await illustrated(ctx, s, scope, step, state));
62
+ if (out.length) blocks.push({ kind: "heading", text: "Steps" }, { kind: "steps", steps: out });
63
+ else blocks.push({ kind: "paragraph", text: "This unit makes no joints or connections: its parts arrive assembled." });
64
+
65
+ // ---- coverage
66
+ const rows = steps.flatMap((x) => [
67
+ ...x.plan.mates.map((m) => ({ link: m.linkId, what: "mate", by: m.harness ?? "", step: `${x.n} · ${x.title}` })),
68
+ ...x.plan.wiring.map((w) => ({ link: w.linkId, what: "connection", by: w.harness ?? "", step: `${x.n} · ${x.title}` })),
69
+ ]);
70
+ if (rows.length) {
71
+ blocks.push({ kind: "heading", text: "Coverage" });
72
+ blocks.push({ kind: "table", columns: [{ key: "link", label: "Link" }, { key: "what", label: "Kind" }, { key: "by", label: "Harness" }, { key: "step", label: "Step" }], rows, note: "Every mate and connection this unit makes, and the step that makes it. Links of sub-units are in their own guides." });
73
+ }
74
+ return { blocks };
75
+ };
76
+
77
+ /** Items laid out at the bench: what one build consumes directly (assemblies and kits opened, sub-units and boards whole). */
78
+ async function kit(ctx: BuiltContext, s: SectionContext): Promise<KitItem[]> {
79
+ const rows = unitBom(s.project, s.unit);
80
+ const whole: string[] = [];
81
+ const by = new Map<string, { def: ModuleDef; qty: number; refs: string[]; unit?: string }>();
82
+ for (const r of rows) {
83
+ if (whole.some((w) => r.path.startsWith(w + "/"))) continue;
84
+ if (r.action === "build" && r.kind !== "unit") continue;
85
+ whole.push(r.path);
86
+ const def = s.project.lookup(r.definition);
87
+ if (!def) continue;
88
+ const x = by.get(def.id) ?? { def, qty: 0, refs: [], unit: r.kind === "unit" ? r.note : undefined };
89
+ x.qty += r.qty_per_unit;
90
+ x.refs.push(r.path);
91
+ by.set(def.id, x);
92
+ }
93
+ const items: KitItem[] = [];
94
+ for (const { def, qty, unit, refs } of by.values()) {
95
+ const th = unit ? await unitThumbnail(ctx, s, refs[0]) : await thumbnail(ctx, s, def);
96
+ items.push({ name: shortName(def), quantity: qty, ref: unit ?? def.id, ...(th ? { image: th.file } : { placeholder: noImage(ctx, def) }) });
97
+ }
98
+ return items;
99
+ }
100
+
101
+ /** A sub-unit as it arrives: its placed bodies, line art, in their assembled positions. */
102
+ async function unitThumbnail(ctx: BuiltContext, s: SectionContext, path: string) {
103
+ const at = s.unit.kind === "root" ? path : `${s.unit.representative}/${path}`;
104
+ const bodies = [...ctx.scene.bodies, ...ctx.scene.hardware].filter((b) => b.glb && (b.path.join("/") === at || b.path.join("/").startsWith(at + "/")));
105
+ if (!bodies.length) return undefined;
106
+ return s.figure(`unit-thumb-${slug(at)}`, { width: 360, height: 360, mode: "lineart", lineScale: 1.25, bodies: bodies.map((b) => renderBody(b, "normal")), camera: { dir: ISO, margin: 0.08 } });
107
+ }
108
+
109
+ interface FigState {
110
+ /** Body keys drawn as already assembled. */
111
+ before: Set<string>;
112
+ hwBefore: Set<string>;
113
+ /** Sub-unit instance → its bodies and hardware keys (they arrive built and move as one). */
114
+ subBodies: Map<string, SceneBody[]>;
115
+ }
116
+
117
+ /**
118
+ * Before the first step: the unit's bodies that no step of this unit moves,
119
+ * except those of a sub-unit that a step mounts (they arrive with it).
120
+ */
121
+ function initialState(ctx: BuiltContext, scope: UnitScope, steps: PlannedStep[]): FigState {
122
+ const scene = ctx.scene;
123
+ const moved = new Set(steps.flatMap((x) => [...x.plan.moving, ...x.plan.placed].map((b) => b.key)));
124
+ const hwMoved = new Set(steps.flatMap((x) => x.plan.hardware.map((h) => h.key)));
125
+ const subBodies = new Map<string, SceneBody[]>();
126
+ for (const b of [...scene.bodies, ...scene.hardware]) {
127
+ const sub = scope.subOf(b.path.join("/"));
128
+ if (sub) subBodies.set(sub, [...(subBodies.get(sub) ?? []), b]);
129
+ }
130
+ const mountedSubs = new Set([...subBodies.entries()].filter(([, bs]) => bs.some((b) => moved.has(b.key) || hwMoved.has(b.key))).map(([k]) => k));
131
+ const later = (b: SceneBody) => {
132
+ const sub = scope.subOf(b.path.join("/"));
133
+ return !!sub && mountedSubs.has(sub);
134
+ };
135
+ const inUnit = (b: SceneBody) => scope.inBuild(b.path.join("/")) && !b.via?.startsWith("hint:");
136
+ return {
137
+ before: new Set(scene.bodies.filter((b) => inUnit(b) && !moved.has(b.key) && !later(b)).map((b) => b.key)),
138
+ hwBefore: new Set(scene.hardware.filter((h) => scope.inBuild(h.path.join("/")) && !hwMoved.has(h.key) && !later(h)).map((h) => h.key)),
139
+ subBodies,
140
+ };
141
+ }
142
+
143
+ async function illustrated(ctx: BuiltContext, s: SectionContext, scope: UnitScope, x: PlannedStep, st: FigState): Promise<Step> {
144
+ const parts: NonNullable<Step["parts"]> = [];
145
+ for (const part of x.parts) {
146
+ const th = await thumbnail(ctx, s, part.def);
147
+ parts.push({ name: shortName(part.def), quantity: part.count, ref: part.def.id, letter: part.letter, accent: part.accent, ...(th ? { image: th.file } : {}) });
148
+ }
149
+ const figure = await stepFigure(ctx, s, scope, x, st);
150
+ const notes = [...x.notes];
151
+ if (x.split) notes.unshift({ level: "tip", text: `Split step: the doc spec's "${x.title}" also covers links made in another unit; this unit does the part listed here.` });
152
+ if (x.afterTest && !notes.some((n) => /after (the )?(bench )?test|after configuration/i.test(n.text))) notes.unshift({ level: "caution", text: `This step follows the bench test and configuration in the work order (${x.afterTest}).` });
153
+ return {
154
+ title: x.title,
155
+ actions: x.actions,
156
+ parts,
157
+ ...(figure ? { figure } : {}),
158
+ ...(x.connections.length
159
+ ? {
160
+ table: {
161
+ columns: [{ key: "name", label: "Link" }, { key: "from", label: "From" }, { key: "to", label: "To" }, { key: "x", label: "" }, { key: "term", label: "Termination" }],
162
+ rows: x.connections.map((c) => ({ name: c.name, from: c.from, to: c.to, x: c.crossover ? "× crossover" : "", term: c.termination })),
163
+ },
164
+ }
165
+ : {}),
166
+ ...(x.torque ? { torque: x.torque } : {}),
167
+ ...(x.tools.length ? { tools: x.tools } : {}),
168
+ ...(x.materials.length ? { materials: x.materials } : {}),
169
+ notes,
170
+ trace: x.trace,
171
+ };
172
+ }
173
+
174
+ /** The step's figure: exploded mates, or the routed cables of its connections; advances the state. */
175
+ async function stepFigure(ctx: BuiltContext, s: SectionContext, scope: UnitScope, x: PlannedStep, st: FigState): Promise<Step["figure"] | undefined> {
176
+ const scene = ctx.scene;
177
+ const p = x.plan;
178
+ const spec = p.spec;
179
+ const cfg = ctx.sys.config;
180
+ const iso = cfg.camera?.dir ?? ISO;
181
+ const E = spec.explode ?? cfg.assembly?.explode ?? 16;
182
+ const name = `ag-${slug(s.unit.id)}-${String(x.n).padStart(2, "0")}-${slug(spec.id)}`;
183
+ const context = (exclude: Set<string>): RenderBody[] => [
184
+ ...scene.bodies.filter((b) => st.before.has(b.key) && !exclude.has(b.key)).map((b) => renderBody(b, "context")),
185
+ ...scene.hardware.filter((h) => st.hwBefore.has(h.key) && !exclude.has(h.key)).map((h) => renderBody(h, "context")),
186
+ ];
187
+ const advance = (keys: string[], hw: string[]) => {
188
+ for (const k of keys) st.before.add(k);
189
+ for (const k of hw) st.hwBefore.add(k);
190
+ };
191
+ const callouts = (anchors: Record<string, { x: number; y: number }>): Callout[] =>
192
+ x.parts.filter((pt) => pt.anchor && anchors[pt.anchor]).map((pt) => ({ ...anchors[pt.anchor!], label: pt.letter, accent: pt.accent }));
193
+
194
+ if (spec.figure !== "wiring" && spec.figure !== "none" && (p.mates.length || p.placed.length)) {
195
+ const ex = explode(scene, p.mates.map((m) => m.linkId), E);
196
+ // a sub-unit mounted here moves as one: its other bodies take the offset of the body the mate placed
197
+ const follow: SceneBody[] = [];
198
+ for (const [sub, bs] of st.subBodies) {
199
+ const off = bs.map((b) => ex.offsets.get(b.key)).find(Boolean);
200
+ if (!off) continue;
201
+ for (const b of bs) if (!ex.offsets.has(b.key) && !st.before.has(b.key) && !st.hwBefore.has(b.key) && !p.moving.includes(b) && !p.hardware.includes(b)) {
202
+ ex.offsets.set(b.key, off);
203
+ follow.push(b);
204
+ }
205
+ void sub;
206
+ }
207
+ const followKeys = new Set(follow.map((b) => b.key));
208
+ const bodies: RenderBody[] = context(followKeys);
209
+ const anchors: { id: string; p: number[] }[] = [];
210
+ for (const b of [...p.moving, ...follow.filter((f) => !f.harness)]) {
211
+ const off = ex.offsets.get(b.key);
212
+ bodies.push(withOffset(renderBody(b, "normal"), off));
213
+ anchors.push({ id: `body:${b.key}`, p: centerOf(b, off) });
214
+ }
215
+ for (const b of p.placed) {
216
+ const off = [0, 0, E * 1.6];
217
+ bodies.push(withOffset(renderBody(b, "normal"), off));
218
+ anchors.push({ id: `body:${b.key}`, p: centerOf(b, off) });
219
+ }
220
+ for (const h of [...p.hardware, ...follow.filter((f) => f.harness)]) {
221
+ const off = ex.offsets.get(h.key);
222
+ bodies.push(withOffset(renderBody(h, p.hardware.includes(h) ? "accent" : "normal"), off));
223
+ anchors.push({ id: `hw:${h.key}`, p: centerOf(h, off) });
224
+ }
225
+ const guides = [...ex.guides];
226
+ for (const b of p.placed) {
227
+ const c = matTranslation(b.matrix);
228
+ guides.push({ from: c, to: [c[0], c[1], c[2] + E * 1.6] });
229
+ }
230
+ advance([...p.moving, ...p.placed, ...follow.filter((f) => !f.harness)].map((b) => b.key), [...p.hardware, ...follow.filter((f) => f.harness)].map((h) => h.key));
231
+ if (!bodies.some((b) => b.glb)) return undefined;
232
+ const r = await s.figure(name, { width: 1900, height: 1000, mode: "lineart", bodies, guides, anchors, camera: { dir: spec.camera?.dir ?? iso, up: spec.camera?.up, margin: spec.camera?.margin ?? 0.1 } });
233
+ return { file: r.file, width: r.width, height: r.height, callouts: callouts(r.anchors), caption: p.mates.length ? `${x.title}: parts exploded along each joint's axis in stacking order; lettered as in the parts strip.` : `${x.title}: shown lifted above its seat.` };
234
+ }
235
+
236
+ if (spec.figure === "none" || !p.wiring.length) {
237
+ advance([...p.moving, ...p.placed].map((b) => b.key), p.hardware.map((h) => h.key));
238
+ return undefined;
239
+ }
240
+ // connections: the modules at the ends drawn dark (those already in place), the routed cables in the accent colour
241
+ const ends = new Set(x.ends);
242
+ const endBodies = scene.bodies.filter((b) => ends.has(b.path.join("/")) && st.before.has(b.key));
243
+ const shown = new Set(endBodies.map((b) => b.key));
244
+ // the camera fits the cables and the parts they join; the rest is context around them
245
+ const bodies: RenderBody[] = [...context(shown).map((b) => ({ ...b, fit: false })), ...endBodies.map((b) => renderBody(b, "normal"))];
246
+ const anchors: { id: string; p: number[] }[] = [];
247
+ let cables = 0;
248
+ for (const h of x.cables) {
249
+ const hp = scene.assembly.harnesses.find((x2) => x2.key === h);
250
+ const glb = hp ? bodyGlb(ctx, hp.def) : undefined;
251
+ if (!hp || !glb || !scope.inBuild(h)) continue;
252
+ cables++;
253
+ bodies.push({ key: `harness:${h}`, glb, matrix: hp.matrix, style: "accent", category: "generic" });
254
+ const frames = hp.def.interfaces.map((i) => i.geometry?.frame?.origin).filter((o): o is [number, number, number] => Array.isArray(o));
255
+ const mid: [number, number, number] = [0, 1, 2].map((k) => (frames.length ? frames.reduce((t, o) => t + o[k], 0) / frames.length : 0)) as [number, number, number];
256
+ anchors.push({ id: `harness:${h}`, p: applyMat4(hp.matrix, mid) });
257
+ }
258
+ advance(endBodies.map((b) => b.key), []);
259
+ if (!cables && !endBodies.some((b) => b.glb)) return undefined;
260
+ const r = await s.figure(name, { width: 1900, height: 1000, mode: "lineart", bodies, anchors, camera: { dir: spec.camera?.dir ?? [0.35, -0.5, 1.4], up: spec.camera?.up, margin: spec.camera?.margin ?? 0.08 } });
261
+ return {
262
+ file: r.file,
263
+ width: r.width,
264
+ height: r.height,
265
+ callouts: callouts(r.anchors),
266
+ caption: cables ? `${x.title}: the ${cables === 1 ? "cable" : `${cables} cables`} in the accent colour, routed as the model's harness CAD lays ${cables === 1 ? "it" : "them"}; the parts they join drawn dark.` : `${x.title}: the parts the connections join; pads and pins in the table.`,
267
+ };
268
+ }