@volter/blender-engine 0.1.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 (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. package/wasm/blender_browser.wasm.br +0 -0
@@ -0,0 +1,511 @@
1
+ /**
2
+ * The tab-side handle on a Blender session: spawns the worker, forwards the
3
+ * four tool calls, and hands every presented frame to whoever displays it.
4
+ * One instance per editor tab; the session and its model die with the worker.
5
+ */
6
+ import type {
7
+ CaptureRequest,
8
+ FileEntry,
9
+ RuntimeStart,
10
+ WorkerReply,
11
+ WorkerRequest,
12
+ } from './protocol';
13
+ import type {
14
+ BlenderActionClip,
15
+ BlenderNodeTree,
16
+ BlenderOutlinerTree,
17
+ BlenderOutlinerWrite,
18
+ BlenderRig,
19
+ BlenderRnaContext,
20
+ BlenderRnaView,
21
+ BlenderRnaWrite,
22
+ BlenderUvLayout,
23
+ } from './rna';
24
+
25
+ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
26
+ type Request = DistributiveOmit<WorkerRequest, 'id'>;
27
+
28
+ export interface BlenderRuntimeOptions {
29
+ /** Display a frame. A screenshot `capture` has its view REMEMBERED so the
30
+ * next document capture photographs what the agent asked for; a render
31
+ * capture (`capture.render`) is photographed here and now, and its answer
32
+ * is what this returns to the operator waiting on it.
33
+ *
34
+ * `description` is the same frame with its columns replaced by digests
35
+ * (`protocol.ts`), for whoever displays it to keep as the record of what was
36
+ * submitted. */
37
+ present(
38
+ frame: unknown,
39
+ description: unknown,
40
+ capture?: CaptureRequest,
41
+ ): Promise<PresentAnswer> | PresentAnswer;
42
+ log?(level: 'log' | 'error', text: string): void;
43
+ }
44
+
45
+ /**
46
+ * What the tab answers a present with.
47
+ *
48
+ * `held` is WHAT THE PRESENTER HELD BEFORE THIS FRAME — the presenter's own
49
+ * report of its state, which the session compares against its record of what
50
+ * it sent (`session.py::_present`). `null` says it held nothing; an ABSENT
51
+ * field says this presenter does not report one (an older editor integration), and
52
+ * the session then judges nothing rather than resetting on every frame.
53
+ */
54
+ export interface PresentAnswer {
55
+ capture?: unknown;
56
+ held?: { session: string; revision: number } | null;
57
+ }
58
+
59
+ /**
60
+ * WHO MODELLED THIS, AND AT WHAT STATE — the two fields every presented frame
61
+ * carries about the session that produced it (`browser/session.py`'s `Session`:
62
+ * a per-session id, and a `revision` that advances once per present).
63
+ *
64
+ * Read by anything that has to say WHERE a byte came from rather than merely
65
+ * display it — `blender-list-files` answers with it, so the CLI's write-back
66
+ * door can record which session's model a mirrored file was exported from
67
+ * (`packages/vgai-cli/src/blender-mcp.ts`, class Mirror). Null until the
68
+ * session's first present.
69
+ */
70
+ export interface PresentedState {
71
+ readonly session: string;
72
+ readonly revision: number;
73
+ }
74
+
75
+ export interface ScreenshotView {
76
+ size: number;
77
+ position?: number[];
78
+ target?: number[];
79
+ }
80
+
81
+ /**
82
+ * HOW LONG THE WORKER TOOK, measured by the PAGE.
83
+ *
84
+ * WHY THE PAGE MEASURES (2026-09-16): a single `blender-execute` held the
85
+ * worker for over 1,800s and wedged the tab, and the only report anyone got
86
+ * was a replay harness timing out. A blocked worker cannot report on itself —
87
+ * the loop that would send the number is the loop that is stuck — so the one
88
+ * vantage point that still works is the side that POSTED the message. Every
89
+ * call goes through {@link BlenderRuntime.metrics}'s meter, which stamps the
90
+ * `postMessage` and the reply.
91
+ *
92
+ * MEASUREMENT ONLY. Nothing here cancels, kills or budgets a call: a budget is
93
+ * a policy decision, and this is the number a policy would have to be made
94
+ * from.
95
+ *
96
+ * Times are milliseconds on `performance.now()`. Counters are monotonic since
97
+ * this runtime was constructed (one per page load).
98
+ */
99
+ export interface BlenderCallMetrics {
100
+ /** Age of the OLDEST outstanding call, or null when the worker is idle.
101
+ * This is the field that has a number during the wedge — the counters
102
+ * below only learn about a call when it comes back. */
103
+ readonly inFlightMs: number | null;
104
+ /** Duration of the newest COMPLETED call; null before the first one. */
105
+ readonly lastCallMs: number | null;
106
+ /** The longest call yet, counting an outstanding one at its current age —
107
+ * otherwise the worst call this session has ever seen is invisible for
108
+ * exactly as long as it keeps running. */
109
+ readonly maxCallMs: number | null;
110
+ /** Calls whose duration passed 5s, counting outstanding ones already past it. */
111
+ readonly callsOver5s: number;
112
+ /** The same at 30s — a call this side of it is slow, past it is a wedge. */
113
+ readonly callsOver30s: number;
114
+ /** The newest call's window on the `performance.now()` clock (`end` null
115
+ * while it is outstanding). The editor's long-task observer intersects its
116
+ * own entries with this to say what the MAIN thread was doing during the
117
+ * call; nothing else reads it. */
118
+ readonly lastCallWindow: { readonly start: number; readonly end: number | null } | null;
119
+ /**
120
+ * The module's LINEAR MEMORY in MB, as the worker last reported it
121
+ * (`protocol.ts`'s `memory` reply); null before the session's first call.
122
+ *
123
+ * The engine's memory, which is a different question from every other number
124
+ * on this tab: the census's `heapUsedMB` is the PAGE's JS heap and the
125
+ * renderer's RSS is everything at once. wasm32 memory never shrinks, so this
126
+ * is also the high-water mark and no separate peak is kept.
127
+ */
128
+ readonly wasmMemoryMB: number | null;
129
+ }
130
+
131
+ export class BlenderRuntime {
132
+ readonly #worker: Worker;
133
+ readonly #options: BlenderRuntimeOptions;
134
+ readonly #pending = new Map<
135
+ number,
136
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
137
+ >();
138
+ #nextId = 0;
139
+ #started: Promise<RuntimeStart> | null = null;
140
+ /** `performance.now()` at the `postMessage` of every outstanding call. */
141
+ readonly #callStarts = new Map<number, number>();
142
+ #lastCallMs: number | null = null;
143
+ #maxCompletedMs = 0;
144
+ #completedOver5s = 0;
145
+ #completedOver30s = 0;
146
+ #lastCallWindow: { start: number; end: number | null } | null = null;
147
+ #presented: PresentedState | null = null;
148
+ /** Bytes of module linear memory at the worker's last report; see
149
+ * {@link BlenderCallMetrics.wasmMemoryMB}. */
150
+ #wasmBytes: number | null = null;
151
+
152
+ constructor(options: BlenderRuntimeOptions) {
153
+ this.#options = options;
154
+ this.#worker = new Worker(new URL('./worker.ts', import.meta.url), {
155
+ type: 'module',
156
+ name: 'blender',
157
+ });
158
+ this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
159
+ this.#worker.onerror = (event) => {
160
+ // A module worker that fails to LOAD reports an ErrorEvent with an empty
161
+ // message, so `${event.message}` alone said "undefined" and named
162
+ // nothing. Every field the event carries goes in: the thrown error's own
163
+ // message and stack when there is one, and the file and line otherwise,
164
+ // which is what a failed nested import leaves behind.
165
+ const thrown = event.error as Error | undefined;
166
+ const where = event.filename ? ` at ${event.filename}:${event.lineno}:${event.colno}` : '';
167
+ const said = thrown?.stack ?? thrown?.message ?? event.message;
168
+ const error = new Error(
169
+ `Blender worker failed: ${said || 'the worker script did not load'}${where}`,
170
+ );
171
+ // The page console is the editor's ledger (`installEditorConsoleReporting`
172
+ // captures it, source-blind), and this package may import nothing of the
173
+ // editor to say it any other way. Without this line the failure reached
174
+ // a toast and `vgai console` read 0/0 while no Model document could open
175
+ // (measured 2026-09-20 from a registry install).
176
+ console.error(error.message);
177
+ for (const id of [...this.#pending.keys()]) this.#settled(id);
178
+ for (const pending of this.#pending.values()) pending.reject(error);
179
+ this.#pending.clear();
180
+ };
181
+ }
182
+
183
+ #project: string | null = null;
184
+ #document: string | null = null;
185
+
186
+ get project(): string | null {
187
+ return this.#project;
188
+ }
189
+
190
+ /** Idempotent: the first call boots Blender at `project`'s
191
+ * absolute path; later calls await it (a different path is refused).
192
+ *
193
+ * `document` is the session's `.blend`, project-relative. An explicit
194
+ * different file is a resource conflict, including while boot is pending.
195
+ * Omitting it on an already-started handle means await that same session. */
196
+ start(project: string, document?: string): Promise<RuntimeStart> {
197
+ if (this.#project !== null && this.#project !== project)
198
+ return Promise.reject(
199
+ new Error(`The Blender session is bound to ${this.#project}, not ${project}`),
200
+ );
201
+ if (document !== undefined && this.#document !== null && this.#document !== document)
202
+ return Promise.reject(
203
+ new Error(
204
+ `Blender resource conflict: the session holds ${this.#document}; cannot open ${document}`,
205
+ ),
206
+ );
207
+ this.#project = project;
208
+ this.#document ??= document ?? 'models/model.blend';
209
+ this.#started ??= this.#request({
210
+ op: 'start',
211
+ project,
212
+ ...(document === undefined ? {} : { document }),
213
+ }) as Promise<RuntimeStart>;
214
+ return this.#started;
215
+ }
216
+
217
+ #ready(): Promise<RuntimeStart> {
218
+ if (this.#project === null)
219
+ return Promise.reject(
220
+ new Error('The Blender session has not been started with a project (blender-start)'),
221
+ );
222
+ return this.start(this.#project);
223
+ }
224
+
225
+ async execute(code: string): Promise<string> {
226
+ await this.#ready();
227
+ return (await this.#request({ op: 'execute', code })) as string;
228
+ }
229
+
230
+ async sceneInfo(): Promise<string> {
231
+ await this.#ready();
232
+ return (await this.#request({ op: 'scene-info' })) as string;
233
+ }
234
+
235
+ async objectInfo(name: string): Promise<string> {
236
+ await this.#ready();
237
+ return (await this.#request({ op: 'object-info', name })) as string;
238
+ }
239
+
240
+ /** One datablock's whole RNA surface (`./rna.ts`). A path that names a
241
+ * COLLECTION answers with its members instead of its properties. */
242
+ async rna(path: string, names?: number): Promise<BlenderRnaView> {
243
+ await this.#ready();
244
+ return (await this.#request({
245
+ op: 'rna',
246
+ path,
247
+ ...(names === undefined ? {} : { names }),
248
+ })) as BlenderRnaView;
249
+ }
250
+
251
+ /** The Properties context: the active object (or the one the caller NAMES),
252
+ * its active bone / material slot / modifier / vertex group, and the tabs
253
+ * Blender would show for it. */
254
+ async rnaContext(object?: string, collection?: string): Promise<BlenderRnaContext> {
255
+ await this.#ready();
256
+ return (await this.#request({
257
+ op: 'rna-context',
258
+ ...(object === undefined ? {} : { object }),
259
+ ...(collection === undefined ? {} : { collection }),
260
+ })) as BlenderRnaContext;
261
+ }
262
+
263
+ /** Write ONE property through bpy. Rejects by name when Blender's own RNA
264
+ * says the property is read-only. */
265
+ async rnaSet(
266
+ path: string,
267
+ property: string,
268
+ value: unknown,
269
+ index?: number,
270
+ ): Promise<BlenderRnaWrite> {
271
+ await this.#ready();
272
+ return (await this.#request({
273
+ op: 'rna-set',
274
+ path,
275
+ property,
276
+ value,
277
+ ...(index === undefined ? {} : { index }),
278
+ })) as BlenderRnaWrite;
279
+ }
280
+
281
+ /** BLENDER'S VIEW LAYER TREE for the scene — what its Outliner shows, as
282
+ * rows (`./rna.ts`, `BlenderOutlinerTree`). `selected` is our viewport's
283
+ * selection by object name; reading it never writes the engine's. */
284
+ async outliner(selected?: readonly string[]): Promise<BlenderOutlinerTree> {
285
+ await this.#ready();
286
+ return (await this.#request({
287
+ op: 'outliner',
288
+ ...(selected === undefined ? {} : { selected }),
289
+ })) as BlenderOutlinerTree;
290
+ }
291
+
292
+ /** ONE MATERIAL'S SHADER NODE TREE, whole (`./rna.ts`, `BlenderNodeTree`) —
293
+ * what the node editor draws. Given neither `path` nor `material`, the
294
+ * active object's active material answers, which is what Blender's own
295
+ * Shading header resolves. */
296
+ async nodeTree(options?: { path?: string; material?: string }): Promise<BlenderNodeTree> {
297
+ await this.#ready();
298
+ return (await this.#request({
299
+ op: 'node-tree',
300
+ ...(options?.path === undefined ? {} : { path: options.path }),
301
+ ...(options?.material === undefined ? {} : { material: options.material }),
302
+ })) as BlenderNodeTree;
303
+ }
304
+
305
+ /** ONE MESH'S UV LAYOUT (`./rna.ts`, `BlenderUvLayout`) — what the UV
306
+ * editor draws. Given no `object`, the view layer's ACTIVE object answers,
307
+ * which is the only subject there is outside edit mode. */
308
+ async uvLayout(options?: { object?: string; uvLayer?: string }): Promise<BlenderUvLayout> {
309
+ await this.#ready();
310
+ return (await this.#request({
311
+ op: 'uv-layout',
312
+ ...(options?.object === undefined ? {} : { object: options.object }),
313
+ ...(options?.uvLayer === undefined ? {} : { uvLayer: options.uvLayer }),
314
+ })) as BlenderUvLayout;
315
+ }
316
+
317
+ /** ONE MESH'S SKIN BINDING (`./rna.ts`, `BlenderRig`) — the armature's
318
+ * bones and the per-vertex influences a `THREE.SkinnedMesh` needs. Given no
319
+ * `object`, the view layer's ACTIVE mesh answers. */
320
+ async rig(options?: { object?: string }): Promise<BlenderRig> {
321
+ await this.#ready();
322
+ return (await this.#request({
323
+ op: 'rig',
324
+ ...(options?.object === undefined ? {} : { object: options.object }),
325
+ })) as BlenderRig;
326
+ }
327
+
328
+ /** ONE ACTION AS A THREE.JS CLIP (`./rna.ts`, `BlenderActionClip`). `bake:
329
+ * false` answers the header and the summary row's key columns without the
330
+ * sampled tracks, which is what a Timeline that only needs to DRAW asks
331
+ * for. */
332
+ async actionClip(options?: { object?: string; bake?: boolean }): Promise<BlenderActionClip> {
333
+ await this.#ready();
334
+ return (await this.#request({
335
+ op: 'action-clip',
336
+ ...(options?.object === undefined ? {} : { object: options.object }),
337
+ ...(options?.bake === undefined ? {} : { bake: options.bake }),
338
+ })) as BlenderActionClip;
339
+ }
340
+
341
+ /** Write ONE restriction column (the eye, the render camera, a collection's
342
+ * Exclude). A column Blender draws on no row of that type is refused by
343
+ * name rather than written somewhere near it. */
344
+ async outlinerSet(path: string, column: string, value: boolean): Promise<BlenderOutlinerWrite> {
345
+ await this.#ready();
346
+ return (await this.#request({
347
+ op: 'outliner-set',
348
+ path,
349
+ column,
350
+ value,
351
+ })) as BlenderOutlinerWrite;
352
+ }
353
+
354
+ /**
355
+ * PRESENT WHAT THE ENGINE HOLDS, with no capture.
356
+ *
357
+ * Every mutation presents (`session.py::dispatch`), so in the ordinary
358
+ * course of a session nothing needs to ask. The exception is the first
359
+ * frame: opening a `.blend` is not a mutation, so a freshly opened Model
360
+ * document had nothing to display until something RAN — measured by I1 and
361
+ * fixed here (WORK.md §Blender in the tab is Blender, "Inspection parity",
362
+ * I2 decision 6). The document asks on mount.
363
+ */
364
+ async present(): Promise<void> {
365
+ await this.#ready();
366
+ await this.#request({ op: 'present' });
367
+ }
368
+
369
+ /** Presents the model with the viewport's camera and returns that view. */
370
+ async screenshotView(maxSize: number): Promise<{ view: ScreenshotView } | { error: string }> {
371
+ await this.#ready();
372
+ return (await this.#request({ op: 'screenshot-view', maxSize })) as
373
+ | { view: ScreenshotView }
374
+ | { error: string };
375
+ }
376
+
377
+ async readFile(path: string): Promise<Uint8Array> {
378
+ await this.#ready();
379
+ return (await this.#request({ op: 'read-file', path })) as Uint8Array;
380
+ }
381
+
382
+ async writeFile(path: string, bytes: Uint8Array): Promise<void> {
383
+ await this.#ready();
384
+ await this.#request({ op: 'write-file', path, bytes });
385
+ }
386
+
387
+ async listFiles(path: string): Promise<FileEntry[]> {
388
+ await this.#ready();
389
+ return (await this.#request({ op: 'list-files', path })) as FileEntry[];
390
+ }
391
+
392
+ /** The session and revision of the last frame this runtime forwarded; null
393
+ * before the first present. See {@link PresentedState}. */
394
+ get presented(): PresentedState | null {
395
+ return this.#presented;
396
+ }
397
+
398
+ terminate(): void {
399
+ this.#worker.terminate();
400
+ const error = new Error('The Blender session was terminated');
401
+ for (const id of [...this.#pending.keys()]) this.#settled(id);
402
+ for (const pending of this.#pending.values()) pending.reject(error);
403
+ this.#pending.clear();
404
+ }
405
+
406
+ /**
407
+ * What this tab knows about the worker's responsiveness, right now.
408
+ *
409
+ * Computed at READ time rather than accumulated, because the interesting
410
+ * call is the one that has not come back: an outstanding call contributes to
411
+ * `maxCallMs` and to the two bucket counts at its CURRENT age, so a wedge
412
+ * shows up while it is happening instead of only in its post-mortem.
413
+ *
414
+ * A `present` is not counted separately — the worker only presents from
415
+ * inside a call this meter is already holding open, and the page-side cost of
416
+ * displaying the frame is main-thread time, which is the long-task observer's
417
+ * subject (`packages/editor/src/blender-tab-metrics.ts`).
418
+ */
419
+ metrics(now: number = performance.now()): BlenderCallMetrics {
420
+ let oldest: number | null = null;
421
+ let max = this.#maxCompletedMs;
422
+ let over5 = this.#completedOver5s;
423
+ let over30 = this.#completedOver30s;
424
+ for (const started of this.#callStarts.values()) {
425
+ const elapsed = now - started;
426
+ if (oldest === null || elapsed > oldest) oldest = elapsed;
427
+ if (elapsed > max) max = elapsed;
428
+ if (elapsed >= 5_000) over5 += 1;
429
+ if (elapsed >= 30_000) over30 += 1;
430
+ }
431
+ return {
432
+ inFlightMs: oldest === null ? null : Math.round(oldest),
433
+ lastCallMs: this.#lastCallMs === null ? null : Math.round(this.#lastCallMs),
434
+ // Zero here would mean "no call has ever taken any time", which is a
435
+ // different claim from "no call has happened yet".
436
+ maxCallMs: this.#lastCallMs === null && oldest === null ? null : Math.round(max),
437
+ callsOver5s: over5,
438
+ callsOver30s: over30,
439
+ lastCallWindow: this.#lastCallWindow,
440
+ wasmMemoryMB: this.#wasmBytes === null ? null : Math.round(this.#wasmBytes / 1048576),
441
+ };
442
+ }
443
+
444
+ /** Close a call's window, whether it answered, threw, or was terminated. */
445
+ #settled(id: number): void {
446
+ const started = this.#callStarts.get(id);
447
+ if (started === undefined) return;
448
+ this.#callStarts.delete(id);
449
+ const elapsed = performance.now() - started;
450
+ this.#lastCallMs = elapsed;
451
+ if (elapsed > this.#maxCompletedMs) this.#maxCompletedMs = elapsed;
452
+ if (elapsed >= 5_000) this.#completedOver5s += 1;
453
+ if (elapsed >= 30_000) this.#completedOver30s += 1;
454
+ if (this.#lastCallWindow !== null && this.#lastCallWindow.start === started)
455
+ this.#lastCallWindow = { start: started, end: started + elapsed };
456
+ }
457
+
458
+ #request(request: Request): Promise<unknown> {
459
+ const id = ++this.#nextId;
460
+ return new Promise((resolve, reject) => {
461
+ this.#pending.set(id, { resolve, reject });
462
+ const started = performance.now();
463
+ this.#callStarts.set(id, started);
464
+ this.#lastCallWindow = { start: started, end: null };
465
+ this.#worker.postMessage({ ...request, id });
466
+ });
467
+ }
468
+
469
+ async #receive(reply: WorkerReply): Promise<void> {
470
+ if ('op' in reply) {
471
+ if (reply.op === 'log') {
472
+ this.#options.log?.(reply.level, reply.text);
473
+ return;
474
+ }
475
+ if (reply.op === 'memory') {
476
+ this.#wasmBytes = reply.bytes;
477
+ return;
478
+ }
479
+ // Recorded BEFORE the display, and whether or not the display throws:
480
+ // the fact being kept is that the session reached this revision, which is
481
+ // true the moment the frame arrives. A presenter that refuses the frame
482
+ // has not un-modelled it.
483
+ const frame = reply.frame as { session?: unknown; revision?: unknown } | null;
484
+ if (typeof frame?.session === 'string' && typeof frame.revision === 'number')
485
+ this.#presented = { session: frame.session, revision: frame.revision };
486
+ try {
487
+ const answer = await this.#options.present(reply.frame, reply.description, reply.capture);
488
+ this.#worker.postMessage({
489
+ op: 'present-result',
490
+ id: reply.id,
491
+ ...(answer?.capture === undefined ? {} : { capture: answer.capture }),
492
+ ...(answer !== null && answer !== undefined && 'held' in answer
493
+ ? { held: answer.held }
494
+ : {}),
495
+ } satisfies WorkerRequest);
496
+ } catch (error) {
497
+ this.#worker.postMessage({
498
+ op: 'present-result',
499
+ id: reply.id,
500
+ error: error instanceof Error ? error.message : String(error),
501
+ } satisfies WorkerRequest);
502
+ }
503
+ return;
504
+ }
505
+ const pending = this.#pending.get(reply.id);
506
+ if (!pending) return;
507
+ this.#pending.delete(reply.id);
508
+ this.#settled(reply.id);
509
+ 'error' in reply ? pending.reject(new Error(reply.error)) : pending.resolve(reply.result);
510
+ }
511
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The session's frame, read out of the EXPORT DOOR'S ARENA as typed arrays.
3
+ *
4
+ * The C++ door (`bpy_web_export.cc`) answers one call with a small JSON frame
5
+ * and a side arena of bytes; every large column lives in the arena and the
6
+ * frame names it as `{offset, length, dtype, count, stride}`. Nothing large is
7
+ * ever spelled in the JSON, because the JSON is parsed and the arena is not.
8
+ * This is the other half: each descriptor becomes the typed array the
9
+ * presenter reads, and the buffers are what `presentToTab` transfers.
10
+ *
11
+ * THIS FILE DOES NOT KNOW WHICH BLENDER IT IS SERVING, and that is the seam
12
+ * working. It is handed THE ARENA'S BYTES -- offset zero at the arena's base --
13
+ * by `BlenderEngine.readArena`, which is a view on `HEAPU8` where the host can
14
+ * reach the module's memory and the contents of `export_frame`'s `buffer_path`
15
+ * file where it cannot. Both are valid until the next `export_frame`; nothing
16
+ * else about the two skews reaches this far up.
17
+ *
18
+ * THE COPY IS MANDATORY. On the standalone skew the arena is wasm linear
19
+ * memory, and under `-sPROXY_TO_PTHREAD` that memory is SHARED: a view onto it
20
+ * cannot be transferred to the tab, and the next `export_frame` overwrites it.
21
+ * `.slice()` is what makes the bytes this thread's own -- and it also puts
22
+ * them at offset zero of a fresh buffer, which is what lets a `Float32Array`
23
+ * be built over bytes the arena only aligned to eight.
24
+ */
25
+
26
+ export interface ColumnDescriptor {
27
+ /** Byte offset into the arena. */
28
+ offset: number;
29
+ /** Byte length. */
30
+ length: number;
31
+ dtype: string;
32
+ /** Elements; `count * stride` values. */
33
+ count: number;
34
+ /** Values per element (3 for a position, 2 for an edge, 1 for a flag). */
35
+ stride: number;
36
+ }
37
+
38
+ const isDescriptor = (value: unknown): value is ColumnDescriptor =>
39
+ typeof value === 'object' &&
40
+ value !== null &&
41
+ typeof (value as ColumnDescriptor).offset === 'number' &&
42
+ typeof (value as ColumnDescriptor).length === 'number' &&
43
+ typeof (value as ColumnDescriptor).dtype === 'string' &&
44
+ typeof (value as ColumnDescriptor).count === 'number' &&
45
+ typeof (value as ColumnDescriptor).stride === 'number';
46
+
47
+ type Typed =
48
+ | Float32Array
49
+ | Float64Array
50
+ | Uint32Array
51
+ | Int32Array
52
+ | Uint8Array
53
+ | Int8Array
54
+ | Int16Array
55
+ | Uint16Array;
56
+
57
+ /** The door's own dtype vocabulary -- the C type each column was written from,
58
+ * not numpy's codes: the Python exporter that spoke numpy is gone. */
59
+ const READERS: Record<string, (bytes: Uint8Array) => Typed> = {
60
+ f32: (b) => new Float32Array(b.buffer, b.byteOffset, b.byteLength >> 2),
61
+ f64: (b) => new Float64Array(b.buffer, b.byteOffset, b.byteLength >> 3),
62
+ u32: (b) => new Uint32Array(b.buffer, b.byteOffset, b.byteLength >> 2),
63
+ i32: (b) => new Int32Array(b.buffer, b.byteOffset, b.byteLength >> 2),
64
+ u16: (b) => new Uint16Array(b.buffer, b.byteOffset, b.byteLength >> 1),
65
+ i16: (b) => new Int16Array(b.buffer, b.byteOffset, b.byteLength >> 1),
66
+ u8: (b) => b,
67
+ i8: (b) => new Int8Array(b.buffer, b.byteOffset, b.byteLength),
68
+ };
69
+
70
+ function readColumn(arena: Uint8Array, name: string, descriptor: ColumnDescriptor): Typed {
71
+ const read = READERS[descriptor.dtype];
72
+ if (!read) throw new Error(`Blender frame column ${name}: unknown dtype ${descriptor.dtype}`);
73
+ const { offset, length } = descriptor;
74
+ // ONE PAST THE ARENA IS AN ERROR NAMING THE COLUMN. A descriptor is an offset
75
+ // into the arena; a wrong one reads whatever else lives there, silently.
76
+ if (offset < 0 || length < 0 || offset + length > arena.byteLength)
77
+ throw new Error(
78
+ `Blender frame column ${name}: bytes ${offset}..${offset + length} lie outside the ${arena.byteLength}-byte export arena`,
79
+ );
80
+ const bytes = arena.subarray(offset, offset + length).slice();
81
+ const view = read(bytes);
82
+ const expected = descriptor.count * descriptor.stride;
83
+ if (view.length !== expected)
84
+ throw new Error(
85
+ `Blender frame column ${name}: ${view.length} values, ${expected} declared (${descriptor.count} x ${descriptor.stride})`,
86
+ );
87
+ return view;
88
+ }
89
+
90
+ /**
91
+ * Every `{offset, length, dtype, count, stride}` in the frame, replaced by its
92
+ * typed array.
93
+ *
94
+ * `co` is widened to `Float64Array` because that is what `MeshColumns`
95
+ * declares and `drawArraysFromColumns` is typed against, while Blender's own
96
+ * vertex array is single precision.
97
+ */
98
+ export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown {
99
+ const walk = (value: unknown, key: string): unknown => {
100
+ if (isDescriptor(value)) {
101
+ const view = readColumn(arena, key, value);
102
+ return key === 'co' ? Float64Array.from(view) : view;
103
+ }
104
+ if (Array.isArray(value)) return value.map((entry) => walk(entry, key));
105
+ if (typeof value === 'object' && value !== null) {
106
+ const out: Record<string, unknown> = {};
107
+ for (const [name, held] of Object.entries(value)) out[name] = walk(held, name);
108
+ return out;
109
+ }
110
+ return value;
111
+ };
112
+ return walk(frame, '');
113
+ }
114
+
115
+ /** A column as the RECORD of it: what it was, how many bytes, and their digest.
116
+ * Never the bytes — see `describeFrame`. */
117
+ export interface ColumnDigest {
118
+ dtype: string;
119
+ length: number;
120
+ /** Lowercase hex SHA-256 of exactly the `length` bytes the descriptor names. */
121
+ sha256: string;
122
+ }
123
+
124
+ /**
125
+ * THE FRAME THIS PRESENT SUBMITTED, as a record that can sit in a JSON file.
126
+ *
127
+ * The presenter is asked, later, whether what it DISPLAYS is what the session
128
+ * SENT (`replay_draw_compare.compare_draws`), and that comparison had no second
129
+ * side: the live document keeps the frame's identity and object table but drops
130
+ * every geometry payload the moment it has built the `BufferGeometry` (850 MB of
131
+ * main-thread heap otherwise — `blender-runtime-view.ts`), and nothing else
132
+ * remembered the frame at all.
133
+ *
134
+ * So the worker records it here, while the arena is still the frame's own: every
135
+ * `{offset, length, dtype, count, stride}` becomes `{dtype, length, sha256}` and
136
+ * everything else — objects, materials, cameras, the world, the counts — is
137
+ * copied as it stands. THE COLUMN BYTES NEVER LEAVE THE TAB; a record of the
138
+ * numbers is not the numbers, and a battery run must not write a scene's meshes
139
+ * to disk a second time.
140
+ *
141
+ * This runs BEFORE `presentToTab` posts, for two reasons that are both fatal
142
+ * otherwise: the buffers are TRANSFERRED (detached the instant they are posted),
143
+ * and the arena is overwritten by the next `export_frame`.
144
+ */
145
+ export async function describeFrame(arena: Uint8Array, frame: unknown): Promise<unknown> {
146
+ const walk = async (value: unknown, key: string): Promise<unknown> => {
147
+ if (isDescriptor(value)) {
148
+ const { offset, length, dtype } = value;
149
+ if (offset < 0 || length < 0 || offset + length > arena.byteLength)
150
+ throw new Error(
151
+ `Blender frame column ${key}: bytes ${offset}..${offset + length} lie outside the ${arena.byteLength}-byte export arena`,
152
+ );
153
+ const bytes = arena.subarray(offset, offset + length).slice();
154
+ const hash = await crypto.subtle.digest('SHA-256', bytes);
155
+ const sha256 = [...new Uint8Array(hash)]
156
+ .map((byte) => byte.toString(16).padStart(2, '0'))
157
+ .join('');
158
+ return { dtype, length, sha256 } satisfies ColumnDigest;
159
+ }
160
+ if (Array.isArray(value)) return Promise.all(value.map((entry) => walk(entry, key)));
161
+ if (typeof value === 'object' && value !== null) {
162
+ const out: Record<string, unknown> = {};
163
+ for (const [name, held] of Object.entries(value)) out[name] = await walk(held, name);
164
+ return out;
165
+ }
166
+ return value;
167
+ };
168
+ return walk(frame, '');
169
+ }