@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,648 @@
1
+ /**
2
+ * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
3
+ * 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
4
+ * WebAssembly, running headless in this worker (`blender-engine.mts`), with
5
+ * `session.py` as its one door and three.js as its renderer. The artifact is
6
+ * served by the editor (`/__editor/blender-wasm/*`, `blender-wasm-artifact.ts`);
7
+ * when it is not served, `start` refuses BY NAME. There is no other engine.
8
+ *
9
+ * NOTHING HERE KNOWS WHICH SKEW ANSWERED. Since the 2026-09-18 amendment there
10
+ * are two builds -- the standalone Emscripten module and Blender as a WALI
11
+ * program on browser-substrate -- and `blender-engine.mts` is where they meet.
12
+ * Above that seam this file uses `engine.files`, `engine.readArena()` and
13
+ * `engine.memoryBytes()` and could not tell them apart; ONE GATE, BOTH SKEWS
14
+ * is what that buys.
15
+ *
16
+ * Nothing here touches a server beyond the editor's own routes. A frame
17
+ * leaves through `present` to the Model document; a photograph comes back the
18
+ * same way. The project's own files are staged into the engine's filesystem
19
+ * before every call, and what the session writes is mirrored back out by the
20
+ * transport (`list-files`/`read-file`).
21
+ *
22
+ * THE DOCUMENT'S DEBOUNCE LIVES HERE, because this is the only side of the
23
+ * session that has a clock. Python's loop cannot ask the tab for anything
24
+ * while it is idle — `serveAsks` only runs inside a request's poll loop, so an
25
+ * `ask` raised between calls is never answered and wedges the Blender pthread.
26
+ * So the session marks a present `saveDue`, this file waits out one idle
27
+ * second, calls `save-document` as an ordinary request (which Python's loop
28
+ * picks up BETWEEN calls, never mid-call), and carries the bytes to the
29
+ * project through `/__editor/blender-document`.
30
+ *
31
+ * WHY THE CARRY IS NOT THE MIRROR'S JOB (`vgai blender-mcp`, class Mirror):
32
+ * the Mirror is pull-based and runs only after an `execute_blender_code`, so
33
+ * a document saved one idle second after the LAST call of a modeling session
34
+ * would never leave the worker — which is exactly the state this closes
35
+ * ("closing the tab loses the model"). The Mirror still lists and mirrors the
36
+ * same file in the MCP lane; it just is not what persistence depends on.
37
+ */
38
+ /// <reference types="vite/client" />
39
+
40
+ import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
41
+ import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
42
+ import { columnsToTypedArrays, describeFrame } from './session-frame.mts';
43
+
44
+ const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(reply);
45
+ const log = (level: 'log' | 'error', text: string) => post({ op: 'log', level, text });
46
+
47
+ interface Session {
48
+ start(project: string): Promise<unknown>;
49
+ execute(code: string): Promise<unknown>;
50
+ sceneInfo(): Promise<unknown>;
51
+ objectInfo(name: string): Promise<unknown>;
52
+ screenshotView(maxSize: number): Promise<unknown>;
53
+ }
54
+ let session: Session | null = null;
55
+ /** The project this session is rooted at, for the per-call project sync. */
56
+ let projectRoot: string | null = null;
57
+ let presentId = 0;
58
+ /** What the tab answered a present with: the capture it produced, and the
59
+ * presenter's own report of what it HELD before the frame (`protocol.ts`). */
60
+ interface PresentAnswer {
61
+ capture?: unknown;
62
+ held?: { session: string; revision: number } | null;
63
+ }
64
+ const pendingPresents = new Map<
65
+ number,
66
+ { resolve: (value: PresentAnswer) => void; reject: (error: Error) => void }
67
+ >();
68
+
69
+ function presentToTab(
70
+ frame: unknown,
71
+ description: unknown,
72
+ capture?: CaptureRequest,
73
+ ): Promise<PresentAnswer> {
74
+ const id = ++presentId;
75
+ // A drawn mesh's buffers move to the tab rather than being copied: the
76
+ // frame is the tab's from here on.
77
+ const transfer: ArrayBuffer[] = [];
78
+ // A picture travels the same way and for the same reason: an entry in the
79
+ // frame's `images` carries a raw RGBA raster the tab uploads into a
80
+ // `DataTexture` (`blender-runtime-view.ts`), once per image per revision.
81
+ const parts = frame as {
82
+ meshes?: Record<string, unknown>;
83
+ images?: Record<string, unknown>;
84
+ } | null;
85
+ for (const carrier of [parts?.meshes ?? {}, parts?.images ?? {}])
86
+ for (const held of Object.values(carrier))
87
+ for (const value of Object.values(held as Record<string, unknown>))
88
+ if (
89
+ ArrayBuffer.isView(value) &&
90
+ value.buffer instanceof ArrayBuffer &&
91
+ !transfer.includes(value.buffer)
92
+ )
93
+ transfer.push(value.buffer);
94
+ return new Promise((resolve, reject) => {
95
+ pendingPresents.set(id, { resolve, reject });
96
+ (self as unknown as Worker).postMessage(
97
+ {
98
+ op: 'present',
99
+ id,
100
+ frame,
101
+ description,
102
+ ...(capture ? { capture } : {}),
103
+ } satisfies WorkerReply,
104
+ transfer,
105
+ );
106
+ });
107
+ }
108
+
109
+ let engine: BlenderEngine | null = null;
110
+
111
+ // ---- The session's document.
112
+ //
113
+ // One session, one `.blend`. `documentPath` is the PROJECT-RELATIVE spelling —
114
+ // the only one that crosses to the server, which joins it to its own root so
115
+ // no host path is ever on the wire.
116
+ let documentPath: string | null = null;
117
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
118
+ /** Calls the tab is waiting on. A save waits for zero. */
119
+ let callsInFlight = 0;
120
+
121
+ /** The idle second. One save per second of quiet, however many presents
122
+ * arrived during it: the timer is RESET by each, so a script presenting in a
123
+ * tight loop writes the document once, when it stops. */
124
+ const DOCUMENT_SAVE_IDLE_MS = 1_000;
125
+
126
+ function armDocumentSave(): void {
127
+ if (documentPath === null) return;
128
+ if (saveTimer !== null) clearTimeout(saveTimer);
129
+ saveTimer = setTimeout(() => {
130
+ saveTimer = null;
131
+ void saveDocument();
132
+ }, DOCUMENT_SAVE_IDLE_MS);
133
+ }
134
+
135
+ /**
136
+ * Save the document and land it in the project.
137
+ *
138
+ * NEVER MID-CALL: a call still outstanding means a script is running, and its
139
+ * half-built model is not the document. The timer re-arms instead of writing.
140
+ */
141
+ async function saveDocument(): Promise<void> {
142
+ if (!engine || documentPath === null) return;
143
+ if (callsInFlight > 0) {
144
+ armDocumentSave();
145
+ return;
146
+ }
147
+ const relative = documentPath;
148
+ let answer: { saved?: boolean; path?: string; size?: number };
149
+ try {
150
+ answer = (await engine.request({ op: 'save-document' })) as typeof answer;
151
+ } catch (error) {
152
+ // A document that cannot be written is the session's work at risk, so it
153
+ // is a named condition in the editor's console, not a debug line.
154
+ log(
155
+ 'error',
156
+ `@@VGAI-ERROR the Blender document ${relative} could not be saved: ${describeThrown(error)}`,
157
+ );
158
+ return;
159
+ }
160
+ if (!answer?.saved || typeof answer.path !== 'string') return;
161
+ let bytes: Uint8Array;
162
+ try {
163
+ bytes = await engine.files.readFile(answer.path);
164
+ } catch (error) {
165
+ log(
166
+ 'error',
167
+ `@@VGAI-ERROR the Blender document ${relative} was saved but could not be read back out of the engine: ${describeThrown(error)}`,
168
+ );
169
+ return;
170
+ }
171
+ // The engine's copy is now the newer one, so the stager must stop treating
172
+ // this path as the host's: an entry left in `staged` would make the next
173
+ // call re-fetch the whole document over the session's own save, and would
174
+ // hide it from `list-files` (which lists only what the SESSION owns).
175
+ staged.delete(answer.path);
176
+ try {
177
+ const posted = await fetch(`/__editor/blender-document?path=${encodeURIComponent(relative)}`, {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/octet-stream' },
180
+ body: new Blob([bytes as BlobPart]),
181
+ });
182
+ if (!posted.ok) {
183
+ const said = await posted.text().catch(() => '');
184
+ log(
185
+ 'error',
186
+ `@@VGAI-ERROR the Blender document ${relative} was not written to the project: HTTP ${posted.status} ${said}`,
187
+ );
188
+ return;
189
+ }
190
+ } catch (error) {
191
+ log(
192
+ 'error',
193
+ `@@VGAI-ERROR the Blender document ${relative} was not written to the project: ${describeThrown(error)}`,
194
+ );
195
+ return;
196
+ }
197
+ log('log', `@@VGAI-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
198
+ }
199
+
200
+ async function startBlender(project: string, document?: string): Promise<unknown> {
201
+ // The engine is named through a holder rather than the module-level
202
+ // `engine`, because `ask` is handed to the engine before the engine exists.
203
+ const holder: { engine: BlenderEngine | null } = { engine: null };
204
+ const started = await startBlenderEngine({
205
+ project,
206
+ log,
207
+ ask: async ({ frame, capture, saveDue }) => {
208
+ if (!holder.engine) throw new Error('The Blender session presented before it started');
209
+ // The session says this present left the document behind the model. The
210
+ // clock is here; the write is one idle second away.
211
+ if (saveDue) armDocumentSave();
212
+ // THE ARENA IS READ ONCE, HERE, and both readers share those bytes: the
213
+ // typed arrays the tab draws from, and the record of what was sent
214
+ // (`describeFrame`). After the post the buffers are detached and the
215
+ // next `export_frame` overwrites the arena -- on either skew -- so there
216
+ // is no later moment at which either could be taken.
217
+ const arena = await holder.engine.readArena();
218
+ const description = await describeFrame(arena, frame);
219
+ const answered = await presentToTab(
220
+ columnsToTypedArrays(arena, frame),
221
+ description,
222
+ capture as CaptureRequest | undefined,
223
+ );
224
+ // THE CAPTURE IS THE ANSWER'S BODY, and `held` rides beside it: the
225
+ // session reads a photograph's own fields off this object
226
+ // (`session.py::_photograph`), and reads `held` to judge whether its
227
+ // record of what the presenter holds is still that presenter's.
228
+ const body =
229
+ typeof answered.capture === 'object' && answered.capture !== null
230
+ ? (answered.capture as Record<string, unknown>)
231
+ : {};
232
+ return { ...body, ...('held' in answered ? { held: answered.held } : {}) };
233
+ },
234
+ });
235
+ holder.engine = started;
236
+ engine = started;
237
+ // THE PROJECT'S FILES BEFORE THE SESSION'S FIRST ACT, because that act may
238
+ // be `open_mainfile` on the document — which lives on the host's disk and is
239
+ // not in the engine's filesystem until it is staged. Every other call stages
240
+ // on its way in (`execute`); start had nothing to open before it did.
241
+ if (document) await stageProjectFiles(started.files, project);
242
+ const banner = (await started.request({
243
+ op: 'start',
244
+ project,
245
+ ...(document ? { document } : {}),
246
+ })) as Record<string, unknown>;
247
+ if (document) documentPath = document;
248
+ session = {
249
+ start: async () => banner,
250
+ execute: async (code: string) => {
251
+ const answer = (await started.request({ op: 'execute', code })) as {
252
+ executed: boolean;
253
+ result: string;
254
+ error?: string;
255
+ };
256
+ // The MCP door's own shape: a script's failure is TEXT, not a rejection.
257
+ return answer.error
258
+ ? `Error executing code: ${answer.error}`
259
+ : `Code executed successfully: ${answer.result}`;
260
+ },
261
+ sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
262
+ objectInfo: async (name: string) =>
263
+ JSON.stringify(await started.request({ op: 'object-info', name }), null, 2),
264
+ screenshotView: async (maxSize: number) => {
265
+ await started.request({ op: 'present', capture: { size: maxSize } });
266
+ return JSON.stringify({ view: { size: maxSize } });
267
+ },
268
+ };
269
+ return {
270
+ ...banner,
271
+ engine: 'blender-wasm',
272
+ // WHICH BUILD ANSWERED. One gate, both skews: a board that cannot say
273
+ // which one it measured cannot call a difference a defect in either.
274
+ skew: started.skew,
275
+ bootMs: Math.round(started.bootMs),
276
+ // What the boot handed back (`blender-engine.mts`). Null says this bundle
277
+ // has no release door and is carrying the payload twice.
278
+ releasedPayloadMB:
279
+ started.releasedPayloadBytes === null
280
+ ? null
281
+ : Math.round(started.releasedPayloadBytes / 1048576),
282
+ };
283
+ }
284
+
285
+ async function blenderIsServed(): Promise<{ available: boolean; missing: string[] }> {
286
+ try {
287
+ const answer = await fetch('/__editor/blender-wasm/status');
288
+ if (!answer.ok) return { available: false, missing: [`status answered ${answer.status}`] };
289
+ return (await answer.json()) as { available: boolean; missing: string[] };
290
+ } catch (error) {
291
+ return { available: false, missing: [String(error)] };
292
+ }
293
+ }
294
+
295
+ async function start(project: string, document?: string): Promise<unknown> {
296
+ if (session) throw new Error('The Blender session is already started');
297
+ if (!project.startsWith('/'))
298
+ throw new Error("The Blender session needs the project's absolute path");
299
+ // The document is the project's own file and is named the project's own way.
300
+ // An absolute path here would be a host path on the wire and a destination
301
+ // the page chose, which is the one thing this transport does not carry.
302
+ if (document !== undefined && !isDocumentPath(document))
303
+ throw new Error(
304
+ `The Blender document must be a project-relative .blend path with no traversal ` +
305
+ `(models/model.blend); got ${JSON.stringify(document)}`,
306
+ );
307
+ const served = await blenderIsServed();
308
+ if (!served.available)
309
+ throw new Error(
310
+ 'Headless Blender is not served by this editor, so there is no modeling engine: ' +
311
+ `${served.missing.join('; ')}. The engine is Blender compiled to WebAssembly ` +
312
+ '(packages/blender-engine/wasm, or the directory VGAI_BLENDER_WASM_DIR names); nothing stands in for it.',
313
+ );
314
+ projectRoot = project;
315
+ return startBlender(project, document);
316
+ }
317
+
318
+ /** A project-relative `.blend`, with no traversal and no absolute root. The
319
+ * server checks the same shape again; this is the half that keeps a bad path
320
+ * from ever reaching the session. */
321
+ export function isDocumentPath(path: string): boolean {
322
+ if (!path.endsWith('.blend') || path.startsWith('/') || path.includes('\\')) return false;
323
+ const segments = path.split('/');
324
+ return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
325
+ }
326
+
327
+ interface ProjectFile {
328
+ path: string;
329
+ size: number;
330
+ mtime: number;
331
+ }
332
+
333
+ const saidOnce = new Set<string>();
334
+ function say(line: string): void {
335
+ if (saidOnce.has(line)) return;
336
+ saidOnce.add(line);
337
+ log('error', line);
338
+ }
339
+
340
+ async function projectIndex(project: string): Promise<ProjectFile[] | null> {
341
+ const unreadable = (reason: string): null => {
342
+ say(`The project's files are not readable from Python: ${reason}`);
343
+ return null;
344
+ };
345
+ let answer: Response;
346
+ try {
347
+ answer = await fetch('/__editor/blender-project-index');
348
+ } catch (error) {
349
+ return unreadable(String(error));
350
+ }
351
+ if (!answer.ok) return unreadable(`/__editor/blender-project-index answered ${answer.status}`);
352
+ let payload: { root: string; files: ProjectFile[] };
353
+ try {
354
+ payload = await answer.json();
355
+ if (typeof payload?.root !== 'string' || !Array.isArray(payload.files))
356
+ return unreadable('invalid project index');
357
+ } catch (error) {
358
+ return unreadable(String(error));
359
+ }
360
+ const { root, files } = payload;
361
+ if (root !== project)
362
+ say(
363
+ `The editor serving this tab has ${root} open, not ${project}; its files ` +
364
+ `are mounted at ${project}, which is where Python is looking.`,
365
+ );
366
+ return files;
367
+ }
368
+
369
+ /**
370
+ * The project files this session has staged IN, and the two stamps that say
371
+ * so: `host`, what the host's index reported (so a re-stage can skip a file
372
+ * that has not moved on disk), and `engine`, what the file looked like in the
373
+ * engine's own filesystem the instant after it was written there.
374
+ *
375
+ * THE SECOND ONE IS WHY THIS IS A PAIR. Ownership is not a property of the
376
+ * PATH, it is a property of the BYTES: a staged file Python never touched is
377
+ * the host's, and the same path after `export_scene.gltf` has written over it
378
+ * is the session's output and has to reach the project. Keyed on the path
379
+ * alone, a re-bake of an artifact that already exists — which is what every
380
+ * bake after the first one is — was listed by nobody and silently went
381
+ * nowhere, while a first bake of a NEW path worked, so nothing about the door
382
+ * looked broken (measured 2026-09-19 re-baking cinematic-story's sky city).
383
+ *
384
+ * `engine` is `size:mtimeMs`, which is what both skews' `stat` answers. A
385
+ * rewrite that lands in the same millisecond at exactly the same length would
386
+ * read as untouched; a bake is seconds long and this is the strongest signal
387
+ * the filesystem offers.
388
+ */
389
+ const staged = new Map<string, { host: string; engine: string }>();
390
+
391
+ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
392
+ const info = await files_.stat(path);
393
+ return info ? `${info.size}:${info.mtimeMs}` : '';
394
+ }
395
+
396
+ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<void> {
397
+ const files = await projectIndex(project);
398
+ if (!files) return;
399
+ const present = new Set(files.map((file) => `${project}/${file.path}`));
400
+ for (const path of [...staged.keys()]) {
401
+ if (present.has(path)) continue;
402
+ try {
403
+ await files_.unlink(path);
404
+ } catch {
405
+ /* already gone */
406
+ }
407
+ staged.delete(path);
408
+ }
409
+ for (const file of files) {
410
+ const path = `${project}/${file.path}`;
411
+ const stamp = `${file.size}:${file.mtime}`;
412
+ if (staged.get(path)?.host === stamp) continue;
413
+ // A path the session already owns keeps its own version.
414
+ if (!staged.has(path) && (await files_.stat(path)) !== null) continue;
415
+ const answer = await fetch(
416
+ `/__editor/blender-project-file?path=${encodeURIComponent(file.path)}`,
417
+ );
418
+ if (!answer.ok) {
419
+ say(`${file.path}: HTTP ${answer.status}`);
420
+ continue;
421
+ }
422
+ const dir = path.slice(0, path.lastIndexOf('/'));
423
+ if (dir) await files_.mkdirTree(dir);
424
+ await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
425
+ staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
426
+ }
427
+ }
428
+
429
+ /** Everything under `root` this SESSION owns -- what the transport mirrors out.
430
+ * A host file staged in and NEVER WRITTEN is the host's and is not output;
431
+ * one Python has written over since it was staged is this session's output,
432
+ * the same as a path it created (see {@link staged}). */
433
+ async function listSessionFiles(files: BlenderFiles, root: string): Promise<FileEntry[]> {
434
+ const out: FileEntry[] = [];
435
+ const walk = async (dir: string): Promise<void> => {
436
+ let names: string[];
437
+ try {
438
+ names = await files.readdir(dir);
439
+ } catch {
440
+ return;
441
+ }
442
+ for (const name of names) {
443
+ if (name === '.' || name === '..') continue;
444
+ const path = `${dir.replace(/\/$/, '')}/${name}`;
445
+ const info = await files.stat(path);
446
+ if (!info) continue;
447
+ // 0o040000 is S_IFDIR; both skews answer the raw mode.
448
+ if ((info.mode & 0o170000) === 0o040000) {
449
+ await walk(path);
450
+ continue;
451
+ }
452
+ if (staged.get(path)?.engine === `${info.size}:${info.mtimeMs}`) continue;
453
+ out.push({ path, size: info.size, mtime: info.mtimeMs });
454
+ }
455
+ };
456
+ await walk(root);
457
+ return out;
458
+ }
459
+
460
+ async function handle(request: WorkerRequest): Promise<unknown> {
461
+ switch (request.op) {
462
+ case 'start':
463
+ return start(request.project, request.document);
464
+ case 'present-result': {
465
+ const pending = pendingPresents.get(request.id);
466
+ pendingPresents.delete(request.id);
467
+ if (pending)
468
+ request.error
469
+ ? pending.reject(new Error(request.error))
470
+ : pending.resolve({
471
+ ...(request.capture === undefined ? {} : { capture: request.capture }),
472
+ ...('held' in request ? { held: request.held } : {}),
473
+ });
474
+ return undefined;
475
+ }
476
+ }
477
+ if (!session || !projectRoot || !engine) throw new Error('The Blender session has not started');
478
+ const files = engine.files;
479
+ /** The session channel itself, for the requests whose answer is already the
480
+ * shape the caller wants (the RNA door). */
481
+ const ask = engine.request.bind(engine);
482
+ switch (request.op) {
483
+ case 'execute':
484
+ // Code about to run may open a file the host wrote since the last call.
485
+ await stageProjectFiles(files, projectRoot);
486
+ return session.execute(request.code);
487
+ case 'present':
488
+ // Straight through to `session.py`'s own `present` op — the worker adds
489
+ // nothing, and a capture-less present answers `{ presented, revision }`.
490
+ return ask({ op: 'present' });
491
+ case 'scene-info':
492
+ return session.sceneInfo();
493
+ case 'object-info':
494
+ return session.objectInfo(request.name);
495
+ // THE RNA DOOR, straight through: the session answers JSON and there is
496
+ // nothing in the middle to shape it. Unlike `scene-info`/`object-info`,
497
+ // which the MCP door's own contract renders as a TEXT blob, these three
498
+ // answer a panel — so the object crosses as an object.
499
+ case 'rna':
500
+ return ask({
501
+ op: 'rna',
502
+ path: request.path,
503
+ ...(request.names === undefined ? {} : { names: request.names }),
504
+ });
505
+ case 'rna-context':
506
+ // EVERY FIELD IS NAMED HERE, and the omission is silent: this switch
507
+ // REBUILDS the request rather than forwarding it, so a field added at
508
+ // `runtime.ts` and read in `session.py` still arrives as `None` and the
509
+ // door answers about something else entirely. Measured 2026-09-19 (I4
510
+ // follow-up (a)): `collection` crossed both ends and never the middle,
511
+ // and the Collection tab simply did not stand.
512
+ return ask({
513
+ op: 'rna-context',
514
+ ...(request.object === undefined ? {} : { object: request.object }),
515
+ ...(request.collection === undefined ? {} : { collection: request.collection }),
516
+ });
517
+ case 'rna-set':
518
+ return ask({
519
+ op: 'rna-set',
520
+ path: request.path,
521
+ property: request.property,
522
+ value: request.value,
523
+ ...(request.index === undefined ? {} : { index: request.index }),
524
+ });
525
+ // THE TREE DOOR, the same way through: `rna_outliner` answers JSON and
526
+ // the worker adds nothing to it.
527
+ case 'outliner':
528
+ return ask({
529
+ op: 'outliner',
530
+ ...(request.selected === undefined ? {} : { selected: [...request.selected] }),
531
+ });
532
+ // THE NODE-TREE DOOR, the same way through — and EVERY FIELD IS NAMED,
533
+ // for the reason `rna-context` above records: this switch REBUILDS the
534
+ // request, so a field added at both ends and not here arrives as `None`.
535
+ case 'node-tree':
536
+ return ask({
537
+ op: 'node-tree',
538
+ ...(request.path === undefined ? {} : { path: request.path }),
539
+ ...(request.material === undefined ? {} : { material: request.material }),
540
+ });
541
+ // THE UV DOOR, every field named for the same reason.
542
+ case 'uv-layout':
543
+ return ask({
544
+ op: 'uv-layout',
545
+ ...(request.object === undefined ? {} : { object: request.object }),
546
+ ...(request.uvLayer === undefined ? {} : { uvLayer: request.uvLayer }),
547
+ });
548
+ // THE RIG AND CLIP DOORS, every field named for the same reason — and the
549
+ // reason is a measured defect: I4's Collection tab simply did not stand
550
+ // because `collection` crossed `runtime.ts` and `session.py` and never
551
+ // this switch, with no error anywhere.
552
+ case 'rig':
553
+ return ask({
554
+ op: 'rig',
555
+ ...(request.object === undefined ? {} : { object: request.object }),
556
+ });
557
+ case 'action-clip':
558
+ return ask({
559
+ op: 'action-clip',
560
+ ...(request.object === undefined ? {} : { object: request.object }),
561
+ ...(request.bake === undefined ? {} : { bake: request.bake }),
562
+ });
563
+ case 'outliner-set':
564
+ return ask({
565
+ op: 'outliner-set',
566
+ path: request.path,
567
+ column: request.column,
568
+ value: request.value,
569
+ });
570
+ case 'screenshot-view':
571
+ return JSON.parse((await session.screenshotView(request.maxSize)) as string);
572
+ case 'read-file':
573
+ return files.readFile(request.path);
574
+ case 'write-file': {
575
+ const dir = request.path.slice(0, request.path.lastIndexOf('/'));
576
+ if (dir) await files.mkdirTree(dir);
577
+ await files.writeFile(request.path, request.bytes);
578
+ staged.delete(request.path);
579
+ return undefined;
580
+ }
581
+ case 'list-files':
582
+ return listSessionFiles(files, request.path);
583
+ }
584
+ throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
585
+ }
586
+
587
+ /** Anything thrown, rendered so the message SURVIVES the boundary.
588
+ *
589
+ * `error instanceof Error ? ... : String(error)` renders a thrown plain object
590
+ * as `[object Object]`, and that is the whole error a script sees: the worker
591
+ * answered `17-workshop-interior` seq 7 with exactly that, which named neither
592
+ * the operation nor the cause and left the next step with nothing to go on.
593
+ * A DOMException carries its name, and a plain object carries its own fields,
594
+ * so both are spelled out rather than coerced. */
595
+ export function describeThrown(error: unknown): string {
596
+ if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`;
597
+ if (typeof error === 'object' && error !== null) {
598
+ const named = error as { name?: unknown; message?: unknown };
599
+ if (typeof named.message === 'string') {
600
+ return typeof named.name === 'string' ? `${named.name}: ${named.message}` : named.message;
601
+ }
602
+ try {
603
+ return JSON.stringify(error) ?? Object.prototype.toString.call(error);
604
+ } catch {
605
+ return Object.prototype.toString.call(error);
606
+ }
607
+ }
608
+ return String(error);
609
+ }
610
+
611
+ /**
612
+ * Tell the tab how big the engine's memory is now.
613
+ *
614
+ * O(1) on the standalone skew — a read of the `HEAPU8` view's length — and
615
+ * posted after every call because that is when it can have changed. It is the
616
+ * engine's memory as opposed to the page's: the census already carries
617
+ * `heapUsedMB` (the PAGE's JS heap) and had no field at all for the wasm,
618
+ * which is the larger half. Before the session exists there is nothing to read
619
+ * and nothing is posted, and the substrate skew answers null — its module's
620
+ * memory lives in a worker with no memory door, and a zero there would be a
621
+ * measurement nobody took.
622
+ */
623
+ function reportMemory(): void {
624
+ if (!engine) return;
625
+ const bytes = engine.memoryBytes();
626
+ if (bytes !== null) post({ op: 'memory', bytes });
627
+ }
628
+
629
+ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
630
+ const request = event.data;
631
+ if (request.op === 'present-result') {
632
+ await handle(request);
633
+ return;
634
+ }
635
+ // Held across the WHOLE call, so the document's save timer can tell "the
636
+ // session is quiet" from "a script is still running".
637
+ callsInFlight += 1;
638
+ try {
639
+ post({ id: request.id, result: await handle(request) });
640
+ } catch (error) {
641
+ post({ id: request.id, error: describeThrown(error) });
642
+ } finally {
643
+ callsInFlight -= 1;
644
+ }
645
+ // AFTER the answer, never before it: the reading is a passenger and must not
646
+ // sit between a finished call and the reply the caller is waiting on.
647
+ reportMemory();
648
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@volter/blender-engine",
3
+ "author": "Volter AI, Inc.",
4
+ "license": "GPL-3.0-or-later",
5
+ "version": "0.1.0",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "description": "Blender WebAssembly engine, worker, wire protocol and Three.js frame presentation.",
10
+ "type": "module",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/volter-ai/editor.git",
14
+ "directory": "packages/blender-engine"
15
+ },
16
+ "files": [
17
+ "browser",
18
+ "wasm",
19
+ "LICENSE"
20
+ ],
21
+ "exports": {
22
+ "./browser": "./browser/index.ts",
23
+ "./browser/protocol": "./browser/protocol.ts",
24
+ "./browser/rna": "./browser/rna.ts",
25
+ "./wasm/*": "./wasm/*",
26
+ "./package.json": "./package.json",
27
+ "./browser/three/*": {
28
+ "types": "./browser/three/*.ts",
29
+ "default": "./browser/three/*"
30
+ }
31
+ },
32
+ "peerDependencies": {
33
+ "three": "^0.180.0",
34
+ "zod": "^3.0.0 || ^4.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "three": {
38
+ "optional": true
39
+ },
40
+ "zod": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build:blender-three": "vite build --config vite.release.config.ts",
46
+ "typecheck": "tsc -p tsconfig.browser.json"
47
+ }
48
+ }