@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,362 @@
1
+ /**
2
+ * The SUBSTRATE skew: Blender as an ordinary Linux program.
3
+ *
4
+ * `blender-engine.mts` is the seam and says how the skew is chosen; this is
5
+ * its other implementation. The artifact is one `blender.wasm` linked for
6
+ * `wasm32-wali-linux-musl` (browser-substrate `programs/blender/5.2.0`) and it
7
+ * runs through `@volter/browser-wali`'s `BrowserWaliWorkerProgram`: argv,
8
+ * stdio, an exit code, syscalls, a filesystem. There is no glue file, no
9
+ * `Module`, and nothing for this page to be cross-origin isolated FOR beyond
10
+ * what the substrate's own pthreads need.
11
+ *
12
+ * NOTHING HERE NAMES A PACKAGE, and that is the standalone skew's guarantee
13
+ * made structural rather than promised. `@volter/editor-blender` declares no
14
+ * dependency on browser-substrate, and a bundler walking this file finds no
15
+ * specifier to resolve: the substrate arrives as MODULES THIS EDITOR SERVES,
16
+ * from the same directory it serves `blender.wasm` out of, exactly as the
17
+ * standalone engine fetches and evaluates its glue from
18
+ * `/__editor/blender-wasm/blender_browser.js`. A mode-A consumer installs
19
+ * nothing extra, downloads nothing extra, and never reaches this module --
20
+ * `startBlenderEngine` imports it only when the served artifact IS the WALI
21
+ * one.
22
+ *
23
+ * THE FOUR EMSCRIPTEN SURFACES, AND THIS SKEW'S ANSWER TO EACH
24
+ * (browser-substrate `programs/blender/5.2.0/README.md`, "The four
25
+ * Emscripten-specific surfaces"):
26
+ * cross-origin isolation -> the substrate's own requirement, answered by the
27
+ * page that already carries the headers for the other skew;
28
+ * a server-patched glue -> there is none; the artifact is one `.wasm`,
29
+ * loaded by URL under a pinned `sha256-` integrity;
30
+ * `Module.FS` -> the program's `BrowserFileSystem`, held here in
31
+ * the page's own realm, whose writes reach a RUNNING program as ordered
32
+ * filesystem patches (which is why the directory channel works at all);
33
+ * `HEAPU8` for the arena -> the module's linear memory is in a worker this
34
+ * host cannot reach and DOES NOT NEED TO. `export_frame`'s `buffer_path`
35
+ * already writes the arena to a file; see `readArena` below.
36
+ *
37
+ * WHAT IT REFUSES, by name, and none of it is this file's to soften: no GPU of
38
+ * any kind (headless, Cycles on the CPU), and no `_ssl`, `_ctypes`,
39
+ * `_multiprocessing` or subprocesses in the embedded interpreter. The editor
40
+ * renders three.js photographs through the export door, so it asks Cycles for
41
+ * nothing.
42
+ */
43
+
44
+ import {
45
+ ARTIFACT_BASE,
46
+ artifactUrl,
47
+ type BlenderArtifactStatus,
48
+ type BlenderEngine,
49
+ type BlenderEngineOptions,
50
+ type BlenderFiles,
51
+ openSessionChannel,
52
+ PAGE_OWNED_DIRECTORIES,
53
+ SESSION_ROOT,
54
+ SESSION_SCRIPT,
55
+ sleep,
56
+ } from './blender-engine.mts';
57
+ import sessionPython from './session.py?raw';
58
+
59
+ /** Blender's own resource root inside the program, spelled the same way the
60
+ * standalone skew spells it so `BLENDER_SYSTEM_*` reads identically. */
61
+ const RESOURCES = '/bw';
62
+ /** Where this engine asks the export door to leave the arena. Inside the
63
+ * session's own root, because it belongs to the session's lifetime. */
64
+ const ARENA_PATH = `${SESSION_ROOT}/frame.bin`;
65
+
66
+ /** The host filesystem seam, in the only shape this file uses. */
67
+ interface BrowserFileSystemLike {
68
+ existsSync(path: string): boolean;
69
+ statSync(path: string): { isDirectory(): boolean; size: number; mode: number; mtimeMs: number };
70
+ readFile(path: string): Promise<Uint8Array>;
71
+ writeFileSync(path: string, data: string | Uint8Array): void;
72
+ mkdirSync(path: string, options?: { recursive?: boolean }): void;
73
+ readdirSync(path: string): string[];
74
+ unlinkSync(path: string): void;
75
+ }
76
+
77
+ interface WaliProgram {
78
+ run(
79
+ argv: readonly string[],
80
+ options: {
81
+ onStdout?(chunk: string): void;
82
+ onStderr?(chunk: string): void;
83
+ /** The WALI RUNTIME's own word to its host, not the program's stream --
84
+ * today the one-shot notice that an unimplemented syscall was answered
85
+ * `-ENOSYS` instead of trapping. Declared here because the call site
86
+ * passes it (`run(...)` below) and this local shape is the only thing
87
+ * that types that call. */
88
+ onDiagnostic?(message: string): void;
89
+ },
90
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }>;
91
+ terminate(): void;
92
+ }
93
+
94
+ interface RuntimeIndex {
95
+ files: { path: string; size: number; mode: number }[];
96
+ }
97
+
98
+ /** The substrate, as modules this editor serves. `@vite-ignore` because the
99
+ * specifier is a URL computed here: there is deliberately no specifier for a
100
+ * bundler to resolve, so the standalone build's graph cannot contain it. */
101
+ async function substrate(module: string): Promise<Record<string, unknown>> {
102
+ const url = artifactUrl(`wali/${module}`);
103
+ try {
104
+ return (await import(/* @vite-ignore */ url)) as Record<string, unknown>;
105
+ } catch (error) {
106
+ throw new Error(
107
+ `The substrate skew needs ${module}, which this editor serves from the WALI pack ` +
108
+ `directory beside blender.wasm (${url}): ${error instanceof Error ? error.message : String(error)}`,
109
+ );
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Blender's runtime tree — its scripts, datafiles and Python — into the
115
+ * program's filesystem.
116
+ *
117
+ * ONE INDEX AND ONE BLOB, not 2,276 requests. The tree is 78 MB in ~2,300
118
+ * files and every one of them has to exist before Blender's first act, so the
119
+ * editor serves a JSON index and the bytes concatenated in its order
120
+ * (`blender-wasm-artifact.ts`); this walks the index and slices. The
121
+ * standalone skew's equivalent is the packed `.data` payload, and this is the
122
+ * same shape for a filesystem that is not a module's.
123
+ */
124
+ async function stageRuntime(
125
+ filesystem: BrowserFileSystemLike,
126
+ log: BlenderEngineOptions['log'],
127
+ ): Promise<{ files: number; bytes: number; ms: number }> {
128
+ const began = performance.now();
129
+ const indexAnswer = await fetch(artifactUrl('runtime.idx'));
130
+ if (!indexAnswer.ok)
131
+ throw new Error(`${ARTIFACT_BASE}/runtime.idx answered ${indexAnswer.status}`);
132
+ const index = (await indexAnswer.json()) as RuntimeIndex;
133
+ const blobAnswer = await fetch(artifactUrl('runtime.bin'));
134
+ if (!blobAnswer.ok) throw new Error(`${ARTIFACT_BASE}/runtime.bin answered ${blobAnswer.status}`);
135
+ const blob = new Uint8Array(await blobAnswer.arrayBuffer());
136
+ let offset = 0;
137
+ for (const entry of index.files) {
138
+ // ONE PAST THE BLOB IS AN ERROR NAMING THE FILE. An index and a blob that
139
+ // disagree would otherwise write silently truncated Python.
140
+ if (offset + entry.size > blob.byteLength)
141
+ throw new Error(
142
+ `Blender runtime ${entry.path}: bytes ${offset}..${offset + entry.size} lie outside ` +
143
+ `the ${blob.byteLength}-byte runtime blob`,
144
+ );
145
+ const path = `${RESOURCES}/${entry.path}`;
146
+ filesystem.mkdirSync(path.slice(0, path.lastIndexOf('/')), { recursive: true });
147
+ filesystem.writeFileSync(path, blob.subarray(offset, offset + entry.size));
148
+ offset += entry.size;
149
+ }
150
+ if (offset !== blob.byteLength)
151
+ throw new Error(
152
+ `Blender runtime index accounts for ${offset} of the ${blob.byteLength} bytes served`,
153
+ );
154
+ const ms = Math.round(performance.now() - began);
155
+ log('log', `blender: staged Blender's ${index.files.length}-file runtime tree in ${ms} ms`);
156
+ return { files: index.files.length, bytes: blob.byteLength, ms };
157
+ }
158
+
159
+ /** The program's filesystem, as {@link BlenderFiles}. */
160
+ function programFiles(filesystem: BrowserFileSystemLike): BlenderFiles {
161
+ return {
162
+ readFile: (path) => filesystem.readFile(path),
163
+ writeFile: async (path, data) => filesystem.writeFileSync(path, data),
164
+ mkdirTree: async (path) => filesystem.mkdirSync(path, { recursive: true }),
165
+ readdir: async (path) => filesystem.readdirSync(path),
166
+ stat: async (path) => {
167
+ if (!filesystem.existsSync(path)) return null;
168
+ const info = filesystem.statSync(path);
169
+ // `listModuleFiles` reads S_IFDIR out of the mode; the seam's stat
170
+ // carries the raw mode and this filesystem's is permissions only.
171
+ return {
172
+ size: info.size,
173
+ mode: (info.mode & 0o7777) | (info.isDirectory() ? 0o040000 : 0o100000),
174
+ mtimeMs: info.mtimeMs,
175
+ };
176
+ },
177
+ unlink: async (path) => filesystem.unlinkSync(path),
178
+ };
179
+ }
180
+
181
+ export async function startWaliBlenderEngine(
182
+ options: BlenderEngineOptions,
183
+ status: BlenderArtifactStatus,
184
+ ): Promise<BlenderEngine> {
185
+ const started = performance.now();
186
+ const [wali, runtime] = await Promise.all([
187
+ substrate('browser-wali/worker-program.js'),
188
+ substrate('browser-runtime/browser-memory-filesystem.js'),
189
+ ]);
190
+ const WaliWorkerProgram = wali['BrowserWaliWorkerProgram'] as
191
+ | (new (
192
+ filesystem: unknown,
193
+ program: Record<string, unknown>,
194
+ ) => WaliProgram)
195
+ | undefined;
196
+ const MemoryFileSystem = runtime['BrowserMemoryFileSystem'] as
197
+ | (new () => BrowserFileSystemLike)
198
+ | undefined;
199
+ if (!WaliWorkerProgram || !MemoryFileSystem)
200
+ throw new Error(
201
+ 'The substrate modules this editor served do not export BrowserWaliWorkerProgram and ' +
202
+ 'BrowserMemoryFileSystem; the WALI pack directory holds a browser-substrate this engine ' +
203
+ 'does not know.',
204
+ );
205
+
206
+ const filesystem = new MemoryFileSystem();
207
+ // `out` and `ask` are deliberately absent: `session.py` owns those two and
208
+ // makes them itself (the channel's ownership rule in `blender-engine.mts`).
209
+ // Everything here is written before the program starts, so all of it is in
210
+ // the base the guest's own patches are checked against.
211
+ for (const directory of [
212
+ '/tmp',
213
+ '/root',
214
+ '/work',
215
+ SESSION_ROOT,
216
+ ...PAGE_OWNED_DIRECTORIES,
217
+ options.project,
218
+ ])
219
+ filesystem.mkdirSync(directory, { recursive: true });
220
+ await stageRuntime(filesystem, options.log);
221
+ filesystem.writeFileSync(SESSION_SCRIPT, sessionPython);
222
+
223
+ const workers = status.workers ?? { pool: 16, blender: 4 };
224
+ const program = new WaliWorkerProgram(filesystem, {
225
+ url: artifactUrl('blender.wasm'),
226
+ // The program loader refuses any non-blob URL without a pinned digest, so
227
+ // the session names the exact bytes it ran. `blender-wasm-artifact.ts`
228
+ // computes it from the file it is about to serve.
229
+ ...(status.integrity ? { integrity: status.integrity } : {}),
230
+ cwd: '/work',
231
+ env: {
232
+ HOME: '/root',
233
+ TMPDIR: '/tmp',
234
+ PATH: '/usr/bin:/bin',
235
+ BLENDER_SYSTEM_RESOURCES: RESOURCES,
236
+ BLENDER_SYSTEM_SCRIPTS: `${RESOURCES}/scripts`,
237
+ BLENDER_SYSTEM_DATAFILES: `${RESOURCES}/datafiles`,
238
+ BLENDER_SYSTEM_PYTHON: `${RESOURCES}/python`,
239
+ // PYTHONHOME IS DELIBERATELY UNSET, following the pack's own gate:
240
+ // Blender points `PyConfig` at its bundled python directory itself, and
241
+ // a PYTHONHOME here would be a second answer to the same question.
242
+ // PYTHONPATH is the other half of statement (b): `<prefix>/lib/
243
+ // python313.zip` is the only zip CPython's getpath composes, so numpy
244
+ // and the wheels sit beside it unreachable until they are named -- and
245
+ // `--python-use-system-env` below is what makes Blender read the
246
+ // variable at all.
247
+ PYTHONPATH: `${RESOURCES}/python/lib/numpy313.zip:${RESOURCES}/python/lib/wheels313.zip`,
248
+ VGAI_SESSION_ROOT: SESSION_ROOT,
249
+ // THE ARENA'S DOOR ON THIS SKEW. See `readArena`.
250
+ VGAI_EXPORT_BUFFER_PATH: ARENA_PATH,
251
+ },
252
+ threadPoolSize: workers.pool,
253
+ // Blender talks to nothing. The editor's own routes are this worker's, not
254
+ // the program's.
255
+ network: { allowedHosts: [], maxConnections: 0, maxBytes: 0 },
256
+ });
257
+
258
+ let readyLine: string | null = null;
259
+ let ended: string | null = null;
260
+ const say = (text: string) => {
261
+ for (const line of text.split('\n')) {
262
+ if (line === '') continue;
263
+ if (line.startsWith('@@VGAI-READY ')) readyLine = line.slice('@@VGAI-READY '.length);
264
+ // BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS
265
+ // -- the same rule as the standalone engine, and for the same measured
266
+ // reason: only the session's own named conditions belong in the set an
267
+ // agent must drive to zero.
268
+ options.log(
269
+ line.startsWith('@@VGAI-WARN') || line.startsWith('@@VGAI-ERROR') ? 'error' : 'log',
270
+ line,
271
+ );
272
+ }
273
+ };
274
+ // THE PROGRAM DOES NOT RETURN. `session.py` loops forever, so `run` settles
275
+ // only when Blender dies or the session is torn down -- which makes it the
276
+ // one place a crash is visible. It is a NAMED CONDITION, because a session
277
+ // whose Blender has exited answers every later call with a timeout and
278
+ // nothing else says why.
279
+ void program
280
+ .run(
281
+ [
282
+ '/blender',
283
+ '-b',
284
+ '--factory-startup',
285
+ '-noaudio',
286
+ // Statement (b): without this Blender's pre-config is isolated and
287
+ // ignores the PYTHONPATH above, so `import numpy` fails inside a
288
+ // Blender whose numpy is built in. The flag reads no stranger's
289
+ // environment here; every variable in this program is the one above.
290
+ '--python-use-system-env',
291
+ '-t',
292
+ String(workers.blender),
293
+ '--python',
294
+ SESSION_SCRIPT,
295
+ ],
296
+ {
297
+ onStdout: say,
298
+ onStderr: say,
299
+ // NOT Blender's stream, and that is exactly why this one IS a named
300
+ // condition where `say` is page output. `onDiagnostic` is the WALI
301
+ // runtime's own word to its host -- today, the one-shot notice that an
302
+ // unimplemented syscall was answered `-ENOSYS` instead of trapping
303
+ // (`@volter/browser-wali`'s `runtime.ts`). Answering like a kernel is
304
+ // only defensible because the CALLER can degrade VISIBLY; without this
305
+ // the degrade is silent, and before it existed the notice reached a
306
+ // nested worker's devtools console and nothing else.
307
+ onDiagnostic: (message: string) => options.log('error', `@@VGAI-ERROR ${message}`),
308
+ },
309
+ )
310
+ .then(
311
+ (result) => {
312
+ ended = `Blender exited ${result.exitCode}`;
313
+ },
314
+ (error) => {
315
+ ended = error instanceof Error ? error.message : String(error);
316
+ },
317
+ )
318
+ .finally(() => {
319
+ if (ended !== null) options.log('error', `@@VGAI-ERROR the Blender program ended: ${ended}`);
320
+ });
321
+
322
+ while (readyLine === null) {
323
+ if (ended !== null) throw new Error(`Blender never reached its session: ${ended}`);
324
+ await sleep(5);
325
+ }
326
+ const bootMs = performance.now() - started;
327
+ const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
328
+ options.log(
329
+ 'log',
330
+ `blender: the substrate skew is up (${workers.blender} Blender threads in a pool of ` +
331
+ `${workers.pool}; Blender is SERIAL without -t N and says so)`,
332
+ );
333
+
334
+ const files = programFiles(filesystem);
335
+ // `ended` is set by the `run` promise above -- the one place a crash is
336
+ // visible on this skew. Handing it to the channel is what stops a dead
337
+ // program from turning every later call into an unbounded wait.
338
+ const { request } = openSessionChannel(files, options, () => ended);
339
+
340
+ return {
341
+ skew: 'wali',
342
+ banner,
343
+ files,
344
+ request,
345
+ // THE ARENA THROUGH ITS FILE, which is this skew's native door and not a
346
+ // fallback: the module's linear memory lives in a worker with no memory
347
+ // door, and `export_frame` wrote the same bytes to `buffer_path` before
348
+ // the ask that brought us here. Ordering is the correctness argument and
349
+ // `session.py` states it beside the write: the program's mutations reach
350
+ // this host as one ORDERED stream of filesystem patches, so anything that
351
+ // carried `ask/<id>.done` out carried the arena with it or before it.
352
+ readArena: () => filesystem.readFile(ARENA_PATH),
353
+ bootMs,
354
+ // The module's memory is the program worker's and no door reports it.
355
+ // Null rather than zero: `vgai status` prints "unreported", which is true,
356
+ // where a zero would be a measurement that was never taken.
357
+ memoryBytes: () => null,
358
+ // There is no packed payload on this skew: Blender's runtime tree is
359
+ // ordinary files in the program's filesystem, staged above.
360
+ releasedPayloadBytes: null,
361
+ };
362
+ }
@@ -0,0 +1,7 @@
1
+ export type { CaptureRequest, FileEntry, RuntimeStart } from './protocol';
2
+ export {
3
+ BlenderRuntime,
4
+ type BlenderRuntimeOptions,
5
+ type PresentAnswer,
6
+ type ScreenshotView,
7
+ } from './runtime';
@@ -0,0 +1,202 @@
1
+ /** Messages between the editor tab and its Blender worker. One session per
2
+ * worker; the model lives in the worker's Python and nowhere else. */
3
+
4
+ export type WorkerRequest =
5
+ /** `document` is the session's `.blend`, PROJECT-RELATIVE (`models/model.blend`
6
+ * by default). An existing one is opened at start; an absent one starts
7
+ * empty and is created by the first save. */
8
+ | { id: number; op: 'start'; project: string; document?: string }
9
+ | { id: number; op: 'execute'; code: string }
10
+ | { id: number; op: 'scene-info' }
11
+ | { id: number; op: 'object-info'; name: string }
12
+ | { id: number; op: 'screenshot-view'; maxSize: number }
13
+ /** PRESENT WHAT THE ENGINE ALREADY HOLDS, with no capture — the frame a
14
+ * freshly opened Model document asks for (`session.py`'s `present` op,
15
+ * which `dispatch` has always answered; nothing sent it until the document
16
+ * did). Every mutation presents, so this is only ever the FIRST one. */
17
+ | { id: number; op: 'present' }
18
+ /** THE RNA DOOR (`./rna.ts` is its contract, `session.py`'s `rna_view`
19
+ * its answer): one datablock's `bl_rna.properties` as typed rows. `path` is
20
+ * the engine's own address (`bpy.data.objects["Cube"]`). */
21
+ | { id: number; op: 'rna'; path: string; names?: number }
22
+ /** The active object / bone / material slot / modifier the Properties tabs
23
+ * key on, and the tabs themselves. `object` names the object the CALLER is
24
+ * looking at (our viewport's selection); absent, the engine's own active
25
+ * object answers. `collection` is the same for the COLLECTION tab: the
26
+ * LayerCollection address of the Outliner row the caller clicked, standing
27
+ * in for `view_layer.active_layer_collection`, because activating it would
28
+ * be a mutation the document saves. */
29
+ | { id: number; op: 'rna-context'; object?: string; collection?: string }
30
+ /** ONE property, written through `setattr`. A read-only property is refused
31
+ * by name. */
32
+ | {
33
+ id: number;
34
+ op: 'rna-set';
35
+ path: string;
36
+ property: string;
37
+ value: unknown;
38
+ index?: number;
39
+ }
40
+ /** THE TREE DOOR (`./rna.ts`'s `BlenderOutlinerTree` is its contract,
41
+ * `session.py`'s `rna_outliner` its answer): Blender's View Layer tree for
42
+ * the scene. `selected` is the CALLER's selection by object name — our
43
+ * viewport's — for the same reason `rna-context` takes an object: reading
44
+ * a selection must never write the engine's. */
45
+ | { id: number; op: 'outliner'; selected?: readonly string[] }
46
+ /** THE NODE-TREE DOOR (`./rna.ts`'s `BlenderNodeTree` is its contract,
47
+ * `session.py`'s `rna_node_tree` its answer): ONE material's shader node
48
+ * tree, whole, because a tree's drawing is a fact about the whole tree and
49
+ * the generic RNA door answers one struct per round trip. `path` opens a
50
+ * tree by the engine's own address; `material` by name; neither reads the
51
+ * active object's active material, which is what Blender's own Shading
52
+ * header resolves (`space_node.py:89-93`). READ ONLY — there is no writer
53
+ * beside it, because moving a node or retyping a value is EDITING. */
54
+ | { id: number; op: 'node-tree'; path?: string; material?: string }
55
+ /** ONE MESH'S UV LAYOUT (`session.py`'s `rna_uv_layout`) — the per-corner
56
+ * arrays as base64 typed-array bytes, because a layout is `len(loops)`
57
+ * two-float corners and the generic door answers a struct at a time.
58
+ * Given no `object`, the view layer's active object answers. READ ONLY:
59
+ * pinning, unwrapping and selecting are edits. */
60
+ | { id: number; op: 'uv-layout'; object?: string; uvLayer?: string }
61
+ /** ONE MESH'S SKIN BINDING (`session.py`'s `rna_rig`) — the armature's bones
62
+ * with their rest AND bind matrices, plus up to four weighted influences
63
+ * per Blender vertex as base64 typed-array bytes. The BIND pose is the
64
+ * pose the exported columns were evaluated at, which is what lets three.js
65
+ * own playback without Blender's frame ever moving. READ ONLY. */
66
+ | { id: number; op: 'rig'; object?: string }
67
+ /** ONE ACTION AS A THREE.JS CLIP (`session.py`'s `rna_action_clip`) — per
68
+ * bone, the LOCAL transform at every integer frame of the action's range,
69
+ * plus the scene's frame range/fps and the summary row's key columns.
70
+ * READ ONLY: keying, moving a key and setting a range are edits. */
71
+ | { id: number; op: 'action-clip'; object?: string; bake?: boolean }
72
+ /** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
73
+ * Blender draws on no row of that type is refused by name. */
74
+ | { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
75
+ | { id: number; op: 'read-file'; path: string }
76
+ | { id: number; op: 'write-file'; path: string; bytes: Uint8Array }
77
+ | { id: number; op: 'list-files'; path: string }
78
+ /** The tab's answer to a `present` the worker asked for.
79
+ *
80
+ * `held` is the PRESENTER'S OWN report of what it held BEFORE this frame
81
+ * (`blender-runtime-view.ts::applyFrame`), carried through to the session so
82
+ * it can correct its record of what it sent. `null` says the presenter held
83
+ * nothing; the field is ABSENT when the presenter does not report one. */
84
+ | {
85
+ id: number;
86
+ op: 'present-result';
87
+ error?: string;
88
+ capture?: unknown;
89
+ held?: { session: string; revision: number } | null;
90
+ };
91
+
92
+ export type WorkerReply =
93
+ | { id: number; result: unknown }
94
+ | { id: number; error: string }
95
+ /** The worker asks the tab to display a frame (and to remember the view a
96
+ * screenshot wants); the tab answers with `present-result` under the same id.
97
+ *
98
+ * `description` is that same frame with every column replaced by its
99
+ * `{dtype, length, sha256}` (`session-frame.mts::describeFrame`). It rides
100
+ * BESIDE the frame rather than inside it because the frame's schema is
101
+ * strict and its buffers are TRANSFERRED: the instant this message is
102
+ * posted there is nothing left in the worker to describe. The tab keeps it
103
+ * as the record of what the session submitted — the second side a
104
+ * displayed-versus-submitted comparison otherwise does not have. */
105
+ | { op: 'present'; id: number; frame: unknown; description: unknown; capture?: CaptureRequest }
106
+ | { op: 'log'; level: 'log' | 'error'; text: string }
107
+ /**
108
+ * HOW BIG THE MODULE'S LINEAR MEMORY IS, posted after every call.
109
+ *
110
+ * The one number about the engine's memory that only the worker can read
111
+ * (`Module.HEAPU8.length`), and the one the tab's census had no field for:
112
+ * `heapUsedMB` is the PAGE's JS heap and says nothing about the wasm. wasm32
113
+ * memory never shrinks, so this value is also the session's high-water mark
114
+ * — a reader needs no separate peak.
115
+ *
116
+ * MEASURED 2026-09-18: 512 MB reserved at boot of which ~166 MB is ever
117
+ * touched (so the reservation itself costs no resident pages), growing to
118
+ * 1036 MB across `17-workshop-interior`. Every byte dlmalloc takes inside it
119
+ * stays taken: there is no `madvise` in wasm, so a scene's peak allocation is
120
+ * a permanent cost for the life of the tab.
121
+ */
122
+ | { op: 'memory'; bytes: number };
123
+
124
+ export interface CaptureRequest {
125
+ /** A viewport screenshot's bound, the longer side of a square frame.
126
+ * Absent on a render, which states its exact pixel dimensions instead. */
127
+ size?: number;
128
+ /** THE SCENE CAMERA'S FULL POSE, IN BLENDER'S OWN (Z-up) FRAME — a render
129
+ * carries all three or none. `position` and `target` are points; `up` is the
130
+ * camera's own up direction (`matrix_world.to_quaternion() @ (0,1,0)`), and
131
+ * it is not optional: a camera with roll — every top-down render has one —
132
+ * cannot be expressed by position and target alone. The tab transforms all
133
+ * three through the model root's world matrix before it builds the
134
+ * photograph's camera; nothing here is in three.js world space. */
135
+ position?: number[];
136
+ target?: number[];
137
+ up?: number[];
138
+ /** Present when this is a RENDER rather than a viewport screenshot. */
139
+ render?: RenderRequest;
140
+ }
141
+
142
+ /**
143
+ * `bpy.ops.render.render()` asking for the scene camera's own picture.
144
+ *
145
+ * three.js IS the renderer (ARCHITECTURE-CORE, "No second implementation of a
146
+ * substrate capability ships"), so a render is the Model document photographed
147
+ * through the scene's camera at `scene.render`'s exact resolution — and unlike
148
+ * a screenshot, whose view is only REMEMBERED for the transport to photograph
149
+ * later, the pixels come back to the caller inside the operator that asked.
150
+ */
151
+ export interface RenderRequest {
152
+ /** Omit the visible world while retaining its illumination. */
153
+ transparent?: boolean;
154
+ width: number;
155
+ height: number;
156
+ /** The camera's VERTICAL field of view, in degrees. */
157
+ fov: number;
158
+ /** The scene's own view transform, as the three.js tone mapping of the same
159
+ * NAME (`bpy/_render_three.py`). A transform with no curve here never
160
+ * reaches this point: Python refuses it. */
161
+ toneMapping: 'none' | 'agx' | 'filmic' | 'neutral';
162
+ /** The colour-management LOOK the scene asks for, as Blender names it.
163
+ * `'None'` means the base transform; anything else is a look the view holds
164
+ * a baked table for, because the renderer refuses the rest by name. */
165
+ look?: string;
166
+ /** Hand back the SCENE-REFERRED half-float frame too. An EXR is written
167
+ * from it, and so is the COMPOSITOR's input. */
168
+ linear?: boolean;
169
+ /** DISPLAY THIS FRAME instead of photographing the scene: the scene-linear
170
+ * half-float RGBA the COMPOSITOR produced, base64 of its raw bytes in the
171
+ * capture's own bottom-up row order. A composited render cannot be a
172
+ * photograph — the pixels are the graph's output, not the viewport's — so
173
+ * the tab runs the view transform over these values and answers with the
174
+ * PNG. Framing is unused when this is present. */
175
+ linearInput?: { base64: string; width: number; height: number };
176
+ /** `view_settings.exposure` as a linear multiplier. */
177
+ exposure: number;
178
+ /** An ORTHO camera; `fov` then carries `ortho_scale` (`_render_three.py`). */
179
+ orthographic: boolean;
180
+ }
181
+
182
+ export interface FileEntry {
183
+ path: string;
184
+ size: number;
185
+ /** Milliseconds since the epoch, the worker filesystem's own clock. */
186
+ mtime: number;
187
+ }
188
+
189
+ export interface RuntimeStart {
190
+ session: string;
191
+ python: string;
192
+ /** Which build of Blender answered: the standalone Emscripten module, or
193
+ * Blender as a WALI program on browser-substrate. One gate, both skews --
194
+ * a board that cannot name the skew it measured cannot call a difference
195
+ * between them a defect in either. */
196
+ skew?: 'emscripten' | 'wali';
197
+ /** The session's document, project-relative, when one was named. */
198
+ document?: string;
199
+ /** True when the project already held that `.blend` and it was OPENED —
200
+ * false when the session started empty and will create it on first save. */
201
+ opened?: boolean;
202
+ }