@protoboardai/ext-docs 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -2
  4. package/evals/build-datasheet/case.json +147 -0
  5. package/fonts/KodeMono-Bold.ttf +0 -0
  6. package/fonts/KodeMono-Medium.ttf +0 -0
  7. package/fonts/KodeMono-Regular.ttf +0 -0
  8. package/fonts/KodeMono-SemiBold.ttf +0 -0
  9. package/fonts/OFL.txt +93 -0
  10. package/lib/blocks.ts +268 -0
  11. package/lib/bom.ts +126 -0
  12. package/lib/build.ts +202 -0
  13. package/lib/charts.ts +241 -0
  14. package/lib/context.ts +122 -0
  15. package/lib/derived.ts +181 -0
  16. package/lib/docs/assembly.ts +674 -0
  17. package/lib/docs/common.ts +81 -0
  18. package/lib/docs/datasheet.ts +875 -0
  19. package/lib/docs/product.ts +45 -0
  20. package/lib/figures.ts +195 -0
  21. package/lib/format.ts +221 -0
  22. package/lib/html.ts +135 -0
  23. package/lib/model.ts +312 -0
  24. package/lib/package/assembly.ts +268 -0
  25. package/lib/package/common.ts +109 -0
  26. package/lib/package/plan.ts +432 -0
  27. package/lib/package/spec.ts +203 -0
  28. package/lib/png.ts +91 -0
  29. package/lib/render/cdp.ts +5 -0
  30. package/lib/render/page/render.html +17 -0
  31. package/lib/render/page/render.js +390 -0
  32. package/lib/render/renderer.ts +161 -0
  33. package/lib/render/server.ts +105 -0
  34. package/lib/standin.ts +36 -0
  35. package/lib/standins/multirotor.ts +283 -0
  36. package/lib/templates/doc.css +247 -0
  37. package/lib/templates/flow.js +131 -0
  38. package/lib/testdata.ts +85 -0
  39. package/lib/types.ts +120 -0
  40. package/lib/values.ts +309 -0
  41. package/lib/verify.ts +276 -0
  42. package/ops/docs.ts +52 -0
  43. package/ops/package.ts +9 -0
  44. package/package.json +51 -4
  45. package/protoboard-extension.json +83 -0
  46. package/skills/uhd-tech-docs/SKILL.md +234 -0
  47. package/skills/uhd-tech-docs/references/docspec.md +56 -0
  48. package/skills/uhd-tech-docs/references/house-style.md +50 -0
  49. package/skills/uhd-tech-docs/references/style-study.md +270 -0
  50. package/skills/uhd-tech-docs/references/test-data.md +49 -0
  51. package/skills/uhd-tech-docs/references/value-queries.md +67 -0
  52. package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
  53. package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
  54. package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
  55. package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
@@ -0,0 +1,390 @@
1
+ /**
2
+ * uhd-docs figure renderer (runs in headless Chrome).
3
+ *
4
+ * window.renderScene(spec) draws UHD bodies (GLB artifacts placed by
5
+ * `assemble` matrices, mm, Z up) as technical illustrations:
6
+ *
7
+ * mode "lineart": flat light fills + screen-space outlines (object id,
8
+ * depth and normal discontinuities) — assembly steps,
9
+ * drawings;
10
+ * mode "shaded": physically based colours + soft outlines — hero renders.
11
+ *
12
+ * Styles per body: normal | context | accent | ghost | guide.
13
+ * Returns a PNG data URL plus projected anchor points (output pixels), so the
14
+ * document draws callouts and leaders as vector SVG on top.
15
+ */
16
+ import * as THREE from "three";
17
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
18
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
19
+
20
+ const STYLE = { bg: 0, normal: 1, context: 2, accent: 3, ghost: 4, guide: 5 };
21
+
22
+ const LINEART = {
23
+ frame: 0xc9cacd, top_plate: 0xc9cacd,
24
+ motor_bell: 0xe8eaed, motor_base: 0xcfd2d7, motor_shaft: 0xf2f2f2, motor_lead: 0x8a8a8a,
25
+ prop: 0xfde3d2,
26
+ esc_pcb: 0xd6e1ee, fc_pcb: 0xd8eadb, components: 0xb9bdc3, grommets: 0x9aa0a8, pads: 0xe0c28a,
27
+ video: 0xd3d4d6, battery: 0xc4c5c8, gnss: 0xe1e6e1,
28
+ screw: 0xeceef1, nut: 0xe3e6ea, spacer: 0xf4f4f4, standoff: 0xd9e1ea,
29
+ capacitor: 0xd9dde3, receiver: 0xe4e4e4, generic: 0xe2e2e2,
30
+ context: 0xf5f5f5, ghost: 0xfbfbfb, accent: 0xffb584, guide: 0xff5c00,
31
+ };
32
+ const SHADED = {
33
+ frame: 0x2e2f31, top_plate: 0x2e2f31,
34
+ motor_bell: 0xaab0b8, motor_base: 0x4c5159, motor_shaft: 0xd6d3d1, motor_lead: 0x1b1b1b,
35
+ prop: 0xff7a33,
36
+ esc_pcb: 0x1f3b5c, fc_pcb: 0x1d4a2c, components: 0x2c3036, grommets: 0x3b3b3b, pads: 0xc8a052,
37
+ video: 0x3a3b3e, battery: 0x303134, gnss: 0x2f4f39,
38
+ screw: 0xc9ccd2, nut: 0xbfc3c9, spacer: 0x1f1f1f, standoff: 0x8f99a6,
39
+ capacitor: 0x2b3440, receiver: 0x2a2a2a, generic: 0x8b8b8b,
40
+ context: 0xd8d8d8, ghost: 0xeeeeee, accent: 0xff5c00, guide: 0xff5c00,
41
+ };
42
+ const METAL = new Set(["motor_bell", "motor_shaft", "screw", "nut", "standoff", "pads"]);
43
+
44
+ function tone(category, node) {
45
+ const n = (node || "").toLowerCase();
46
+ if (category === "motor") {
47
+ if (n.startsWith("lead")) return "motor_lead";
48
+ if (n.includes("shaft")) return "motor_shaft";
49
+ if (n === "base") return "motor_base";
50
+ return "motor_bell";
51
+ }
52
+ if (category === "esc" || category === "fc") {
53
+ if (n === "components") return "components";
54
+ if (n === "grommets") return "grommets";
55
+ if (n.includes("pads")) return "pads";
56
+ return category === "esc" ? "esc_pcb" : "fc_pcb";
57
+ }
58
+ return category in LINEART ? category : "generic";
59
+ }
60
+
61
+ const loader = new GLTFLoader();
62
+ const cache = new Map();
63
+ const loadGlb = (path) => {
64
+ if (!cache.has(path)) cache.set(path, loader.loadAsync("/" + path).catch((e) => (console.warn("glb", path, String(e)), null)));
65
+ return cache.get(path);
66
+ };
67
+
68
+ let renderer;
69
+ function getRenderer(w, h) {
70
+ if (!renderer) {
71
+ renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, preserveDrawingBuffer: true });
72
+ renderer.setPixelRatio(1);
73
+ document.body.appendChild(renderer.domElement);
74
+ }
75
+ renderer.setSize(w, h);
76
+ return renderer;
77
+ }
78
+
79
+ const compositeMaterial = new THREE.ShaderMaterial({
80
+ uniforms: {
81
+ tColor: { value: null }, tNormal: { value: null }, tDepth: { value: null }, tId: { value: null },
82
+ texel: { value: new THREE.Vector2() }, depthRange: { value: 1 }, px: { value: 1 },
83
+ shaded: { value: 0 },
84
+ cNormal: { value: new THREE.Color(0x242424) }, cContext: { value: new THREE.Color(0xa8a8a8) },
85
+ cAccent: { value: new THREE.Color(0xd24a00) }, cGhost: { value: new THREE.Color(0xdadada) },
86
+ cGuide: { value: new THREE.Color(0xff5c00) },
87
+ },
88
+ vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }`,
89
+ fragmentShader: `
90
+ precision highp float;
91
+ varying vec2 vUv;
92
+ uniform sampler2D tColor, tNormal, tDepth, tId;
93
+ uniform vec2 texel; uniform float depthRange, px, shaded;
94
+ uniform vec3 cNormal, cContext, cAccent, cGhost, cGuide;
95
+ int styleAt(vec2 uv){ return int(texture2D(tId, uv).b * 255. + .5); }
96
+ vec2 idAt(vec2 uv){ return floor(texture2D(tId, uv).rg * 255. + .5); }
97
+ float dAt(vec2 uv){ return texture2D(tDepth, uv).r * depthRange; }
98
+ vec3 nAt(vec2 uv){ return normalize(texture2D(tNormal, uv).rgb * 2. - 1.); }
99
+ vec3 lineColor(int s){
100
+ if (s == 2) return cContext; if (s == 3) return cAccent; if (s == 4) return cGhost; if (s == 5) return cGuide; return cNormal;
101
+ }
102
+ vec3 toSRGB(vec3 c){ return mix(c * 12.92, 1.055 * pow(c, vec3(1./2.4)) - .055, step(.0031308, c)); }
103
+ void main(){
104
+ int s = styleAt(vUv);
105
+ vec2 id = idAt(vUv);
106
+ float d = dAt(vUv);
107
+ float sil = 0.; int ls = s; float nearest = s == 0 ? 1e9 : d;
108
+ // silhouettes and part boundaries: id changes (radius ~1.5 px)
109
+ for (int i = 0; i < 8; i++) {
110
+ float a = float(i) * 0.7853982;
111
+ vec2 o = vec2(cos(a), sin(a)) * texel * 1.5 * px;
112
+ vec2 idn = idAt(vUv + o);
113
+ if (idn != id) {
114
+ sil = 1.;
115
+ int sn = styleAt(vUv + o);
116
+ float dn = sn == 0 ? 1e9 : dAt(vUv + o);
117
+ if (dn < nearest) { nearest = dn; ls = sn; }
118
+ }
119
+ }
120
+ // self-occlusion: depth laplacian within one part
121
+ float crease = 0.;
122
+ if (s != 0 && sil == 0.) {
123
+ vec2 ox = vec2(texel.x, 0.) * px, oy = vec2(0., texel.y) * px;
124
+ float lap = abs(dAt(vUv + ox) + dAt(vUv - ox) - 2. * d) + abs(dAt(vUv + oy) + dAt(vUv - oy) - 2. * d);
125
+ if (lap > 0.6) sil = 1.;
126
+ vec3 n = nAt(vUv);
127
+ float m = min(min(dot(n, nAt(vUv + ox)), dot(n, nAt(vUv - ox))), min(dot(n, nAt(vUv + oy)), dot(n, nAt(vUv - oy))));
128
+ if (m < 0.82) crease = 1.;
129
+ }
130
+ vec4 base = texture2D(tColor, vUv);
131
+ vec3 fill = toSRGB(base.rgb);
132
+ float alpha = s == 0 ? 0. : 1.;
133
+ if (s == 5 || ls == 5) { sil = 0.; crease = 0.; }
134
+ if (s == 5) fill = toSRGB(cGuide);
135
+ vec3 lc = toSRGB(lineColor(ls));
136
+ float la = sil > 0. ? (shaded > .5 ? .55 : 1.) : crease > 0. ? (shaded > .5 ? .18 : .55) : 0.;
137
+ if (s == 4 && ls == 4) la *= .6;
138
+ vec3 outc = mix(fill, lc, la);
139
+ float outa = max(alpha, la > 0. && sil > 0. ? 1. : 0.);
140
+ if (s == 0) outc = lc;
141
+ gl_FragColor = vec4(outc, outa);
142
+ }`,
143
+ });
144
+
145
+ const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), compositeMaterial);
146
+ const quadScene = new THREE.Scene();
147
+ quadScene.add(quad);
148
+ const quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
149
+
150
+ function rowMajor(m) {
151
+ return new THREE.Matrix4().set(...m);
152
+ }
153
+
154
+ window.renderScene = async function renderScene(spec) {
155
+ const ssaa = spec.ssaa ?? 2;
156
+ const W = spec.width, H = spec.height;
157
+ const IW = W * ssaa, IH = H * ssaa;
158
+ const shaded = spec.mode === "shaded";
159
+ const PAL = shaded ? SHADED : LINEART;
160
+ const r = getRenderer(IW, IH);
161
+
162
+ const scene = new THREE.Scene();
163
+ const idScene = new THREE.Scene();
164
+ const idTargets = [];
165
+ let nextId = 1;
166
+ const fitObjects = [];
167
+
168
+ // lights
169
+ if (shaded) {
170
+ const pm = new THREE.PMREMGenerator(r);
171
+ scene.environment = pm.fromScene(new RoomEnvironment(), 0.03).texture;
172
+ scene.environmentIntensity = 0.85;
173
+ const key = new THREE.DirectionalLight(0xffffff, 1.8);
174
+ key.position.set(150, -220, 400);
175
+ scene.add(key, new THREE.AmbientLight(0xffffff, 0.35));
176
+ } else {
177
+ scene.add(new THREE.AmbientLight(0xffffff, 2.1));
178
+ const key = new THREE.DirectionalLight(0xffffff, 1.1);
179
+ key.position.set(120, -160, 420);
180
+ scene.add(key);
181
+ }
182
+
183
+ const materialCache = new Map();
184
+ const colorMaterial = (styleName, t) => {
185
+ const k = styleName + ":" + t;
186
+ if (!materialCache.has(k)) {
187
+ const hex = styleName === "normal" ? PAL[t] : PAL[styleName] ?? PAL[t];
188
+ const m = shaded
189
+ ? new THREE.MeshStandardMaterial({
190
+ color: hex,
191
+ metalness: styleName === "normal" && METAL.has(t) ? 0.75 : 0.05,
192
+ roughness: styleName === "normal" && METAL.has(t) ? 0.32 : t === "frame" || t === "top_plate" ? 0.42 : 0.6,
193
+ side: THREE.DoubleSide,
194
+ })
195
+ : new THREE.MeshLambertMaterial({ color: hex, side: THREE.DoubleSide });
196
+ materialCache.set(k, m);
197
+ }
198
+ return materialCache.get(k);
199
+ };
200
+ const idMaterial = (id, style) =>
201
+ new THREE.MeshBasicMaterial({ color: new THREE.Color().setRGB((id % 256) / 255, Math.floor(id / 256) / 255, style / 255, THREE.LinearSRGBColorSpace), side: THREE.DoubleSide });
202
+
203
+ const bodies = await Promise.all(spec.bodies.map(async (b) => ({ b, gltf: b.glb ? await loadGlb(b.glb) : null })));
204
+ for (const { b, gltf } of bodies) {
205
+ if (!gltf || b.style === "hidden") continue;
206
+ const hide = new Set(b.hide || []);
207
+ const accentNodes = new Set(b.accentNodes || []);
208
+ const holder = new THREE.Group();
209
+ holder.matrixAutoUpdate = false;
210
+ holder.matrix.copy(rowMajor(b.matrix));
211
+ const wrap = new THREE.Group();
212
+ wrap.rotation.x = Math.PI / 2;
213
+ wrap.scale.setScalar(1000);
214
+ const model = gltf.scene.clone(true);
215
+ wrap.add(model);
216
+ holder.add(wrap);
217
+ scene.add(holder);
218
+ holder.updateMatrixWorld(true);
219
+
220
+ const bodyId = nextId++;
221
+ const accentId = nextId++;
222
+ const meshes = [];
223
+ model.traverse((o) => {
224
+ if (o.name && hide.has(o.name) && !accentNodes.has(o.name)) o.visible = false;
225
+ });
226
+ model.traverse((o) => {
227
+ if (!o.isMesh) return;
228
+ // visible only if no ancestor is hidden
229
+ let v = true;
230
+ for (let p = o; p; p = p.parent) if (p.visible === false) v = false;
231
+ let accent = false;
232
+ for (let p = o; p && p !== model; p = p.parent) if (accentNodes.has(p.name)) accent = true;
233
+ if (accent) v = true;
234
+ if (!v) return;
235
+ let node = o.name;
236
+ for (let p = o; p && p.parent && p.parent !== model; p = p.parent) node = p.parent.name || node;
237
+ if (o.parent === model) node = o.name;
238
+ const style = accent ? "accent" : b.style;
239
+ const t = tone(b.category, nodeTopName(o, model));
240
+ if (b.vendor && style === "normal" && shaded) {
241
+ o.material = o.material.clone();
242
+ } else {
243
+ o.material = colorMaterial(style, b.vendor && !shaded ? b.category : t);
244
+ }
245
+ meshes.push({ mesh: o, style, id: accent ? accentId : bodyId });
246
+ });
247
+ // id scene: clones sharing geometry, same world transform
248
+ for (const { mesh, style, id } of meshes) {
249
+ const c = new THREE.Mesh(mesh.geometry, idMaterial(id, STYLE[style] ?? 1));
250
+ c.matrixAutoUpdate = false;
251
+ c.matrix.copy(mesh.matrixWorld);
252
+ idScene.add(c);
253
+ idTargets.push(c);
254
+ }
255
+ if (b.fit !== false && b.style !== "ghost") fitObjects.push(...meshes.map((m) => m.mesh));
256
+ }
257
+
258
+ // guides: dashed accent rods between world points
259
+ for (const g of spec.guides || []) {
260
+ const a = new THREE.Vector3(...g.from), c = new THREE.Vector3(...g.to);
261
+ const len = a.distanceTo(c);
262
+ if (len < 0.01) continue;
263
+ const dir = c.clone().sub(a).normalize();
264
+ const [dash, gap] = g.dash || [2.2, 1.6];
265
+ const rad = g.radius ?? 0.28;
266
+ const geo = new THREE.CylinderGeometry(rad, rad, 1, 8, 1);
267
+ const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
268
+ for (let s = 0; s < len; s += dash + gap) {
269
+ const l = Math.min(dash, len - s);
270
+ const m = new THREE.Mesh(geo, colorMaterial("guide", "guide"));
271
+ m.scale.set(1, l, 1);
272
+ m.quaternion.copy(q);
273
+ m.position.copy(a.clone().add(dir.clone().multiplyScalar(s + l / 2)));
274
+ m.updateMatrixWorld(true);
275
+ scene.add(m);
276
+ const c2 = new THREE.Mesh(geo, idMaterial(4095, STYLE.guide));
277
+ c2.matrixAutoUpdate = false;
278
+ c2.matrix.copy(m.matrixWorld);
279
+ idScene.add(c2);
280
+ }
281
+ }
282
+ scene.updateMatrixWorld(true);
283
+ idScene.updateMatrixWorld(true);
284
+
285
+ // camera: orthographic, fitted to the projected vertices of the fit set
286
+ const cam = spec.camera || {};
287
+ const dir = new THREE.Vector3(...(cam.dir || [1, -1.15, 0.85])).normalize();
288
+ const up = new THREE.Vector3(...(cam.up || [0, 0, 1]));
289
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10000);
290
+ camera.up.copy(up);
291
+ const box = new THREE.Box3();
292
+ for (const m of fitObjects) box.expandByObject(m);
293
+ if (box.isEmpty()) box.set(new THREE.Vector3(-50, -50, -50), new THREE.Vector3(50, 50, 50));
294
+ const center = box.getCenter(new THREE.Vector3());
295
+ const radius = box.getSize(new THREE.Vector3()).length();
296
+ camera.position.copy(center.clone().add(dir.clone().multiplyScalar(radius * 2)));
297
+ camera.lookAt(center);
298
+ camera.updateMatrixWorld(true);
299
+ const inv = camera.matrixWorldInverse;
300
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity;
301
+ const v = new THREE.Vector3();
302
+ const extra = (spec.anchors || []).filter((a) => a.fit).map((a) => new THREE.Vector3(...a.p));
303
+ for (const m of fitObjects) {
304
+ const pos = m.geometry.attributes.position;
305
+ const step = Math.max(1, Math.floor(pos.count / 20000));
306
+ for (let i = 0; i < pos.count; i += step) {
307
+ v.fromBufferAttribute(pos, i).applyMatrix4(m.matrixWorld).applyMatrix4(inv);
308
+ if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;
309
+ if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;
310
+ if (v.z < minZ) minZ = v.z; if (v.z > maxZ) maxZ = v.z;
311
+ }
312
+ }
313
+ for (const p of extra) {
314
+ v.copy(p).applyMatrix4(inv);
315
+ minX = Math.min(minX, v.x); maxX = Math.max(maxX, v.x); minY = Math.min(minY, v.y); maxY = Math.max(maxY, v.y);
316
+ }
317
+ // include everything visible in depth range
318
+ const all = new THREE.Box3().setFromObject(scene);
319
+ for (const x of [all.min.x, all.max.x]) for (const y of [all.min.y, all.max.y]) for (const z of [all.min.z, all.max.z]) {
320
+ v.set(x, y, z).applyMatrix4(inv);
321
+ minZ = Math.min(minZ, v.z); maxZ = Math.max(maxZ, v.z);
322
+ }
323
+ const margin = cam.margin ?? 0.05;
324
+ let w = maxX - minX, h = maxY - minY;
325
+ const cx = (minX + maxX) / 2 + (cam.shiftX || 0) * w, cy = (minY + maxY) / 2 + (cam.shiftY || 0) * h;
326
+ const aspect = W / H;
327
+ if (w / h > aspect) h = w / aspect; else w = h * aspect;
328
+ w *= 1 + 2 * margin; h *= 1 + 2 * margin;
329
+ if (cam.scaleMmPerPx) { w = W * cam.scaleMmPerPx; h = H * cam.scaleMmPerPx; }
330
+ camera.left = cx - w / 2; camera.right = cx + w / 2; camera.top = cy + h / 2; camera.bottom = cy - h / 2;
331
+ camera.near = Math.max(0.1, -maxZ - 10); camera.far = -minZ + 10;
332
+ camera.updateProjectionMatrix();
333
+
334
+ // passes
335
+ const mk = (depth) => {
336
+ const t = new THREE.WebGLRenderTarget(IW, IH, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, type: THREE.UnsignedByteType });
337
+ if (depth) { t.depthTexture = new THREE.DepthTexture(IW, IH); t.depthTexture.type = THREE.FloatType; }
338
+ return t;
339
+ };
340
+ const rtColor = mk(false), rtNormal = mk(true), rtId = mk(false);
341
+ rtColor.texture.minFilter = THREE.LinearFilter;
342
+ r.setClearColor(0x000000, 0);
343
+ r.toneMapping = THREE.NoToneMapping;
344
+ r.setRenderTarget(rtColor); r.clear(); r.render(scene, camera);
345
+ const normalMat = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide });
346
+ scene.overrideMaterial = normalMat;
347
+ r.setRenderTarget(rtNormal); r.clear(); r.render(scene, camera);
348
+ scene.overrideMaterial = null;
349
+ r.setRenderTarget(rtId); r.clear(); r.render(idScene, camera);
350
+ r.setRenderTarget(null);
351
+
352
+ const u = compositeMaterial.uniforms;
353
+ u.tColor.value = rtColor.texture; u.tNormal.value = rtNormal.texture; u.tDepth.value = rtNormal.depthTexture; u.tId.value = rtId.texture;
354
+ u.texel.value.set(1 / IW, 1 / IH);
355
+ u.depthRange.value = camera.far - camera.near;
356
+ u.px.value = (spec.lineScale ?? 1) * ssaa / 2;
357
+ u.shaded.value = shaded ? 1 : 0;
358
+ r.setClearColor(0x000000, 0);
359
+ r.clear();
360
+ r.render(quadScene, quadCam);
361
+
362
+ // downsample (SSAA)
363
+ const out = document.createElement("canvas");
364
+ out.width = W; out.height = H;
365
+ const ctx = out.getContext("2d");
366
+ ctx.imageSmoothingEnabled = true;
367
+ ctx.imageSmoothingQuality = "high";
368
+ ctx.drawImage(r.domElement, 0, 0, W, H);
369
+ const png = out.toDataURL("image/png");
370
+
371
+ const project = (p) => {
372
+ const q = new THREE.Vector3(...p).project(camera);
373
+ return { x: ((q.x + 1) / 2) * W, y: ((1 - q.y) / 2) * H };
374
+ };
375
+ const anchors = (spec.anchors || []).map((a) => ({ id: a.id, ...project(a.p) }));
376
+ const mmPerPx = (camera.right - camera.left) / W;
377
+
378
+ for (const t of [rtColor, rtNormal, rtId]) t.dispose();
379
+ scene.traverse((o) => { if (o.isMesh && o.geometry && o.geometry.type === "CylinderGeometry") o.geometry.dispose(); });
380
+ return { png, anchors, mmPerPx };
381
+ };
382
+
383
+ function nodeTopName(o, model) {
384
+ // name of the top-level part node under the model (GLB root child), e.g. "bell", "pcb"
385
+ let p = o;
386
+ while (p.parent && p.parent !== model && p.parent.parent !== model) p = p.parent;
387
+ return p.name || o.name;
388
+ }
389
+
390
+ window.__ready = true;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Node side of the figure renderer: one server + one headless Chrome for a
3
+ * whole build, figures cached by content hash (spec + GLB bytes' mtime/size)
4
+ * so re-running a build only re-renders what changed.
5
+ */
6
+ import { createHash } from "crypto";
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
8
+ import { join } from "path";
9
+ import { launchBrowser, type Browser } from "./cdp.ts";
10
+ import { resolveServed, startServer, type ServerRoots, type StaticServer } from "./server.ts";
11
+
12
+ export interface RenderBody {
13
+ key: string;
14
+ /** GLB as a served path ("project/…" or "pkg/<package>/…", server.ts). */
15
+ glb?: string;
16
+ /** Row-major 4x4, mm, assembly root coordinates. */
17
+ matrix: number[];
18
+ style: "normal" | "context" | "accent" | "ghost" | "hidden";
19
+ category: string;
20
+ /** GLB node names not drawn (interface feature sub-shapes). */
21
+ hide?: string[];
22
+ /** GLB node names drawn in the accent style (interface features to call out). */
23
+ accentNodes?: string[];
24
+ vendor?: boolean;
25
+ /** Excluded from the camera fit when false. */
26
+ fit?: boolean;
27
+ }
28
+
29
+ export interface RenderSpec {
30
+ width: number;
31
+ height: number;
32
+ mode: "lineart" | "shaded";
33
+ ssaa?: number;
34
+ lineScale?: number;
35
+ bodies: RenderBody[];
36
+ guides?: { from: number[]; to: number[]; dash?: [number, number]; radius?: number }[];
37
+ anchors?: { id: string; p: number[]; fit?: boolean }[];
38
+ camera?: { dir?: number[]; up?: number[]; margin?: number; shiftX?: number; shiftY?: number; scaleMmPerPx?: number };
39
+ }
40
+
41
+ export interface RenderResult {
42
+ file: string;
43
+ width: number;
44
+ height: number;
45
+ anchors: Record<string, { x: number; y: number }>;
46
+ mmPerPx: number;
47
+ }
48
+
49
+ export class FigureRenderer {
50
+ private browser?: Browser;
51
+ private server?: StaticServer;
52
+ private ready = false;
53
+ private cacheFile: string;
54
+ private cache: Record<string, Omit<RenderResult, "file"> & { file: string }>;
55
+ rendered = 0;
56
+ reused = 0;
57
+
58
+ constructor(
59
+ private outDir: string,
60
+ private roots: ServerRoots,
61
+ ) {
62
+ mkdirSync(outDir, { recursive: true });
63
+ this.cacheFile = join(outDir, ".render-cache.json");
64
+ this.cache = existsSync(this.cacheFile) ? JSON.parse(readFileSync(this.cacheFile, "utf8")) : {};
65
+ }
66
+
67
+ private async init() {
68
+ if (this.ready) return;
69
+ this.server ??= await startServer(this.roots);
70
+ this.browser ??= await launchBrowser();
71
+ await this.browser.setViewport(800, 600);
72
+ await this.browser.navigate(`${this.server.url}/page/render.html`);
73
+ for (let i = 0; i < 100; i++) {
74
+ if (await this.browser.evaluate<boolean>("Boolean(window.__ready)")) break;
75
+ await new Promise((r) => setTimeout(r, 100));
76
+ }
77
+ this.ready = true;
78
+ }
79
+
80
+ private hash(spec: RenderSpec): string {
81
+ const h = createHash("sha256");
82
+ h.update(JSON.stringify(spec));
83
+ h.update(readFileSync(new URL("./page/render.js", import.meta.url)));
84
+ for (const b of spec.bodies) {
85
+ if (!b.glb) continue;
86
+ const p = resolveServed(this.roots, b.glb);
87
+ if (p && existsSync(p)) {
88
+ const s = statSync(p);
89
+ h.update(`${b.glb}:${s.size}:${s.mtimeMs}`);
90
+ }
91
+ }
92
+ return h.digest("hex").slice(0, 16);
93
+ }
94
+
95
+ /** Render `spec` to `<outDir>/<name>.png`; reuses the previous result when nothing changed. */
96
+ async render(name: string, spec: RenderSpec): Promise<RenderResult> {
97
+ const file = join(this.outDir, `${name}.png`);
98
+ const key = this.hash(spec);
99
+ const hit = this.cache[name];
100
+ if (hit && hit.file === key && existsSync(file)) {
101
+ this.reused++;
102
+ return { ...hit, file };
103
+ }
104
+ await this.init();
105
+ const r = await this.browser!.evaluate<{ png: string; anchors: { id: string; x: number; y: number }[]; mmPerPx: number }>(
106
+ `window.renderScene(${JSON.stringify(spec)})`,
107
+ );
108
+ writeFileSync(file, Buffer.from(r.png.split(",")[1], "base64"));
109
+ const result = {
110
+ width: spec.width,
111
+ height: spec.height,
112
+ anchors: Object.fromEntries(r.anchors.map((a) => [a.id, { x: a.x, y: a.y }])),
113
+ mmPerPx: r.mmPerPx,
114
+ };
115
+ this.cache[name] = { ...result, file: key };
116
+ writeFileSync(this.cacheFile, JSON.stringify(this.cache, null, 1));
117
+ this.rendered++;
118
+ return { ...result, file };
119
+ }
120
+
121
+ /**
122
+ * Load a draft document (a served path, server.ts), let its flow script
123
+ * paginate it, and return the paginated HTML with the script removed.
124
+ */
125
+ async paginate(htmlPath: string): Promise<{ html: string; pages: number; overflow: number[] }> {
126
+ await this.init();
127
+ const b = this.browser!;
128
+ await b.setViewport(1200, 1600);
129
+ await b.navigate(`${this.server!.url}/${htmlPath}`);
130
+ this.ready = false; // the render page is no longer loaded
131
+ // the flow script waits for fonts and images, then paginates; never return a half-paginated page
132
+ let done = false;
133
+ for (let i = 0; i < 1200 && !done; i++) {
134
+ done = await b.evaluate<boolean>("Boolean(window.__flowDone)");
135
+ if (!done) await new Promise((r) => setTimeout(r, 50));
136
+ }
137
+ if (!done) throw new Error(`${htmlPath}: pagination did not finish within 60 s`);
138
+ const r = await b.evaluate<{ html: string; pages: number; overflow: number[] }>(`(() => {
139
+ document.getElementById("flow-script")?.remove();
140
+ const pages = [...document.querySelectorAll(".page")];
141
+ return {
142
+ html: "<!doctype html>\\n" + document.documentElement.outerHTML,
143
+ pages: pages.length,
144
+ overflow: pages.map((p, i) => p.classList.contains("overflow") ? i + 1 : 0).filter(Boolean),
145
+ };
146
+ })()`);
147
+ return r;
148
+ }
149
+
150
+ /** Print an HTML file (a served path) to PDF. */
151
+ async printPdf(htmlPath: string): Promise<Buffer> {
152
+ await this.init();
153
+ this.ready = false;
154
+ return this.browser!.printToPdf(`${this.server!.url}/${htmlPath}`);
155
+ }
156
+
157
+ async close() {
158
+ await this.browser?.close();
159
+ await this.server?.close();
160
+ }
161
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tiny static file server for the doc renderer and the print step.
3
+ *
4
+ * /project/<path> files of the project (documents, local CAD)
5
+ * /pkg/<package>/<path> files of an installed package that a catalog
6
+ * definition's artifacts resolve against
7
+ * (the package name is one URL-encoded segment)
8
+ * /three/<path> the three.js package (see resolveThree)
9
+ * /page/<path> the render page (lib/render/page)
10
+ *
11
+ * Bound to 127.0.0.1 on a random port; closed when the build finishes.
12
+ */
13
+ import { createServer, type Server } from "node:http";
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { dirname, extname, join, normalize } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const PAGE_DIR = fileURLToPath(new URL("./page/", import.meta.url));
19
+ const PACKAGE_DIR = fileURLToPath(new URL("../../", import.meta.url));
20
+
21
+ /** Where served paths point: the project root, and installed packages by name. */
22
+ export interface ServerRoots {
23
+ project: string;
24
+ packageRoot: (name: string) => string | undefined;
25
+ }
26
+
27
+ /** "project/<rel>" for a project file; "pkg/<encoded name>/<rel>" for a package file. */
28
+ export const projectPath = (rel: string) => `project/${rel}`;
29
+ export const packagePath = (name: string, rel: string) => `pkg/${encodeURIComponent(name)}/${rel}`;
30
+
31
+ /** The absolute file a served path names, or undefined outside the roots. */
32
+ export function resolveServed(roots: ServerRoots, served: string): string | undefined {
33
+ const [mount, ...rest] = served.split("/").filter(Boolean);
34
+ let root: string | undefined;
35
+ let parts = rest;
36
+ if (mount === "project") root = roots.project;
37
+ else if (mount === "pkg" && rest.length) {
38
+ root = roots.packageRoot(decodeURIComponent(rest[0]));
39
+ parts = rest.slice(1);
40
+ }
41
+ if (!root) return undefined;
42
+ const file = normalize(join(root, ...parts.map(decodeURIComponent)));
43
+ return file.startsWith(normalize(root)) ? file : undefined;
44
+ }
45
+
46
+ /**
47
+ * three.js serves the render page. Use, in order: $PROTOBOARD_THREE_DIR (or
48
+ * the older $UHD_THREE_DIR), the project's own three, then this extension's
49
+ * dependency.
50
+ */
51
+ export function resolveThree(projectRoot: string): string {
52
+ // node_modules/three from `base` upward (three's exports do not include package.json)
53
+ const installed = (base: string) => {
54
+ for (let dir = base; ; dir = dirname(dir)) {
55
+ const c = join(dir, "node_modules", "three");
56
+ if (existsSync(join(c, "build/three.module.js"))) return c;
57
+ if (dirname(dir) === dir) return undefined;
58
+ }
59
+ };
60
+ const candidates = [process.env.PROTOBOARD_THREE_DIR, process.env.UHD_THREE_DIR, installed(projectRoot), installed(PACKAGE_DIR)].filter(Boolean) as string[];
61
+ for (const c of candidates) if (existsSync(join(c, "build/three.module.js"))) return c;
62
+ throw new Error(`three.js not found. Install @protoboardai/ext-docs's dependencies, or set PROTOBOARD_THREE_DIR to a three@>=0.160 package directory (tried: ${candidates.join(", ") || "none"}).`);
63
+ }
64
+
65
+ const TYPES: Record<string, string> = {
66
+ ".html": "text/html; charset=utf-8",
67
+ ".js": "text/javascript",
68
+ ".mjs": "text/javascript",
69
+ ".css": "text/css",
70
+ ".json": "application/json",
71
+ ".glb": "model/gltf-binary",
72
+ ".png": "image/png",
73
+ ".svg": "image/svg+xml",
74
+ ".woff2": "font/woff2",
75
+ ".woff": "font/woff",
76
+ ".ttf": "font/ttf",
77
+ };
78
+
79
+ export interface StaticServer {
80
+ url: string;
81
+ close: () => Promise<void>;
82
+ }
83
+
84
+ export async function startServer(roots: ServerRoots): Promise<StaticServer> {
85
+ const fixed: Record<string, string> = { three: resolveThree(roots.project), page: PAGE_DIR };
86
+ const server: Server = createServer((req, res) => {
87
+ const url = new URL(req.url ?? "/", "http://x");
88
+ const [mount, ...rest] = url.pathname.split("/").filter(Boolean);
89
+ const file = fixed[mount] ? normalize(join(fixed[mount], ...rest.map(decodeURIComponent))) : resolveServed(roots, url.pathname);
90
+ const inside = file && (!fixed[mount] || file.startsWith(fixed[mount]));
91
+ if (!file || !inside || !existsSync(file) || !statSync(file).isFile()) {
92
+ res.writeHead(404).end("not found");
93
+ return;
94
+ }
95
+ res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-store" });
96
+ res.end(readFileSync(file));
97
+ });
98
+ await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
99
+ const addr = server.address();
100
+ const port = typeof addr === "object" && addr ? addr.port : 0;
101
+ return {
102
+ url: `http://127.0.0.1:${port}`,
103
+ close: () => new Promise<void>((ok) => server.close(() => ok())),
104
+ };
105
+ }