@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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The datasheet's first page after the cover (PB-935): what the device is,
|
|
3
|
+
* its features, what it does and what it must meet, from the project's own
|
|
4
|
+
* statement (`docs/product.json` and the brief's requirements table;
|
|
5
|
+
* @protoboardai/core readProduct). These are the project's words, not model
|
|
6
|
+
* values, so they are not bound values. What the project has not stated is
|
|
7
|
+
* said on the page; nothing is written for it.
|
|
8
|
+
*/
|
|
9
|
+
import { PRODUCT_FILE, PRODUCT_PART_TEXT, readProduct, type ProductPart } from "@protoboardai/core";
|
|
10
|
+
import { sec, sub, table } from "../blocks.ts";
|
|
11
|
+
import { esc } from "../html.ts";
|
|
12
|
+
|
|
13
|
+
const missing = (k: ProductPart) => `<p class="small muted"><i>${esc(PRODUCT_PART_TEXT[k].missing)} State it in <code>${esc(PRODUCT_FILE)}</code>.</i></p>`;
|
|
14
|
+
|
|
15
|
+
/** The page, as flow blocks: the heading with the summary, then features, functions and requirements. */
|
|
16
|
+
export function productPage(root: string, title: string, description?: string): string {
|
|
17
|
+
const p = readProduct(root);
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
out.push(
|
|
20
|
+
`<div class="block">${sec("00", "The product", esc(title))}${sub(PRODUCT_PART_TEXT.summary.title)}${p.summary ? `<p class="lead">${esc(p.summary)}</p>` : `${description ? `<p class="lead">${esc(description)}</p>` : ""}${missing("summary")}`}</div>`,
|
|
21
|
+
);
|
|
22
|
+
out.push(`<div class="block">${sub(PRODUCT_PART_TEXT.features.title)}${p.features.length ? `<ul class="plain">${p.features.map((f) => `<li>${esc(f)}</li>`).join("")}</ul>` : missing("features")}</div>`);
|
|
23
|
+
const by = p.functions.some((f) => f.by);
|
|
24
|
+
out.push(
|
|
25
|
+
`<div class="block">${sub(PRODUCT_PART_TEXT.functions.title)}${
|
|
26
|
+
p.functions.length ? table([{ h: "ID" }, { h: "Function" }, ...(by ? [{ h: "Done by" }] : [])], p.functions.map((f) => [esc(f.id ?? ""), esc(f.text), ...(by ? [esc(f.by ?? "")] : [])]), { cls: "dense", ...(p.functionsFrom && p.functionsFrom !== PRODUCT_FILE ? { foot: `From ${esc(p.functionsFrom)}.` } : {}) }) : missing("functions")
|
|
27
|
+
}</div>`,
|
|
28
|
+
);
|
|
29
|
+
const pass = p.requirements.some((r) => r.pass);
|
|
30
|
+
const ids = p.requirements.some((r) => r.id);
|
|
31
|
+
const unshown = p.requirements.filter((r) => !r.how).length;
|
|
32
|
+
out.push(
|
|
33
|
+
`<div class="block">${sub(PRODUCT_PART_TEXT.requirements.title)}${
|
|
34
|
+
p.requirements.length
|
|
35
|
+
? table([...(ids ? [{ h: "ID" }] : []), { h: "Requirement" }, { h: "How it is shown" }, ...(pass ? [{ h: "Pass" }] : [])], p.requirements.map((r) => [...(ids ? [esc(r.id ?? "")] : []), esc(r.text), r.how ? esc(r.how) : `<span class="muted"><i>Not stated</i></span>`, ...(pass ? [esc(r.pass ?? "")] : [])]), {
|
|
36
|
+
cls: "dense",
|
|
37
|
+
// a table that fits a page moves to the next page whole rather than leave a row behind
|
|
38
|
+
split: p.requirements.length > 18,
|
|
39
|
+
foot: `From ${esc(p.requirementsFrom ?? "")}.${unshown ? ` ${unshown} of ${p.requirements.length} do not say how they are shown.` : ""}`,
|
|
40
|
+
})
|
|
41
|
+
: missing("requirements")
|
|
42
|
+
}</div>`,
|
|
43
|
+
);
|
|
44
|
+
return out.join("\n");
|
|
45
|
+
}
|
package/lib/figures.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figures from the assembly: hero, orthographic views, interface callouts,
|
|
3
|
+
* part thumbnails and exploded assembly steps. All placement comes from
|
|
4
|
+
* assemble(); explosion runs along each joint's structure-side normal, with
|
|
5
|
+
* parts ranked by where they sit on the joint (the fastenerStack positions and
|
|
6
|
+
* the mate gaps), so the exploded order is the stacking order.
|
|
7
|
+
*/
|
|
8
|
+
import type { InterfaceDef, ModuleDef } from "@deltarobotics/uhd";
|
|
9
|
+
import { applyMat4, bodyKey, IDENTITY, type MateEdge } from "@deltarobotics/uhd";
|
|
10
|
+
import type { RenderBody, RenderSpec } from "./render/renderer.ts";
|
|
11
|
+
import { matTranslation, offsetMatrix, worldFrame, type SceneBody, type SceneModel } from "./model.ts";
|
|
12
|
+
import type { CameraSpec } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export const ISO: number[] = [1, -1.18, 0.82];
|
|
15
|
+
|
|
16
|
+
export type Style = RenderBody["style"];
|
|
17
|
+
|
|
18
|
+
export function renderBody(b: SceneBody, style: Style, extra: Partial<RenderBody> = {}): RenderBody {
|
|
19
|
+
return {
|
|
20
|
+
key: b.key,
|
|
21
|
+
glb: b.glb,
|
|
22
|
+
matrix: b.matrix,
|
|
23
|
+
style,
|
|
24
|
+
category: b.category,
|
|
25
|
+
hide: b.features,
|
|
26
|
+
vendor: b.vendor,
|
|
27
|
+
...extra,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sub = (a: number[], b: number[]) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
32
|
+
const add = (a: number[], b: number[]) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
33
|
+
const mul = (a: number[], s: number) => [a[0] * s, a[1] * s, a[2] * s];
|
|
34
|
+
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
35
|
+
|
|
36
|
+
const isStructure = (iface: InterfaceDef) => iface.protocols?.some((p) => p.type === "bolt_pattern" && p.roles?.includes("structure"));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Structure end and moving end of a mate (the moving end is the one the mate
|
|
40
|
+
* placed). When the scene placed neither body (no geometry, or vendor CAD not
|
|
41
|
+
* available), the end that is not the structure moves onto the one that is
|
|
42
|
+
* (a shield's component pattern onto the UNO's structure), and otherwise B
|
|
43
|
+
* onto A, along whose normal the mate's gap is measured.
|
|
44
|
+
*/
|
|
45
|
+
export function mateEnds(scene: SceneModel, e: MateEdge) {
|
|
46
|
+
const placedB = scene.byKey.get(bodyKey(e.b.path, e.b.frame.artifact))?.via === e.linkId;
|
|
47
|
+
const placedA = scene.byKey.get(bodyKey(e.a.path, e.a.frame.artifact))?.via === e.linkId;
|
|
48
|
+
// fastener stacks are measured from the structure-side frame
|
|
49
|
+
const structure = isStructure(e.b.iface) && !isStructure(e.a.iface) ? e.b : e.a;
|
|
50
|
+
const movesB = placedB || (!placedA && structure === e.a);
|
|
51
|
+
const moving = movesB ? e.b : e.a;
|
|
52
|
+
const fixed = movesB ? e.a : e.b;
|
|
53
|
+
return { moving, fixed, structure };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Explosion {
|
|
57
|
+
offsets: Map<string, number[]>;
|
|
58
|
+
guides: { from: number[]; to: number[] }[];
|
|
59
|
+
/** Hardware keys in this explosion, grouped by harness child (for callouts). */
|
|
60
|
+
hardwareGroups: Map<string, SceneBody[]>;
|
|
61
|
+
moving: SceneBody[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Explode the mates `links` (assemble linkIds). Bodies placed by those mates
|
|
66
|
+
* and the hardware on them move along the joint normal by stacking rank.
|
|
67
|
+
*/
|
|
68
|
+
export function explode(scene: SceneModel, links: string[], dist: number, dirOverride?: number[]): Explosion {
|
|
69
|
+
const offsets = new Map<string, number[]>();
|
|
70
|
+
const guides: Explosion["guides"] = [];
|
|
71
|
+
const hardwareGroups = new Map<string, SceneBody[]>();
|
|
72
|
+
const moving: SceneBody[] = [];
|
|
73
|
+
const mates = scene.assembly.mates.filter((m) => links.includes(m.linkId));
|
|
74
|
+
|
|
75
|
+
// group mates by structure frame (one joint = one structure pattern)
|
|
76
|
+
const joints = new Map<string, { mates: MateEdge[]; origin: number[]; normal: number[] }>();
|
|
77
|
+
for (const e of mates) {
|
|
78
|
+
const { structure } = mateEnds(scene, e);
|
|
79
|
+
const sKey = bodyKey(structure.path, structure.frame.artifact);
|
|
80
|
+
const sBody = scene.byKey.get(sKey);
|
|
81
|
+
if (!sBody) continue;
|
|
82
|
+
const wf = worldFrame(sBody.matrix, structure.frame);
|
|
83
|
+
const k = `${sKey}.${structure.iface.id}`;
|
|
84
|
+
const j = joints.get(k) ?? { mates: [], ...wf };
|
|
85
|
+
j.mates.push(e);
|
|
86
|
+
joints.set(k, j);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// parents first, so a joint on a body that itself moved carries that body's offset
|
|
90
|
+
const order = (k: string) => scene.bodies.findIndex((b) => k.startsWith(b.key + "."));
|
|
91
|
+
const sorted = [...joints.entries()].sort((a, b) => order(a[0]) - order(b[0]));
|
|
92
|
+
for (const [jk, j] of sorted) {
|
|
93
|
+
const structureKey = jk.slice(0, jk.lastIndexOf("."));
|
|
94
|
+
const base = offsets.get(structureKey) ?? [0, 0, 0];
|
|
95
|
+
const items: { body: SceneBody; pos: number; override?: boolean }[] = [];
|
|
96
|
+
for (const e of j.mates) {
|
|
97
|
+
const { moving: mv } = mateEnds(scene, e);
|
|
98
|
+
const key = bodyKey(mv.path, mv.frame.artifact);
|
|
99
|
+
const body = scene.byKey.get(key);
|
|
100
|
+
if (body && body.via === e.linkId) {
|
|
101
|
+
const wf = worldFrame(body.matrix, mv.frame);
|
|
102
|
+
items.push({ body, pos: dot(sub(wf.origin, j.origin), j.normal), override: Boolean(dirOverride) });
|
|
103
|
+
moving.push(body);
|
|
104
|
+
}
|
|
105
|
+
for (const h of scene.hardware.filter((x) => x.linkId === e.linkId)) {
|
|
106
|
+
items.push({ body: h, pos: dot(sub(matTranslation(h.matrix), j.origin), j.normal) });
|
|
107
|
+
const g = `${h.harness}/${h.child}`;
|
|
108
|
+
hardwareGroups.set(g, [...(hardwareGroups.get(g) ?? []), h]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const ranked = items.filter((i) => !i.override);
|
|
112
|
+
const round = (x: number) => Math.round(x * 100) / 100;
|
|
113
|
+
const below = [...new Set(ranked.filter((i) => i.pos < -0.01).map((i) => round(i.pos)))].sort((a, b) => b - a);
|
|
114
|
+
const above = [...new Set(ranked.filter((i) => i.pos >= -0.01).map((i) => round(i.pos)))].sort((a, b) => a - b);
|
|
115
|
+
for (const it of items) {
|
|
116
|
+
let off: number[];
|
|
117
|
+
if (it.override) off = mul(dirOverride!, dist * 1.6);
|
|
118
|
+
else {
|
|
119
|
+
const p = round(it.pos);
|
|
120
|
+
const rank = p < -0.01 ? -(below.indexOf(p) + 1) : above.indexOf(p) + 1;
|
|
121
|
+
off = mul(j.normal, rank * dist);
|
|
122
|
+
}
|
|
123
|
+
offsets.set(it.body.key, add(base, off));
|
|
124
|
+
}
|
|
125
|
+
// guides: one dashed axis per hole through the exploded column
|
|
126
|
+
const holes = new Map<string, { base: number[]; ts: number[] }>();
|
|
127
|
+
for (const it of items) {
|
|
128
|
+
if (!it.body.harness) continue;
|
|
129
|
+
const p0 = matTranslation(it.body.matrix);
|
|
130
|
+
const t0 = dot(sub(p0, j.origin), j.normal);
|
|
131
|
+
const inPlane = sub(p0, mul(j.normal, t0));
|
|
132
|
+
const k = inPlane.map((x) => x.toFixed(1)).join(",");
|
|
133
|
+
const h = holes.get(k) ?? { base: add(inPlane, base), ts: [0] };
|
|
134
|
+
const off = sub(offsets.get(it.body.key) ?? [0, 0, 0], base);
|
|
135
|
+
h.ts.push(t0, t0 + dot(off, j.normal));
|
|
136
|
+
holes.set(k, h);
|
|
137
|
+
}
|
|
138
|
+
// the axis runs on to the exploded seat of the part the hardware holds
|
|
139
|
+
for (const it of items.filter((i) => !i.body.harness && !i.override)) {
|
|
140
|
+
const off = sub(offsets.get(it.body.key) ?? [0, 0, 0], base);
|
|
141
|
+
const t = it.pos + dot(off, j.normal);
|
|
142
|
+
for (const h of holes.values()) h.ts.push(t);
|
|
143
|
+
}
|
|
144
|
+
for (const h of holes.values()) {
|
|
145
|
+
const lo = Math.min(...h.ts) - 3;
|
|
146
|
+
const hi = Math.max(...h.ts) + 3;
|
|
147
|
+
guides.push({ from: add(h.base, mul(j.normal, lo)), to: add(h.base, mul(j.normal, hi)) });
|
|
148
|
+
}
|
|
149
|
+
// bodies without hardware (a prop on a shaft): axis from seat to exploded seat
|
|
150
|
+
for (const it of items.filter((i) => !i.body.harness)) {
|
|
151
|
+
const hasHw = items.some((i) => i.body.harness);
|
|
152
|
+
if (hasHw && !it.override) continue;
|
|
153
|
+
const e = j.mates.find((m) => scene.byKey.get(bodyKey(mateEnds(scene, m).moving.path, mateEnds(scene, m).moving.frame.artifact)) === it.body);
|
|
154
|
+
if (!e) continue;
|
|
155
|
+
const wf = worldFrame(it.body.matrix, mateEnds(scene, e).moving.frame);
|
|
156
|
+
const off = sub(offsets.get(it.body.key)!, base);
|
|
157
|
+
guides.push({ from: add(wf.origin, base), to: add(add(wf.origin, off), base) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { offsets, guides, hardwareGroups, moving };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function withOffset(b: RenderBody, off?: number[]): RenderBody {
|
|
164
|
+
return off ? { ...b, matrix: offsetMatrix(b.matrix, off) } : b;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function cameraOf(c: CameraSpec | undefined, fallback: CameraSpec): RenderSpec["camera"] {
|
|
168
|
+
return { ...fallback, ...(c ?? {}) } as RenderSpec["camera"];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Everything assembled, one style. */
|
|
172
|
+
export function allBodies(scene: SceneModel, style: Style, hardwareStyle: Style = style): RenderBody[] {
|
|
173
|
+
return [...scene.bodies.map((b) => renderBody(b, style)), ...scene.hardware.map((h) => renderBody(h, hardwareStyle))];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A module on its own at its CAD origin (kit thumbnails). */
|
|
177
|
+
export function isolated(def: ModuleDef, glb: string, features: string[], category: string, vendor: boolean): RenderBody[] {
|
|
178
|
+
return [{ key: def.id, glb, matrix: IDENTITY, style: "normal", category, hide: features, vendor }];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** World origin of an interface frame on a placed instance ("frame.motor_mount_fl"). */
|
|
182
|
+
export function interfaceAnchor(scene: SceneModel, spec: string): { p: number[]; body: SceneBody; iface: InterfaceDef } | undefined {
|
|
183
|
+
const dot = spec.lastIndexOf(".");
|
|
184
|
+
const path = spec.slice(0, dot);
|
|
185
|
+
const id = spec.slice(dot + 1);
|
|
186
|
+
for (const b of scene.bodies) {
|
|
187
|
+
if (b.path.join("/") !== path) continue;
|
|
188
|
+
const iface = b.def.interfaces.find((i) => i.id === id);
|
|
189
|
+
const frame = iface?.geometry?.frame;
|
|
190
|
+
if (!iface || !frame) continue;
|
|
191
|
+
if ((frame.artifact ?? undefined) !== (b.key.includes("#") ? b.key.split("#")[1] : frame.artifact)) continue;
|
|
192
|
+
return { p: applyMat4(b.matrix, frame.origin), body: b, iface };
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
package/lib/format.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic value formatting. The verifier re-formats every re-queried
|
|
3
|
+
* value with the same spec and compares strings, so all formatting of
|
|
4
|
+
* model values goes through `formatValue`.
|
|
5
|
+
*
|
|
6
|
+
* Format spec (the `f` string, `|`-separated):
|
|
7
|
+
* d<n> exactly n decimals (d1 -> 22.2)
|
|
8
|
+
* s<n> n significant digits max (s3 -> 171.9 -> 172)
|
|
9
|
+
* si scale by SI prefix (400000 Hz -> 400 kHz)
|
|
10
|
+
* u=<unit> unit override (u=g)
|
|
11
|
+
* nounit value only
|
|
12
|
+
* pct multiply by 100, unit %
|
|
13
|
+
* hex integer as 0x..
|
|
14
|
+
* x "×" join for arrays ([45, 41, 6] -> 45 × 41 × 6)
|
|
15
|
+
* upper upper-case a string (ccw -> CCW)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const NBSP = " ";
|
|
19
|
+
|
|
20
|
+
const UNIT_DISPLAY: Record<string, string> = {
|
|
21
|
+
dimensionless: "",
|
|
22
|
+
C: "°C",
|
|
23
|
+
degC: "°C",
|
|
24
|
+
deg: "°",
|
|
25
|
+
ohm: "ohm",
|
|
26
|
+
mohm: "mohm",
|
|
27
|
+
"kbit/s": "kbit/s",
|
|
28
|
+
uF: "\u03bcF",
|
|
29
|
+
uA: "\u03bcA",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Characters the document fonts lack, mapped to look-alikes they have (µ → Greek mu, Σ → n-ary sum, Φ → Ø, Ω → ohm). */
|
|
33
|
+
export function glyphSafe(s: string): string {
|
|
34
|
+
return s
|
|
35
|
+
.replace(/\u00b5/g, "\u03bc")
|
|
36
|
+
.replace(/\u03a3/g, "\u2211")
|
|
37
|
+
.replace(/[\u03a6\u03c6]/g, "\u00d8")
|
|
38
|
+
.replace(/\u03a9/g, " ohm")
|
|
39
|
+
.replace(/\u221d/g, "~")
|
|
40
|
+
.replace(/\u202f|\u2009/g, NBSP);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function unitLabel(unit?: string): string {
|
|
44
|
+
if (!unit) return "";
|
|
45
|
+
return UNIT_DISPLAY[unit] ?? unit;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function groupThousands(s: string): string {
|
|
49
|
+
const [int, frac] = s.split(".");
|
|
50
|
+
const neg = int.startsWith("-");
|
|
51
|
+
const digits = neg ? int.slice(1) : int;
|
|
52
|
+
const grouped = digits.length > 4 ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, " ") : digits;
|
|
53
|
+
return (neg ? "−" : "") + grouped + (frac ? "." + frac : "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatNumber(v: number, opts: { d?: number; s?: number } = {}): string {
|
|
57
|
+
if (!Number.isFinite(v)) return String(v);
|
|
58
|
+
let s: string;
|
|
59
|
+
if (opts.d !== undefined) s = v.toFixed(opts.d);
|
|
60
|
+
else if (opts.s !== undefined) {
|
|
61
|
+
const p = Number(v.toPrecision(opts.s));
|
|
62
|
+
s = String(p);
|
|
63
|
+
if (s.includes("e")) s = p.toFixed(0);
|
|
64
|
+
} else {
|
|
65
|
+
// default: up to 3 decimals, trailing zeros trimmed
|
|
66
|
+
s = String(Number(v.toFixed(3)));
|
|
67
|
+
}
|
|
68
|
+
return groupThousands(s);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SI: [number, string][] = [
|
|
72
|
+
[1e9, "G"],
|
|
73
|
+
[1e6, "M"],
|
|
74
|
+
[1e3, "k"],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export interface FormatOpts {
|
|
78
|
+
d?: number;
|
|
79
|
+
s?: number;
|
|
80
|
+
si?: boolean;
|
|
81
|
+
unit?: string;
|
|
82
|
+
nounit?: boolean;
|
|
83
|
+
pct?: boolean;
|
|
84
|
+
hex?: boolean;
|
|
85
|
+
x?: boolean;
|
|
86
|
+
upper?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseFormat(f?: string): FormatOpts {
|
|
90
|
+
const o: FormatOpts = {};
|
|
91
|
+
for (const part of (f ?? "").split("|").filter(Boolean)) {
|
|
92
|
+
if (/^d\d+$/.test(part)) o.d = Number(part.slice(1));
|
|
93
|
+
else if (/^s\d+$/.test(part)) o.s = Number(part.slice(1));
|
|
94
|
+
else if (part === "si") o.si = true;
|
|
95
|
+
else if (part === "nounit") o.nounit = true;
|
|
96
|
+
else if (part === "pct") o.pct = true;
|
|
97
|
+
else if (part === "hex") o.hex = true;
|
|
98
|
+
else if (part === "x") o.x = true;
|
|
99
|
+
else if (part === "upper") o.upper = true;
|
|
100
|
+
else if (part.startsWith("u=")) o.unit = part.slice(2);
|
|
101
|
+
else throw new Error(`unknown format token "${part}"`);
|
|
102
|
+
}
|
|
103
|
+
return o;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Format a model value (number, [min,max] range, string, boolean, array) with its unit. */
|
|
107
|
+
export function formatValue(value: unknown, unit?: string, f?: string): string {
|
|
108
|
+
const o = parseFormat(f);
|
|
109
|
+
let u = o.unit ?? unit;
|
|
110
|
+
const num = (n: number): string => {
|
|
111
|
+
let x = n;
|
|
112
|
+
if (o.pct) x = n * 100;
|
|
113
|
+
if (o.hex) return "0x" + Math.round(x).toString(16).toUpperCase().padStart(2, "0");
|
|
114
|
+
return formatNumber(x, o);
|
|
115
|
+
};
|
|
116
|
+
const withUnit = (s: string, unitStr = u) => {
|
|
117
|
+
const label = o.pct ? "%" : unitLabel(unitStr);
|
|
118
|
+
if (o.nounit || !label) return s;
|
|
119
|
+
return label === "°" ? s + label : s + NBSP + label;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (value === undefined || value === null) return "—";
|
|
123
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
124
|
+
if (typeof value === "string") return glyphSafe(o.upper ? value.toUpperCase() : value);
|
|
125
|
+
if (typeof value === "number") {
|
|
126
|
+
if (o.si && u && !o.pct) {
|
|
127
|
+
for (const [k, p] of SI) {
|
|
128
|
+
if (Math.abs(value) >= k) return withUnit(formatNumber(value / k, o), p + u);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return withUnit(num(value));
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
if (value.length === 2 && value.every((x) => typeof x === "number") && !o.x) {
|
|
135
|
+
const [a, b] = value as number[];
|
|
136
|
+
if (o.si && u) {
|
|
137
|
+
for (const [k, p] of SI) {
|
|
138
|
+
if (Math.abs(a) >= k) return withUnit(`${formatNumber(a / k, o)}–${formatNumber(b / k, o)}`, p + u);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return withUnit(`${num(a)}–${num(b)}`);
|
|
142
|
+
}
|
|
143
|
+
if (value.every((x) => typeof x === "number")) return withUnit((value as number[]).map(num).join(" × "));
|
|
144
|
+
return value.map((x) => (typeof x === "number" ? num(x) : String(x))).join(", ");
|
|
145
|
+
}
|
|
146
|
+
if (typeof value === "object") {
|
|
147
|
+
const o2 = value as Record<string, unknown>;
|
|
148
|
+
// {min, typ, max}: a range, or the typical value with its limits
|
|
149
|
+
if (typeof o2.min === "number" && typeof o2.max === "number") {
|
|
150
|
+
const r = withUnit(`${num(o2.min)}–${num(o2.max)}`);
|
|
151
|
+
return typeof o2.typ === "number" ? `${withUnit(num(o2.typ))} (${r})` : r;
|
|
152
|
+
}
|
|
153
|
+
// {length, width, height} and other all-number records: a size
|
|
154
|
+
const vals = Object.values(o2);
|
|
155
|
+
if (vals.length > 1 && vals.length <= 4 && vals.every((x) => typeof x === "number")) return withUnit((vals as number[]).map(num).join(" × "));
|
|
156
|
+
}
|
|
157
|
+
return JSON.stringify(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Unit symbols a key may end with (`weight_g`, `supply_V`), and how they are shown. */
|
|
161
|
+
const KEY_UNITS = /_(mAh|Wh|mm|mm2|cm|m|g|kg|V|A|mA|uA|W|mW|ms|us|s|min|h|Hz|kHz|MHz|GHz|dBm|dB|C|degC|deg|ohm|mohm|uF|nF|pF|uH|in|pct|rpm|Nm|N|Bd|bps|kbps|Mbps|Gbps|ml|mL|l|L)$/;
|
|
162
|
+
const KEY_UNIT_DISPLAY: Record<string, string> = { pct: "%", mm2: "mm²", Nm: "N·m", bps: "bit/s", kbps: "kbit/s", Mbps: "Mbit/s", Gbps: "Gbit/s", degC: "C", ml: "mL", l: "L" };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Unit from a metadata key: its suffix (weight_g -> g, supply_V -> V,
|
|
166
|
+
* operating_temperature_C -> C), a ratio (kv_rpm_per_V -> rpm/V,
|
|
167
|
+
* density_g_cm3 -> g/cm³), a camel-case suffix (torqueNm -> N·m), or a key
|
|
168
|
+
* that names its quantity (rpm, baud_rate -> Bd, hole_count -> count).
|
|
169
|
+
*/
|
|
170
|
+
export function unitFromKey(key: string): string | undefined {
|
|
171
|
+
const per = key.match(/_([A-Za-z0-9]+)_per_([A-Za-z0-9]+)$/);
|
|
172
|
+
if (per) return `${unitFromKey(`_${per[1]}`) ?? per[1]}/${unitFromKey(`_${per[2]}`) ?? per[2]}`;
|
|
173
|
+
if (/_g_cm3$/.test(key)) return "g/cm³";
|
|
174
|
+
if (/[a-z]Nm$/.test(key)) return "N·m";
|
|
175
|
+
if (/^rpm$/i.test(key)) return "rpm";
|
|
176
|
+
if (/(^|_)baud(_rate)?$/i.test(key)) return "Bd";
|
|
177
|
+
if (/(^|_)count$|[a-z]Count$/.test(key)) return "count";
|
|
178
|
+
const m = key.match(KEY_UNITS);
|
|
179
|
+
if (!m) return undefined;
|
|
180
|
+
return KEY_UNIT_DISPLAY[m[1]] ?? m[1];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The nearest unit along a path of keys, last key first (`lowest_latency_ms.goggles_3` -> ms). */
|
|
184
|
+
export function unitFromKeys(keys: string[]): string | undefined {
|
|
185
|
+
for (let i = keys.length - 1; i >= 0; i--) {
|
|
186
|
+
const u = unitFromKey(keys[i]);
|
|
187
|
+
if (u) return u;
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const isNumeric = (value: unknown): boolean =>
|
|
193
|
+
typeof value === "number" ||
|
|
194
|
+
(Array.isArray(value) && value.length > 0 && value.every((x) => typeof x === "number")) ||
|
|
195
|
+
(!!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).length > 0 && Object.values(value).every((x) => typeof x === "number"));
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The unit a formatted value shows, as its values.json records it: the
|
|
199
|
+
* format's override (u=), its SI prefix (si: 6300 g shows as 6.3 kg) and %
|
|
200
|
+
* (pct) applied to the model's unit; "count" for a whole number with no unit
|
|
201
|
+
* (a count of mates, UARTs, props in a pack) and "dimensionless" for a
|
|
202
|
+
* fraction with none (a ratio). Undefined for text. A value
|
|
203
|
+
* shown without its unit (nounit, a table whose header names it) keeps it.
|
|
204
|
+
*/
|
|
205
|
+
export function displayUnit(value: unknown, unit?: string, f?: string): string | undefined {
|
|
206
|
+
if (!isNumeric(value)) return undefined;
|
|
207
|
+
const o = parseFormat(f);
|
|
208
|
+
if (o.pct) return "%";
|
|
209
|
+
if (o.hex) return unit ?? "dimensionless";
|
|
210
|
+
const u = o.unit ?? unit;
|
|
211
|
+
if (!u || u === "dimensionless") {
|
|
212
|
+
const whole = (typeof value === "number" ? [value] : Array.isArray(value) ? value : Object.values(value as object)) as number[];
|
|
213
|
+
// a whole number with no unit counts something; a fraction with none is a ratio (thrust-to-weight)
|
|
214
|
+
return whole.every((x) => Number.isInteger(x)) ? (u ?? "count") : "dimensionless";
|
|
215
|
+
}
|
|
216
|
+
if (o.si) {
|
|
217
|
+
const first = typeof value === "number" ? value : Array.isArray(value) ? (value[0] as number) : undefined;
|
|
218
|
+
if (first !== undefined) for (const [k, p] of SI) if (Math.abs(first) >= k) return unitLabel(p + u) || p + u;
|
|
219
|
+
}
|
|
220
|
+
return unitLabel(u) || u;
|
|
221
|
+
}
|
package/lib/html.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML building blocks for documents: escaping, value spans bound to model
|
|
3
|
+
* queries, markers, the page shell (CSS + pagination script).
|
|
4
|
+
*
|
|
5
|
+
* Every model value in a document is emitted by `doc.v(query, format)`:
|
|
6
|
+
*
|
|
7
|
+
* <span class="v" data-q="def:…" data-f="d1" data-s="source">22.2 V</span><sup class="mk">A</sup>
|
|
8
|
+
*
|
|
9
|
+
* The verifier re-resolves each data-q against the live model, re-formats
|
|
10
|
+
* it with data-f and compares the text.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { displayUnit, formatValue, glyphSafe } from "./format.ts";
|
|
14
|
+
import type { DocContext, ResolvedValue, ValueStatus } from "./values.ts";
|
|
15
|
+
|
|
16
|
+
export const esc = (s: unknown) =>
|
|
17
|
+
glyphSafe(String(s ?? ""))
|
|
18
|
+
.replace(/&/g, "&")
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """);
|
|
22
|
+
|
|
23
|
+
export const MARKERS: Record<ValueStatus, { mark: string; label: string; help: string } | undefined> = {
|
|
24
|
+
source: undefined,
|
|
25
|
+
model: undefined,
|
|
26
|
+
measured: { mark: "M", label: "Measured", help: "from a test-data file with status measured" },
|
|
27
|
+
assumption: { mark: "A", label: "Assumption", help: "the model marks this value as an assumption (assumption trait)" },
|
|
28
|
+
derived: { mark: "D", label: "Derived", help: "computed from model values; formula in the provenance appendix" },
|
|
29
|
+
standin: { mark: "S", label: "Stand-in", help: "stand-in data derived from the model, not measured" },
|
|
30
|
+
gap: { mark: "—", label: "Not in model", help: "the model does not state this value (data gap)" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface DocMeta {
|
|
34
|
+
title: string;
|
|
35
|
+
docType: string;
|
|
36
|
+
docId: string;
|
|
37
|
+
revision: string;
|
|
38
|
+
date: string;
|
|
39
|
+
system: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RenderedValue extends ResolvedValue {
|
|
43
|
+
f?: string;
|
|
44
|
+
text: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Doc {
|
|
48
|
+
values = new Map<string, RenderedValue>();
|
|
49
|
+
statuses = new Set<ValueStatus>();
|
|
50
|
+
constructor(
|
|
51
|
+
public ctx: DocContext,
|
|
52
|
+
public meta: DocMeta,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
/** Resolve and format a value; records it for the provenance appendix and the verifier. */
|
|
56
|
+
text(q: string, f?: string): { text: string; r: ResolvedValue } {
|
|
57
|
+
const r = this.ctx.resolve(q);
|
|
58
|
+
const text = r.status === "gap" && (r.value === undefined || r.value === null) ? "—" : formatValue(r.value, r.unit, f);
|
|
59
|
+
const key = `${q}|${f ?? ""}`;
|
|
60
|
+
// the unit the text shows (values.json): the format's override, SI prefix and % applied
|
|
61
|
+
if (!this.values.has(key)) this.values.set(key, { ...r, unit: displayUnit(r.value, r.unit, f) ?? r.unit, f, text });
|
|
62
|
+
this.statuses.add(r.status);
|
|
63
|
+
return { text, r };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Inline value span with its status marker. */
|
|
67
|
+
v(q: string, f?: string, opts: { marker?: boolean; cls?: string } = {}): string {
|
|
68
|
+
const { text, r } = this.text(q, f);
|
|
69
|
+
const mk = MARKERS[r.status];
|
|
70
|
+
const title = [r.source && `Source: ${r.source}`, r.formula && `Formula: ${r.formula}`, r.note].filter(Boolean).join(" · ");
|
|
71
|
+
// number and unit in separate spans so display type can set the unit in the text face (textContent unchanged)
|
|
72
|
+
const nu = text.match(/^(.*\d)\u00a0([^\d]+)$/);
|
|
73
|
+
const inner = nu ? `<span class="n">${esc(nu[1])}</span>\u00a0<span class="u">${esc(nu[2])}</span>` : esc(text);
|
|
74
|
+
const span = `<span class="v${opts.cls ? " " + opts.cls : ""} st-${r.status}" data-q="${esc(q)}"${f ? ` data-f="${esc(f)}"` : ""} data-s="${r.status}"${title ? ` title="${esc(title)}"` : ""}>${inner}</span>`;
|
|
75
|
+
return opts.marker === false || !mk ? span : `${span}<sup class="mk mk-${r.status}">${mk.mark}</sup>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** SVG <text> bound to a query (figures, callouts). */
|
|
79
|
+
svgText(q: string, f: string | undefined, attrs: string): string {
|
|
80
|
+
const { text, r } = this.text(q, f);
|
|
81
|
+
return `<text ${attrs} data-q="${esc(q)}"${f ? ` data-f="${esc(f)}"` : ""} data-s="${r.status}">${esc(text)}</text>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Where the marker legend goes; filled in once every value is known (fillLegends). */
|
|
85
|
+
legendPlaceholder(): string {
|
|
86
|
+
return `<div class="legend-slot"></div>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fillLegends(html: string): string {
|
|
90
|
+
return html.replace(/<div class="legend-slot"><\/div>/g, this.legend());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
legend(): string {
|
|
94
|
+
const used = (Object.keys(MARKERS) as ValueStatus[]).filter((s) => MARKERS[s] && this.statuses.has(s));
|
|
95
|
+
if (!used.length) return "";
|
|
96
|
+
return `<div class="legend">${used
|
|
97
|
+
.map((s) => `<span><sup class="mk mk-${s}">${MARKERS[s]!.mark}</sup> ${esc(MARKERS[s]!.label)}: ${esc(MARKERS[s]!.help)}</span>`)
|
|
98
|
+
.join("")}<span>Unmarked values are cited from the part's sources (sources.json) or are design values of the model.</span></div>`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const TEMPLATES = new URL("./templates/", import.meta.url);
|
|
103
|
+
export const readTemplate = (name: string) => readFileSync(new URL(name, TEMPLATES), "utf8");
|
|
104
|
+
|
|
105
|
+
/** Full HTML document around the flow blocks; `flow.js` paginates it in the browser. */
|
|
106
|
+
export function shell(meta: DocMeta, body: string, opts: { fontsHref: string; extraCss?: string }): string {
|
|
107
|
+
const css = readTemplate("doc.css").replace(/__FONTS__/g, opts.fontsHref);
|
|
108
|
+
return `<!doctype html>
|
|
109
|
+
<html lang="en">
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="utf-8">
|
|
112
|
+
<meta name="generator" content="uhd-tech-docs (@protoboardai/ext-docs)">
|
|
113
|
+
<meta name="uhd-system" content="${esc(meta.system)}">
|
|
114
|
+
<meta name="uhd-doc-type" content="${esc(meta.docType)}">
|
|
115
|
+
<meta name="uhd-doc-id" content="${esc(meta.docId)}">
|
|
116
|
+
<title>${esc(meta.title)} — ${esc(meta.docType)}</title>
|
|
117
|
+
<style>${css}${opts.extraCss ?? ""}</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body>
|
|
120
|
+
<template id="page-template">
|
|
121
|
+
<div class="page">
|
|
122
|
+
<header class="pg-head"><span class="pg-mark"></span><span class="pg-title">${esc(meta.title)}</span><span class="pg-type">${esc(meta.docType)}</span><span class="pg-id">${esc(meta.docId)}</span></header>
|
|
123
|
+
<main class="pg-body"></main>
|
|
124
|
+
<footer class="pg-foot"><span class="pg-brand">PR<b>0</b>TOBOARD · UHD</span><span class="pg-rev">${esc(meta.docId)} · Rev ${esc(meta.revision)} · ${esc(meta.date)}</span><span class="pg-num"></span></footer>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
<div id="pages"></div>
|
|
128
|
+
<section id="flow">
|
|
129
|
+
${body}
|
|
130
|
+
</section>
|
|
131
|
+
<script id="flow-script">${readTemplate("flow.js")}</script>
|
|
132
|
+
</body>
|
|
133
|
+
</html>
|
|
134
|
+
`;
|
|
135
|
+
}
|