@protoboardai/ext-docs 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +51 -4
  45. package/protoboard-extension.json +83 -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,674 @@
1
+ /**
2
+ * Assembly guide: kit, hardware index with fastener stack-ups, then one step
3
+ * per group of mates (from assemble()) and wiring links (wiringChecklist),
4
+ * with the hardware each step consumes, exploded figures, generated actions,
5
+ * model notes, and a coverage appendix proving every mate, hardware placement
6
+ * and wiring link appears in a step.
7
+ */
8
+ import type { ModuleDef } from "@deltarobotics/uhd";
9
+ import { bodyKey, type HardwarePlacement, type MateEdge } from "@deltarobotics/uhd";
10
+ import type { WiringStep } from "@deltarobotics/uhd";
11
+ import { callouts, figure, leafOf, note, parseEnd, sec, sub, table, wiringDiagram, wiringRows } from "../blocks.ts";
12
+ import { esc } from "../html.ts";
13
+ import { explode, mateEnds, renderBody, withOffset, ISO } from "../figures.ts";
14
+ import { matTranslation, worldFrame, type SceneBody } from "../model.ts";
15
+ import type { RenderBody } from "../render/renderer.ts";
16
+ import type { StepGroupSpec } from "../types.ts";
17
+ import { bodyFileMissing, coverPage, figNum, thumbnail, type BuildEnv } from "./common.ts";
18
+ import { bomFile } from "../context.ts";
19
+ import { designed } from "../bom.ts";
20
+ import { systemTools, toolLabel, toolsFor } from "@deltarobotics/uhd";
21
+ import { propMounts } from "@deltarobotics/uhd";
22
+ import { jointTorques } from "@deltarobotics/uhd";
23
+ import { isNet } from "@deltarobotics/uhd";
24
+
25
+ const W = 178;
26
+
27
+ const wild = (pattern: string) => new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
28
+ const matches = (patterns: string[] | undefined, id: string) => (patterns ?? []).some((p) => wild(p).test(id));
29
+
30
+ export interface StepPlan {
31
+ n: number;
32
+ spec: StepGroupSpec;
33
+ mates: MateEdge[];
34
+ wiring: WiringStep[];
35
+ moving: SceneBody[];
36
+ placed: SceneBody[];
37
+ hardware: SceneBody[];
38
+ /** Unplaced instances introduced here (strap, receiver). */
39
+ extras: { path: string; def: ModuleDef }[];
40
+ }
41
+
42
+ /**
43
+ * Which links and placements a document covers: the Manufacturing Package's
44
+ * assembly guide of one unit covers the links that unit makes (ext-docs
45
+ * lib/package). Unscoped, a plan covers the whole system.
46
+ */
47
+ export interface StepScope {
48
+ /** Mate and wiring link ids (assemble / fullWiring ids, prefixed with the declaring instance path). */
49
+ link: (linkId: string) => boolean;
50
+ /** Instance paths a step may place without a mate (doc spec `place`). */
51
+ place: (path: string) => boolean;
52
+ }
53
+
54
+ /** Steps from the doc spec, plus automatic steps for anything the spec leaves out. Scoped, steps left with nothing are dropped. */
55
+ export function planSteps(env: Pick<BuildEnv, "ctx">, scope?: StepScope): StepPlan[] {
56
+ const { ctx } = env;
57
+ const scene = ctx.scene;
58
+ const inScope = (linkId: string) => !scope || scope.link(linkId);
59
+ const specs = [...(ctx.sys.config.assembly?.steps ?? [])];
60
+ const usedMates = new Set<string>();
61
+ const usedWires = new Set<string>();
62
+ const plans: StepPlan[] = [];
63
+ const introduced = new Set<string>();
64
+ const root = bodyKey(ctx.sys.config.root ?? []);
65
+ introduced.add(root);
66
+
67
+ const make = (spec: StepGroupSpec): StepPlan => {
68
+ const mates = scene.assembly.mates.filter((m) => inScope(m.linkId) && !usedMates.has(m.linkId) && matches(spec.mates, m.linkId));
69
+ const wiring = ctx.wiring.filter((w) => w.domain !== "mechanical" && inScope(w.linkId) && !usedWires.has(w.linkId) && matches(spec.wiring, w.linkId));
70
+ mates.forEach((m) => usedMates.add(m.linkId));
71
+ wiring.forEach((w) => usedWires.add(w.linkId));
72
+ const moving = scene.bodies.filter((b) => mates.some((m) => m.linkId === b.via));
73
+ const place = (spec.place ?? []).filter((p) => !scope || scope.place(p));
74
+ const placed = scene.bodies.filter((b) => place.includes(b.path.join("/")));
75
+ const hardware = scene.hardware.filter((h) => mates.some((m) => m.linkId === h.linkId));
76
+ const extras: StepPlan["extras"] = [];
77
+ for (const p of place) {
78
+ if (placed.some((b) => b.path.join("/") === p)) continue;
79
+ const def = ctx.instances.get(p);
80
+ if (def) extras.push({ path: p, def });
81
+ }
82
+ // wiring ends not yet introduced (a receiver with no mechanical interface)
83
+ for (const w of wiring) {
84
+ const prefix = w.linkId.includes("/") ? w.linkId.split("/").slice(0, -1).join("/") + "/" : "";
85
+ for (const end of [w.a, w.b]) {
86
+ const e = parseEnd(end);
87
+ const leaf = leafOf(ctx.sys.system, ctx.sys.lookup, e.path, e.iface);
88
+ if (!leaf) continue;
89
+ const p = prefix && !leaf.path.join("/").startsWith(prefix) ? prefix + leaf.path.join("/") : leaf.path.join("/");
90
+ const isBody = scene.bodies.some((b) => b.path.join("/") === p);
91
+ if (!isBody && !introduced.has(p) && !extras.some((x) => x.path === p)) extras.push({ path: p, def: leaf.def });
92
+ }
93
+ }
94
+ for (const b of [...moving, ...placed]) introduced.add(b.key);
95
+ for (const x of extras) introduced.add(x.path);
96
+ return { n: plans.length + 1, spec, mates, wiring, moving, placed, hardware, extras };
97
+ };
98
+
99
+ for (const s of specs) {
100
+ const plan = make(s);
101
+ if (!scope || plan.mates.length || plan.wiring.length || plan.placed.length || plan.extras.length) plans.push(plan);
102
+ }
103
+ // anything left: one step per harness (mates), then per harness (wiring)
104
+ const leftMates = scene.assembly.mates.filter((m) => inScope(m.linkId) && !usedMates.has(m.linkId));
105
+ const byHarness = new Map<string, string[]>();
106
+ for (const m of leftMates) byHarness.set(m.harness ?? m.linkId, [...(byHarness.get(m.harness ?? m.linkId) ?? []), m.linkId]);
107
+ for (const [h, ids] of byHarness) plans.push(make({ id: `auto-${h.replace(/[^A-Za-z0-9_-]+/g, "-")}`, title: `Assemble ${h}`, mates: ids }));
108
+ const leftWires = ctx.wiring.filter((w) => w.domain !== "mechanical" && inScope(w.linkId) && !usedWires.has(w.linkId));
109
+ const wByH = new Map<string, string[]>();
110
+ for (const w of leftWires) wByH.set(w.harness ?? "direct", [...(wByH.get(w.harness ?? "direct") ?? []), w.linkId]);
111
+ for (const [h, ids] of wByH) plans.push(make({ id: `auto-wire-${h.replace(/[^A-Za-z0-9_-]+/g, "-")}`, title: `Wire ${h}`, wiring: ids, figure: "wiring" }));
112
+ return plans;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export function groupBy<T>(xs: T[], key: (x: T) => string): Map<string, T[]> {
118
+ const m = new Map<string, T[]>();
119
+ for (const x of xs) m.set(key(x), [...(m.get(key(x)) ?? []), x]);
120
+ return m;
121
+ }
122
+
123
+ export const shortName = (d: ModuleDef) => d.name.replace(/\s*\((included[^)]*|custom)\)/gi, "").replace(/, ISO 4762.*$/, "").replace(/ socket head cap screw/, " SHCS");
124
+
125
+ export function connectorOf(def: ModuleDef, ifaceId: string): string | undefined {
126
+ const i = def.interfaces.find((x) => x.id === ifaceId);
127
+ const t = i?.traits?.find((x) => x.type === "connector");
128
+ return t?.params?.connector_type as string | undefined;
129
+ }
130
+
131
+ export async function buildAssemblyGuide(env: BuildEnv): Promise<string> {
132
+ const { ctx, doc } = env;
133
+ const scene = ctx.scene;
134
+ const cfg = ctx.sys.config;
135
+ const iso = cfg.camera?.dir ?? ISO;
136
+ const plans = planSteps(env);
137
+ const out: string[] = [];
138
+ const E0 = cfg.assembly?.explode ?? 16;
139
+
140
+ // ---------------------------------------------------------------- cover: whole-system explosion
141
+ const allMates = scene.assembly.mates.map((m) => m.linkId);
142
+ const ex = explode(scene, allMates, 11);
143
+ const batt = scene.bodies.filter((b) => b.via?.startsWith("hint:"));
144
+ const coverBodies: RenderBody[] = [
145
+ ...scene.bodies.map((b) => withOffset(renderBody(b, "normal"), ex.offsets.get(b.key) ?? (b.via?.startsWith("hint:") ? [0, 0, 40] : undefined))),
146
+ ...scene.hardware.map((h) => withOffset(renderBody(h, "normal"), ex.offsets.get(h.key))),
147
+ ];
148
+ const hero = await env.r.render("ag-hero", { width: 2000, height: 1180, mode: "shaded", bodies: coverBodies, camera: { dir: iso, margin: 0.02 } });
149
+ const partCount = ctx.bom.reduce((s: number, b: any) => s + b.quantity, 0);
150
+ out.push(
151
+ coverPage(env, {
152
+ kicker: "Assembly guide",
153
+ hero,
154
+ heroCaption: `Exploded along each joint's axis in fastener-stack order (${scene.assembly.mates.length} mates, ${scene.hardware.length} hardware parts${batt.length ? ", battery by placement hint" : ""}).`,
155
+ bottom: `<div class="kpis">
156
+ <div class="kpi"><div class="k-label">Steps</div><div class="k-value">${plans.length}</div><div class="k-sub">mechanical and wiring</div></div>
157
+ <div class="kpi"><div class="k-label">Mates</div><div class="k-value">${doc.v("sys:assembly.mates", undefined, { marker: false })}</div><div class="k-sub">from assemble()</div></div>
158
+ <div class="kpi"><div class="k-label">Hardware</div><div class="k-value">${doc.v("derived:mech.hardware_count", undefined, { marker: false })}</div><div class="k-sub">screws, spacers, standoffs, nuts</div></div>
159
+ <div class="kpi"><div class="k-label">Parts</div><div class="k-value">${partCount}</div><div class="k-sub">${ctx.bom.length} BOM lines</div></div></div>
160
+ <div class="cover-grid"><div><h2 class="sub">How this guide is made</h2><p class="small">Steps, parts and hardware come from the UHD model: mates and fastener stacks from <code>assemble()</code>, wiring from <code>wiringChecklist</code>. The coverage table at the end lists where every mate, fastener and wire appears.</p></div><div>${doc.legendPlaceholder()}</div></div>`,
161
+ }),
162
+ );
163
+
164
+ // ---------------------------------------------------------------- 01 before you begin
165
+ // tool sizes from the fasteners themselves (src/system/tools.ts)
166
+ const tools: string[] = [];
167
+ for (const t of systemTools(ctx.sys.system, ctx.sys.lookup)) {
168
+ const names = [...new Set(t.parts.map((id) => shortName(ctx.sys.lookup(id)!)))];
169
+ const why = t.use === "hold" ? "to hold" : "for";
170
+ const basis = t.basis === "standard" ? " (ISO 4762 socket size)" : "";
171
+ tools.push(`<b>${esc(toolLabel(t))}</b> <span class="muted small">${why} ${esc(names.join(", "))}${esc(basis)}</span>`);
172
+ }
173
+ const solder = ctx.wiring.some((w) => {
174
+ const e = parseEnd(w.a);
175
+ const l = leafOf(ctx.sys.system, ctx.sys.lookup, e.path, e.iface);
176
+ return (l && /solder|bare_wire/.test(connectorOf(l.def, l.iface.id) ?? "")) || Boolean(w.harness && /xt60/.test(w.harness));
177
+ });
178
+ if (solder) tools.push(esc("Soldering iron, solder, heat-shrink"));
179
+ // a multirotor's propeller step (none: no propeller warning)
180
+ const propStep = plans.findIndex((p) => p.spec.id.includes("prop"));
181
+ out.push(`<div class="block">${sec("01", "Before you begin")}<div class="cols-60"><div>
182
+ ${sub("Contents")}<ol class="toc">${["Before you begin", "Kit contents", "Hardware", ...plans.map((p) => p.spec.title), "Coverage"].map((t, i) => `<li><span class="toc-n">${String(i + 1).padStart(2, "0")}</span><span class="toc-t">${esc(t)}</span><span class="toc-dots"></span><span class="toc-p">00</span></li>`).join("")}</ol>
183
+ </div><div>
184
+ ${sub("Tools")}<ul class="plain">${tools.map((t) => `<li>${t}</li>`).join("")}</ul>
185
+ <p class="small muted">Derived from the fasteners in the model: hex keys from each screw's stated socket or the ISO 4762 socket size for its thread, drivers and spanners from each nut's and standoff's across-flats.</p>
186
+ ${sub("Reading the figures")}
187
+ <div class="keyrow"><span class="sw sw-ctx"></span>Already assembled</div>
188
+ <div class="keyrow"><span class="sw sw-new"></span>Fitted in this step</div>
189
+ <div class="keyrow"><span class="sw sw-acc"></span>Hardware for this step</div>
190
+ <div class="keyrow"><span class="sw sw-guide"></span>Assembly axis</div>
191
+ <p class="small muted">Parts are drawn exploded along each joint's axis in stacking order, from the model's fastener stacks and mate gaps.</p>
192
+ </div></div>
193
+ ${propStep >= 0 || scene.bodies.some((b) => b.category === "prop") ? note("warning", `Remove the propellers for any work with the battery connected. Fit them last (step ${propStep >= 0 ? propStep + 1 : plans.length}).`) : ""}
194
+ ${!scene.hardware.length ? "" : note("info", `Each step's torque line gives the tightening torque of the part turned, from the harness's fastener stack. ${doc.v("sys:checks.diagnostics[id=fastener_torque:summary].details.assumed", undefined, { marker: false })} of them are <b>assumptions</b> (marked A): no frame, motor or DJI source states a torque, so they are derated from supplier tables for the screw grade into steel. Threadlocker is only advised where a harness says so (steel into aluminium).`)}</div>`);
195
+
196
+ // ---------------------------------------------------------------- 02 kit
197
+ const kitItems: string[] = [];
198
+ // a circuit board (a definition with Net interfaces) comes assembled from the fab, and a designed module with parts
199
+ // (a housing with its inserts set) comes made: one kit item each, and the parts in them are not laid out at the bench
200
+ const boards = [...ctx.instances.entries()].filter(([, d]) => d.interfaces.some(isNet));
201
+ const made = [...ctx.instances.entries()].filter(([, d]) => d.interfaces.some(isNet) || (designed(d) && d.children?.length));
202
+ const onBoard = (path: string) => made.some(([bp]) => path.startsWith(bp + "/"));
203
+ for (const [, def] of made.filter(([bp], i, all) => all.findIndex(([, d]) => d.id === all[i][1].id) === i && !onBoard(bp))) {
204
+ const n = made.filter(([bp, d]) => d.id === def.id && !onBoard(bp)).length;
205
+ const board = def.interfaces.some(isNet);
206
+ const th = await thumbnail(env, def);
207
+ kitItems.push(`<div class="kit-item${th ? "" : " noimg"}">${th ? `<img src="${esc(env.img(th.file))}" alt="">` : `<div class="ph">${board ? "assembled board" : "made part"}</div>`}<div class="k-qty">×${n}</div><div class="k-name">${esc(shortName(def))} (${board ? "assembled" : "with its parts fitted"})</div><div class="k-id">${esc(def.id)}</div></div>`);
208
+ }
209
+ for (const b of ctx.bom as any[]) {
210
+ if (made.some(([, d]) => d.id === b.partId)) continue;
211
+ if (made.length && (b.usedBy as string[] | undefined)?.length && (b.usedBy as string[]).every(onBoard)) continue;
212
+ const def = ctx.sys.lookup(b.partId);
213
+ const th = def ? await thumbnail(env, def) : undefined;
214
+ kitItems.push(`<div class="kit-item${th ? "" : " noimg"}">${th ? `<img src="${esc(env.img(th.file))}" alt="">` : `<div class="ph">${esc(def?.kind === "harness" ? "cable / lead" : def && bodyFileMissing(env, def) ? "CAD file not available" : "no CAD")}</div>`}<div class="k-qty">×${doc.v(`sys:bom[line=${b.line}].quantity`, undefined, { marker: false })}</div><div class="k-name">${esc(def ? shortName(def) : b.name)}</div><div class="k-id">${esc(b.partId)}</div></div>`);
215
+ }
216
+ // five rows of five per block, so a long kit (routed harnesses are BOM lines too) flows onto the next page
217
+ const KIT_BLOCK = 25;
218
+ for (let k = 0; k < kitItems.length || k === 0; k += KIT_BLOCK) {
219
+ const last = k + KIT_BLOCK >= kitItems.length;
220
+ out.push(
221
+ `<div class="block"${k === 0 ? ' data-break="before"' : ""}>${k === 0 ? sec("02", "Kit contents", bomFile(ctx.sys)) : ""}<div class="kit">${kitItems.slice(k, k + KIT_BLOCK).join("")}</div>${last ? `<p class="fn">Quantities from the generated BOM${boards.length ? "; a circuit board is one item, assembled by the fab (its parts are in the BOM, not at the bench)" : ""}${made.length > boards.length ? "; a made part with parts of its own (a housing with its inserts set) is one item" : ""}; drawings are the parts' CAD artifacts, not to scale.</p>` : ""}</div>`,
222
+ );
223
+ }
224
+
225
+ // ---------------------------------------------------------------- 03 hardware and stack-ups
226
+ const byDef = groupBy(scene.hardware, (h) => ctx.instances.get(h.harness!)?.id ?? h.harness!);
227
+ const hwRows: string[][] = [];
228
+ const stackSvgs: string[] = [];
229
+ for (const [hid, items] of byDef) {
230
+ const hpaths = [...new Set(items.map((h) => h.harness!))];
231
+ const hdef = ctx.instances.get(hpaths[0]);
232
+ if (!hdef) continue;
233
+ const byChild = groupBy(items, (h) => h.child!);
234
+ const steps = [...new Set(plans.filter((p) => p.hardware.some((h) => hpaths.includes(h.harness!))).map((p) => p.n))];
235
+ hwRows.push([
236
+ `<b>${esc(hdef.name)}</b>${hpaths.length > 1 ? ` <span class="accent-q">×${hpaths.length}</span>` : ""}<br><span class="muted small">joint ${esc([...new Set(items.map((h) => h.linkId))].join(", "))}</span>`,
237
+ [...byChild.values()].map((hs) => `${hs.length} × ${esc(shortName(hs[0].def))}`).join("<br>"),
238
+ jointTorques(hdef, ctx.sys.lookup).map((t) => `${doc.v(`def:${hdef.id}:fastenerStack[${t.index}].torque.torqueNm`, "u=N·m")} <span class="muted small">${esc(shortName(t.part ?? hdef))}</span>`).join("<br>") || '<span class="v st-gap">—</span>',
239
+ steps.length ? steps.map((n) => `Step ${n}`).join(", ") : '<span class="x-flag">none</span>',
240
+ ]);
241
+ if (hdef.fastenerStack?.length) stackSvgs.push(stackDiagram(env, hdef, items.filter((h) => h.harness === hpaths[0])));
242
+ void hid;
243
+ }
244
+ out.push(`<div class="block" data-break="before">${sec("03", "Hardware", "fastener stacks")}<p class="prose">Every screw, spacer, standoff and nut is placed by a harness's <code>fastenerStack</code>. The sections below are drawn from those positions and the mate gaps: one hole of each joint, to one scale, millimetres from the structure face.</p></div>`);
245
+ out.push(table([{ h: "Harness" }, { h: "Parts placed" }, { h: "Torque", cls: "num" }, { h: "Used in" }], hwRows, { split: true, caption: "Hardware by harness", cls: "dense hwtable" }));
246
+ for (let i = 0; i < stackSvgs.length; i += 2) out.push(`<div class="block keep"><div class="cols">${stackSvgs.slice(i, i + 2).join("")}</div></div>`);
247
+
248
+ // ---------------------------------------------------------------- steps
249
+ const before = new Set<string>([bodyKey(cfg.root ?? [])]);
250
+ const hwBefore = new Set<string>();
251
+ for (const p of plans) {
252
+ out.push(await stepBlock(env, p, before, hwBefore, E0, iso, plans.length));
253
+ for (const b of [...p.moving, ...p.placed]) before.add(b.key);
254
+ for (const h of p.hardware) hwBefore.add(h.key);
255
+ }
256
+ return out.join("\n");
257
+ }
258
+
259
+ async function stepBlock(env: BuildEnv, p: StepPlan, before: Set<string>, hwBefore: Set<string>, E0: number, iso: number[], total: number): Promise<string> {
260
+ const { ctx, doc } = env;
261
+ const scene = ctx.scene;
262
+ const s = p.spec;
263
+ const E = s.explode ?? E0;
264
+ const letters = "ABCDEFGHJKLMNP";
265
+ const parts: { key: string; def: ModuleDef; count: number; letter: string; anchor?: string; accent: boolean }[] = [];
266
+
267
+ // parts: moving modules, placed modules, extras, hardware by child, cables of the wiring
268
+ for (const [id, bs] of groupBy([...p.moving, ...p.placed], (b) => b.def.id)) {
269
+ const main = bs.find((b) => !b.key.includes("#")) ?? bs[0];
270
+ parts.push({ key: id, def: bs[0].def, count: new Set(bs.map((b) => b.path.join("/"))).size, letter: "", anchor: `body:${main.key}`, accent: false });
271
+ }
272
+ for (const x of p.extras) parts.push({ key: x.path, def: x.def, count: 1, letter: "", accent: false });
273
+ for (const [, hs] of groupBy(p.hardware, (h) => h.def.id)) parts.push({ key: hs[0].def.id, def: hs[0].def, count: hs.length, letter: "", anchor: `hw:${hs[0].key}`, accent: true });
274
+ for (const [h] of groupBy(p.wiring.filter((w) => w.harness), (w) => w.harness!)) {
275
+ const def = ctx.instances.get(h);
276
+ if (def && !parts.some((x) => x.def.id === def.id)) parts.push({ key: h, def, count: 1, letter: "", accent: false });
277
+ }
278
+ parts.forEach((x, i) => (x.letter = letters[i] ?? String(i + 1)));
279
+
280
+ // ---- figure
281
+ let fig = "";
282
+ const figKind = s.figure ?? (p.mates.length || p.placed.length ? "assembly" : "wiring");
283
+ const ctxBodies = (exclude: Set<string>) => [
284
+ ...scene.bodies.filter((b) => before.has(b.key) && !exclude.has(b.key)).map((b) => renderBody(b, "context")),
285
+ ...scene.hardware.filter((h) => hwBefore.has(h.key)).map((h) => renderBody(h, "context")),
286
+ ];
287
+ if (figKind === "assembly" && (p.mates.length || p.placed.length)) {
288
+ const ex = explode(scene, p.mates.map((m) => m.linkId), E);
289
+ const bodies: RenderBody[] = ctxBodies(new Set());
290
+ const anchors: { id: string; p: number[] }[] = [];
291
+ for (const b of p.moving) {
292
+ const off = ex.offsets.get(b.key);
293
+ bodies.push(withOffset(renderBody(b, "normal"), off));
294
+ anchors.push({ id: `body:${b.key}`, p: centerOf(b, off) });
295
+ }
296
+ for (const b of p.placed) {
297
+ const off = [0, 0, E * 1.6];
298
+ bodies.push(withOffset(renderBody(b, "normal"), off));
299
+ anchors.push({ id: `body:${b.key}`, p: centerOf(b, off) });
300
+ }
301
+ for (const h of p.hardware) {
302
+ const off = ex.offsets.get(h.key);
303
+ bodies.push(withOffset(renderBody(h, "accent"), off));
304
+ anchors.push({ id: `hw:${h.key}`, p: centerOf(h, off) });
305
+ }
306
+ const guides = [...ex.guides];
307
+ for (const b of p.placed) {
308
+ const c = matTranslation(b.matrix);
309
+ guides.push({ from: c, to: [c[0], c[1], c[2] + E * 1.6] });
310
+ }
311
+ const r = await env.r.render(`ag-step-${String(p.n).padStart(2, "0")}-${s.id}`, {
312
+ width: 1900,
313
+ height: 1000,
314
+ mode: "lineart",
315
+ bodies,
316
+ guides,
317
+ anchors,
318
+ camera: { dir: s.camera?.dir ?? iso, up: s.camera?.up, margin: s.camera?.margin ?? 0.1 },
319
+ });
320
+ const overlay = callouts(r, parts.filter((x) => x.anchor).map((x) => ({ anchor: x.anchor!, label: x.letter, accent: x.accent })), W);
321
+ fig = figure({ src: env.img(r.file), w: r.width, h: r.height, overlay, num: figNum(env), caption: p.mates.length ? `${esc(s.title)}: parts exploded along each joint's axis in stacking order; lettered as in the parts strip.` : `${esc(s.title)}: shown lifted above its seat.` });
322
+ } else if (p.wiring.length) {
323
+ // 3D when every link has geometry on both ends (routes), otherwise a pad diagram
324
+ const routes = scene.assembly.routes.filter((rt) => p.wiring.some((w) => w.linkId === rt.linkId));
325
+ if (routes.length === p.wiring.length && routes.length) {
326
+ const guides: { from: number[]; to: number[] }[] = [];
327
+ const anchors: { id: string; p: number[] }[] = [];
328
+ const accent = new Map<string, string[]>();
329
+ for (const rt of routes) {
330
+ const ba = scene.bodies.find((b) => b.path.join("/") === rt.a.path.join("/"));
331
+ const bb = scene.bodies.find((b) => b.path.join("/") === rt.b.path.join("/"));
332
+ if (!ba || !bb) continue;
333
+ const pa = rt.a.frames.map((f) => worldFrame(ba.matrix, f.frame).origin);
334
+ const pb = rt.b.frames.map((f) => worldFrame(bb.matrix, f.frame).origin);
335
+ for (const q of pb) guides.push({ from: q, to: pa[0] });
336
+ anchors.push({ id: `route:${rt.linkId}`, p: pb[Math.floor(pb.length / 2)] });
337
+ accent.set(ba.key, [...(accent.get(ba.key) ?? []), ...(ba.def.interfaces.find((i) => i.id === rt.a.iface.id) ? [`${rt.a.iface.id}_pads`] : [])]);
338
+ accent.set(bb.key, [...(accent.get(bb.key) ?? []), "lead_a", "lead_b", "lead_c"]);
339
+ }
340
+ const bodies = scene.bodies.filter((b) => before.has(b.key)).map((b) => renderBody(b, accent.has(b.key) ? "normal" : "context", { accentNodes: accent.get(b.key) ?? [] }));
341
+ bodies.push(...scene.hardware.filter((h) => hwBefore.has(h.key)).map((h) => renderBody(h, "context")));
342
+ const r = await env.r.render(`ag-step-${String(p.n).padStart(2, "0")}-${s.id}`, { width: 1900, height: 1000, mode: "lineart", bodies, guides, anchors, camera: { dir: s.camera?.dir ?? [0.35, -0.5, 1.4], margin: 0.08 } });
343
+ const overlay = callouts(r, routes.map((rt) => ({ anchor: `route:${rt.linkId}`, label: (p.wiring.find((w) => w.linkId === rt.linkId)?.name ?? rt.linkId).replace(/^Motor /, "M"), accent: true })), W);
344
+ fig = figure({ src: env.img(r.file), w: r.width, h: r.height, overlay, num: figNum(env), caption: "Each motor's three leads run to its ESC channel pads (accent); routes from the interfaces' geometry frames." });
345
+ }
346
+ const groups = wiringRows(ctx.sys.system, ctx.sys.lookup, p.wiring);
347
+ const label = (path: string) => {
348
+ const w = p.wiring.find((x) => parseEnd(x.a).path.split("/")[0] === path.split("/")[0]) ?? p.wiring[0];
349
+ const prefix = w.linkId.includes("/") ? w.linkId.split("/").slice(0, -1) : [];
350
+ let owner = ctx.sys.system;
351
+ let name = path;
352
+ for (const id of [...prefix, ...path.split("/")]) {
353
+ const ref = owner.children?.find((c) => c.id === id);
354
+ if (!ref) break;
355
+ name = ref.name ?? ref.moduleDefId;
356
+ const d = ctx.sys.lookup(ref.moduleDefId);
357
+ if (!d) break;
358
+ owner = d;
359
+ }
360
+ return name;
361
+ };
362
+ const diagrams = groups.map((g) => wiringDiagram({ aTitle: label(g.aPath), bTitle: label(g.bPath), aSub: shortName(g.a), bSub: shortName(g.b), rows: g.rows, harness: g.harness ? ctx.instances.get(g.harness)?.name ?? g.harness : undefined }));
363
+ if (!fig) fig = `<figure class="block wiringfig">${diagrams.join("")}<figcaption><b>Figure ${figNum(env)}</b><span>Pad-to-pad connections from the stored links (wiringChecklist); crossovers in accent.</span></figcaption></figure>`;
364
+ }
365
+
366
+ // ---- actions (generated), per joint in stacking order: parts behind the structure face first, then outward
367
+ const handActions = (s.before ?? []).map((t) => esc(t).replace(/\{\{([^|}]+)(?:\|([^}]+))?\}\}/g, (_m, q: string, f?: string) => doc.v(q.replace(/&quot;/g, '"').trim(), f?.trim())));
368
+ const actions: string[] = [...handActions, ...jointActions(env, p, { v: (q, f) => doc.v(q, f), e: esc })];
369
+ // props: which handed variant goes on which motor, from the resolved spins (PB-797)
370
+ const mounts = propMounts(ctx.sys.system, ctx.sys.lookup).filter((m) => p.mates.some((x) => x.linkId === m.linkId));
371
+ if (mounts.length) {
372
+ const motorNo = (armPath: string) => {
373
+ for (const l of ctx.sys.system.links ?? []) {
374
+ const ends = [l.a, l.b];
375
+ if (!ends.some((e) => "child" in e && e.child === armPath.split("/")[0])) continue;
376
+ const other = ends.find((e) => "child" in e && e.child !== armPath.split("/")[0]);
377
+ const n = other && /motor_(\d+)$/.exec(other.interfaceId)?.[1];
378
+ if (n) return `M${n}`;
379
+ }
380
+ return undefined;
381
+ };
382
+ for (const m of mounts) {
383
+ const arm = m.motor.setAt ?? m.motor.path.split("/")[0];
384
+ const armName = ctx.sys.system.children?.find((c) => c.id === arm.split("/")[0])?.name ?? arm;
385
+ const n = motorNo(m.motor.path);
386
+ const spinQ = `sys:system.children[id=${arm.split("/")[0]}].spin`;
387
+ actions.push(
388
+ `${esc(armName)}${n ? ` (${n})` : ""}: motor spins ${doc.v(spinQ, "upper")} from above, so fit a <b>${doc.v(spinQ, "upper", { marker: false })}</b> ${esc(shortName(m.prop.def))}.`,
389
+ );
390
+ }
391
+ }
392
+ if (p.placed.length) for (const b of p.placed) actions.push(`Place the ${esc(shortName(b.def))} as shown. It has no mechanical interface in the model; the figure places it by ${esc(b.via?.replace(/^hint:/, "") ?? "a hint")}.`);
393
+ for (const x of p.extras.filter((x) => !p.wiring.length)) actions.push(`Fit the ${esc(shortName(x.def))}.`);
394
+ // work the model does not show (fitting printed bodies of a module, a fold): the doc spec's own actions
395
+ for (const a of p.spec.actions ?? []) actions.push(esc(a));
396
+ const wireTable: string[][] = [];
397
+ for (const w of p.wiring) {
398
+ const ea = parseEnd(w.a);
399
+ const eb = parseEnd(w.b);
400
+ const la = leafOf(ctx.sys.system, ctx.sys.lookup, ea.path, ea.iface);
401
+ const lb = leafOf(ctx.sys.system, ctx.sys.lookup, eb.path, eb.iface);
402
+ const conn = [la && connectorOf(la.def, la.iface.id), lb && connectorOf(lb.def, lb.iface.id)].filter(Boolean).join(" / ");
403
+ const rows = w.connections.length ? w.connections : [{ from: `${w.a} ${la?.iface.pin ?? ""}`, to: `${w.b} ${lb?.iface.pin ?? ""}`, crossover: false }];
404
+ const pads = (sel: (c: { from: string; to: string }) => string) => rows.map((c) => `<b>${esc(parseEnd(sel(c)).pad || parseEnd(sel(c)).iface)}</b>`).join(" · ");
405
+ const cross = rows.some((c) => c.crossover);
406
+ wireTable.push([
407
+ `<b>${esc(w.name)}</b>`,
408
+ `${esc(la ? instanceLabelOf(env, w, "a") : ea.path)}<br>${pads((c) => c.from)}`,
409
+ `${esc(lb ? instanceLabelOf(env, w, "b") : eb.path)}<br>${pads((c) => c.to)}`,
410
+ cross ? '<span class="x-flag">× crossover</span>' : "",
411
+ esc(conn || w.protocol),
412
+ `<span class="tag ${w.state === "configured" ? "ok" : "err"}">${esc(w.state)}</span>`,
413
+ ]);
414
+ }
415
+ if (p.wiring.length) {
416
+ const byH = groupBy(p.wiring, (w) => w.harness ?? "");
417
+ for (const [h, ws] of byH) {
418
+ const hdef = h ? ctx.instances.get(h) : undefined;
419
+ actions.push(`${hdef ? `Using the ${esc(shortName(hdef))}, connect` : "Connect"} ${ws.map((w) => esc(w.name)).join(", ")} as in the table (${ws.reduce((s2, w) => s2 + Math.max(1, w.connections.length), 0)} connections).`);
420
+ }
421
+ }
422
+
423
+ // ---- notes: doc spec + model usage notes of the step's harnesses and modules
424
+ const notes: string[] = [];
425
+ const fill = (t: string) => esc(t).replace(/\{\{([^|}]+)(?:\|([^}]+))?\}\}/g, (_m, q: string, f?: string) => doc.v(q.replace(/&quot;/g, '"').trim(), f?.trim()));
426
+ for (const n of s.notes ?? []) notes.push(note(n.kind, fill(n.text)));
427
+ const noteDefs = new Map<string, ModuleDef>();
428
+ for (const h of new Set([...p.hardware.map((x) => x.harness!), ...p.wiring.map((w) => w.harness).filter(Boolean)] as string[])) {
429
+ const d = ctx.instances.get(h);
430
+ if (d) noteDefs.set(d.id, d);
431
+ }
432
+ for (const b of [...p.moving, ...p.placed]) noteDefs.set(b.def.id, b.def);
433
+ for (const x of p.extras) noteDefs.set(x.def.id, x.def);
434
+ for (const d of noteDefs.values()) {
435
+ const un = (d.traits ?? []).filter((t) => t.type === "usage_note" && d.kind === "harness").slice(0, 2);
436
+ for (const t of un) notes.push(note(t.params?.threadlocker ? "tip" : "info", `${esc(String(t.params?.note ?? ""))} <span class="muted small">— ${esc(d.id)}</span>`));
437
+ const cooling = (d.traits ?? []).find((t) => t.type === "operating_conditions")?.params?.cooling;
438
+ if (cooling) notes.push(note("caution", `${esc(String(cooling).split(". ").slice(0, 3).join(". "))}. <span class="muted small">— ${esc(d.id)}</span>`));
439
+ const mounting = (d.domains?.find((x) => x.domain === "mechanical")?.metadata as any)?.mounting;
440
+ if (mounting && !d.interfaces.some((i) => i.domain === "mechanical")) notes.push(note("info", `Mounting: ${esc(String(mounting))}. <span class="muted small">— ${esc(d.id)}</span>`));
441
+ }
442
+
443
+ // ---- parts strip
444
+ const strip: string[] = [];
445
+ for (const x of parts) {
446
+ const th = await thumbnail(env, x.def);
447
+ strip.push(`<div class="part">${th ? `<img src="${esc(env.img(th.file))}" alt="">` : `<span class="ph-s"></span>`}<div class="p-qty"><span class="letter${x.accent ? " acc" : ""}">${x.letter}</span>×${x.count}</div><div class="p-name">${esc(shortName(x.def))}</div></div>`);
448
+ }
449
+ const trace = [
450
+ p.mates.length ? `mates: ${p.mates.map((m) => m.linkId).join(", ")}` : "",
451
+ p.wiring.length ? `links: ${p.wiring.map((w) => w.linkId).join(", ")}` : "",
452
+ ].filter(Boolean).join(" · ");
453
+ const hasHw = p.hardware.length > 0;
454
+ // torque per harness used in this step, as model queries (the fastenerStack item that is turned)
455
+ const stepHarnessDefs = [...new Map(p.hardware.map((h) => ctx.instances.get(h.harness!)).filter((d): d is ModuleDef => Boolean(d)).map((d) => [d.id, d])).values()];
456
+ const torqueText = stepHarnessDefs
457
+ .flatMap((hd) => {
458
+ const ts = jointTorques(hd, ctx.sys.lookup);
459
+ const seen = new Set<number>();
460
+ return ts
461
+ .filter((t) => (seen.has(t.torque.torqueNm) ? false : (seen.add(t.torque.torqueNm), true)))
462
+ .map((t) => `${doc.v(`def:${hd.id}:fastenerStack[${t.index}].torque.torqueNm`, "u=N·m")} <span class="muted small">${esc(shortName(t.part ?? hd))}${stepHarnessDefs.length > 1 ? ` · ${esc(hd.name)}` : ""}</span>`);
463
+ })
464
+ .join("; ");
465
+ const stepTools = toolsFor([...new Map(p.hardware.map((h) => [h.def.id, h.def])).values()]);
466
+ const attrs = `data-step="${esc(s.id)}" data-mates="${esc(p.mates.map((m) => m.linkId).join(" "))}" data-wiring="${esc(p.wiring.map((w) => w.linkId).join(" "))}" data-hardware="${esc(p.hardware.map((h) => h.key).join(" "))}"`;
467
+ const blocks = [
468
+ `<div class="step step-top block keep-next"${p.n === 1 ? ' data-break="before"' : ""} ${attrs}>
469
+ <div class="step-head"><div class="step-no">${p.n}</div><h2 data-num="${String(p.n + 3).padStart(2, "0")}" data-toc="${esc(s.title)}">${esc(s.title)}</h2><div class="trace">${esc(trace)}</div></div>
470
+ <div class="parts">${strip.join("")}</div>
471
+ ${fig}</div>`,
472
+ `<div class="step-cont block"><ol class="actions">${actions.map((a) => `<li>${a}</li>`).join("")}</ol></div>`,
473
+ wireTable.length ? table([{ h: "Link" }, { h: "From" }, { h: "To" }, { h: "" }, { h: "Termination" }, { h: "DRC" }], wireTable, { cls: "dense wiretable split", caption: s.title }) : "",
474
+ `<div class="step-cont block"><div class="step-meta">${hasHw ? `<span><b>Torque</b> ${torqueText || '<span class="v st-gap">—</span><sup class="mk mk-gap">—</sup> not in model'}</span>` : ""}${stepTools.length ? `<span><b>Tools</b> ${esc(stepTools.map(toolLabel).join(", "))}</span>` : ""}<span><b>Step</b> ${p.n} of ${total}</span></div></div>`,
475
+ ...notes.map((n, i) => `<div class="step-cont block${i === notes.length - 1 ? " step-end" : ""}">${n}</div>`),
476
+ ];
477
+ if (!notes.length) blocks[blocks.length - 1] = blocks[blocks.length - 1].replace('class="step-cont block"', 'class="step-cont block step-end"');
478
+ return blocks.filter(Boolean).join("\n");
479
+ }
480
+
481
+ /**
482
+ * Actions for the mates of a step. Mates sharing a structure interface form a
483
+ * joint; identical joints (four motor mounts) are described once with "each".
484
+ * Within a joint, hardware (fastenerStack) and mounted modules (mate gap) are
485
+ * sorted by their position along the joint normal.
486
+ */
487
+ /** How generated text shows model values and names: HTML spans (the technical documents) or plain text (the package). */
488
+ export interface ValueText {
489
+ v: (q: string, f?: string) => string;
490
+ e: (s: string) => string;
491
+ }
492
+
493
+ export function jointActions(env: Pick<BuildEnv, "ctx">, p: StepPlan, t: ValueText): string[] {
494
+ const { ctx } = env;
495
+ const doc = { v: t.v };
496
+ const esc = t.e;
497
+ const scene = ctx.scene;
498
+ const joints = groupBy(p.mates, (m) => {
499
+ const { structure } = mateEnds(scene, m);
500
+ return `${bodyKey(structure.path, structure.frame.artifact)}.${structure.iface.id}`;
501
+ });
502
+ const signature = (ms: MateEdge[]) => {
503
+ const { structure } = mateEnds(scene, ms[0]);
504
+ const movers = ms.map((m) => mateEnds(scene, m).moving.def.id).sort().join("+");
505
+ const h = ms[0].harnessPath ? ctx.instances.get(ms[0].harnessPath.join("/"))?.id : "";
506
+ return `${structure.def.id}|${structure.iface.protocols?.[0]?.type}|${structure.iface.parameters?.map((x) => x.value).join(",")}|${movers}|${h}`;
507
+ };
508
+ const groups = groupBy([...joints.values()], signature);
509
+ const out: string[] = [];
510
+ const lowerFirst = (x: string) => (/^[A-Z][a-z]/.test(x) ? x[0].toLowerCase() + x.slice(1) : x);
511
+ const generic = (x: string) => lowerFirst(x.replace(/^(Front|Rear)-(left|right) /i, ""));
512
+ for (const js of groups.values()) {
513
+ const n = js.length;
514
+ const ms = js[0];
515
+ const { structure } = mateEnds(scene, ms[0]);
516
+ const sName = esc(n > 1 ? generic(structure.iface.name ?? structure.iface.id) : lowerFirst(structure.iface.name ?? structure.iface.id));
517
+ const sBody = scene.byKey.get(bodyKey(structure.path, structure.frame.artifact));
518
+ const vertical = sBody ? Math.abs(worldFrame(sBody.matrix, structure.frame).normal[2]) > 0.7 : true;
519
+ type Item = { pos: number; text: string };
520
+ const items: Item[] = [];
521
+ const rots = [...new Set(js.flatMap((j) => j.map((m) => m.mate?.rotationDeg).filter((x): x is number => Boolean(x))))];
522
+ for (const m of ms) {
523
+ const { moving } = mateEnds(scene, m);
524
+ const gap = m.mate?.gapMm;
525
+ const gapText = gap ? ` (${doc.v(`sys:system.links[id=${m.linkId}].mate.gapMm`, "u=mm")} gap)` : "";
526
+ const rotText = rots.length && n > 1 ? ` (turned ${rots.map((r) => `${r}°`).join(" / ")} by corner, per the mates)` : "";
527
+ items.push({ pos: gap ?? 0.001, text: `${gap ? "Lower" : "Seat"} ${n > 1 ? "a" : "the"} ${esc(shortName(moving.def))} onto ${n > 1 ? `each ${sName}` : `the ${sName}`}${gapText}${rotText}.` });
528
+ }
529
+ const hw = p.hardware.filter((h) => ms.some((m) => m.linkId === h.linkId));
530
+ const hd = hw[0] && ctx.instances.get(hw[0].harness!);
531
+ const through = new Set<string>();
532
+ for (const it of hd?.fastenerStack ?? []) {
533
+ const hs = hw.filter((h) => h.child === it.child);
534
+ if (!hs.length) continue;
535
+ const count = Math.round(hs.length / hd!.fastenerStack!.filter((x) => x.child === it.child).length);
536
+ const def = hs[0].def;
537
+ const name = `${count} × ${esc(shortName(def))}`;
538
+ const kind = def.tags?.includes("screw") ? "screw" : def.tags?.includes("nut") ? "nut" : def.tags?.includes("standoff") ? "standoff" : "spacer";
539
+ const dir = it.direction ?? 1;
540
+ let text: string;
541
+ if (kind === "screw" && it.atMm < 0 && dir > 0) {
542
+ text = vertical ? `Insert ${name} from below, up through the ${sName}.` : `Insert ${name} through the ${sName} from one side.`;
543
+ through.add("screw");
544
+ } else if (kind === "screw" && dir < 0) text = vertical ? `Drive ${name} from above, head down.` : `Insert ${name} from the opposite side.`;
545
+ else if (kind === "nut" && it.atMm < 0) text = `Hold ${name} under the ${sName}.`;
546
+ else if (kind === "nut") text = `Finish with ${name}; tighten until snug.`;
547
+ else if (kind === "spacer" || kind === "standoff") {
548
+ const threaded = def.interfaces.some((i) => i.capabilities?.includes("internal_thread"));
549
+ text = through.has("screw") ? (threaded ? `Thread ${name} onto the screws.` : `Slide ${name} over the screws.`) : `Fit ${name} on the ${sName}.`;
550
+ }
551
+ else text = `Fit ${name}.`;
552
+ items.push({ pos: it.atMm, text });
553
+ }
554
+ const below = items.filter((i) => i.pos < 0).sort((a, b) => b.pos - a.pos);
555
+ const above = items.filter((i) => i.pos >= 0).sort((a, b) => a.pos - b.pos);
556
+ const seq = [...below, ...above].map((i) => i.text);
557
+ if (n > 1 && seq.length) seq[seq.length - 1] += ` Repeat on all ${n}.`;
558
+ out.push(...seq);
559
+ }
560
+ return out;
561
+ }
562
+
563
+ /** Instance label for one end of a wiring step ("Flight controller", "ELRS receiver"). */
564
+ export function instanceLabelOf(env: Pick<BuildEnv, "ctx">, w: WiringStep, end: "a" | "b"): string {
565
+ const e = parseEnd(end === "a" ? w.a : w.b);
566
+ const prefix = w.linkId.includes("/") ? w.linkId.split("/").slice(0, -1) : [];
567
+ const leaf = leafOf(env.ctx.sys.system, env.ctx.sys.lookup, e.path, e.iface);
568
+ const path = [...prefix, ...(leaf?.path ?? e.path.split("/"))];
569
+ let owner = env.ctx.sys.system;
570
+ let name = path.join("/");
571
+ for (const id of path) {
572
+ const ref = owner.children?.find((c) => c.id === id);
573
+ if (!ref) break;
574
+ name = ref.name ?? ref.moduleDefId;
575
+ const d = env.ctx.sys.lookup(ref.moduleDefId);
576
+ if (!d) break;
577
+ owner = d;
578
+ }
579
+ return name;
580
+ }
581
+
582
+ export function centerOf(b: SceneBody, off?: number[]): number[] {
583
+ const t = matTranslation(b.matrix);
584
+ const d = b.def.domains?.find((x) => x.domain === "mechanical")?.dimensions_mm;
585
+ const lift = b.category === "screw" || b.category === "nut" || b.category === "spacer" || b.category === "standoff" ? 0 : Math.min(8, (d?.height ?? 0) / 2);
586
+ const o = off ?? [0, 0, 0];
587
+ return [t[0] + o[0], t[1] + o[1], t[2] + o[2] + lift];
588
+ }
589
+
590
+ /** One-hole section of a fastener harness: structure face at 0, parts at their atMm, mounted modules at their gaps. Fixed scale across harnesses. */
591
+ function stackDiagram(env: BuildEnv, hdef: ModuleDef, items: SceneBody[]): string {
592
+ const { ctx } = env;
593
+ const len = (d: ModuleDef) => d.interfaces.map((i) => i.parameters?.find((x) => x.id === "length")?.value).find((v) => v !== undefined) as number | undefined;
594
+ const dia = (d: ModuleDef) => d.interfaces.map((i) => i.parameters?.find((x) => x.id === "fastener_diameter")?.value).find((v) => v !== undefined) as number | undefined;
595
+ const linkIds = [...new Set(items.map((h) => h.linkId!))];
596
+ const mates = ctx.scene.assembly.mates.filter((m) => linkIds.includes(m.linkId));
597
+ type El = { kind: string; name: string; from: number; to: number; w: number };
598
+ const els: El[] = [];
599
+ for (const it of hdef.fastenerStack ?? []) {
600
+ const ref = hdef.children?.find((c) => c.id === it.child);
601
+ const d = ref && ctx.sys.lookup(ref.moduleDefId);
602
+ if (!d) continue;
603
+ const L = len(d) ?? 2;
604
+ const dir = it.direction ?? 1;
605
+ const md = d.domains?.find((x) => x.domain === "mechanical")?.metadata as any;
606
+ const kind = d.tags?.includes("screw") ? "screw" : d.tags?.includes("nut") ? "nut" : d.tags?.includes("standoff") ? "standoff" : "spacer";
607
+ const w = kind === "screw" ? dia(d) ?? 3 : md?.outer_mm ?? (dia(d) ?? 3) * 2;
608
+ if (kind === "screw") {
609
+ const hk = md?.head_height_mm ?? 2;
610
+ const hd = md?.head_diameter_mm ?? (dia(d) ?? 3) * 1.8;
611
+ els.push({ kind: "shaft", name: "", from: Math.min(it.atMm, it.atMm + dir * L), to: Math.max(it.atMm, it.atMm + dir * L), w });
612
+ els.push({ kind: "head", name: shortName(d), from: dir > 0 ? it.atMm - hk : it.atMm, to: dir > 0 ? it.atMm : it.atMm + hk, w: hd });
613
+ } else els.push({ kind, name: shortName(d), from: Math.min(it.atMm, it.atMm + dir * L), to: Math.max(it.atMm, it.atMm + dir * L), w });
614
+ }
615
+ const mounted = mates.map((m) => {
616
+ const { moving } = mateEnds(ctx.scene, m);
617
+ return { name: shortName(moving.def), at: m.mate?.gapMm ?? 0 };
618
+ });
619
+ // structure thickness: the part nearest behind the face sits on its far side (screw head, nut)
620
+ const behind = (hdef.fastenerStack ?? []).filter((x) => x.atMm < 0).map((x) => -x.atMm);
621
+ const plateT = behind.length ? Math.min(...behind) : 0;
622
+ const lo = Math.min(...els.map((e) => e.from), -plateT) - 1;
623
+ const hi = Math.max(...els.map((e) => e.to), ...mounted.map((m) => m.at + 1.6)) + 1;
624
+ const k = 2.5; // units per mm, same for every harness
625
+ const y = (v: number) => 6 + (hi - v) * k;
626
+ const cx = 44;
627
+ const colX = 100;
628
+ const H = y(lo) + 6;
629
+ const parts: string[] = [];
630
+ parts.push(`<rect x="8" y="${y(0)}" width="${cx * 2 - 16}" height="${Math.max(1.5, plateT * k)}" fill="#cfcfd1" stroke="#1b1b1b" stroke-width="0.6"/>`);
631
+ const labels: { y: number; ty: number; x0: number; text: string; sub: string; accent: boolean }[] = [];
632
+ labels.push({ y: y(-plateT / 2), ty: 0, x0: cx * 2 - 8, text: "structure", sub: plateT ? `${plateT} mm` : "face", accent: false });
633
+ for (const m of mounted) {
634
+ parts.push(`<rect x="12" y="${y(m.at + 1.6)}" width="${cx * 2 - 24}" height="${1.6 * k}" fill="#dfe8f2" stroke="#1b1b1b" stroke-width="0.6"/>`);
635
+ labels.push({ y: y(m.at + 0.8), ty: 0, x0: cx * 2 - 12, text: m.name, sub: `at ${m.at} mm`, accent: false });
636
+ }
637
+ for (const e of els) {
638
+ const fill = e.kind === "spacer" ? "#ffffff" : e.kind === "standoff" ? "#e6ebf1" : "#ffc9a3";
639
+ const stroke = e.kind === "shaft" || e.kind === "head" || e.kind === "nut" ? "#d24a00" : "#1b1b1b";
640
+ parts.push(`<rect x="${cx - (e.w * k) / 2}" y="${y(e.to)}" width="${e.w * k}" height="${(e.to - e.from) * k}" fill="${fill}" stroke="${stroke}" stroke-width="0.6"/>`);
641
+ if (e.name) labels.push({ y: y((e.from + e.to) / 2), ty: 0, x0: cx + (e.w * k) / 2, text: e.name, sub: `${e.from}…${e.to} mm`, accent: e.kind !== "spacer" && e.kind !== "standoff" });
642
+ }
643
+ // label column, spread so they never overlap
644
+ labels.sort((a, b) => a.y - b.y);
645
+ labels.forEach((l, i) => (l.ty = Math.max(l.y, i ? labels[i - 1].ty + 14 : 8)));
646
+ const height = Math.max(H, (labels.at(-1)?.ty ?? 0) + 12);
647
+ for (const l of labels) {
648
+ parts.push(`<polyline points="${l.x0},${l.y} ${colX - 6},${l.y} ${colX - 2},${l.ty}" fill="none" stroke="#a3a3a3" stroke-width="0.4"/>`);
649
+ parts.push(`<text x="${colX}" y="${l.ty + 2}" font-size="7" fill="${l.accent ? "#d24a00" : "#1b1b1b"}" font-weight="600">${esc(l.text.length > 34 ? l.text.slice(0, 33) + "…" : l.text)}</text>`);
650
+ parts.push(`<text x="${colX}" y="${l.ty + 9}" font-size="5.8" fill="#6f6f6f">${esc(l.sub)}</text>`);
651
+ }
652
+ return `<div class="stackfig"><div class="st-title">${esc(hdef.name)} <span class="muted small">${esc(hdef.id)}</span></div><svg viewBox="0 0 240 ${height}" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="inherit">${parts.join("")}</svg></div>`;
653
+ }
654
+
655
+ /** Coverage appendix: every mate, hardware placement and wiring link, and the step that covers it. */
656
+ export function assemblyProvenance(env: BuildEnv): string {
657
+ const { ctx } = env;
658
+ const plans = planSteps(env);
659
+ const stepOf = (pred: (p: StepPlan) => boolean) => {
660
+ const p = plans.find(pred);
661
+ return p ? `${p.n} · ${esc(p.spec.title)}` : '<span class="x-flag">not covered</span>';
662
+ };
663
+ const mateRows = ctx.scene.assembly.mates.map((m) => [`<code>${esc(m.linkId)}</code>`, esc(`${m.a.path.join("/")}.${m.a.iface.id} ↔ ${m.b.path.join("/")}.${m.b.iface.id}`), esc(m.harness ?? "—"), stepOf((p) => p.mates.includes(m))]);
664
+ const hwGroups = groupBy(ctx.scene.hardware, (h) => `${h.harness}/${h.child}`);
665
+ const hwRows = [...hwGroups.entries()].map(([k, hs]) => [`<code>${esc(k)}</code>`, `${hs.length} × ${esc(shortName(hs[0].def))}`, `<code>${esc(hs[0].linkId ?? "")}</code>`, stepOf((p) => hs.every((h) => p.hardware.includes(h)))]);
666
+ const wRows = ctx.wiring.filter((w) => w.domain !== "mechanical").map((w) => [`<code>${esc(w.linkId)}</code>`, esc(w.name), esc(w.harness ?? "—"), stepOf((p) => p.wiring.includes(w))]);
667
+ return `${sec(String(plans.length + 4).padStart(2, "0"), "Coverage", "assemble() · wiringChecklist", ' data-break="before"')}
668
+ <p class="prose">Every rigid mate, every hardware placement and every electrical link in the model, and the step that covers it. The verifier fails the guide if any row is not covered.</p>
669
+ ${sub("Mates")}${table([{ h: "Mate" }, { h: "Interfaces" }, { h: "Harness" }, { h: "Step" }], mateRows, { split: true, caption: "Mates", cls: "dense covtable" })}
670
+ ${sub("Hardware placements")}${table([{ h: "Harness / child" }, { h: "Parts" }, { h: "Joint" }, { h: "Step" }], hwRows, { split: true, caption: "Hardware", cls: "dense covtable" })}
671
+ ${sub("Wiring links")}${table([{ h: "Link" }, { h: "Name" }, { h: "Harness" }, { h: "Step" }], wRows, { split: true, caption: "Wiring", cls: "dense covtable" })}`;
672
+ }
673
+
674
+ export type { HardwarePlacement };