@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.
- package/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- 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
|
+
}
|
package/browser/index.ts
ADDED
|
@@ -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
|
+
}
|