@protoboardai/ext-release 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.
package/lib/cutlist.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A wire harness's cut list from its UHD facts (tool-neutral): one row per
3
+ * conductor, from the ends' connector positions through the harness's
4
+ * internal conductor link, with the cable facts (`performance` kind cable:
5
+ * installed and supplied length, gauge) and build facts (`cable_build`:
6
+ * colour per position of the first end, lay, strip length).
7
+ */
8
+ import type { InterfaceDef, ModuleDef } from "@deltarobotics/uhd";
9
+ import { toCsv } from "./util.ts";
10
+
11
+ export interface CutRow {
12
+ conductor: number;
13
+ from: string;
14
+ to: string;
15
+ colour: string;
16
+ length_mm: number | null;
17
+ supplied_length_mm: number | null;
18
+ gauge_awg: number | null;
19
+ od_mm: number | null;
20
+ strip_mm: number | null;
21
+ lay: string;
22
+ note: string;
23
+ }
24
+
25
+ export const CUT_COLUMNS = ["conductor", "from", "to", "colour", "length_mm", "supplied_length_mm", "gauge_awg", "od_mm", "strip_mm", "lay", "note"];
26
+
27
+ const params = (def: ModuleDef, type: string, kind?: string) =>
28
+ (def.traits ?? []).find((t) => t.type === type && (!kind || (t.params as Record<string, unknown> | undefined)?.kind === kind))?.params as Record<string, unknown> | undefined;
29
+ const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null);
30
+
31
+ /** True for a cable: a wire harness with conductors between connector ends. */
32
+ export const isCable = (def: ModuleDef) => def.kind === "harness" && (def as { topology?: string }).topology === "wire";
33
+
34
+ export function cutList(def: ModuleDef): CutRow[] {
35
+ const cable = params(def, "performance", "cable") ?? {};
36
+ const build = params(def, "cable_build") ?? {};
37
+ const wires = (def.links ?? []).find((l) => "self" in l.a && "self" in l.b);
38
+ const ends = (def.interfaces ?? []).filter((i) => i.slots?.length);
39
+ const a = wires ? ends.find((i) => i.id === wires.a.interfaceId) : ends[0];
40
+ const b = wires ? ends.find((i) => i.id === wires.b.interfaceId) : ends[1];
41
+ const count = n(cable.conductors) ?? a?.slots?.length ?? 0;
42
+ const pairs: [string, string][] = wires?.childLinks?.length
43
+ ? wires.childLinks.map((c) => [c.a, c.b])
44
+ : Array.from({ length: count }, (_, i) => [`p${i + 1}`, `p${i + 1}`]);
45
+ const label = (end: InterfaceDef | undefined, slot: string) => {
46
+ const s = end?.slots?.find((x) => x.id === slot);
47
+ return end ? `${end.name ?? end.id} ${s?.label ?? slot}` : slot;
48
+ };
49
+ const colours = Array.isArray(build.colors) ? (build.colors as string[]) : [];
50
+ const lay = [build.bundle, build.twist_pitch_mm ? `${build.twist_pitch_mm} mm pitch` : ""].filter(Boolean).join(", ");
51
+ const position = (slot: string, i: number) => {
52
+ const k = a?.slots?.findIndex((s) => s.id === slot) ?? -1;
53
+ return k >= 0 ? k : i;
54
+ };
55
+ return pairs.map(([x, y], i) => ({
56
+ conductor: i + 1,
57
+ from: label(a, x),
58
+ to: label(b, y),
59
+ colour: colours[position(x, i)] ?? "",
60
+ length_mm: n(cable.length_mm),
61
+ supplied_length_mm: n(cable.supplied_length_mm),
62
+ gauge_awg: n(cable.wire_gauge_awg),
63
+ od_mm: n(build.od_mm),
64
+ strip_mm: n(build.strip_mm),
65
+ lay,
66
+ note: i === 0 && typeof cable.length_basis === "string" ? `length ${cable.length_basis}` : "",
67
+ }));
68
+ }
69
+
70
+ export const cutListCsv = (def: ModuleDef) => toCsv(CUT_COLUMNS, cutList(def) as unknown as Record<string, unknown>[]);
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The package's documents: one per unit and section (PDF), the overview
3
+ * (00-overview.pdf, with the traceability appendix) and index.html, which
4
+ * holds all of it with navigation by unit and by section.
5
+ */
6
+ import { readTemplate } from "@protoboardai/ext-docs/html";
7
+ import type { ProductStatement } from "@protoboardai/core";
8
+ import { blocksHtml, esc, sec, sub } from "./html.ts";
9
+ import { productBlocks } from "./product.ts";
10
+ import type { ReleaseManifest } from "./manifest.ts";
11
+ import { SECTION_DOCS, SECTIONS, type Block, type PackageFile, type SectionContribution, type SectionId, type Unit } from "./types.ts";
12
+
13
+ export interface Contributed extends SectionContribution {
14
+ extension: string;
15
+ }
16
+
17
+ export interface DocSet {
18
+ project: string;
19
+ title: string;
20
+ description?: string;
21
+ /** What the product is, as the project states it (docs/product.json): the first page. */
22
+ product: ProductStatement;
23
+ version: string;
24
+ date: string;
25
+ units: Unit[];
26
+ /** unit id → section → contributions in order. */
27
+ content: Map<string, Map<SectionId, Contributed[]>>;
28
+ /** unit id → figure (package path and size). */
29
+ figures: Map<string, { file: string; width: number; height: number; bodies: number }>;
30
+ }
31
+
32
+ /** Package path of a unit's document. */
33
+ export const docPath = (u: Unit, s: SectionId) => `${u.folder}/${SECTION_DOCS[s].file}.pdf`;
34
+ /** Document id: PROJECT-UNIT-CODE; a unit named after the project's module ("quadcopter-5in-arm") drops the repeated project prefix. */
35
+ export const docId = (set: DocSet, u: Unit, s: SectionId | "overview") => {
36
+ const project = set.project.toUpperCase();
37
+ const unit = u.kind === "root" ? "SYS" : u.id.toUpperCase();
38
+ const short = unit.startsWith(`${project}-`) && unit.length > project.length + 1 ? unit.slice(project.length + 1) : unit;
39
+ return `${project}-${short}-${s === "overview" ? "OV" : SECTION_DOCS[s].code}`;
40
+ };
41
+
42
+ const heroBlock = (set: DocSet, u: Unit): Block[] => {
43
+ const f = set.figures.get(u.id);
44
+ return f ? [{ kind: "image", file: f.file, width: f.width, height: f.height, caption: `${u.name}, assembled from the model (${f.bodies} placed bodies${u.kind === "module" ? ", one build" : ""}).` }] : [];
45
+ };
46
+
47
+ /** The flow body of one unit document. */
48
+ export function unitDocBody(set: DocSet, u: Unit, s: SectionId, img: (p: string) => string): string {
49
+ const parts = set.content.get(u.id)?.get(s) ?? [];
50
+ const main = parts.filter((c) => !c.title);
51
+ const titled = parts.filter((c) => c.title);
52
+ const head = `<div class="doc-head block"><span><b>${esc(u.name)}</b></span><span>${esc(u.kind === "root" ? "system (final assembly)" : u.folder)}</span><span>release ${esc(set.version)}</span>${u.kind !== "root" ? `<span>× ${u.perProduct} per product</span>` : ""}</div>`;
53
+ const out = [sec("1", SECTION_DOCS[s].title, esc(u.name)), head];
54
+ const blocks = [...(s === "spec" ? heroBlock(set, u) : []), ...main.flatMap((c) => c.blocks)];
55
+ out.push(blocks.length ? blocksHtml(blocks, img) : `<p class="muted block">No content was contributed to this section for this unit.</p>`);
56
+ titled.forEach((c, i) => out.push(sec(String(i + 2), c.title!, `<span class="muted">${esc(c.extension)}</span>`, ' data-break="before"'), blocksHtml(c.blocks, img)));
57
+ return out.join("\n");
58
+ }
59
+
60
+ const unitRow = (u: Unit) => ({ unit: u.id, name: u.name, qty: u.kind === "root" ? 1 : u.perProduct, into: u.parents.join(", ") || "—", folder: `${u.folder}/` });
61
+
62
+ /** The overview's body: cover, product, units, order of work, totals, index of files, traceability appendix. */
63
+ export function overviewBody(set: DocSet, m: ReleaseManifest, totals: { label: string; value: string; sub?: string }[], img: (p: string) => string): string {
64
+ const root = set.units.find((u) => u.kind === "root")!;
65
+ const hero = set.figures.get(root.id);
66
+ const words = set.title.split(" ");
67
+ const last = words.pop();
68
+ const cover = `<div class="coverpage" data-page="cover">
69
+ <div class="cover-top"><div class="grid-bg"></div>
70
+ <div class="cover-brand">PR<b>0</b>TOBOARD <span class="muted">·</span> UHD</div>
71
+ <div class="cover-rev"><span>Release ${esc(set.version)}</span><span>${esc(set.date)}</span><span>${esc(docId(set, root, "overview"))}</span></div>
72
+ <div class="cover-kicker">Manufacturing package · Overview</div>
73
+ <div class="cover-title">${esc(words.join(" "))} <span class="accent">${esc(last)}</span></div>
74
+ <div class="cover-sub">${esc(set.product.summary ?? set.description ?? "")}</div>
75
+ ${hero ? `<div class="cover-hero"><img src="${esc(img(hero.file))}" width="${hero.width}" height="${hero.height}" alt="${esc(set.title)}"></div><div class="cover-herocap">Assembled from the model: ${hero.bodies} placed bodies</div>` : ""}
76
+ </div>
77
+ <div class="cover-bottom">${blocksHtml([{ kind: "figures", items: totals }], img)}
78
+ <p class="small">Everything needed to order, make, assemble and check this product, laid out by unit. Start with the order of work below; index.html holds the whole package in one place.</p></div>
79
+ <div class="cover-foot"><span class="pg-brand">PR<b>0</b>TOBOARD · UHD</span><span>Generated from the UHD model of <code>${esc(set.project)}</code> by @protoboardai/ext-release</span><span class="right">Release ${esc(set.version)} · ${esc(set.date)}</span></div>
80
+ </div>`;
81
+ const order: Block[] = [
82
+ {
83
+ kind: "list",
84
+ ordered: true,
85
+ items: [
86
+ "Order everything on system/ordering-list.csv (system/bom.pdf: ordering list) and place the fabrication orders in each unit's work order.",
87
+ ...set.units.filter((u) => u.kind !== "root").map((u) => `Build ${u.perProduct} × ${u.name} (${u.folder}/): work order, assembly guide, then the test plan.`),
88
+ `Final assembly (system/): assemble ${set.units.length > 1 ? "the units into the " : "the "}${root.name} and test it, in the order of the system work order's operations; it says where each check of the system test plan runs (the bench test and any configuration come before the steps that must follow them, such as fitting propellers).`,
89
+ ],
90
+ },
91
+ ];
92
+ const files: Block[] = [
93
+ {
94
+ kind: "table",
95
+ columns: [{ key: "path", label: "File" }, { key: "unit", label: "Unit" }, { key: "role", label: "Role" }, { key: "bytes", label: "Bytes", align: "right" }],
96
+ rows: [
97
+ ...[["00-overview.pdf", "this overview"], ["index.html", "the whole package, by unit and by section"], ["manifest.json", "release manifest: every file's digest"]].map(([path, role]) => ({ path, unit: "", role, bytes: "" })),
98
+ ...m.files.filter((f) => !f.path.startsWith("assets/")).map((f) => ({ path: f.path, unit: f.unit ?? "", role: f.role, bytes: f.bytes })),
99
+ // figures and fonts the documents use, one line per folder
100
+ ...[...new Set(m.files.filter((f) => f.path.startsWith("assets/")).map((f) => f.path.slice(0, f.path.lastIndexOf("/") + 1)))].map((dir) => {
101
+ const these = m.files.filter((f) => f.path.startsWith(dir) && !f.path.slice(dir.length).includes("/"));
102
+ return { path: `${dir} (${these.length} files)`, unit: "", role: [...new Set(these.map((f) => f.role))].join(", "), bytes: these.reduce((t, f) => t + f.bytes, 0) };
103
+ }),
104
+ ],
105
+ caption: "Files",
106
+ note: "Digests of every file are in manifest.json; the documents' own digests are only there, since a document cannot list its own.",
107
+ },
108
+ ];
109
+ return [
110
+ cover,
111
+ // the first page after the cover: what it is, its features, what it does, what it must meet (PB-935)
112
+ sec("1", "The product", esc(set.title)),
113
+ blocksHtml(productBlocks(set.product, set.description), img),
114
+ sec("2", "Units and how they fit", "", ' data-break="before"'),
115
+ blocksHtml(
116
+ [
117
+ { kind: "paragraph", text: "Each unit has its own folder with a spec sheet, bill of materials, work order, assembly guide and test plan. A unit is built separately and assembled into the unit above it; the system folder is final assembly." },
118
+ { kind: "table", columns: [{ key: "unit", label: "Unit" }, { key: "name", label: "Module" }, { key: "qty", label: "Per product", align: "right" }, { key: "into", label: "Assembled into" }, { key: "folder", label: "Folder" }], rows: set.units.map(unitRow) },
119
+ ],
120
+ img,
121
+ ),
122
+ sec("3", "Order of work"),
123
+ blocksHtml(order, img),
124
+ sec("4", "Index", "", ' data-break="before"'),
125
+ blocksHtml(files, img),
126
+ sec("A", "Traceability", "", ' data-break="before"'),
127
+ blocksHtml(traceability(m), img),
128
+ ].join("\n");
129
+ }
130
+
131
+ /** The release manifest in readable form (overview appendix and index.html page). */
132
+ export function traceability(m: ReleaseManifest): Block[] {
133
+ const short = (d?: string) => (d ? d.replace(/^sha256:/, "").slice(0, 16) : "");
134
+ return [
135
+ {
136
+ kind: "keyvalues",
137
+ rows: [
138
+ { label: "Project", value: `${m.project.name} (root ${m.project.root})` },
139
+ { label: "Release", value: `${m.version} · ${m.date}` },
140
+ { label: "Source", value: m.source ? `git ${m.source.commit.slice(0, 12)}${m.source.dirty.length ? ` with ${m.source.dirty.length} uncommitted change(s)` : ""}` : "not a git work tree" },
141
+ { label: "Model", value: `${m.model.bundle} · ${short(m.model.digest)}` },
142
+ { label: "Framework", value: `@protoboardai/core ${m.framework.protoboard} · @deltarobotics/uhd ${m.framework.uhd.version ?? "?"}${m.framework.uhd.revision ? ` (${m.framework.uhd.revision.slice(0, 12)})` : ""}` },
143
+ { label: "Check", value: `${m.check.errors} errors, ${m.check.warnings} warnings, ${m.check.info} info at release time` },
144
+ ],
145
+ },
146
+ { kind: "heading", text: "Parts" },
147
+ {
148
+ kind: "table",
149
+ columns: [{ key: "id", label: "Part" }, { key: "tier", label: "Tier" }, { key: "rev", label: "Revision / source" }, { key: "digest", label: "Digest" }],
150
+ rows: m.parts.map((x) => ({ id: x.id, tier: x.tier, rev: x.revision !== undefined ? `${x.service ?? x.provider ?? "catalog"} rev ${x.revision}` : x.source, digest: short(x.digest) })),
151
+ },
152
+ { kind: "heading", text: "Provider outputs" },
153
+ {
154
+ kind: "table",
155
+ columns: [{ key: "module", label: "Module" }, { key: "cap", label: "Capability" }, { key: "provider", label: "Provider" }, { key: "tools", label: "Tools" }, { key: "record", label: "Operation record" }],
156
+ rows: m.outputs.map((o) => ({ module: o.module, cap: o.capability, provider: `${o.provider.id}@${o.provider.version}`, tools: o.tools.map((t) => t.detail ?? t.id).join(", "), record: o.record })),
157
+ },
158
+ ...(m.generated.length
159
+ ? ([
160
+ { kind: "heading", text: "Other generated files" },
161
+ { kind: "table", columns: [{ key: "path", label: "File" }, { key: "op", label: "Operation" }, { key: "record", label: "Record" }, { key: "digest", label: "Digest" }], rows: m.generated.map((g) => ({ path: g.path, op: `${g.extension} ${g.operation}`, record: g.record ?? "", digest: short(g.digest) })) },
162
+ ] as Block[])
163
+ : []),
164
+ ...(m.check.findings.length
165
+ ? ([
166
+ { kind: "heading", text: "Warnings at release time" },
167
+ { kind: "table", columns: [{ key: "code", label: "Code" }, { key: "message", label: "Finding" }], rows: m.check.findings.map((f) => ({ code: f.code, message: f.message })) },
168
+ ] as Block[])
169
+ : []),
170
+ ];
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // index.html
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const anchor = (u: Unit, s?: SectionId) => `u-${u.id}${s ? `-${s}` : ""}`;
178
+
179
+ export function indexHtml(set: DocSet, m: ReleaseManifest, totals: { label: string; value: string; sub?: string }[], files: PackageFile[]): string {
180
+ const img = (p: string) => p;
181
+ const css = readTemplate("doc.css").replace(/__FONTS__/g, "assets/fonts");
182
+ const nav = [
183
+ `<a href="#overview">Overview</a>`,
184
+ `<div class="nav-h">By unit</div>`,
185
+ ...set.units.map((u) => `<a href="#${anchor(u)}">${esc(u.kind === "root" ? `system · ${u.name}` : `${u.id} · ${u.name}`)}</a><div class="nav-sub">${SECTIONS.map((s) => `<a href="#${anchor(u, s)}">${esc(SECTION_DOCS[s].title)}</a>`).join("")}</div>`),
186
+ `<div class="nav-h">By section</div>`,
187
+ ...SECTIONS.map((s) => `<a href="#s-${s}">${esc(SECTION_DOCS[s].title)}</a><div class="nav-sub">${set.units.map((u) => `<a href="#${anchor(u, s)}">${esc(u.id)}</a>`).join("")}</div>`),
188
+ `<div class="nav-h">Package</div>`,
189
+ `<a href="#files">Files</a>`,
190
+ `<a href="#traceability">Traceability</a>`,
191
+ ].join("\n");
192
+ const units = set.units
193
+ .map((u) => {
194
+ const unitFiles = files.filter((f) => f.unit === u.id && f.path.startsWith(`${u.folder}/files/`));
195
+ return `<section class="unit" id="${anchor(u)}">
196
+ <h1 class="unit-h">${esc(u.name)} <span class="muted">${esc(u.kind === "root" ? "system/" : `${u.folder}/`)}${u.kind !== "root" ? ` · × ${u.perProduct} per product` : ""}</span></h1>
197
+ <div class="doclinks">${SECTIONS.map((s) => `<a href="${docPath(u, s)}">${esc(SECTION_DOCS[s].file)}.pdf</a>`).join("")}<a href="${u.folder}/bom.csv">bom.csv</a>${u.kind === "root" ? `<a href="system/ordering-list.csv">ordering-list.csv</a>` : ""}</div>
198
+ ${SECTIONS.map((s) => `<article class="doc" id="${anchor(u, s)}"><h2 class="doc-h">${esc(SECTION_DOCS[s].title)}</h2>${docInner(set, u, s, img)}</article>`).join("\n")}
199
+ <article class="doc"><h2 class="doc-h">Files</h2>${unitFiles.length ? `<ul class="plain">${unitFiles.map((f) => `<li><a href="${esc(f.path)}">${esc(f.path.slice(u.folder.length + 1))}</a> <span class="muted">${esc(f.description ?? f.role)}</span></li>`).join("")}</ul>` : `<p class="muted">No manufacturing files for this unit.</p>`}</article>
200
+ </section>`;
201
+ })
202
+ .join("\n");
203
+ const bySection = SECTIONS.map((s) => `<p id="s-${s}" class="sec-index"><b>${esc(SECTION_DOCS[s].title)}:</b> ${set.units.map((u) => `<a href="#${anchor(u, s)}">${esc(u.id)}</a>`).join(" · ")}</p>`).join("");
204
+ const root = set.units.find((u) => u.kind === "root")!;
205
+ const hero = set.figures.get(root.id);
206
+ return `<!doctype html>
207
+ <html lang="en">
208
+ <head>
209
+ <meta charset="utf-8">
210
+ <meta name="viewport" content="width=device-width, initial-scale=1">
211
+ <meta name="generator" content="@protoboardai/ext-release">
212
+ <title>${esc(set.title)} — Manufacturing package ${esc(set.version)}</title>
213
+ <style>${css}${INDEX_CSS}</style>
214
+ </head>
215
+ <body class="index">
216
+ <header class="top"><span class="pg-mark"></span><span class="brand">PR<b>0</b>TOBOARD</span><span class="t">${esc(set.title)}</span><span class="muted">Manufacturing package · release ${esc(set.version)} · ${esc(set.date)}</span></header>
217
+ <nav class="side">${nav}</nav>
218
+ <main class="main">
219
+ <section id="overview">
220
+ <h1 class="unit-h">${esc(set.title)} <span class="muted">overview</span></h1>
221
+ <div class="doclinks"><a href="00-overview.pdf">00-overview.pdf</a><a href="manifest.json">manifest.json</a></div>
222
+ ${hero ? `<img class="hero" src="${esc(hero.file)}" alt="${esc(set.title)}">` : ""}
223
+ ${blocksHtml(productBlocks(set.product, set.description), img)}
224
+ ${blocksHtml([{ kind: "figures", items: totals }], img)}
225
+ ${sub("Units")}
226
+ ${blocksHtml([{ kind: "table", columns: [{ key: "unit", label: "Unit" }, { key: "name", label: "Module" }, { key: "qty", label: "Per product", align: "right" }, { key: "into", label: "Assembled into" }, { key: "folder", label: "Folder" }], rows: set.units.map(unitRow) }], img)}
227
+ ${sub("By section")}
228
+ ${bySection}
229
+ </section>
230
+ ${units}
231
+ <section id="files"><h1 class="unit-h">Files</h1>${filesTable(m)}</section>
232
+ <section id="traceability"><h1 class="unit-h">Traceability</h1>${blocksHtml(traceability(m), img)}</section>
233
+ </main>
234
+ </body>
235
+ </html>
236
+ `;
237
+ }
238
+
239
+ function docInner(set: DocSet, u: Unit, s: SectionId, img: (p: string) => string): string {
240
+ const parts = set.content.get(u.id)?.get(s) ?? [];
241
+ const blocks = [...parts.filter((c) => !c.title).flatMap((c) => c.blocks)];
242
+ return [
243
+ blocks.length ? blocksHtml(blocks, img) : `<p class="muted">No content was contributed to this section for this unit.</p>`,
244
+ ...parts.filter((c) => c.title).map((c) => `${sub(c.title!)}${blocksHtml(c.blocks, img)}`),
245
+ ].join("\n");
246
+ }
247
+
248
+ /** Screen layout for index.html over ext-docs' print theme. */
249
+ const INDEX_CSS = `
250
+ body.index { background: #fff; font-size: 9pt; }
251
+ body.index .top { position: sticky; top: 0; z-index: 2; display: flex; align-items: baseline; gap: 4mm; padding: 3mm 6mm; background: #fff; border-bottom: 0.5pt solid var(--ink); }
252
+ body.index .top .brand { font-family: var(--display); letter-spacing: .08em; }
253
+ body.index .top .brand b { color: var(--accent); font-weight: 400; }
254
+ body.index .top .t { font-family: var(--display); font-size: 11pt; }
255
+ body.index .pg-mark { width: 2.4mm; height: 2.4mm; background: var(--accent); display: inline-block; align-self: center; }
256
+ body.index nav.side { position: fixed; top: 13mm; bottom: 0; left: 0; width: 62mm; overflow-y: auto; padding: 4mm 5mm; border-right: 0.35pt solid var(--rule); font-size: 8pt; }
257
+ body.index nav.side a { display: block; color: var(--ink); text-decoration: none; padding: .5mm 0; }
258
+ body.index nav.side a:hover { color: var(--accent-ink); }
259
+ body.index .nav-h { margin-top: 4mm; font-size: 6.8pt; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
260
+ body.index .nav-sub { padding-left: 3mm; border-left: 0.35pt solid var(--rule); margin-bottom: 1.5mm; }
261
+ body.index .nav-sub a { color: var(--muted); }
262
+ body.index main.main { margin-left: 66mm; padding: 6mm 8mm 30mm; max-width: 200mm; }
263
+ body.index section { margin-bottom: 10mm; }
264
+ body.index .unit-h { font-family: var(--display); font-weight: 400; font-size: 16pt; border-bottom: 0.6pt solid var(--ink); padding-bottom: 1.5mm; margin: 6mm 0 3mm; }
265
+ body.index .unit-h .muted { font-family: var(--mono); font-size: 8pt; }
266
+ body.index .doc-h { font-family: var(--display); font-weight: 400; font-size: 12pt; margin: 6mm 0 2mm; color: var(--ink); }
267
+ body.index .doc-h::before { content: ""; display: inline-block; width: 2mm; height: 2mm; background: var(--accent); margin-right: 2mm; vertical-align: middle; }
268
+ body.index .doclinks { display: flex; flex-wrap: wrap; gap: 2mm; margin-bottom: 3mm; }
269
+ body.index .doclinks a { font-size: 7pt; border: 0.4pt solid var(--rule); padding: .4mm 1.6mm; color: var(--ink); text-decoration: none; }
270
+ body.index .doclinks a:hover { border-color: var(--accent); }
271
+ body.index img.hero { width: 100%; max-height: 110mm; object-fit: contain; margin: 2mm 0 4mm; }
272
+ body.index .sec-index { margin: 1mm 0; }
273
+ body.index main a { color: var(--accent-ink); }
274
+ body.index table { font-size: 7.6pt; }
275
+ body.index .cont-note { display: none; }
276
+ body.index .step { break-inside: avoid; }
277
+ body.index td.nw { white-space: nowrap; }
278
+ body.index .part.noimg { grid-template-columns: auto; min-width: 0; }
279
+ body.index .part.noimg .p-name { grid-column: 1; }
280
+ body.index .step-no.wide { width: auto; min-width: 8.5mm; padding: 0 2.2mm; border-radius: 4.25mm; font-size: 9.5pt; }
281
+ @media (max-width: 900px) { body.index nav.side { display: none; } body.index main.main { margin-left: 0; } }
282
+ `;
283
+
284
+ function filesTable(m: ReleaseManifest): string {
285
+ const rows = m.files.map((f) => `<tr><td><a href="${esc(f.path)}">${esc(f.path)}</a></td><td>${esc(f.unit ?? "")}</td><td>${esc(f.role)}</td><td class="num">${f.bytes}</td></tr>`).join("");
286
+ return `<table class="dense"><thead><tr><th>File</th><th>Unit</th><th>Role</th><th class="num">Bytes</th></tr></thead><tbody>${rows}</tbody></table>`;
287
+ }
package/lib/glb.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Overall size of a body from a provider's GLB standard output: the bounding
3
+ * box of every mesh's POSITION bounds (accessor min/max, which glTF requires)
4
+ * through the node transforms. glTF is Y-up in metres; the box is returned in
5
+ * the model's Z-up millimetres, as the providers export it (build123d writes
6
+ * the Z-up body under a root rotation, so the result is the body's own box).
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+
10
+ type M = number[]; // column-major 4×4, as glTF stores it
11
+
12
+ const I: M = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
13
+ const mul = (a: M, b: M): M => {
14
+ const o = new Array(16).fill(0);
15
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
16
+ return o;
17
+ };
18
+ function trs(n: { matrix?: number[]; translation?: number[]; rotation?: number[]; scale?: number[] }): M {
19
+ if (n.matrix) return n.matrix;
20
+ const [x, y, z, w] = n.rotation ?? [0, 0, 0, 1];
21
+ const [sx, sy, sz] = n.scale ?? [1, 1, 1];
22
+ const [tx, ty, tz] = n.translation ?? [0, 0, 0];
23
+ return [
24
+ (1 - 2 * (y * y + z * z)) * sx, 2 * (x * y + z * w) * sx, 2 * (x * z - y * w) * sx, 0,
25
+ 2 * (x * y - z * w) * sy, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z + x * w) * sy, 0,
26
+ 2 * (x * z + y * w) * sz, 2 * (y * z - x * w) * sz, (1 - 2 * (x * x + y * y)) * sz, 0,
27
+ tx, ty, tz, 1,
28
+ ];
29
+ }
30
+ const apply = (m: M, p: number[]) => [0, 1, 2].map((r) => m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]);
31
+
32
+ export interface GlbBox {
33
+ min: [number, number, number];
34
+ max: [number, number, number];
35
+ /** Extents along X, Y, Z (mm). */
36
+ size: [number, number, number];
37
+ }
38
+
39
+ interface Gltf {
40
+ scene?: number;
41
+ scenes?: { nodes?: number[] }[];
42
+ nodes?: { children?: number[]; mesh?: number; matrix?: number[]; translation?: number[]; rotation?: number[]; scale?: number[] }[];
43
+ meshes?: { primitives: { attributes: Record<string, number> }[] }[];
44
+ accessors?: { min?: number[]; max?: number[] }[];
45
+ }
46
+
47
+ /** The body's bounding box in mm (Z up), or undefined when the file is not a readable GLB. */
48
+ export function glbBox(file: string): GlbBox | undefined {
49
+ let json: Gltf;
50
+ try {
51
+ const b = readFileSync(file);
52
+ if (b.readUInt32LE(0) !== 0x46546c67) return undefined; // "glTF"
53
+ const len = b.readUInt32LE(12);
54
+ json = JSON.parse(b.subarray(20, 20 + len).toString("utf8")) as Gltf;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ const lo = [Infinity, Infinity, Infinity];
59
+ const hi = [-Infinity, -Infinity, -Infinity];
60
+ const visit = (i: number, parent: M) => {
61
+ const n = json.nodes?.[i];
62
+ if (!n) return;
63
+ const m = mul(parent, trs(n));
64
+ for (const prim of n.mesh !== undefined ? (json.meshes?.[n.mesh]?.primitives ?? []) : []) {
65
+ const a = json.accessors?.[prim.attributes.POSITION];
66
+ if (!a?.min || !a.max) continue;
67
+ for (const x of [a.min[0], a.max[0]]) for (const y of [a.min[1], a.max[1]]) for (const z of [a.min[2], a.max[2]]) {
68
+ const p = apply(m, [x, y, z]);
69
+ for (let k = 0; k < 3; k++) {
70
+ lo[k] = Math.min(lo[k], p[k]);
71
+ hi[k] = Math.max(hi[k], p[k]);
72
+ }
73
+ }
74
+ }
75
+ for (const c of n.children ?? []) visit(c, m);
76
+ };
77
+ const scene = json.scenes?.[json.scene ?? 0];
78
+ for (const r of scene?.nodes ?? []) visit(r, I);
79
+ if (!Number.isFinite(lo[0])) return undefined;
80
+ // glTF Y-up metres → Z-up millimetres: X = x, Y = −z, Z = y
81
+ const r3 = (v: number) => Math.round(v * 1000 * 100) / 100;
82
+ const min: [number, number, number] = [r3(lo[0]), r3(-hi[2]), r3(lo[1])];
83
+ const max: [number, number, number] = [r3(hi[0]), r3(-lo[2]), r3(hi[1])];
84
+ return { min, max, size: [r3((hi[0] - lo[0])), r3((hi[2] - lo[2])), r3((hi[1] - lo[1]))] };
85
+ }
package/lib/helpers.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What section contributors share with the release (`@protoboardai/ext-release/helpers`):
3
+ * which custom modules a unit builds, how many per build, the files collected
4
+ * for them, a module's standard outputs, and the model-derived cut list and
5
+ * bill of materials. Providers import this when the release calls them, so
6
+ * it is only loaded where ext-release is installed.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { CapabilityBinding, Project } from "@protoboardai/core";
11
+ import { unitModules } from "./collect.ts";
12
+ import type { PackageFile, Unit } from "./types.ts";
13
+
14
+ export { fileGroup, unitModules } from "./collect.ts";
15
+ export { CUT_COLUMNS, cutList, cutListCsv, isCable, type CutRow } from "./cutlist.ts";
16
+ export { classify, includedWith, orderingList, unitBom, type OrderLine, type UnitBomRow } from "./bom.ts";
17
+ export { glbBox, type GlbBox } from "./glb.ts";
18
+ export { unitLinks, type UnitLink } from "./sections/model.ts";
19
+ export { toCsv } from "./util.ts";
20
+
21
+ /** Instances of `module` in one build of the unit (the unit's own module counts once). */
22
+ export function perBuild(unit: Unit, module: string): number {
23
+ const own = unit.kind !== "root" && unit.definition === module ? 1 : 0;
24
+ return own + unit.members.filter((m) => !m.unit && m.definition === module).reduce((s, m) => s + m.quantity, 0);
25
+ }
26
+
27
+ /** Files the release collected for a module in this unit (package paths under files/<group>/<module>/). */
28
+ export function moduleFiles(files: PackageFile[], unit: Unit, module: string): PackageFile[] {
29
+ return files.filter((f) => f.unit === unit.id && new RegExp(`^${unit.folder}/files/[^/]+/${module.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).test(f.path));
30
+ }
31
+
32
+ /** Unit-relative form of a package path ("files/mechanical/frame/frame.dxf"). */
33
+ export const inUnit = (unit: Unit, path: string) => (path.startsWith(unit.folder + "/") ? path.slice(unit.folder.length + 1) : path);
34
+
35
+ /** A JSON standard output of a bound module (`out/<file>`), if present and readable. */
36
+ export function readOutput<T>(p: Project, b: CapabilityBinding, file: string): T | undefined {
37
+ const f = join(p.root, b.out, file);
38
+ if (!existsSync(f)) return undefined;
39
+ try {
40
+ return JSON.parse(readFileSync(f, "utf8")) as T;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /** The unit's bound modules of one provider (the provider's own contributions). */
47
+ export function providerModules(p: Project, unit: Unit, provider: string): CapabilityBinding[] {
48
+ return unitModules(p, unit).filter((b) => b.provider === provider);
49
+ }
package/lib/html.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Section content → HTML. The same markup goes into the paginated PDFs
3
+ * (ext-docs' page shell, flow paginator and print theme) and into index.html.
4
+ */
5
+ import { esc } from "@protoboardai/ext-docs/html";
6
+ import { callouts as calloutSvg, figure, note, sec, sub, table } from "@protoboardai/ext-docs/blocks";
7
+ import type { Block, Callout, Cell, Column, KitItem, Step } from "./types.ts";
8
+
9
+ /** Printed width of a full-width figure (mm): the A4 text block. Callout bubbles are sized against it. */
10
+ const TEXT_WIDTH_MM = 178;
11
+
12
+ const cell = (v: Cell) => (v === null || v === undefined ? "" : esc(String(v)));
13
+
14
+ /** Blocks as flow blocks (each top-level element can move to the next page). `img` maps a package path to a URL relative to the document. */
15
+ export function blocksHtml(blocks: Block[], img: (packagePath: string) => string): string {
16
+ return blocks.map((b) => blockHtml(b, img)).join("\n");
17
+ }
18
+
19
+ function blockHtml(b: Block, img: (p: string) => string): string {
20
+ switch (b.kind) {
21
+ case "heading":
22
+ return sub(b.text);
23
+ case "paragraph":
24
+ return `<p class="prose block${b.keep ? " keep-next" : ""}">${esc(b.text)}</p>`;
25
+ case "list":
26
+ return `<${b.ordered ? "ol" : "ul"} class="plain block">${b.items.map((i) => `<li>${esc(i)}</li>`).join("")}</${b.ordered ? "ol" : "ul"}>`;
27
+ case "keyvalues":
28
+ return table(
29
+ [{ h: "Item" }, { h: "Value" }],
30
+ b.rows.map((r) => [`<b>${esc(r.label)}</b>`, `${esc(r.value)}${r.note ? `<div class="muted small">${esc(r.note)}</div>` : ""}`]),
31
+ { cls: "dense kv" },
32
+ );
33
+ case "figures": {
34
+ const k = b.items.length > 4 ? " k6" : "";
35
+ return `<div class="kpis${k} block">${b.items.map((i) => `<div class="kpi"><div class="k-label">${esc(i.label)}</div><div class="k-value">${kpiValue(i.value)}</div>${i.sub ? `<div class="k-sub">${esc(i.sub)}</div>` : ""}</div>`).join("")}</div>`;
36
+ }
37
+ case "table": {
38
+ const cols = b.columns.map((c) => ({ h: esc(c.label), cls: [c.align === "right" ? "num" : "", c.nowrap ? "nw" : ""].filter(Boolean).join(" ") }));
39
+ const rows = b.rows.map((r) => ("group" in r && typeof r.group === "string" ? { group: r.group } : b.columns.map((c) => cell((r as Record<string, Cell>)[c.key]))));
40
+ return table(cols, rows as string[][], { split: true, cls: "dense", caption: b.caption, foot: b.note ? esc(b.note) : undefined });
41
+ }
42
+ case "note":
43
+ return note(b.level, esc(b.text));
44
+ case "steps":
45
+ return b.steps.map((s, i) => stepHtml(s, i + 1, img)).join("\n");
46
+ case "image":
47
+ return figure({ src: img(b.file), w: b.width ?? 2000, h: b.height ?? 1180, caption: esc(b.caption), widthPct: b.size === "small" ? 55 : b.callouts?.length || b.size === "full" ? undefined : 78, overlay: overlay(b.width ?? 2000, b.height ?? 1180, b.callouts) });
48
+ case "kit":
49
+ return kitHtml(b.items, img, b.note);
50
+ }
51
+ }
52
+
53
+ /** Lettered bubbles with leaders (ext-docs' callouts), for an image of `w` × `h` pixels. */
54
+ function overlay(w: number, h: number, items?: Callout[]): string | undefined {
55
+ if (!items?.length) return undefined;
56
+ const anchors = Object.fromEntries(items.map((c, i) => [`c${i}`, { x: c.x, y: c.y }]));
57
+ return calloutSvg({ file: "", width: w, height: h, anchors, mmPerPx: 0 }, items.map((c, i) => ({ anchor: `c${i}`, label: c.label, accent: c.accent })), TEXT_WIDTH_MM);
58
+ }
59
+
60
+ /** Kit grid: five per row, blocks of 25 so a long kit flows onto the next page. */
61
+ function kitHtml(items: KitItem[], img: (p: string) => string, foot?: string): string {
62
+ const cells = items.map(
63
+ (k) =>
64
+ `<div class="kit-item${k.image ? "" : " noimg"}">${k.image ? `<img src="${esc(img(k.image))}" alt="">` : `<div class="ph">${esc(k.placeholder ?? "no CAD")}</div>`}<div class="k-qty">×${k.quantity}</div><div class="k-name">${esc(k.name)}</div>${k.ref ? `<div class="k-id">${esc(k.ref)}</div>` : ""}</div>`,
65
+ );
66
+ const out: string[] = [];
67
+ for (let i = 0; i < cells.length; i += 25) out.push(`<div class="block"><div class="kit">${cells.slice(i, i + 25).join("")}</div>${i + 25 >= cells.length && foot ? `<p class="fn">${esc(foot)}</p>` : ""}</div>`);
68
+ return out.join("\n");
69
+ }
70
+
71
+ function stepTable(t: { columns: Column[]; rows: Record<string, Cell>[] }): string {
72
+ return table(
73
+ t.columns.map((c) => ({ h: esc(c.label), cls: [c.align === "right" ? "num" : "", c.nowrap ? "nw" : ""].filter(Boolean).join(" ") })),
74
+ t.rows.map((r) => t.columns.map((c) => cell(r[c.key]))),
75
+ { cls: "dense" },
76
+ );
77
+ }
78
+
79
+ /** Number and unit in separate spans, so the unit is set in the text face (as ext-docs does). */
80
+ /** A figure's value with its unit set apart; a sum in several currencies ("12 USD + 3 EUR") sets each one apart. */
81
+ function kpiValue(v: string): string {
82
+ const one = (x: string) => {
83
+ const nu = x.match(/^(.*\d)\s([^\d\s][^\d]*)$/);
84
+ return nu ? `<span class="n">${esc(nu[1])}</span>\u00a0<span class="u">${esc(nu[2])}</span>` : undefined;
85
+ };
86
+ const parts = v.split(" + ");
87
+ const each = parts.length > 1 ? parts.map(one) : [];
88
+ if (each.length && each.every((x) => x !== undefined)) return each.join(" + ");
89
+ return one(v) ?? esc(v);
90
+ }
91
+
92
+ function stepHtml(s: Step, n: number, img: (p: string) => string): string {
93
+ const meta = [
94
+ s.tools?.length ? `<span><b>Tools</b> ${esc(s.tools.join(", "))}</span>` : "",
95
+ s.materials?.length ? `<span><b>Materials</b> ${esc(s.materials.join(", "))}</span>` : "",
96
+ s.torque ? `<span><b>Torque</b> ${esc(s.torque)}</span>` : "",
97
+ s.inspect ? `<span><b>Inspect</b> ${esc(s.inspect)}</span>` : "",
98
+ s.test ? `<span><b>Then test</b> ${esc(s.test)}</span>` : "",
99
+ ].filter(Boolean);
100
+ const no = s.id ?? String(n);
101
+ const pictured = s.parts?.some((p) => p.image);
102
+ const part = (p: NonNullable<Step["parts"]>[number]) =>
103
+ `<div class="part${pictured ? "" : " noimg"}">${p.image ? `<img src="${esc(img(p.image))}" alt="">` : pictured ? `<span class="ph-s"></span>` : ""}<span class="p-qty">${p.letter ? `<span class="letter${p.accent ? " acc" : ""}">${esc(p.letter)}</span>` : ""}× ${p.quantity}</span><span class="p-name">${esc(p.name)}</span></div>`;
104
+ const fig = s.figure
105
+ ? figure({ src: img(s.figure.file), w: s.figure.width, h: s.figure.height, caption: s.figure.caption ? esc(s.figure.caption) : undefined, overlay: overlay(s.figure.width, s.figure.height, s.figure.callouts) })
106
+ : "";
107
+ // the head, parts and figure stay together; the rest may follow on the next page
108
+ const top = `<div class="step block${s.figure ? " step-top keep-next" : ""}">
109
+ <div class="step-head"><div class="step-no${no.length > 2 ? " wide" : ""}">${esc(no)}</div><h2 data-num="${esc(no)}">${esc(s.title)}</h2>${s.trace?.length ? `<div class="trace">${esc(s.trace.join(" · "))}</div>` : ""}</div>
110
+ ${s.parts?.length ? `<div class="parts">${s.parts.map(part).join("")}</div>` : ""}
111
+ ${fig}
112
+ ${!s.figure && s.actions?.length ? `<ol class="actions">${s.actions.map((a) => `<li>${esc(a)}</li>`).join("")}</ol>` : ""}
113
+ ${!s.figure && !s.table && meta.length ? `<div class="step-meta">${meta.join("")}</div>` : ""}
114
+ ${!s.figure && !s.table ? (s.notes ?? []).map((x) => note(x.level, esc(x.text))).join("") : ""}
115
+ </div>`;
116
+ if (!s.figure && !s.table) return top;
117
+ const rest = [
118
+ s.figure && s.actions?.length ? `<div class="step-cont block"><ol class="actions">${s.actions.map((a) => `<li>${esc(a)}</li>`).join("")}</ol></div>` : "",
119
+ s.table ? stepTable(s.table) : "",
120
+ meta.length ? `<div class="step-cont block"><div class="step-meta">${meta.join("")}</div></div>` : "",
121
+ ...(s.notes ?? []).map((x) => `<div class="step-cont block">${note(x.level, esc(x.text))}</div>`),
122
+ ].filter(Boolean);
123
+ return [top, ...rest].join("\n").replace(/class="step-cont block">(?![\s\S]*class="step-cont block">)/, 'class="step-cont block step-end">');
124
+ }
125
+
126
+ export { esc, sec, sub };