@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.
- package/LICENSE +202 -0
- package/NOTICE +18 -0
- package/README.md +78 -2
- package/evals/build-datasheet/case.json +147 -0
- package/fonts/KodeMono-Bold.ttf +0 -0
- package/fonts/KodeMono-Medium.ttf +0 -0
- package/fonts/KodeMono-Regular.ttf +0 -0
- package/fonts/KodeMono-SemiBold.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/lib/blocks.ts +268 -0
- package/lib/bom.ts +126 -0
- package/lib/build.ts +202 -0
- package/lib/charts.ts +241 -0
- package/lib/context.ts +122 -0
- package/lib/derived.ts +181 -0
- package/lib/docs/assembly.ts +674 -0
- package/lib/docs/common.ts +81 -0
- package/lib/docs/datasheet.ts +875 -0
- package/lib/docs/product.ts +45 -0
- package/lib/figures.ts +195 -0
- package/lib/format.ts +221 -0
- package/lib/html.ts +135 -0
- package/lib/model.ts +312 -0
- package/lib/package/assembly.ts +268 -0
- package/lib/package/common.ts +109 -0
- package/lib/package/plan.ts +432 -0
- package/lib/package/spec.ts +203 -0
- package/lib/png.ts +91 -0
- package/lib/render/cdp.ts +5 -0
- package/lib/render/page/render.html +17 -0
- package/lib/render/page/render.js +390 -0
- package/lib/render/renderer.ts +161 -0
- package/lib/render/server.ts +105 -0
- package/lib/standin.ts +36 -0
- package/lib/standins/multirotor.ts +283 -0
- package/lib/templates/doc.css +247 -0
- package/lib/templates/flow.js +131 -0
- package/lib/testdata.ts +85 -0
- package/lib/types.ts +120 -0
- package/lib/values.ts +309 -0
- package/lib/verify.ts +276 -0
- package/ops/docs.ts +52 -0
- package/ops/package.ts +9 -0
- package/package.json +51 -4
- package/protoboard-extension.json +83 -0
- package/skills/uhd-tech-docs/SKILL.md +234 -0
- package/skills/uhd-tech-docs/references/docspec.md +56 -0
- package/skills/uhd-tech-docs/references/house-style.md +50 -0
- package/skills/uhd-tech-docs/references/style-study.md +270 -0
- package/skills/uhd-tech-docs/references/test-data.md +49 -0
- package/skills/uhd-tech-docs/references/value-queries.md +67 -0
- package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
- package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
- package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
- package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System datasheet: overview, specifications, mechanical drawing with
|
|
3
|
+
* interface geometry, interfaces and pinout, electrical characteristics,
|
|
4
|
+
* performance (test data), design checks, BOM, evidence and provenance.
|
|
5
|
+
*/
|
|
6
|
+
import type { InterfaceDef, ModuleDef } from "@deltarobotics/uhd";
|
|
7
|
+
import { interfaceGeometry, slotBindings } from "@deltarobotics/uhd";
|
|
8
|
+
import { barChart, lineChart, meterChart, ACCENT } from "../charts.ts";
|
|
9
|
+
import { callouts, dimension, endOf, figure, groupRow, note, sec, sub, table, leafOf, parseEnd } from "../blocks.ts";
|
|
10
|
+
import { esc, MARKERS } from "../html.ts";
|
|
11
|
+
import { allBodies, interfaceAnchor, renderBody, ISO } from "../figures.ts";
|
|
12
|
+
import { thrustTests } from "@deltarobotics/uhd";
|
|
13
|
+
import { massByDefinition, moduleMass, systemMass } from "@deltarobotics/uhd";
|
|
14
|
+
import { bomFile } from "../context.ts";
|
|
15
|
+
import { cadVolume, category } from "../model.ts";
|
|
16
|
+
import { column } from "../testdata.ts";
|
|
17
|
+
import { coverPage, figNum, testOf, testTag, thumbnail, type BuildEnv } from "./common.ts";
|
|
18
|
+
import { productPage } from "./product.ts";
|
|
19
|
+
import { moduleSources, moduleVerification } from "../values.ts";
|
|
20
|
+
import type { ValueStatus } from "../values.ts";
|
|
21
|
+
|
|
22
|
+
const W = 178; // printed content width (mm)
|
|
23
|
+
|
|
24
|
+
export async function buildDatasheet(env: BuildEnv): Promise<string> {
|
|
25
|
+
const { ctx, doc } = env;
|
|
26
|
+
const cfg = ctx.sys.config;
|
|
27
|
+
const ds = cfg.datasheet ?? {};
|
|
28
|
+
const out: string[] = [];
|
|
29
|
+
const iso = cfg.camera?.dir ?? ISO;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------- cover
|
|
32
|
+
const heroBodies = [
|
|
33
|
+
...ctx.scene.bodies.filter((b) => ds.heroHints || !b.via?.startsWith("hint:")).map((b) => renderBody(b, "normal")),
|
|
34
|
+
...ctx.scene.hardware.map((h) => renderBody(h, "normal")),
|
|
35
|
+
];
|
|
36
|
+
const hero = await env.r.render("ds-hero", { width: 2000, height: 1180, mode: "shaded", bodies: heroBodies, camera: { dir: iso, margin: 0.03 } });
|
|
37
|
+
const kpis = (ds.keyFigures ?? [])
|
|
38
|
+
.map((k) => `<div class="kpi"><div class="k-label">${esc(k.label)}</div><div class="k-value">${doc.v(k.q, k.f)}</div>${k.sub ? `<div class="k-sub">${esc(k.sub)}</div>` : ""}</div>`)
|
|
39
|
+
.join("");
|
|
40
|
+
const placedCount = ctx.scene.bodies.length;
|
|
41
|
+
out.push(
|
|
42
|
+
coverPage(env, {
|
|
43
|
+
kicker: "Datasheet · Reference system",
|
|
44
|
+
hero,
|
|
45
|
+
heroCaption: `Assembled from UHD mates (${placedCount} placed bodies, ${ctx.scene.hardware.length} fasteners)${ds.heroHints ? "; parts held inside placed by the project's placement hints" : "; strapped battery not shown"}.`,
|
|
46
|
+
bottom: `<div class="kpis k6">${kpis}</div>
|
|
47
|
+
<div class="cover-grid">
|
|
48
|
+
<div><h2 class="sub">About this document</h2><p class="small">Every value is queried from the UHD model at build time and re-checked by <code>uhd-tech-docs-verify</code>. Markers show where a value is not a cited source value.</p></div>
|
|
49
|
+
<div>${doc.legendPlaceholder()}</div>
|
|
50
|
+
</div>`,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- 01 overview
|
|
55
|
+
const diagram = systemDiagram(env);
|
|
56
|
+
const sections = ["Overview", "Specifications", "Mechanical", "Interfaces and pinout", "Electrical characteristics", "Performance", "Design checks", "Bill of materials", "Evidence and assumptions", "Value provenance"];
|
|
57
|
+
// the product as the project states it, before the component detail (PB-935)
|
|
58
|
+
out.push(productPage(ctx.sys.root, ctx.sys.system.name ?? ctx.sys.system.id, ctx.sys.system.description));
|
|
59
|
+
out.push(`<div class="block" data-break="before">${sec("01", "Overview", `${esc(ctx.sys.system.id)} · v${esc(ctx.sys.system.version ?? "")}`)}
|
|
60
|
+
<div class="cols-60"><div><p class="lead">${doc.v("sys:system.description", undefined, { cls: "wrap" })}</p>
|
|
61
|
+
${table(
|
|
62
|
+
[{ h: "Role" }, { h: "Module" }, { h: "Qty", cls: "num" }],
|
|
63
|
+
(ctx.sys.system.children ?? [])
|
|
64
|
+
.filter((c) => ctx.sys.lookup(c.moduleDefId)?.kind !== "harness")
|
|
65
|
+
.reduce((acc: { name: string; id: string; n: number }[], c) => {
|
|
66
|
+
const ex = acc.find((a) => a.id === c.moduleDefId);
|
|
67
|
+
if (ex) ex.n++;
|
|
68
|
+
else acc.push({ name: c.name ?? c.id, id: c.moduleDefId, n: 1 });
|
|
69
|
+
return acc;
|
|
70
|
+
}, [])
|
|
71
|
+
.map((c) => [esc(c.n > 1 ? c.name.replace(/^(Front|Rear)-(left|right) (\w)/i, (_m, _a, _b, x: string) => x.toUpperCase()) + "s" : c.name), doc.v(`def:${c.id}:name`, undefined, { cls: "wrap" }), String(c.n)]),
|
|
72
|
+
{ cls: "dense" },
|
|
73
|
+
)}</div>
|
|
74
|
+
<div><h2 class="sub">Contents</h2><ol class="toc">${sections.map((s, i) => `<li><span class="toc-n">${String(i + 1).padStart(2, "0")}</span><span class="toc-t">${esc(s)}</span><span class="toc-dots"></span><span class="toc-p">00</span></li>`).join("")}</ol></div></div></div>`);
|
|
75
|
+
if (diagram) out.push(`<div class="block keep">${sub("System architecture")}${diagram}<div class="cap"><b>Figure ${figNum(env)}</b><span>Power and signal links as stored in the model (validated with validateLinks); labels are the hub-side interfaces.</span></div></div>`);
|
|
76
|
+
const boardFig = boardDiagram(env);
|
|
77
|
+
if (boardFig) out.push(`<div class="block keep">${sub(`${ctx.instances.get(ds.hub!)?.name ?? "Board"} architecture`)}${boardFig}<div class="cap"><b>Figure ${figNum(env)}</b><span>The board's components (passives left out) and the nets that join them to its controller, from the board module's net links. Parts with no net to the controller hang off the part they share a rail with.</span></div></div>`);
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- 02 specifications
|
|
80
|
+
const groups = ds.specGroups ?? [];
|
|
81
|
+
const half = Math.ceil(groups.reduce((s, g) => s + g.rows.length + 1, 0) / 2);
|
|
82
|
+
const left: typeof groups = [];
|
|
83
|
+
const right: typeof groups = [];
|
|
84
|
+
let acc = 0;
|
|
85
|
+
for (const g of groups) {
|
|
86
|
+
(acc < half ? left : right).push(g);
|
|
87
|
+
acc += g.rows.length + 1;
|
|
88
|
+
}
|
|
89
|
+
const specTable = (gs: typeof groups) =>
|
|
90
|
+
table(
|
|
91
|
+
[{ h: "Parameter" }, { h: "Value" }],
|
|
92
|
+
gs.flatMap((g) => [groupRow(g.title), ...g.rows.map((r) => [esc(r.label) + (r.sub ? ` <span class="muted small">${esc(r.sub)}</span>` : ""), doc.v(r.q, r.f, { cls: "wrap" })])]) as any,
|
|
93
|
+
);
|
|
94
|
+
out.push(`<div class="block" data-break="before">${sec("02", "Specifications")}<div class="cols">${specTable(left)}${specTable(right)}</div>${doc.legendPlaceholder()}</div>`);
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- 03 mechanical
|
|
97
|
+
out.push(sec("03", "Mechanical", "", ' data-break="before"'));
|
|
98
|
+
const drawingBodies = [
|
|
99
|
+
...ctx.scene.bodies.filter((b) => !b.via?.startsWith("hint:")).map((b) => renderBody(b, b.category === "prop" ? "ghost" : "normal")),
|
|
100
|
+
...ctx.scene.hardware.map((h) => renderBody(h, "normal")),
|
|
101
|
+
];
|
|
102
|
+
const mm = (spec: string) => {
|
|
103
|
+
const a = interfaceAnchor(ctx.scene, spec);
|
|
104
|
+
return a ? [{ id: spec, p: a.p }] : [];
|
|
105
|
+
};
|
|
106
|
+
const topAnchors = ["frame.motor_mount_fl", "frame.motor_mount_fr", "frame.motor_mount_rl", "frame.motor_mount_rr"].flatMap(mm);
|
|
107
|
+
const top = await env.r.render("ds-top", { width: 1500, height: 1260, mode: "lineart", bodies: drawingBodies, anchors: topAnchors, camera: { dir: [0, 0, 1], up: [1, 0, 0], margin: 0.12 } });
|
|
108
|
+
const lbl = (q: string, f?: string) => (x: number, y: number, size: number, rot: number) =>
|
|
109
|
+
doc.svgText(q, f, `x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${size.toFixed(1)}" text-anchor="middle" transform="rotate(${rot.toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)})" fill="#d94e00" font-weight="600" class="dimtext"`);
|
|
110
|
+
const topW = W * 0.56;
|
|
111
|
+
const frameDims = !ds.envelope;
|
|
112
|
+
const topDims = !frameDims ? "" : [
|
|
113
|
+
dimension(top, "frame.motor_mount_rl", "frame.motor_mount_fr", lbl("def:quadcopter-5in-frame:domains[domain=mechanical].metadata.wheelbase_mm"), topW, 0),
|
|
114
|
+
dimension(top, "frame.motor_mount_fl", "frame.motor_mount_fr", lbl("derived:frameDistance(frame.motor_mount_fl,frame.motor_mount_fr)", "d1"), topW, -9),
|
|
115
|
+
dimension(top, "frame.motor_mount_rr", "frame.motor_mount_fr", lbl("derived:frameDistance(frame.motor_mount_rr,frame.motor_mount_fr)", "d1"), topW, 9),
|
|
116
|
+
].join("");
|
|
117
|
+
// plate gap drawn at a standoff: its bottom (placement) to the top plate's underside (the top plate's standoff frame)
|
|
118
|
+
const so = ctx.scene.hardware.find((h) => h.category === "standoff" && h.matrix[7] < 0);
|
|
119
|
+
const tp = interfaceAnchor(ctx.scene, "top_plate.standoff_mount");
|
|
120
|
+
const sideAnchors = so && tp ? [{ id: "so-bottom", p: [so.matrix[3], so.matrix[7], so.matrix[11]] }, { id: "so-top", p: [so.matrix[3], so.matrix[7], tp.p[2]] }] : [];
|
|
121
|
+
const side = await env.r.render("ds-side", { width: 2000, height: 560, mode: "lineart", bodies: drawingBodies, anchors: sideAnchors, camera: { dir: [0, -1, 0], up: [0, 0, 1], margin: 0.04 } });
|
|
122
|
+
const sideDims = !frameDims ? "" : dimension(side, "so-bottom", "so-top", lbl("def:quadcopter-5in-frame:domains[domain=mechanical].metadata.standoff_height_mm"), W, 16);
|
|
123
|
+
// the doc spec's envelope table and captions; without one, the multirotor recipe (frameDims)
|
|
124
|
+
const envSpec = ds.envelope;
|
|
125
|
+
const envRows: string[][] = envSpec
|
|
126
|
+
? envSpec.rows.map((r) => [esc(r.label) + (r.sub ? ` <span class="muted small">${esc(r.sub)}</span>` : ""), doc.v(r.q, r.f, { cls: "wrap" })])
|
|
127
|
+
: [
|
|
128
|
+
["Wheelbase", doc.v("def:quadcopter-5in-frame:domains[domain=mechanical].metadata.wheelbase_mm")],
|
|
129
|
+
["Arm spacing (x)", doc.v("derived:frameDistance(frame.motor_mount_fl,frame.motor_mount_rl)", "d1")],
|
|
130
|
+
["Arm spacing (y)", doc.v("derived:frameDistance(frame.motor_mount_fl,frame.motor_mount_fr)", "d1")],
|
|
131
|
+
["Plate thickness", doc.v("def:quadcopter-5in-frame:domains[domain=mechanical].metadata.plate_thickness_mm")],
|
|
132
|
+
["Standoff height", doc.v("def:quadcopter-5in-frame:domains[domain=mechanical].metadata.standoff_height_mm")],
|
|
133
|
+
["Propeller diameter", doc.v("def:hqprop-ethix-s5:domains[domain=mechanical].metadata.prop_diameter_in", "u=in")],
|
|
134
|
+
["Motor diameter", doc.v("def:meps-neon-2207-v2-1950kv:domains[domain=mechanical].metadata.diameter_mm")],
|
|
135
|
+
["Plate material", doc.v("def:quadcopter-5in-frame:domains[domain=mechanical].material.name", undefined, { cls: "wrap" })],
|
|
136
|
+
["Material density", doc.v("def:quadcopter-5in-frame:domains[domain=mechanical].material.density_g_cm3", "u=g/cm³")],
|
|
137
|
+
["Frame mass", doc.v("derived:mass.module(quadcopter-5in-frame)", "d1")],
|
|
138
|
+
["Top plate mass", doc.v("derived:mass.module(quadcopter-5in-top-plate)", "d1")],
|
|
139
|
+
];
|
|
140
|
+
const envNote = envSpec ? envSpec.note : "Plate masses are the generated CAD volume × the declared material density (systemMass), so they follow the design.";
|
|
141
|
+
out.push(`<div class="block"><div class="cols-60">
|
|
142
|
+
${figure({ src: env.img(top.file), w: top.width, h: top.height, overlay: topDims, num: figNum(env), caption: envSpec?.topCaption ?? (frameDims ? "Top view, front up. Wheelbase is the frame's stated value; arm spacings are measured between the placed motor-mount frames." : "Top view."), unit: "mm" })}
|
|
143
|
+
<div>${sub("Envelope")}${table([{ h: "Parameter" }, { h: "Value", cls: "num" }], envRows, { cls: "dense" })}
|
|
144
|
+
${envNote ? `<p class="small muted">${esc(envNote)}</p>` : ""}</div></div></div>`);
|
|
145
|
+
out.push(`<div class="block">${figure({ src: env.img(side.file), w: side.width, h: side.height, overlay: sideDims, num: figNum(env), caption: envSpec?.sideCaption ?? (frameDims ? "Right side, front to the right. Plate gap is the frame's standoff height." : "Side view."), unit: "mm" })}</div>`);
|
|
146
|
+
|
|
147
|
+
// mass budget (systemMass): every instance by definition, and what has no mass
|
|
148
|
+
{
|
|
149
|
+
const sm = systemMass(ctx.sys.system, ctx.sys.lookup, cadVolume(ctx.sys));
|
|
150
|
+
const groups = massByDefinition(sm);
|
|
151
|
+
const rows = groups
|
|
152
|
+
.filter((g) => g.mass)
|
|
153
|
+
.sort((a, b) => (b.totalG ?? 0) - (a.totalG ?? 0))
|
|
154
|
+
.map((g) => {
|
|
155
|
+
const unit = g.mass!.basis === "stated" ? doc.v(`def:${g.def.id}:domains[domain=mechanical].weight_g`) : doc.v(`derived:mass.module(${g.def.id})`);
|
|
156
|
+
return [esc(shortDefName(g.def)), String(g.quantity), unit, g.mass!.basis === "stated" ? "stated" : `CAD × ${esc(g.mass!.material!.name.replace(/ \(.*\)/, ""))}`];
|
|
157
|
+
});
|
|
158
|
+
const missing = groups.filter((g) => !g.mass);
|
|
159
|
+
out.push(`<div class="block keep">${sub("Mass budget")}${table([{ h: "Part" }, { h: "Qty", cls: "num" }, { h: "Each", cls: "num" }, { h: "Basis" }], rows, {
|
|
160
|
+
cls: "dense",
|
|
161
|
+
foot: `All-up weight ${doc.v("derived:mass.all_up", "d1")} (systemMass), of which ${doc.v("derived:mass.assumed", "d1")} is computed from assumed material or nominal CAD geometry. Not included, no mass in the model: ${missing.map((g) => `${esc(g.def.name)} ×${g.quantity}`).join(", ") || "none"}. Rows in order of total mass (quantity × each).`,
|
|
162
|
+
})}</div>`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// interface callouts
|
|
166
|
+
const calls = (ds.mechanicalCallouts ?? []).map((s) => ({ s, a: interfaceAnchor(ctx.scene, s) })).filter((x) => x.a);
|
|
167
|
+
const accentByBody = new Map<string, string[]>();
|
|
168
|
+
for (const c of calls) accentByBody.set(c.a!.body.key, [...(accentByBody.get(c.a!.body.key) ?? []), c.a!.iface.id]);
|
|
169
|
+
const ifBodies = ctx.scene.bodies
|
|
170
|
+
// the structures (a frame, a top plate) and any other body an interface callout sits on (an enclosure)
|
|
171
|
+
.filter((b) => ["frame", "top_plate"].includes(b.category) || accentByBody.has(b.key))
|
|
172
|
+
.map((b) => renderBody(b, "normal", { accentNodes: accentByBody.get(b.key) ?? [] }));
|
|
173
|
+
const standoffs = ctx.scene.hardware.filter((h) => h.category === "standoff").map((h) => renderBody(h, "context"));
|
|
174
|
+
const ifFig = await env.r.render("ds-interfaces", {
|
|
175
|
+
width: 1900,
|
|
176
|
+
height: 1050,
|
|
177
|
+
mode: "lineart",
|
|
178
|
+
bodies: [...ifBodies, ...standoffs],
|
|
179
|
+
anchors: calls.map((c) => ({ id: c.s, p: c.a!.p })),
|
|
180
|
+
camera: { dir: [1, -1.25, 1.05], margin: 0.1 },
|
|
181
|
+
});
|
|
182
|
+
const ifOverlay = callouts(ifFig, calls.map((c, i) => ({ anchor: c.s, label: String(i + 1), accent: true })), W);
|
|
183
|
+
const ifRows = calls.map((c, i) => {
|
|
184
|
+
const { body, iface } = c.a!;
|
|
185
|
+
const q = (pid: string) => `inst:${body.path.join("/")}:interfaces[id=${iface.id}].parameters[id=${pid}].value`;
|
|
186
|
+
const shape = iface.traits?.find((t) => t.type === "bolt_pattern")?.params?.shape;
|
|
187
|
+
const hasY = iface.parameters?.some((p) => p.id === "hole_spacing_y");
|
|
188
|
+
const pattern = `${doc.v(q("hole_count"))} × on ${shape === "circle" ? "Ø" : ""}${doc.v(q("hole_spacing"))}${hasY ? ` × ${doc.v(q("hole_spacing_y"))}` : ""} ${esc(shape ?? "")}`;
|
|
189
|
+
const mates = ctx.scene.assembly.mates.filter((m) => (m.a.path.join("/") === body.path.join("/") && m.a.iface.id === iface.id) || (m.b.path.join("/") === body.path.join("/") && m.b.iface.id === iface.id));
|
|
190
|
+
const other = mates.map((m) => {
|
|
191
|
+
const o = m.a.path.join("/") === body.path.join("/") && m.a.iface.id === iface.id ? m.b : m.a;
|
|
192
|
+
return `${esc(o.def.name)}${m.harness ? ` <span class="muted">via ${esc(m.harness)}</span>` : ""}`;
|
|
193
|
+
});
|
|
194
|
+
const g = interfaceGeometry(body.def, iface.id);
|
|
195
|
+
const kinds = [...new Set(g.refs.map((r) => r.ref.kind))].map((k) => ({ feature: "D1", artifact: "D2", procedural: "D3" })[k]).join(" ");
|
|
196
|
+
return [`<span class="co-inline">${i + 1}</span>`, `<b>${esc(iface.name)}</b><br><span class="muted small">${esc(body.path.join("/"))}.${esc(iface.id)}</span>`, pattern, doc.v(q("fastener_diameter"), "u=mm"), [...new Set(other)].join("<br>"), `<span class="tag">${kinds || "none"}</span>`];
|
|
197
|
+
});
|
|
198
|
+
out.push(`<div class="block keep" data-break="before">${sub("Interface geometry")}${figure({ src: env.img(ifFig.file), w: ifFig.width, h: ifFig.height, overlay: ifOverlay, num: figNum(env), caption: "Structural interfaces of the frame and top plate (accent), from each interface's geometry frame and CAD feature binding." })}</div>`);
|
|
199
|
+
out.push(table([{ h: "#" }, { h: "Interface" }, { h: "Pattern" }, { h: "Fastener", cls: "num" }, { h: "Mates with" }, { h: "Binding" }], ifRows, { foot: "Binding: D1 named CAD feature, D2 per-interface artifact, D3 procedural from parameters (docs/geometry-artifacts.md)." }));
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------- 04 interfaces and pinout
|
|
202
|
+
out.push(sec("04", "Interfaces and pinout", "", ' data-break="before"'));
|
|
203
|
+
const hub = ds.hub;
|
|
204
|
+
const hubRows: string[][] = [];
|
|
205
|
+
const peerParams = ["baud_rate", "clock_freq", "i2c_address", "voltage", "max_current"];
|
|
206
|
+
for (const s of ctx.wiring) {
|
|
207
|
+
if (s.domain !== "electrical") continue;
|
|
208
|
+
const ea = parseEnd(s.a);
|
|
209
|
+
const eb = parseEnd(s.b);
|
|
210
|
+
const la = endOf(ctx.sys.system, ctx.sys.lookup, ea.path, ea.iface, s.name);
|
|
211
|
+
const lb = endOf(ctx.sys.system, ctx.sys.lookup, eb.path, eb.iface, s.name);
|
|
212
|
+
if (!la || !lb) continue;
|
|
213
|
+
// the hub itself or a part inside it (a board's export resolves to the component behind it)
|
|
214
|
+
const inHub = (path: string[]) => !!hub && (path.join("/") === hub || path.join("/").startsWith(hub + "/"));
|
|
215
|
+
const isHubA = inHub(la.path);
|
|
216
|
+
const isHubB = inHub(lb.path);
|
|
217
|
+
if (!isHubA && !isHubB) continue;
|
|
218
|
+
const [h, p] = isHubA ? [la, lb] : [lb, la];
|
|
219
|
+
const pads = s.connections.length
|
|
220
|
+
? s.connections.map((c) => {
|
|
221
|
+
const [hc, pc] = isHubA ? [c.from, c.to] : [c.to, c.from];
|
|
222
|
+
return `${esc(parseEnd(hc).pad)} → ${esc(parseEnd(pc).pad)}${c.crossover ? ' <span class="x-flag">×</span>' : ""}`;
|
|
223
|
+
}).join("<br>")
|
|
224
|
+
: `${esc(h.iface.pin ?? h.iface.id)} → ${esc(p.iface.pin ?? p.iface.id)}`;
|
|
225
|
+
const params = peerParams.flatMap((pid) => {
|
|
226
|
+
for (const [end, endPath] of [[p, p.path.join("/")], [h, h.path.join("/")]] as const) {
|
|
227
|
+
if (end.iface.parameters?.some((x) => x.id === pid)) {
|
|
228
|
+
const base = ctx.instances.has(endPath) ? `inst:${endPath}` : `def:${end.def.id}`;
|
|
229
|
+
return [`${esc(pid.replace(/_/g, " "))} ${doc.v(`${base}:interfaces[id=${end.iface.id}].parameters[id=${pid}].value`, pid === "i2c_address" ? "hex" : pid === "baud_rate" ? "u=Bd" : pid === "clock_freq" ? "si" : undefined)}`];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
});
|
|
234
|
+
hubRows.push([`<b>${esc(h.iface.name)}</b>`, pads, `${esc(p.def.name)}<br><span class="muted small">${esc(p.iface.name)}</span>`, `<span class="tag">${esc(s.protocol)}</span>`, params.join("<br>") || '<span class="muted">—</span>', `<span class="tag ${s.state === "configured" ? "ok" : "err"}">${esc(s.state)}</span>`]);
|
|
235
|
+
}
|
|
236
|
+
const hubDef = hub ? ctx.instances.get(hub) : undefined;
|
|
237
|
+
out.push(`<p class="prose">Allocation of the ${esc(hubDef?.name ?? "hub")} (<code>${esc(hub ?? "")}</code>) interfaces, pad to pad, from the stored links. Crossovers (TX→RX) are marked <span class="x-flag">×</span>. The link state is the result of pairwise DRC.</p>`);
|
|
238
|
+
out.push(table([{ h: "Hub interface" }, { h: "Pads (hub → peer)" }, { h: "Peer" }, { h: "Protocol" }, { h: "Parameters" }, { h: "State" }], hubRows, { split: true, caption: "Interface allocation" }));
|
|
239
|
+
// a board hub: its controller's pins, net by net (the board's own pinout; its edge is in the table above)
|
|
240
|
+
const pinRows = controllerPinout(env);
|
|
241
|
+
if (pinRows.length) {
|
|
242
|
+
out.push(`<p class="prose">Pin allocation of the ${esc(hubDef?.name ?? "board")}'s controller, from the board module's nets: every controller pin on a net other than ground, with what else is on that net. Passives are listed by their role.</p>`);
|
|
243
|
+
out.push(table([{ h: "Pad", cls: "num" }, { h: "Pin" }, { h: "Net" }, { h: "Also on the net" }], pinRows, { split: true, caption: "Controller pinout", cls: "dense" }));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// power rails: the system's, then each board's own rails (checked on the board module, as protoboard check does)
|
|
247
|
+
const budgets: { d: (typeof ctx.checks.diagnostics)[number]; base: string; prefix: string }[] = [
|
|
248
|
+
...ctx.checks.diagnostics.filter((d) => d.rule === "supply_budget").map((d) => ({ d, base: `sys:checks.diagnostics[id=${d.id}]`, prefix: "" })),
|
|
249
|
+
...ctx.boards.flatMap((b) => b.diagnostics.filter((d) => d.rule === "supply_budget").map((d) => ({ d, base: `sys:boards[path=${b.path}].diagnostics[id=${d.id}]`, prefix: b.path }))),
|
|
250
|
+
];
|
|
251
|
+
const full = (prefix: string, ref: string) => (prefix ? `${prefix}/${ref}` : ref);
|
|
252
|
+
const railRows = budgets.map(({ d, base, prefix }) => {
|
|
253
|
+
const q = (k: string) => `${base}.details.${k}`;
|
|
254
|
+
const det = (d.details ?? {}) as any;
|
|
255
|
+
const [path, iface] = full(prefix, d.refs[0]).split(":");
|
|
256
|
+
const leaf = leafOf(ctx.sys.system, ctx.sys.lookup, path, iface);
|
|
257
|
+
const loads = d.refs.slice(1).filter((r) => !r.startsWith("link:")).map((r) => full(prefix, r));
|
|
258
|
+
const net = prefix ? boardNetOf(env, prefix, d.refs[0]) : undefined;
|
|
259
|
+
return [
|
|
260
|
+
`<b>${esc(net ? `${net} · ${leaf?.iface.name ?? d.refs[0]}` : (leaf?.iface.name ?? d.refs[0]))}</b><br><span class="muted small">${esc(full(prefix, d.refs[0]))}</span>`,
|
|
261
|
+
leaf ? doc.v(`def:${leaf.def.id}:interfaces[id=${leaf.iface.id}].parameters[id=voltage].value`, "d1") : "—",
|
|
262
|
+
det.capacityW !== undefined
|
|
263
|
+
? doc.v(q("capacityW"), "u=W")
|
|
264
|
+
: det.suppliedFrom
|
|
265
|
+
? `from ${doc.v(q("suppliedFrom"))}${det.assumption ? ' <sup class="mk mk-assumption">A</sup>' : ""}`
|
|
266
|
+
: '<span class="v st-gap">—</span><sup class="mk mk-gap">—</sup>',
|
|
267
|
+
det.loadW !== undefined ? doc.v(q("loadW"), "u=W|s3") : det.suppliedFrom ? '<span class="muted">in parent</span>' : '<span class="v st-gap">—</span>',
|
|
268
|
+
esc(loads.join(", ")),
|
|
269
|
+
esc((det.unknownLoads ?? []).map((r: string) => full(prefix, r)).join(", ") || (det.suppliedFrom ? `budgeted on ${det.suppliedFrom}${det.assumption ? " (assumed branch)" : ""}` : det.capacityW === undefined ? "source has no current rating" : "—")),
|
|
270
|
+
];
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------- 05 electrical
|
|
274
|
+
out.push(sec("05", "Electrical characteristics", "", ' data-break="before"'));
|
|
275
|
+
const meters = meterChart({
|
|
276
|
+
width: 330,
|
|
277
|
+
status: "model",
|
|
278
|
+
rows: budgets
|
|
279
|
+
.filter(({ d }) => (d.details as any)?.capacityW !== undefined)
|
|
280
|
+
.map(({ d, prefix }) => ({ label: prefix ? `${boardNetOf(env, prefix, d.refs[0]) ?? railName(env, full(prefix, d.refs[0]))}` : railName(env, d.refs[0]), value: (d.details as any).loadW, capacity: (d.details as any).capacityW, unit: "W", note: ((d.details as any).unknownLoads ?? []).length ? `+ ${((d.details as any).unknownLoads as string[]).length} load(s) with no stated draw` : undefined })),
|
|
281
|
+
});
|
|
282
|
+
const pc = ctx.checks.diagnostics.find((d) => d.rule === "propulsion_current");
|
|
283
|
+
const pcd = (pc?.details ?? {}) as any;
|
|
284
|
+
const propChart = pc
|
|
285
|
+
? barChart({
|
|
286
|
+
width: 330,
|
|
287
|
+
height: 180,
|
|
288
|
+
status: "model",
|
|
289
|
+
categories: ["Motors, peak (sum)"],
|
|
290
|
+
series: [{ label: "Peak", values: [pcd.totalPeak], color: ACCENT }],
|
|
291
|
+
y: { label: "Current", unit: "A" },
|
|
292
|
+
refs: [
|
|
293
|
+
...(pcd.cont !== undefined ? [{ value: pcd.cont, label: `battery continuous ${pcd.cont} A` }] : []),
|
|
294
|
+
...(pcd.burst !== undefined ? [{ value: pcd.burst, label: `battery burst ${pcd.burst} A` }] : []),
|
|
295
|
+
],
|
|
296
|
+
valueLabels: true,
|
|
297
|
+
decimals: 1,
|
|
298
|
+
})
|
|
299
|
+
: "";
|
|
300
|
+
const meterRows = budgets.filter(({ d }) => (d.details as any)?.capacityW !== undefined);
|
|
301
|
+
const elCharts = [
|
|
302
|
+
...(meterRows.length ? [`<div class="chartbox"><h3>${pc ? "BEC and rail" : "Rail"} utilisation (known loads)</h3>${meters}<div class="cap"><b>Figure ${figNum(env)}</b><span>checkSystem supply_budget.</span></div></div>`] : []),
|
|
303
|
+
...(pc ? [`<div class="chartbox"><h3>Propulsion peak current vs battery ratings</h3>${propChart}<div class="cap"><b>Figure ${figNum(env)}</b><span>${esc(pc.message)}</span></div></div>`] : []),
|
|
304
|
+
];
|
|
305
|
+
if (elCharts.length) out.push(`<div class="block"><div class="cols">${elCharts.join("")}${elCharts.length < 2 ? "<div></div>" : ""}</div></div>`);
|
|
306
|
+
out.push(`<div class="block keep">${sub("Power rails")}${table([{ h: "Rail" }, { h: "Voltage", cls: "num" }, { h: "Capacity", cls: "num" }, { h: "Known load", cls: "num" }, { h: "Loads" }, { h: "Unbudgeted" }], railRows, { foot: "From checkSystem rule supply_budget: capacity = rail voltage × current rating; loads that state no draw cannot be budgeted. A branch rail (supplied_from) is budgeted on the output that feeds it; A marks a relation assumed, not cited." })}</div>`);
|
|
307
|
+
// operating ranges of power inputs
|
|
308
|
+
const opRows: string[][] = [];
|
|
309
|
+
for (const inst of ctx.scene.assembly.instances.filter((i) => i.kind === "module")) {
|
|
310
|
+
for (const iface of inst.def.interfaces) {
|
|
311
|
+
if (!iface.protocols?.some((p) => p.type === "power") || !iface.parameters?.some((p) => p.id === "voltage")) continue;
|
|
312
|
+
const roles = iface.protocols.flatMap((p) => p.roles ?? []);
|
|
313
|
+
if (!roles.some((r) => /sink|input|consumer/.test(r)) && !/in$|_in|vcc|vin/.test(iface.id)) continue;
|
|
314
|
+
opRows.push([esc(inst.def.name), `${esc(iface.name)} <span class="muted small">${esc(iface.pin ?? "")}</span>`, doc.v(`def:${inst.def.id}:interfaces[id=${iface.id}].parameters[id=voltage].value`, "d1"), iface.parameters.some((p) => p.id === "min_supply_power") ? doc.v(`def:${inst.def.id}:interfaces[id=${iface.id}].parameters[id=min_supply_power].value`) : '<span class="muted">—</span>']);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
out.push(`<div class="block">${sub("Supply inputs")}${table([{ h: "Module" }, { h: "Input" }, { h: "Voltage range", cls: "num" }, { h: "Min. supply power", cls: "num" }], opRows, { cls: "dense" })}</div>`);
|
|
318
|
+
const linkStates = ctx.checks.links;
|
|
319
|
+
out.push(`<p class="small muted">Stored links: ${linkStates.length}; configured: ${linkStates.filter((l) => l.state === "configured").length}. Diagnostics on the nominal build: ${ctx.checks.diagnostics.map((d) => `${d.severity} ${d.rule}`).join(", ")}.</p>`);
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------- 06 performance
|
|
322
|
+
out.push(sec("06", "Performance", "test data", ' data-break="before"'));
|
|
323
|
+
const perfStart = out.length;
|
|
324
|
+
const motor = ctx.scene.assembly.instances.find((i) => category(i.def) === "motor")?.def;
|
|
325
|
+
const tests = motor ? thrustTests(motor) : [];
|
|
326
|
+
const mq = (i: number, k: string) => `def:${motor!.id}:traits[type=performance].params.thrust_tests[${i}].${k}`;
|
|
327
|
+
const fitted = ctx.scene.assembly.instances.find((i) => category(i.def) === "prop")?.def.name ?? "";
|
|
328
|
+
if (motor && tests.length) {
|
|
329
|
+
// the maker's full table: every printed row of every test prop, side by side by throttle
|
|
330
|
+
const throttles = [...new Set(tests.flatMap((t) => t.rows.map((r) => r.throttle_pct)))].sort((a, b) => a - b);
|
|
331
|
+
const cols = [{ h: "Throttle", cls: "num" }, ...tests.flatMap((t) => [
|
|
332
|
+
{ h: `${t.propeller.replace(/^MEPS /, "")} V`, cls: "num" },
|
|
333
|
+
{ h: "A", cls: "num" },
|
|
334
|
+
{ h: "rpm", cls: "num" },
|
|
335
|
+
{ h: "g", cls: "num" },
|
|
336
|
+
{ h: "W", cls: "num" },
|
|
337
|
+
])];
|
|
338
|
+
const rowq = (i: number, pct: number, k: string) => mq(i, `rows[throttle_pct=${pct}].${k}`);
|
|
339
|
+
const rows = throttles.map((pct) => [
|
|
340
|
+
`${pct} %`,
|
|
341
|
+
...tests.flatMap((t, i) =>
|
|
342
|
+
t.rows.some((r) => r.throttle_pct === pct)
|
|
343
|
+
? [doc.v(rowq(i, pct, "voltage_V"), "d1|nounit"), doc.v(rowq(i, pct, "current_A"), "d1|nounit"), doc.v(rowq(i, pct, "rpm"), "nounit"), doc.v(rowq(i, pct, "thrust_g"), "nounit"), doc.v(rowq(i, pct, "power_W"), "nounit")]
|
|
344
|
+
: ["—", "—", "—", "—", "—"],
|
|
345
|
+
),
|
|
346
|
+
]);
|
|
347
|
+
const heads = tests.map((_, i) => `${doc.v(mq(i, "propeller"))} at ${doc.v(mq(i, "supply_V"), "d1")}`).join("; ");
|
|
348
|
+
out.push(`<div class="block keep">${sub("Motor thrust tables (manufacturer)")}${table(cols, rows, {
|
|
349
|
+
cls: "dense",
|
|
350
|
+
foot: `${esc(motor.name)}: every row of the maker's KV table, per test propeller (${heads}); columns per propeller are measured supply voltage, current, speed, thrust and electrical power. The fitted propeller (${esc(fitted)}) is not one the maker tested, so these figures, and every figure derived from them below, are for the maker's test props.`,
|
|
351
|
+
})}</div>`);
|
|
352
|
+
}
|
|
353
|
+
const sweep = testOf(env, "throttle-sweep");
|
|
354
|
+
const hover = testOf(env, "hover-current");
|
|
355
|
+
const flight = testOf(env, "flight-time");
|
|
356
|
+
const twr = testOf(env, "thrust-to-weight");
|
|
357
|
+
const escT = testOf(env, "esc-temp-punchout");
|
|
358
|
+
const dis = testOf(env, "battery-discharge");
|
|
359
|
+
// test data the multirotor recipe does not draw (any other product's files): one block each,
|
|
360
|
+
// its rows as a table, its last numeric column as a bar chart, its summary bound as values
|
|
361
|
+
const KNOWN_TESTS = new Set(["throttle-sweep", "hover-current", "flight-time", "thrust-to-weight", "esc-temp-punchout", "battery-discharge"]);
|
|
362
|
+
const others = [...ctx.tests.values()].filter((t) => !KNOWN_TESTS.has(t.id)).sort((a, b) => a.id.localeCompare(b.id));
|
|
363
|
+
const anyStandin = [sweep, hover, flight, twr, escT, dis, ...others].some((t) => t?.status === "standin");
|
|
364
|
+
if (anyStandin) {
|
|
365
|
+
out.push(note("caution", `Charts and figures tagged <span class="tag warn">stand-in · not measured</span> are derived from the model to show the expected shape. Replace them by dropping measured files into <code>${esc(env.docRel)}/test-data/</code> (status <code>measured</code>); the next build swaps them in.`));
|
|
366
|
+
}
|
|
367
|
+
const charts: string[] = [];
|
|
368
|
+
if (sweep && motor && tests.length) {
|
|
369
|
+
const thr = column(sweep, "throttle");
|
|
370
|
+
const tpm = column(sweep, "thrust_motor");
|
|
371
|
+
const ipm = column(sweep, "current_motor");
|
|
372
|
+
charts.push(`<div class="chartbox"><h3>Thrust and current per motor vs throttle ${testTag(sweep)}</h3>${lineChart({
|
|
373
|
+
status: sweep.status,
|
|
374
|
+
testIds: [sweep.id],
|
|
375
|
+
x: { label: "Throttle", unit: "%", min: 0, max: 100 },
|
|
376
|
+
y: { label: "Thrust", unit: "g", min: 0 },
|
|
377
|
+
y2: { label: "Current", unit: "A", min: 0 },
|
|
378
|
+
legend: true,
|
|
379
|
+
series: [
|
|
380
|
+
{ label: "thrust", points: thr.map((t, i) => [t, tpm[i]]) },
|
|
381
|
+
{ label: "current", points: thr.map((t, i) => [t, ipm[i]]), axis: "y2", color: "#1b1b1b", dashed: true },
|
|
382
|
+
{ label: "maker's rows", points: tests[0].rows.map((r) => [r.throttle_pct, r.thrust_g]), markersOnly: true, color: ACCENT },
|
|
383
|
+
{ label: "", points: tests[0].rows.map((r) => [r.throttle_pct, r.current_A]), markersOnly: true, axis: "y2", color: "#1b1b1b", endLabel: false },
|
|
384
|
+
],
|
|
385
|
+
})}<div class="cap"><b>Figure ${figNum(env)}</b><span>Circles: every row of the maker's table (${esc(tests[0].propeller)} at ${tests[0].supply_V} V, not the fitted prop). Lines: ${esc(sweep.provenance.method)}</span></div></div>`);
|
|
386
|
+
}
|
|
387
|
+
if (flight) {
|
|
388
|
+
const names = flight.rows.map((r) => String(r[0]));
|
|
389
|
+
charts.push(`<div class="chartbox"><h3>Flight time by regime ${testTag(flight)}</h3>${barChart({
|
|
390
|
+
status: flight.status,
|
|
391
|
+
testIds: [flight.id],
|
|
392
|
+
categories: names,
|
|
393
|
+
series: [{ label: "Flight time (min)", values: column(flight, "minutes"), color: ACCENT }],
|
|
394
|
+
y: { label: "Flight time", unit: "min" },
|
|
395
|
+
valueLabels: true,
|
|
396
|
+
decimals: 1,
|
|
397
|
+
})}<div class="cap"><b>Figure ${figNum(env)}</b><span>${esc(flight.provenance.method)}</span></div></div>`);
|
|
398
|
+
}
|
|
399
|
+
if (dis) {
|
|
400
|
+
const used = column(dis, "used");
|
|
401
|
+
charts.push(`<div class="chartbox"><h3>Battery discharge ${testTag(dis)}</h3>${lineChart({
|
|
402
|
+
status: dis.status,
|
|
403
|
+
testIds: [dis.id],
|
|
404
|
+
x: { label: "Capacity used", unit: "mAh", min: 0 },
|
|
405
|
+
legend: true,
|
|
406
|
+
y: { label: "Pack voltage", unit: "V", min: Math.floor(Math.min(...column(dis, "v_mix")) - 1), max: Math.ceil(Math.max(...column(dis, "v_rest")) + 0.5) },
|
|
407
|
+
series: [
|
|
408
|
+
{ label: "open circuit", points: used.map((u, i) => [u, column(dis, "v_rest")[i]]), color: "#8c8c8c" },
|
|
409
|
+
{ label: "hover", points: used.map((u, i) => [u, column(dis, "v_hover")[i]]), color: "#1b1b1b" },
|
|
410
|
+
{ label: "freestyle avg", points: used.map((u, i) => [u, column(dis, "v_mix")[i]]), color: ACCENT },
|
|
411
|
+
],
|
|
412
|
+
})}<div class="cap"><b>Figure ${figNum(env)}</b><span>${esc(dis.provenance.method)}</span></div></div>`);
|
|
413
|
+
}
|
|
414
|
+
if (escT) {
|
|
415
|
+
const t = column(escT, "time");
|
|
416
|
+
charts.push(`<div class="chartbox"><h3>ESC temperature over a punch-out ${testTag(escT)}</h3>${lineChart({
|
|
417
|
+
status: escT.status,
|
|
418
|
+
testIds: [escT.id],
|
|
419
|
+
x: { label: "Time", unit: "s", min: 0 },
|
|
420
|
+
y: { label: "Temperature", unit: "°C", min: Math.floor(Math.min(...column(escT, "temperature")) / 5) * 5 - 5 },
|
|
421
|
+
y2: { label: "Throttle", unit: "%", min: 0, max: 100 },
|
|
422
|
+
legend: true,
|
|
423
|
+
series: [
|
|
424
|
+
{ label: "ESC board", points: t.map((x, i) => [x, column(escT, "temperature")[i]]) },
|
|
425
|
+
{ label: "throttle", points: t.map((x, i) => [x, column(escT, "throttle")[i]]), axis: "y2", color: "#9a9a9a", dashed: true },
|
|
426
|
+
],
|
|
427
|
+
})}<div class="cap"><b>Figure ${figNum(env)}</b><span>${esc(escT.provenance.method)}</span></div></div>`);
|
|
428
|
+
}
|
|
429
|
+
for (let i = 0; i < charts.length; i += 2) out.push(`<div class="block keep"><div class="cols">${charts[i]}${charts[i + 1] ?? "<div></div>"}</div></div>`);
|
|
430
|
+
|
|
431
|
+
for (const t of others) {
|
|
432
|
+
const numeric = t.columns.map((_, j) => t.rows.length > 0 && t.rows.every((r) => typeof r[j] === "number"));
|
|
433
|
+
const last = numeric.lastIndexOf(true);
|
|
434
|
+
const keyed = last > 0 && !numeric[0];
|
|
435
|
+
const cell = (i: number, j: number) => doc.v(`test:${t.id}:rows[${i}][${j}]`, numeric[j] ? "nounit" : undefined);
|
|
436
|
+
const tableCols = [...(keyed ? [{ h: "#" }] : []), ...t.columns.map((c, j) => ({ h: c.unit ? `${esc(c.label)} (${esc(c.unit)})` : esc(c.label), cls: numeric[j] ? "num" : undefined }))];
|
|
437
|
+
const tableRows = t.rows.map((r, i) => [...(keyed ? [`P${i + 1}`] : []), ...r.map((_, j) => cell(i, j))]);
|
|
438
|
+
const cond = t.conditions.length ? ` Conditions: ${t.conditions.map((c) => `${esc(c.name)} ${esc(String(c.value))}${c.unit ? "\u00a0" + esc(c.unit) : ""}`).join("; ")}.` : "";
|
|
439
|
+
out.push(`<div class="block keep">${sub(t.title)}<p class="small">${testTag(t)}</p>${table(tableCols, tableRows, { cls: "dense", foot: `${esc(t.provenance.method)}${cond}` })}</div>`);
|
|
440
|
+
const sum = t.summary.map((x) => [esc(x.label), doc.v(`test:${t.id}:summary[id=${x.id}].value`), esc(t.id)]);
|
|
441
|
+
const sumTable = sum.length ? `${sub("Performance summary")}${table([{ h: "Figure" }, { h: "Value", cls: "num" }, { h: "Test data" }], sum, { cls: "dense" })}` : "";
|
|
442
|
+
if (keyed) {
|
|
443
|
+
const col = t.columns[last];
|
|
444
|
+
const chart = `<div class="chartbox"><h3>${esc(col.label)} by row ${testTag(t)}</h3>${barChart({
|
|
445
|
+
status: t.status,
|
|
446
|
+
testIds: [t.id],
|
|
447
|
+
categories: t.rows.map((_, i) => `P${i + 1}`),
|
|
448
|
+
series: [{ label: col.label, values: column(t, col.id), color: ACCENT }],
|
|
449
|
+
y: { label: col.label, unit: col.unit },
|
|
450
|
+
valueLabels: true,
|
|
451
|
+
decimals: 0,
|
|
452
|
+
})}<div class="cap"><b>Figure ${figNum(env)}</b><span>${esc(t.title)}; P1-P${t.rows.length} are the table's rows.</span></div></div>`;
|
|
453
|
+
out.push(`<div class="block keep"><div class="cols">${chart}<div>${sumTable}</div></div></div>`);
|
|
454
|
+
} else if (sumTable) out.push(`<div class="block keep">${sumTable}</div>`);
|
|
455
|
+
if (t.status === "standin" && t.provenance.assumptions?.length) out.push(`<div class="block keep">${sub("Stand-in assumptions")}<ul class="small tight">${t.provenance.assumptions.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (hover || twr || sweep) {
|
|
459
|
+
const rows: string[][] = [];
|
|
460
|
+
if (hover) {
|
|
461
|
+
for (const s of hover.summary) rows.push([esc(s.label), doc.v(`test:${hover.id}:summary[id=${s.id}].value`, s.unit === "A" ? "d1" : undefined), esc(hover.id)]);
|
|
462
|
+
}
|
|
463
|
+
if (sweep) for (const s of sweep.summary) rows.push([esc(s.label), doc.v(`test:${sweep.id}:summary[id=${s.id}].value`, s.unit === "g" ? "si|d2" : "d1"), esc(sweep.id)]);
|
|
464
|
+
if (twr) {
|
|
465
|
+
for (let i = 0; i < twr.rows.length; i++) rows.push([`Thrust-to-weight at ${doc.v(`test:${twr.id}:rows[${i}][0]`, "d1")}`, doc.v(`test:${twr.id}:rows[${i}][2]`, "d1|nounit") + " : 1", esc(twr.id)]);
|
|
466
|
+
}
|
|
467
|
+
if (flight) for (const s of flight.summary) rows.push([`Flight time, ${esc(s.label.toLowerCase())}`, doc.v(`test:${flight.id}:summary[id=${s.id}].value`, "d1"), esc(flight.id)]);
|
|
468
|
+
if (escT) for (const s of escT.summary) rows.push([`ESC ${esc(s.label.toLowerCase())}`, doc.v(`test:${escT.id}:summary[id=${s.id}].value`, "d1"), esc(escT.id)]);
|
|
469
|
+
out.push(`<div class="block keep">${sub("Performance summary")}${table([{ h: "Figure" }, { h: "Value", cls: "num" }, { h: "Test data" }], rows, { cls: "dense" })}</div>`);
|
|
470
|
+
const assumptions = [...new Set([sweep, hover, flight, twr, escT, dis].flatMap((t) => (t?.status === "standin" ? t.provenance.assumptions ?? [] : [])))];
|
|
471
|
+
if (assumptions.length) out.push(`<div class="block keep">${sub("Stand-in assumptions")}<ul class="small tight">${assumptions.map((a) => `<li>${esc(a)}</li>`).join("")}</ul></div>`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// figures the doc spec names (model queries): a system that is not a multirotor states its own (battery life)
|
|
475
|
+
const perf = ds.performance;
|
|
476
|
+
if (perf?.charts?.length) {
|
|
477
|
+
const boxes = perf.charts.map((c) => {
|
|
478
|
+
const vals = c.items.map((it) => Number(ctx.resolve(it.q).value));
|
|
479
|
+
const refs = (c.refs ?? []).map((r) => ({ value: Number(ctx.resolve(r.q).value), label: r.label }));
|
|
480
|
+
const status = c.items.some((it) => ctx.resolve(it.q).status === "standin") ? "standin" : "model";
|
|
481
|
+
return `<div class="chartbox"><h3>${esc(c.title)}</h3>${barChart({ status, categories: c.items.map((it) => it.label), series: [{ label: c.y, values: vals, color: ACCENT }], y: { label: c.y, unit: c.unit }, refs, valueLabels: true, decimals: c.decimals ?? 1 })}<div class="cap"><b>Figure ${figNum(env)}</b><span>${esc(c.caption ?? "")}</span></div></div>`;
|
|
482
|
+
});
|
|
483
|
+
for (let i = 0; i < boxes.length; i += 2) out.push(`<div class="block keep"><div class="cols">${boxes[i]}${boxes[i + 1] ?? "<div></div>"}</div></div>`);
|
|
484
|
+
}
|
|
485
|
+
if (perf?.rows?.length) {
|
|
486
|
+
out.push(`<div class="block keep">${sub(perf.title ?? "Performance summary")}${table([{ h: "Figure" }, { h: "Value", cls: "num" }, { h: "Basis" }], perf.rows.map((r) => [esc(r.label), doc.v(r.q, r.f), esc(r.sub ?? "")]), { cls: "dense", ...(perf.note ? { foot: perf.note } : {}) })}</div>`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (out.length === perfStart)
|
|
490
|
+
out.push(`<p class="prose">No performance test data for this system yet: no part states a performance table, and <code>${esc(ctx.sys.docsDir)}/test-data/</code> has no measured files. Measured results placed there appear here on the next build.</p>`);
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------- 07 design checks
|
|
493
|
+
out.push(sec("07", "Design checks", "checkSystem", ' data-break="before"'));
|
|
494
|
+
out.push(`<p class="prose">The nominal build and each fault scenario in <code>scenarios.ts</code> run through <code>checkSystem</code> (the system and each circuit board in it). A fault scenario is expected to raise at least one error or warning.</p>`);
|
|
495
|
+
const scenRows = ctx.scenarioChecks.map((s) => {
|
|
496
|
+
const top = s.diagnostics.filter((d) => d.severity !== "info").slice(0, 3);
|
|
497
|
+
return [
|
|
498
|
+
`<b>${esc(s.label)}</b><br><span class="muted small">${esc(s.id)}</span>`,
|
|
499
|
+
`<span class="tag err">${doc.v(`derived:diagCount(${s.id},error)`, undefined, { marker: false })} err</span> <span class="tag warn">${doc.v(`derived:diagCount(${s.id},warning)`, undefined, { marker: false })} warn</span> <span class="tag info">${doc.v(`derived:diagCount(${s.id},info)`, undefined, { marker: false })} info</span>`,
|
|
500
|
+
top.length ? top.map((d) => `<span class="tag ${d.severity === "error" ? "err" : "warn"}">${esc(d.rule)}</span> ${esc(d.message)}`).join("<br>") : '<span class="muted">no errors or warnings</span>',
|
|
501
|
+
];
|
|
502
|
+
});
|
|
503
|
+
out.push(table([{ h: "Scenario" }, { h: "Diagnostics" }, { h: "Findings (errors and warnings)" }], scenRows, { split: true, caption: "Design checks" }));
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------- 08 BOM
|
|
506
|
+
out.push(sec("08", "Bill of materials", bomFile(ctx.sys), ' data-break="before"'));
|
|
507
|
+
const bomRows = ctx.bom.map((b: any) => {
|
|
508
|
+
const q = (k: string) => `sys:bom[line=${b.line}].${k}`;
|
|
509
|
+
const def = ctx.sys.lookup(b.partId);
|
|
510
|
+
const hasW = def?.domains?.some((d) => d.domain === "mechanical" && d.weight_g !== undefined);
|
|
511
|
+
const computed = def && !hasW ? moduleMass(def, cadVolume(ctx.sys)) : undefined;
|
|
512
|
+
return [
|
|
513
|
+
doc.v(q("line")),
|
|
514
|
+
`${doc.v(q("name"), undefined, { cls: "wrap" })}${b.value ? ` <span class="small">${doc.v(q("value"), undefined, { marker: false })}</span>` : ""}<br><span class="muted small">${esc(b.partId)}</span>`,
|
|
515
|
+
`${doc.v(q("manufacturer"), undefined, { cls: "wrap" })}${b.partNumber ? `<br><span class="muted small">${doc.v(q("partNumber"), undefined, { cls: "wrap" })}</span>` : ""}`,
|
|
516
|
+
doc.v(q("quantity")) + (b.packQuantity ? `<br><span class="muted small">pack of ${doc.v(q("packQuantity"))}</span>` : ""),
|
|
517
|
+
b.buy === "included" ? `<span class="tag">incl.</span><br><span class="muted small">${esc(b.includedWith)}</span>` : `<span class="tag ${b.buy === "fabricate" ? "warn" : ""}">${esc(b.buy)}</span>`,
|
|
518
|
+
hasW ? doc.v(`def:${b.partId}:domains[domain=mechanical].weight_g`) : computed ? doc.v(`derived:mass.module(${b.partId})`) : '<span class="v st-gap">—</span>',
|
|
519
|
+
`${doc.v(q("verification"))}<br><span class="muted small">A ${doc.v(q("assumptions"), undefined, { marker: false })} · gaps ${doc.v(q("openGaps"), undefined, { marker: false })}</span>`,
|
|
520
|
+
];
|
|
521
|
+
});
|
|
522
|
+
out.push(`<div class="bom">${table([{ h: "#", cls: "num" }, { h: "Part" }, { h: "Manufacturer / P/N" }, { h: "Qty", cls: "num" }, { h: "Supply" }, { h: "Mass", cls: "num" }, { h: "Verification" }], bomRows, { split: true, caption: "Bill of materials", cls: "bomtable", foot: `Rows as written by the document build (${bomFile(ctx.sys)}). Mass per piece: the part's stated weight, else CAD volume × declared material density (derived); — = no mass in the model. A = assumption traits.` })}</div>`);
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------- 09 evidence
|
|
525
|
+
out.push(sec("09", "Evidence and assumptions", "", ' data-break="before"'));
|
|
526
|
+
const partIds = [...new Set(ctx.bom.map((b: any) => b.partId as string))];
|
|
527
|
+
const evRows = partIds.map((id) => {
|
|
528
|
+
const def = ctx.sys.lookup(id)!;
|
|
529
|
+
const v = moduleVerification(ctx.sys, id);
|
|
530
|
+
const srcs = moduleSources(ctx.sys, id);
|
|
531
|
+
const assumptions = (def.traits ?? []).filter((t) => t.type === "assumption");
|
|
532
|
+
const gaps: string[] = v?.data_gaps ?? (def.traits ?? []).filter((t) => t.type === "data_gap").flatMap((t) => (t.params?.fields as string[]) ?? []);
|
|
533
|
+
return [
|
|
534
|
+
`<b>${esc(def.name)}</b>`,
|
|
535
|
+
String(srcs.length || "—"),
|
|
536
|
+
v?.audit && typeof v.audit.checked === "number" ? `${doc.v(`ver:${id}:audit.confirmed`, undefined, { marker: false })}/${doc.v(`ver:${id}:audit.checked`, undefined, { marker: false })}` : v?.audit ? "audited" : '<span class="muted">not audited</span>',
|
|
537
|
+
assumptions.length ? assumptions.map((a) => `<span class="mk-inline">A</span> ${esc(String(a.params?.field ?? ""))}: ${esc(String(a.params?.value ?? a.params?.reason ?? "")).slice(0, 90)}`).join("<br>") : '<span class="muted">—</span>',
|
|
538
|
+
gaps.length ? esc(gaps.slice(0, 5).join("; ")) + (gaps.length > 5 ? ` <span class="muted">(+${gaps.length - 5})</span>` : "") : '<span class="muted">—</span>',
|
|
539
|
+
];
|
|
540
|
+
});
|
|
541
|
+
out.push(table([{ h: "Part" }, { h: "Sources", cls: "num" }, { h: "Audit" }, { h: "Assumptions" }, { h: "Open data gaps" }], evRows, { split: true, caption: "Evidence", cls: "evtable", foot: "Sources: sources.json entries. Audit: independent re-check of facts against the sources (verification.json)." }));
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------- 10 provenance (built last: it lists every value above)
|
|
544
|
+
return out.join("\n");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Provenance section: counts by status, then every value that is not a plain cited source value. */
|
|
548
|
+
export function provenanceSection(env: BuildEnv): string {
|
|
549
|
+
const { doc } = env;
|
|
550
|
+
const vals = [...doc.values.values()];
|
|
551
|
+
const count = (s: ValueStatus) => vals.filter((v) => v.status === s).length;
|
|
552
|
+
const statuses: ValueStatus[] = ["source", "model", "derived", "assumption", "standin", "measured", "gap"];
|
|
553
|
+
const flagged = vals.filter((v) => v.status !== "source" && v.status !== "model");
|
|
554
|
+
const seen = new Set<string>();
|
|
555
|
+
const rows = flagged
|
|
556
|
+
.filter((v) => (seen.has(v.q) ? false : (seen.add(v.q), true)))
|
|
557
|
+
.map((v) => [
|
|
558
|
+
`<sup class="mk mk-${v.status}">${MARKERS[v.status]?.mark ?? ""}</sup> ${esc(MARKERS[v.status]?.label ?? v.status)}`,
|
|
559
|
+
esc(v.text),
|
|
560
|
+
`<code class="q">${esc(v.q)}</code>`,
|
|
561
|
+
// allow breaks after "/" so long source URLs wrap inside the column
|
|
562
|
+
esc([v.formula, v.note, v.inputs?.length ? `inputs: ${v.inputs.length}` : "", v.source && v.status !== "derived" ? v.source : ""].filter(Boolean).join(" · ")).slice(0, 260).replace(/\//g, "/\u200b"),
|
|
563
|
+
]);
|
|
564
|
+
return `${sec("10", "Value provenance", "", ' data-break="before"')}
|
|
565
|
+
<p class="prose">This document shows ${vals.length} bound values. Each is a query into the UHD model, re-resolved and compared by <code>uhd-tech-docs-verify</code>; the full list with sources is in <code>${esc(env.doc.meta.docId.toLowerCase())}.values.json</code> next to the document.</p>
|
|
566
|
+
<div class="block">${table(
|
|
567
|
+
[{ h: "Status" }, { h: "Values", cls: "num" }, { h: "Meaning" }],
|
|
568
|
+
statuses.filter((s) => count(s)).map((s) => [esc(MARKERS[s]?.label ?? (s === "source" ? "Cited source" : "Model design value")), String(count(s)), esc(MARKERS[s]?.help ?? (s === "source" ? "stated by a source in the part's sources.json" : "a design value of a custom module, or a UHD check result"))]),
|
|
569
|
+
{ cls: "dense" },
|
|
570
|
+
)}</div>
|
|
571
|
+
${table([{ h: "Status" }, { h: "Value" }, { h: "Query" }, { h: "Derivation / reason" }], rows, { split: true, caption: "Flagged values", cls: "provtable dense" })}`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The net a board-relative output (`u7:vout`, a pin or a composite of pins) drives on the board at `boardPath`: its name. */
|
|
575
|
+
function boardNetOf(env: BuildEnv, boardPath: string, ref: string): string | undefined {
|
|
576
|
+
const board = env.ctx.instances.get(boardPath);
|
|
577
|
+
if (!board) return undefined;
|
|
578
|
+
const [child, iface] = ref.split(":");
|
|
579
|
+
const def = env.ctx.sys.lookup(board.children?.find((c) => c.id === child)?.moduleDefId ?? "");
|
|
580
|
+
const i = def?.interfaces.find((x) => x.id === iface);
|
|
581
|
+
if (!i) return undefined;
|
|
582
|
+
// the pins behind it: itself, or the composite's bound pins (its first power pin: the rail, not ground)
|
|
583
|
+
const pins = [iface, ...Object.entries(slotBindings(i)).filter(([slot]) => !/gnd|ground|neg/.test(slot)).map(([, leaf]) => leaf)];
|
|
584
|
+
for (const pin of pins) {
|
|
585
|
+
const l = (board.links ?? []).find((x) => "child" in x.a && x.a.child === child && x.a.interfaceId === pin && "self" in x.b);
|
|
586
|
+
const netId = l && "interfaceId" in l.b ? l.b.interfaceId : undefined;
|
|
587
|
+
const net = netId ? board.interfaces.find((x) => x.id === netId) : undefined;
|
|
588
|
+
if (net) return net.name ?? net.id;
|
|
589
|
+
}
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function railName(env: BuildEnv, ref: string): string {
|
|
594
|
+
const [path, iface] = ref.split(":");
|
|
595
|
+
const leaf = leafOf(env.ctx.sys.system, env.ctx.sys.lookup, path, iface);
|
|
596
|
+
return leaf ? `${leaf.iface.name} (${ref})` : ref;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Instance label along a path of child ids: the child refs' names ("Flight controller"). */
|
|
600
|
+
function instanceLabel(env: BuildEnv, path: string[]): string {
|
|
601
|
+
let owner = env.ctx.sys.system;
|
|
602
|
+
let name = path.join("/");
|
|
603
|
+
for (const id of path) {
|
|
604
|
+
const ref = owner.children?.find((c) => c.id === id);
|
|
605
|
+
if (!ref) break;
|
|
606
|
+
name = ref.name ?? ref.moduleDefId;
|
|
607
|
+
const d = env.ctx.sys.lookup(ref.moduleDefId);
|
|
608
|
+
if (!d) break;
|
|
609
|
+
owner = d;
|
|
610
|
+
}
|
|
611
|
+
return name;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Power and signal architecture drawn from the stored links around the hub module. */
|
|
615
|
+
function systemDiagram(env: BuildEnv): string {
|
|
616
|
+
const { ctx } = env;
|
|
617
|
+
const hub = ctx.sys.config.datasheet?.hub;
|
|
618
|
+
type Node = { key: string; def: ModuleDef; label: string; count: number };
|
|
619
|
+
const edges: { a: string; b: string; labels: string[]; power: boolean }[] = [];
|
|
620
|
+
const nodes = new Map<string, Node>();
|
|
621
|
+
const motorCount = ctx.scene.assembly.instances.filter((i) => category(i.def) === "motor").length;
|
|
622
|
+
for (const s of ctx.wiring) {
|
|
623
|
+
if (s.domain !== "electrical") continue;
|
|
624
|
+
const ea = parseEnd(s.a);
|
|
625
|
+
const eb = parseEnd(s.b);
|
|
626
|
+
const la = endOf(ctx.sys.system, ctx.sys.lookup, ea.path, ea.iface);
|
|
627
|
+
const lb = endOf(ctx.sys.system, ctx.sys.lookup, eb.path, eb.iface);
|
|
628
|
+
if (!la || !lb) continue;
|
|
629
|
+
const prefix = s.linkId.includes("/") ? s.linkId.split("/").slice(0, -1) : [];
|
|
630
|
+
// a leaf inside the hub (a board's pads, reached through its exports) is the hub
|
|
631
|
+
const hubDef = hub ? ctx.instances.get(hub) : undefined;
|
|
632
|
+
const toHub = (p: string[], d: ModuleDef): [string[], ModuleDef] => (hub && hubDef && p.join("/").startsWith(hub + "/") ? [hub.split("/"), hubDef] : [p, d]);
|
|
633
|
+
const [pa, da] = toHub([...prefix, ...la.path], la.def);
|
|
634
|
+
const [pb, db] = toHub([...prefix, ...lb.path], lb.def);
|
|
635
|
+
// identical instances (four arms) collapse into one node
|
|
636
|
+
const keyOf = (p: string[], d: ModuleDef) => (category(d) === "motor" ? `motor:${d.id}` : p.join("/"));
|
|
637
|
+
const ka = keyOf(pa, da);
|
|
638
|
+
const kb = keyOf(pb, db);
|
|
639
|
+
for (const [k, d, p] of [[ka, da, pa], [kb, db, pb]] as const) {
|
|
640
|
+
if (!nodes.has(k)) nodes.set(k, { key: k, def: d, label: category(d) === "motor" ? "Motors" : instanceLabel(env, [...p]), count: category(d) === "motor" ? motorCount : 1 });
|
|
641
|
+
}
|
|
642
|
+
const power = s.protocol === "power";
|
|
643
|
+
const hubEnd = ka === hub ? la : kb === hub ? lb : undefined;
|
|
644
|
+
const label: string = hubEnd ? (hubEnd.iface.pin && power ? String(hubEnd.iface.pin) : (hubEnd.iface.name ?? hubEnd.iface.id).replace(/ \(.*\)$/, "")) : s.protocol === "power" ? s.name.replace(/\s*[+−-]$/, "") : s.protocol.replace("bldc_3phase", "3-phase").replace("fc_esc_connector", "8-pin FC–ESC");
|
|
645
|
+
const ex = edges.find((e) => (e.a === ka && e.b === kb) || (e.a === kb && e.b === ka));
|
|
646
|
+
if (ex) {
|
|
647
|
+
if (!ex.labels.includes(label)) ex.labels.push(label);
|
|
648
|
+
ex.power = ex.power && power;
|
|
649
|
+
} else edges.push({ a: ka, b: kb, labels: [label], power });
|
|
650
|
+
}
|
|
651
|
+
if (!hub || !nodes.has(hub)) return "";
|
|
652
|
+
const nb = (k: string) => edges.filter((e) => e.a === k || e.b === k).map((e) => (e.a === k ? e.b : e.a));
|
|
653
|
+
const battery = [...nodes.values()].find((n) => category(n.def) === "battery")?.key;
|
|
654
|
+
const prev = new Map<string, string>();
|
|
655
|
+
const q = [hub];
|
|
656
|
+
const seen = new Set([hub]);
|
|
657
|
+
while (q.length) {
|
|
658
|
+
const k = q.shift()!;
|
|
659
|
+
for (const n of nb(k)) if (!seen.has(n)) (seen.add(n), prev.set(n, k), q.push(n));
|
|
660
|
+
}
|
|
661
|
+
const chain: string[] = []; // hub-adjacent first
|
|
662
|
+
for (let k = battery; k && k !== hub; k = prev.get(k)) chain.unshift(k);
|
|
663
|
+
chain.reverse();
|
|
664
|
+
const chainOrdered = [...chain].reverse(); // nearest the hub first
|
|
665
|
+
const right = nb(hub).filter((k) => !chain.includes(k));
|
|
666
|
+
const below = [...new Set(chain.flatMap((k) => nb(k)).filter((k) => k !== hub && !chain.includes(k) && !right.includes(k)))];
|
|
667
|
+
|
|
668
|
+
const boxW = 124;
|
|
669
|
+
const boxH = 40;
|
|
670
|
+
const colGap = 74;
|
|
671
|
+
const rowGap = 58;
|
|
672
|
+
const pos = new Map<string, { x: number; y: number }>();
|
|
673
|
+
const nChain = chain.length;
|
|
674
|
+
const hubX = nChain * (boxW + colGap);
|
|
675
|
+
const rightX = hubX + boxW + 104;
|
|
676
|
+
const hubY = 20 + Math.max(0, ((right.length - 1) * rowGap) / 2);
|
|
677
|
+
pos.set(hub, { x: hubX, y: hubY });
|
|
678
|
+
right.forEach((k, i) => pos.set(k, { x: rightX, y: 20 + i * rowGap }));
|
|
679
|
+
chainOrdered.forEach((k, i) => pos.set(k, { x: hubX - (i + 1) * (boxW + colGap), y: hubY }));
|
|
680
|
+
for (const c of chainOrdered) {
|
|
681
|
+
const kids = below.filter((b) => nb(c).includes(b) && !pos.has(b));
|
|
682
|
+
kids.forEach((k, j) => {
|
|
683
|
+
const p = pos.get(c)!;
|
|
684
|
+
pos.set(k, { x: p.x, y: p.y + boxH + 46 + j * rowGap });
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
// one more column: what the right-hand modules lead on to (a part behind its cable)
|
|
688
|
+
const rightX2 = rightX + boxW + colGap;
|
|
689
|
+
const beyond = right.flatMap((r) => nb(r).filter((k) => !pos.has(k)).map((k) => [r, k] as const));
|
|
690
|
+
beyond.forEach(([r, k], j) => {
|
|
691
|
+
if (pos.has(k)) return;
|
|
692
|
+
const y = pos.get(r)!.y;
|
|
693
|
+
pos.set(k, { x: rightX2, y: beyond.slice(0, j).some(([r2]) => r2 === r) ? y + j * rowGap : y });
|
|
694
|
+
});
|
|
695
|
+
const H = Math.max(...[...pos.values()].map((p) => p.y)) + boxH + 30;
|
|
696
|
+
const Wd = (beyond.length ? rightX2 : rightX) + boxW + 4;
|
|
697
|
+
const parts: string[] = [];
|
|
698
|
+
const label = (x: number, y: number, lines: string[], color: string, anchor = "middle") =>
|
|
699
|
+
lines.map((l, i) => `<text x="${x}" y="${y + i * 9}" font-size="7.4" text-anchor="${anchor}" fill="${color}">${esc(l)}</text>`).join("");
|
|
700
|
+
for (const e of edges) {
|
|
701
|
+
const a = pos.get(e.a);
|
|
702
|
+
const b = pos.get(e.b);
|
|
703
|
+
if (!a || !b) continue;
|
|
704
|
+
const col = e.power ? "#1b1b1b" : ACCENT;
|
|
705
|
+
const sw = e.power ? 1.6 : 1.2;
|
|
706
|
+
const [p, r] = a.x <= b.x ? [a, b] : [b, a];
|
|
707
|
+
if (Math.abs(p.x - r.x) < 1) {
|
|
708
|
+
// vertical (chain module to the part below it)
|
|
709
|
+
const [t, u] = p.y < r.y ? [p, r] : [r, p];
|
|
710
|
+
parts.push(`<path d="M${t.x + boxW / 2} ${t.y + boxH} V${u.y}" stroke="${col}" stroke-width="${sw}" fill="none"/>`);
|
|
711
|
+
parts.push(label(t.x + boxW / 2 + 6, (t.y + boxH + u.y) / 2 + 3, e.labels.slice(0, 2), e.power ? "#1b1b1b" : "#d94e00", "start"));
|
|
712
|
+
} else if (Math.abs(p.y - r.y) < 1) {
|
|
713
|
+
parts.push(`<path d="M${p.x + boxW} ${p.y + boxH / 2} H${r.x}" stroke="${col}" stroke-width="${sw}" fill="none"/>`);
|
|
714
|
+
parts.push(label((p.x + boxW + r.x) / 2, p.y + boxH / 2 - 5 - (e.labels.length - 1) * 9, e.labels.slice(0, 3), e.power ? "#1b1b1b" : "#d94e00"));
|
|
715
|
+
} else {
|
|
716
|
+
const mx = p.x + boxW + 40;
|
|
717
|
+
parts.push(`<path d="M${p.x + boxW} ${p.y + boxH / 2} H${mx} V${r.y + boxH / 2} H${r.x}" stroke="${col}" stroke-width="${sw}" fill="none"/>`);
|
|
718
|
+
parts.push(label(mx + 8, r.y + boxH / 2 - 4 - (Math.min(e.labels.length, 3) - 1) * 9, e.labels.slice(0, 3), e.power ? "#1b1b1b" : "#d94e00", "start"));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const [k, p] of pos) {
|
|
722
|
+
const n = nodes.get(k)!;
|
|
723
|
+
const isHub = k === hub;
|
|
724
|
+
const part = n.def.name.replace(/\s*\(.*?\)\s*/g, " ").trim();
|
|
725
|
+
parts.push(`<rect x="${p.x}" y="${p.y}" width="${boxW}" height="${boxH}" fill="${isHub ? "#1b1b1b" : "#fff"}" stroke="#1b1b1b" stroke-width="0.9"/>`);
|
|
726
|
+
if (isHub) parts.push(`<rect x="${p.x}" y="${p.y}" width="4" height="${boxH}" fill="${ACCENT}"/>`);
|
|
727
|
+
parts.push(`<text x="${p.x + 9}" y="${p.y + 17}" font-size="9" font-weight="600" fill="${isHub ? "#fff" : "#1b1b1b"}">${esc(n.label)}${n.count > 1 ? ` ×${n.count}` : ""}</text>`);
|
|
728
|
+
parts.push(`<text x="${p.x + 9}" y="${p.y + 31}" font-size="6.6" fill="${isHub ? "#bdbdbd" : "#6f6f6f"}">${esc(part.length > 27 ? part.slice(0, 26) + "…" : part)}</text>`);
|
|
729
|
+
}
|
|
730
|
+
const ly = H - 8;
|
|
731
|
+
parts.push(`<line x1="0" x2="22" y1="${ly}" y2="${ly}" stroke="#1b1b1b" stroke-width="1.6"/><text x="28" y="${ly + 2.6}" font-size="7" fill="#1b1b1b">power</text>`);
|
|
732
|
+
parts.push(`<line x1="78" x2="100" y1="${ly}" y2="${ly}" stroke="${ACCENT}" stroke-width="1.2"/><text x="106" y="${ly + 2.6}" font-size="7" fill="#1b1b1b">signal / data</text>`);
|
|
733
|
+
return `<svg class="sysdiag" viewBox="-2 0 ${Wd + 4} ${H}" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="inherit">${parts.join("")}</svg>`;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The hub board's controller pins on nets other than ground: pad, pin name, net, and the other members. */
|
|
737
|
+
function controllerPinout(env: BuildEnv): string[][] {
|
|
738
|
+
const { ctx, doc } = env;
|
|
739
|
+
const hub = ctx.sys.config.datasheet?.hub;
|
|
740
|
+
const board = hub ? ctx.instances.get(hub) : undefined;
|
|
741
|
+
if (!board) return [];
|
|
742
|
+
const nets = new Map(board.interfaces.filter((i) => i.protocols?.some((p) => p.type === "net")).map((i) => [i.id, i]));
|
|
743
|
+
if (!nets.size) return [];
|
|
744
|
+
const ctl = (board.children ?? []).find((c) => (ctx.sys.lookup(c.moduleDefId)?.categories ?? []).some((x) => x.startsWith("microcontroller")));
|
|
745
|
+
const ctlDef = ctl && ctx.sys.lookup(ctl.moduleDefId);
|
|
746
|
+
if (!ctl || !ctlDef) return [];
|
|
747
|
+
const members = new Map<string, { child: string; pin: string }[]>();
|
|
748
|
+
for (const l of board.links ?? []) {
|
|
749
|
+
if (!("child" in l.a) || !("self" in l.b) || !nets.has(l.b.interfaceId)) continue;
|
|
750
|
+
members.set(l.b.interfaceId, [...(members.get(l.b.interfaceId) ?? []), { child: l.a.child, pin: l.a.interfaceId }]);
|
|
751
|
+
}
|
|
752
|
+
const rows: { pad: number; row: string[] }[] = [];
|
|
753
|
+
for (const [netId, ms] of members) {
|
|
754
|
+
if (/^(gnd|ground)$/i.test(netId)) continue;
|
|
755
|
+
for (const m of ms.filter((x) => x.child === ctl.id)) {
|
|
756
|
+
const iface = ctlDef.interfaces.find((i) => i.id === m.pin);
|
|
757
|
+
const others = ms.filter((x) => x.child !== ctl.id).map((x) => {
|
|
758
|
+
const ref = board.children?.find((c) => c.id === x.child);
|
|
759
|
+
const d = ref && ctx.sys.lookup(ref.moduleDefId);
|
|
760
|
+
return { x, ref, passive: (d?.categories ?? []).some((c) => c.startsWith("component.passive")), pin: d?.interfaces.find((i) => i.id === x.pin) };
|
|
761
|
+
});
|
|
762
|
+
// parts by pin; passives (decoupling, pull-ups, dividers) by reference only, on one line
|
|
763
|
+
const parts = [...new Map(others.filter((o) => !o.passive).map((o) => [`${o.x.child}:${o.pin?.name ?? o.x.pin}`, o])).values()].map(
|
|
764
|
+
(o) => `${esc(`${o.x.child.toUpperCase()} ${o.pin?.name ?? o.x.pin}`)}${o.ref?.name ? ` <span class="muted small">(${esc(o.ref.name)})</span>` : ""}`,
|
|
765
|
+
);
|
|
766
|
+
const passives = [...new Set(others.filter((o) => o.passive).map((o) => o.x.child.toUpperCase()))];
|
|
767
|
+
if (passives.length) parts.push(`<span class="muted small">${esc(passives.join(", "))}</span>`);
|
|
768
|
+
rows.push({
|
|
769
|
+
pad: Number(iface?.pin ?? 0),
|
|
770
|
+
row: [String(iface?.pin ?? "—"), doc.v(`def:${ctlDef.id}:interfaces[id=${m.pin}].name`), `<b>${esc(nets.get(netId)?.name ?? netId)}</b>`, parts.join("<br>") || '<span class="muted">—</span>'],
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return rows.sort((a, b) => a.pad - b.pad).map((r) => r.row);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The hub board's own architecture: its components (passives left out) and the nets that join them to the
|
|
779
|
+
* board's controller: signal nets in the accent colour, supply rails in black. Components with no net to the
|
|
780
|
+
* controller hang off the component they share a rail with (USB-C to the charger). Empty when the hub has no nets.
|
|
781
|
+
*/
|
|
782
|
+
function boardDiagram(env: BuildEnv): string {
|
|
783
|
+
const { ctx } = env;
|
|
784
|
+
const hub = ctx.sys.config.datasheet?.hub;
|
|
785
|
+
const board = hub ? ctx.instances.get(hub) : undefined;
|
|
786
|
+
if (!board || !board.interfaces.some((i) => i.protocols?.some((p) => p.type === "net"))) return "";
|
|
787
|
+
const kids = (board.children ?? []).map((c) => ({ c, def: ctx.sys.lookup(c.moduleDefId) })).filter((x): x is { c: (typeof x)["c"]; def: ModuleDef } => !!x.def);
|
|
788
|
+
const ctl = kids.find((k) => (k.def.categories ?? []).some((c) => c.startsWith("microcontroller")));
|
|
789
|
+
if (!ctl) return "";
|
|
790
|
+
const shown = kids.filter((k) => !(k.def.categories ?? []).some((c) => c.startsWith("component.passive")));
|
|
791
|
+
const netName = new Map(board.interfaces.filter((i) => i.protocols?.some((p) => p.type === "net")).map((i) => [i.id, i]));
|
|
792
|
+
const netsOf = new Map<string, Set<string>>();
|
|
793
|
+
for (const l of board.links ?? []) {
|
|
794
|
+
if (!("child" in l.a) || !("self" in l.b) || !netName.has(l.b.interfaceId)) continue;
|
|
795
|
+
const set = netsOf.get(l.a.child) ?? new Set<string>();
|
|
796
|
+
set.add(l.b.interfaceId);
|
|
797
|
+
netsOf.set(l.a.child, set);
|
|
798
|
+
}
|
|
799
|
+
const isRail = (id: string) => netName.get(id)?.parameters?.some((p) => p.id === "voltage") ?? false;
|
|
800
|
+
const isGround = (id: string) => /^(gnd|ground|agnd|dgnd)$/i.test(id);
|
|
801
|
+
const shared = (a: string, b: string) => [...(netsOf.get(a) ?? [])].filter((n) => !isGround(n) && netsOf.get(b)?.has(n));
|
|
802
|
+
type E = { a: string; b: string; labels: string[]; power: boolean };
|
|
803
|
+
const edges: E[] = [];
|
|
804
|
+
// the power path: supplies, protection, and connectors that carry a rail (USB-C, the cell pads)
|
|
805
|
+
const power = (k: (typeof kids)[number]) =>
|
|
806
|
+
(k.def.categories ?? []).some((c) => /^(power|component\.protection)/.test(c)) ||
|
|
807
|
+
((k.def.categories ?? []).some((c) => c.startsWith("connector")) && [...(netsOf.get(k.c.id) ?? [])].some(isRail));
|
|
808
|
+
for (const k of shown) {
|
|
809
|
+
if (k === ctl) continue;
|
|
810
|
+
let to = ctl.c.id;
|
|
811
|
+
let nets = shared(k.c.id, to);
|
|
812
|
+
if (!nets.length) {
|
|
813
|
+
const peer = shown.find((o) => o !== k && o !== ctl && power(o) && shared(k.c.id, o.c.id).length) ?? shown.find((o) => o !== k && o !== ctl && shared(k.c.id, o.c.id).length);
|
|
814
|
+
if (!peer) continue;
|
|
815
|
+
to = peer.c.id;
|
|
816
|
+
nets = shared(k.c.id, to);
|
|
817
|
+
}
|
|
818
|
+
const signal = nets.filter((n) => !isRail(n));
|
|
819
|
+
const rails = nets.filter(isRail);
|
|
820
|
+
const names = (ids: string[]) => ids.map((n) => netName.get(n)?.name ?? n);
|
|
821
|
+
if (signal.length) edges.push({ a: k.c.id, b: to, labels: names(signal), power: false });
|
|
822
|
+
else if (rails.length) edges.push({ a: k.c.id, b: to, labels: names(rails), power: true });
|
|
823
|
+
}
|
|
824
|
+
const left = shown.filter((k) => k !== ctl && power(k) && edges.some((e) => e.a === k.c.id));
|
|
825
|
+
const right = shown.filter((k) => k !== ctl && !power(k) && edges.some((e) => e.a === k.c.id));
|
|
826
|
+
const boxW = 128;
|
|
827
|
+
const boxH = 30;
|
|
828
|
+
const rowGap = 40;
|
|
829
|
+
const leftX = 0;
|
|
830
|
+
const ctlX = 262;
|
|
831
|
+
const rightX = 524;
|
|
832
|
+
const rows = Math.max(left.length, right.length, 1);
|
|
833
|
+
const H = rows * rowGap + 30;
|
|
834
|
+
const pos = new Map<string, { x: number; y: number }>();
|
|
835
|
+
left.forEach((k, i) => pos.set(k.c.id, { x: leftX, y: 10 + i * rowGap + ((rows - left.length) * rowGap) / 2 }));
|
|
836
|
+
right.forEach((k, i) => pos.set(k.c.id, { x: rightX, y: 10 + i * rowGap + ((rows - right.length) * rowGap) / 2 }));
|
|
837
|
+
pos.set(ctl.c.id, { x: ctlX, y: 10 + ((rows - 1) * rowGap) / 2 - 20 });
|
|
838
|
+
const ctlH = boxH + 40;
|
|
839
|
+
const parts: string[] = [];
|
|
840
|
+
const lab = (x: number, y: number, t: string, color: string, anchor = "middle") => `<text x="${x}" y="${y}" font-size="6.6" text-anchor="${anchor}" fill="${color}">${esc(t)}</text>`;
|
|
841
|
+
for (const e of edges) {
|
|
842
|
+
const a = pos.get(e.a);
|
|
843
|
+
const b = pos.get(e.b);
|
|
844
|
+
if (!a || !b) continue;
|
|
845
|
+
const col = e.power ? "#1b1b1b" : ACCENT;
|
|
846
|
+
const bh = e.b === ctl.c.id ? ctlH : boxH;
|
|
847
|
+
const [x1, y1] = a.x < b.x ? [a.x + boxW, a.y + boxH / 2] : [a.x, a.y + boxH / 2];
|
|
848
|
+
const [x2, y2] = a.x < b.x ? [b.x, b.y + bh / 2] : b.x === a.x ? [b.x, b.y + bh / 2] : [b.x + boxW, b.y + bh / 2];
|
|
849
|
+
if (a.x === b.x) {
|
|
850
|
+
// two parts in one column: a bracket on their outer side
|
|
851
|
+
const ox = a.x - 10;
|
|
852
|
+
parts.push(`<path d="M${a.x} ${y1} H${ox} V${y2} H${b.x}" stroke="${col}" stroke-width="1.1" fill="none"/>`);
|
|
853
|
+
parts.push(lab(ox - 3, (y1 + y2) / 2 + 2, e.labels.slice(0, 2).join(", "), col, "end"));
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const text = e.labels.length > 3 ? `${e.labels.slice(0, 3).join(", ")} +${e.labels.length - 3}` : e.labels.join(", ");
|
|
857
|
+
parts.push(`<path d="M${x1} ${y1} L${x2} ${y2}" stroke="${col}" stroke-width="${e.power ? 1.4 : 1.1}" fill="none"/>`);
|
|
858
|
+
const tx = a.x < b.x ? x1 + 4 : x1 - 4;
|
|
859
|
+
parts.push(lab(tx, y1 - 3, text, col, a.x < b.x ? "start" : "end"));
|
|
860
|
+
}
|
|
861
|
+
const box = (id: string, p: { x: number; y: number }, title: string, subt: string, h: number, dark: boolean) => {
|
|
862
|
+
parts.push(`<rect x="${p.x}" y="${p.y}" width="${boxW}" height="${h}" fill="${dark ? "#1b1b1b" : "#fff"}" stroke="#1b1b1b" stroke-width="0.9"/>`);
|
|
863
|
+
if (dark) parts.push(`<rect x="${p.x}" y="${p.y}" width="4" height="${h}" fill="${ACCENT}"/>`);
|
|
864
|
+
parts.push(`<text x="${p.x + 8}" y="${p.y + 12}" font-size="8" font-weight="600" fill="${dark ? "#fff" : "#1b1b1b"}">${esc(title.length > 24 ? title.slice(0, 23) + "…" : title)}</text>`);
|
|
865
|
+
parts.push(`<text x="${p.x + 8}" y="${p.y + 23}" font-size="6.4" fill="${dark ? "#bdbdbd" : "#6f6f6f"}">${esc((t => (t.length > 27 ? t.slice(0, 26) + "…" : t))(`${id.toUpperCase()} · ${subt}`))}</text>`);
|
|
866
|
+
};
|
|
867
|
+
for (const k of [...left, ...right]) box(k.c.id, pos.get(k.c.id)!, k.c.name ?? k.def.name, shortDefName(k.def).replace(/\s*\(.*?\)\s*/g, " ").trim(), boxH, false);
|
|
868
|
+
box(ctl.c.id, pos.get(ctl.c.id)!, ctl.c.name ?? ctl.def.name, shortDefName(ctl.def).replace(/\s*\(.*?\)\s*/g, " ").trim(), ctlH, true);
|
|
869
|
+
const ly = H - 6;
|
|
870
|
+
parts.push(`<line x1="0" x2="22" y1="${ly}" y2="${ly}" stroke="#1b1b1b" stroke-width="1.4"/><text x="28" y="${ly + 2.6}" font-size="7" fill="#1b1b1b">supply rail</text>`);
|
|
871
|
+
parts.push(`<line x1="90" x2="112" y1="${ly}" y2="${ly}" stroke="${ACCENT}" stroke-width="1.1"/><text x="118" y="${ly + 2.6}" font-size="7" fill="#1b1b1b">signal nets to the controller</text>`);
|
|
872
|
+
return `<svg class="sysdiag" viewBox="-60 0 ${rightX + boxW + 64} ${H}" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="inherit">${parts.join("")}</svg>`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const shortDefName = (d: ModuleDef) => d.name.replace(/\s*\((included[^)]*|custom)\)/gi, "").replace(/, ISO 4762.*$/, "");
|