@volter/editor-blender 0.1.8 → 0.1.10

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 (39) hide show
  1. package/contributions/blender-header-menus.tsx +4 -9
  2. package/contributions/blender-icons.source.mjs +74 -6
  3. package/contributions/blender-outliner-authoring.ts +10 -21
  4. package/contributions/blender-palette.source.mjs +16 -5
  5. package/contributions/blender-properties-view.tsx +28 -9
  6. package/contributions/blender-runtime-skin.ts +6 -0
  7. package/contributions/blender-runtime.document.tsx +219 -23
  8. package/contributions/blender.command.ts +5 -1
  9. package/contributions/blender.environment.ts +32 -0
  10. package/contributions/blender.icons.json +22 -2
  11. package/contributions/blender.icons.traced.json +28 -0
  12. package/contributions/blender.keymap.ts +33 -0
  13. package/contributions/blender.palette.json +6 -0
  14. package/contributions/blender.status.tsx +2 -6
  15. package/contributions/blender.style.ts +30 -89
  16. package/contributions/model.layout.ts +7 -7
  17. package/contributions/models.finder.ts +16 -0
  18. package/contributions/sculpt.layout.ts +3 -0
  19. package/contributions/shading.layout.ts +3 -0
  20. package/contributions/studiolights/world/city.exr +0 -0
  21. package/contributions/studiolights/world/courtyard.exr +0 -0
  22. package/contributions/studiolights/world/forest.exr +0 -0
  23. package/contributions/studiolights/world/interior.exr +0 -0
  24. package/contributions/studiolights/world/license.txt +15 -0
  25. package/contributions/studiolights/world/night.exr +0 -0
  26. package/contributions/studiolights/world/studio.exr +0 -0
  27. package/contributions/studiolights/world/sunrise.exr +0 -0
  28. package/contributions/studiolights/world/sunset.exr +0 -0
  29. package/contributions/uv-editing.layout.ts +3 -0
  30. package/dist-node/serving.mjs +624 -0
  31. package/host/blender-runtime-host.ts +11 -1
  32. package/node/blender-mcp.ts +2 -2
  33. package/package.json +20 -10
  34. package/serving/blender-routes.ts +427 -0
  35. package/serving/blender-wasm-artifact.ts +449 -0
  36. package/serving/index.ts +10 -0
  37. package/src/layouts.tsx +4 -5
  38. package/src/presentation.ts +111 -0
  39. package/src/regions.ts +59 -0
@@ -0,0 +1,449 @@
1
+ /**
2
+ * The headless Blender build, served to the editor tab — and the door that
3
+ * says WHICH BUILD it is.
4
+ *
5
+ * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
6
+ * 2026-09-17), and since the 2026-09-18 amendment there are two builds of it:
7
+ *
8
+ * emscripten — the STANDALONE skew, and the one `@volter/blender-engine` SHIPS under
9
+ * `wasm/`: the glue as text plus the two large files pre-compressed
10
+ * (`blender_browser.wasm.br`, `blender_browser.data.br`, ~38 MB together
11
+ * against ~156 MB raw). They are served as-is with `Content-Encoding: br`;
12
+ * the browser decompresses before the runtime sees a byte, so the glue's
13
+ * own `instantiateStreaming` and `.data` preload run unchanged.
14
+ *
15
+ * wali — the SUBSTRATE skew: one `blender.wasm` linked for
16
+ * `wasm32-wali-linux-musl` beside the `runtime/` tree it reads through
17
+ * `BLENDER_SYSTEM_*` (browser-substrate `programs/blender/5.2.0`, whose
18
+ * `gate.sh` stages exactly that pair).
19
+ *
20
+ * THE SKEW IS NOT CONFIGURED, IT IS READ. One editor serves one Blender, and
21
+ * the served directory holds either `blender_browser.js` or `blender.wasm`;
22
+ * that IS the answer, and `status` carries it. There is no manifest field, no
23
+ * setting and no flag, because nothing a user does may depend on the skew —
24
+ * the ruling's "a skew may refuse a capability by name, never answer the same
25
+ * question differently" leaves nothing for a chooser to choose.
26
+ * `VGAI_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
27
+ * different build, and pointing it at a WALI pack is how the substrate skew is
28
+ * reached in development.
29
+ *
30
+ * ONE PATCH IS APPLIED TO THE EMSCRIPTEN GLUE, and it is applied HERE rather
31
+ * than in the worker so that the pthread workers — which the runtime spawns by
32
+ * loading this same URL as a classic worker (`Module.mainScriptUrlOrBlob`) —
33
+ * get the identical text. The build is `-sPROXY_TO_PTHREAD`, and its
34
+ * `pthread_create` glue transfers a `#canvas` to the spawned thread by default.
35
+ * There is no canvas in a worker, and the transfer aborts the spawn, so the
36
+ * transfer list is emptied. The upstream release already does exactly this for
37
+ * node (`if(ENVIRONMENT_IS_NODE){transferredCanvasNames=0}`); a worker needs
38
+ * the same, and for the same reason.
39
+ *
40
+ * THE SAME PATCHING IS WHAT CARRIES THE SUBSTRATE, and it is the precedent
41
+ * being transcribed rather than a new mechanism: `@volter/browser-wali` and
42
+ * `@volter/browser-runtime` are served out of this door too
43
+ * (`/__editor/blender-wasm/wali/<package>/<file>.js`), with their bare
44
+ * `@volter/...` specifiers rewritten to those URLs on the way out. That is
45
+ * what keeps `@volter/blender-engine` free of any dependency on browser-substrate: the
46
+ * standalone skew installs nothing, bundles nothing and fetches nothing, and
47
+ * the WALI engine imports the substrate from URLs instead of specifiers. The
48
+ * nested workers `BrowserWaliWorkerProgram` spawns load from these same URLs,
49
+ * so they get the identical rewritten text — exactly the reason the glue patch
50
+ * lives here.
51
+ *
52
+ * A missing directory is a LOUD 404 naming the variable, never a silent
53
+ * fall-through: the engine either is Blender or says why it is not.
54
+ */
55
+ import { createHash } from 'node:crypto';
56
+ import { createReadStream, existsSync } from 'node:fs';
57
+ import { readdir, readFile, stat } from 'node:fs/promises';
58
+ import { dirname, isAbsolute, join, posix, relative } from 'node:path';
59
+ import { fileURLToPath } from 'node:url';
60
+ import { brotliDecompressSync } from 'node:zlib';
61
+
62
+ export type BlenderSkew = 'emscripten' | 'wali';
63
+
64
+ /** Engine artifacts plus the separately packaged Essentials asset payload. */
65
+ export const BLENDER_WASM_FILES = [
66
+ 'blender_browser.js',
67
+ 'blender_browser.wasm',
68
+ 'blender_browser.data',
69
+ 'essentials.json',
70
+ 'essentials.bin',
71
+ ] as const;
72
+
73
+ export type BlenderWasmFile = (typeof BLENDER_WASM_FILES)[number];
74
+
75
+ /** What the WALI skew's directory must hold: the artifact, and the runtime
76
+ * tree Blender reads through `BLENDER_SYSTEM_*`. */
77
+ export const BLENDER_WALI_ARTIFACT = 'blender.wasm';
78
+ export const BLENDER_WALI_RUNTIME = 'runtime';
79
+
80
+ /**
81
+ * THE THREAD BUDGET, stated once, here, because it is a property of this RUN
82
+ * SURFACE and not of the program.
83
+ *
84
+ * Blender is SERIAL unless it is told `-t N` and says so on stderr
85
+ * (browser-substrate `programs/blender/5.2.0/README.md` §(a): `-t N` sets
86
+ * oneTBB's global control and blenlib's explicitly sized arena). The POOL is a
87
+ * different number and must be comfortably above it: measured 2026-09-18 in
88
+ * the pack's own emulator, `blender -t 4` TRAPS with a host pool of 4, because
89
+ * the stated count buys four oneTBB workers and Blender starts threads of its
90
+ * own besides. 4 in a pool of 16 is the pair this lane measured end to end
91
+ * (`blender-n-gate`, 2026-09-19: boot 12.0 s, first request 0.9 s, later calls
92
+ * ~1.5 s), and a pool of 8 under `-t 4` trapped during boot on this box.
93
+ */
94
+ /**
95
+ * FOUR, MEASURED, NOT THE MACHINE'S CORES. The Emscripten skew runs Blender at 6
96
+ * on this 12-core box; measured 2026-09-20 on the same operations, WALI at 12
97
+ * threads was SLOWER than at 4 everywhere (subsurf level 6: 40 ms against 17;
98
+ * an ico sphere: 30 against 15; the courtyard 31.1 s against 28.6) -- the
99
+ * pool's scheduling costs more than the cores return, so more `-t` is not more
100
+ * speed here. The pool stays four times the count, the ratio that measured
101
+ * clean (16 under 4; 8 under 4 trapped at boot), because Blender starts threads
102
+ * of its own beyond the oneTBB workers `-t` buys.
103
+ */
104
+ export const BLENDER_WALI_WORKERS = { pool: 16, blender: 4 } as const;
105
+
106
+ /** The substrate packages the WALI engine imports, by the URL segment each is
107
+ * served under and the specifier prefix rewritten to it. */
108
+ export const BLENDER_WALI_SUBSTRATE = [
109
+ { segment: 'browser-wali', specifier: '@volter/browser-wali' },
110
+ { segment: 'browser-runtime', specifier: '@volter/browser-runtime' },
111
+ ] as const;
112
+
113
+ export const BLENDER_WASM_DIR_VARIABLE = 'VGAI_BLENDER_WASM_DIR';
114
+
115
+ /**
116
+ * The bundle `@volter/blender-engine` ships — Blender itself, so its own GPL
117
+ * package (ARCHITECTURE-CORE §Licensing, "Blender's licence stops at the
118
+ * wire"). This resolution is a PATH, not an import: the editor serves those
119
+ * bytes and never imports the engine. `import.meta.url` is THIS FILE's esbuild bundle
120
+ * (`<pkg>/dist-node/serving.mjs`, the `vgai.serving` module); from it, two
121
+ * directories up and across is the sibling package -- `packages/blender-engine/wasm`
122
+ * in a checkout, `node_modules/@volter/blender-engine/wasm` installed. Existence is
123
+ * checked once so a realm that lacks it gets a named absence, not a path.
124
+ */
125
+ export const SHIPPED_BLENDER_WASM_DIR: string | null = (() => {
126
+ const candidate = fileURLToPath(new URL('../../blender-engine/wasm/', import.meta.url));
127
+ return existsSync(candidate) ? candidate.replace(/\/$/, '') : null;
128
+ })();
129
+
130
+ /** Where the build is: the named directory, else the shipped one, else null. */
131
+ export function blenderWasmDir(env: NodeJS.ProcessEnv = process.env): string | null {
132
+ const named = env[BLENDER_WASM_DIR_VARIABLE];
133
+ if (typeof named === 'string' && named.trim() !== '') {
134
+ const dir = named.trim();
135
+ return isAbsolute(dir) ? dir : null;
136
+ }
137
+ return SHIPPED_BLENDER_WASM_DIR;
138
+ }
139
+
140
+ /** A file on disk as the build stores it: raw, or pre-compressed as `.br`. */
141
+ export interface BlenderWasmOnDisk {
142
+ path: string;
143
+ size: number;
144
+ encoding: 'br' | null;
145
+ }
146
+
147
+ export async function blenderWasmOnDisk(
148
+ dir: string,
149
+ file: string,
150
+ ): Promise<BlenderWasmOnDisk | null> {
151
+ for (const [suffix, encoding] of [
152
+ ['.br', 'br'],
153
+ ['', null],
154
+ ] as const) {
155
+ const path = join(dir, `${file}${suffix}`);
156
+ try {
157
+ const info = await stat(path);
158
+ if (info.isFile()) return { path, size: info.size, encoding };
159
+ } catch {
160
+ /* try the other spelling */
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export interface BlenderWasmStatus {
167
+ available: boolean;
168
+ /** Which build the served directory holds; null when nothing is served. */
169
+ skew: BlenderSkew | null;
170
+ /** The directory that was looked in, or null when the variable is unset. */
171
+ dir: string | null;
172
+ /** On-disk byte sizes of the files, for the ones that are there
173
+ * (the `.br` size when a file is stored pre-compressed). */
174
+ sizes: Record<string, number>;
175
+ /** Which files are stored pre-compressed, by name. */
176
+ encoded: Record<string, 'br'>;
177
+ /** Why it is unavailable, named. Empty when it is available. */
178
+ missing: string[];
179
+ /** Emscripten: each file's SHA-256 as it decodes (`BUNDLE.json#rawFiles`,
180
+ * `essentials.json`), so a page can keep the bytes it fetched once. */
181
+ digests?: Record<string, string>;
182
+ /** WALI only: the `sha256-` SRI of `blender.wasm`. The program loader
183
+ * refuses any non-blob URL without one, so the session names the exact
184
+ * bytes it ran. */
185
+ integrity?: string;
186
+ /** WALI only: {@link BLENDER_WALI_WORKERS}. */
187
+ workers?: { pool: number; blender: number };
188
+ }
189
+
190
+ /** Which skew a directory holds, by what is in it. Null when it holds neither,
191
+ * which is what `missing` then has to explain. */
192
+ async function skewOf(dir: string): Promise<BlenderSkew | null> {
193
+ if (await blenderWasmOnDisk(dir, 'blender_browser.js')) return 'emscripten';
194
+ if (existsSync(join(dir, BLENDER_WALI_ARTIFACT))) return 'wali';
195
+ return null;
196
+ }
197
+
198
+ export async function blenderWasmStatus(
199
+ env: NodeJS.ProcessEnv = process.env,
200
+ ): Promise<BlenderWasmStatus> {
201
+ const dir = blenderWasmDir(env);
202
+ if (dir === null)
203
+ return {
204
+ available: false,
205
+ skew: null,
206
+ dir: null,
207
+ sizes: {},
208
+ encoded: {},
209
+ missing: [
210
+ `no packages/blender-engine/wasm beside this editor, and ${BLENDER_WASM_DIR_VARIABLE} ` +
211
+ `is not set to an absolute directory holding either ${BLENDER_WASM_FILES.join(', ')} ` +
212
+ `or ${BLENDER_WALI_ARTIFACT} beside its ${BLENDER_WALI_RUNTIME}/ tree`,
213
+ ],
214
+ };
215
+ const skew = await skewOf(dir);
216
+ if (skew === null)
217
+ return {
218
+ available: false,
219
+ skew: null,
220
+ dir,
221
+ sizes: {},
222
+ encoded: {},
223
+ missing: [
224
+ `${dir} holds neither ${BLENDER_WASM_FILES.join(', ')} (the standalone skew) nor ` +
225
+ `${BLENDER_WALI_ARTIFACT} (the substrate skew)`,
226
+ ],
227
+ };
228
+ return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir);
229
+ }
230
+
231
+ async function emscriptenStatus(dir: string): Promise<BlenderWasmStatus> {
232
+ const sizes: Record<string, number> = {};
233
+ const encoded: Record<string, 'br'> = {};
234
+ const missing: string[] = [];
235
+ for (const file of BLENDER_WASM_FILES) {
236
+ const found = await blenderWasmOnDisk(dir, file);
237
+ if (!found) {
238
+ missing.push(`${join(dir, file)} (or .br)`);
239
+ continue;
240
+ }
241
+ sizes[file] = found.size;
242
+ if (found.encoding) encoded[file] = found.encoding;
243
+ }
244
+ return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir) };
245
+ }
246
+
247
+ /** The decoded bytes' digests the build records, for the files it records them for. */
248
+ async function emscriptenDigests(dir: string): Promise<Record<string, string>> {
249
+ const digests: Record<string, string> = {};
250
+ const read = async (file: string): Promise<unknown> => {
251
+ try { return JSON.parse(await readFile(join(dir, file), 'utf8')); } catch { return undefined; }
252
+ };
253
+ const bundle = await read('BUNDLE.json') as { rawFiles?: Record<string, { sha256?: unknown }> } | undefined;
254
+ for (const [file, record] of Object.entries(bundle?.rawFiles ?? {})) {
255
+ if (typeof record?.sha256 === 'string') digests[file] = record.sha256;
256
+ }
257
+ const essentials = await read('essentials.json') as { sha256?: unknown } | undefined;
258
+ if (typeof essentials?.sha256 === 'string') digests['essentials.bin'] = essentials.sha256;
259
+ return digests;
260
+ }
261
+
262
+ async function waliStatus(dir: string): Promise<BlenderWasmStatus> {
263
+ const sizes: Record<string, number> = {};
264
+ const missing: string[] = [];
265
+ const artifact = join(dir, BLENDER_WALI_ARTIFACT);
266
+ try {
267
+ sizes[BLENDER_WALI_ARTIFACT] = (await stat(artifact)).size;
268
+ } catch {
269
+ missing.push(artifact);
270
+ }
271
+ let index: RuntimeIndex | null = null;
272
+ try {
273
+ index = await runtimeIndex(dir);
274
+ sizes[`${BLENDER_WALI_RUNTIME}/`] = index.files.reduce((total, file) => total + file.size, 0);
275
+ } catch (error) {
276
+ missing.push(
277
+ `${join(dir, BLENDER_WALI_RUNTIME)} (Blender's scripts, datafiles and python; ` +
278
+ `${error instanceof Error ? error.message : String(error)})`,
279
+ );
280
+ }
281
+ for (const { segment, specifier } of BLENDER_WALI_SUBSTRATE)
282
+ if (substrateDir(specifier) === null)
283
+ missing.push(
284
+ `${specifier} (served at wali/${segment}/; the substrate skew runs Blender through it, ` +
285
+ `so a project that opts into this skew declares it)`,
286
+ );
287
+ const integrity =
288
+ missing.length === 0
289
+ ? `sha256-${createHash('sha256')
290
+ .update(await readFile(artifact))
291
+ .digest('base64')}`
292
+ : undefined;
293
+ return {
294
+ available: missing.length === 0,
295
+ skew: 'wali',
296
+ dir,
297
+ sizes,
298
+ encoded: {},
299
+ missing,
300
+ ...(integrity ? { integrity } : {}),
301
+ workers: { ...BLENDER_WALI_WORKERS },
302
+ };
303
+ }
304
+
305
+ /**
306
+ * The glue, patched. Read once and held: it is 700 KB of text and every
307
+ * pthread the runtime spawns asks for it again.
308
+ */
309
+ let patchedGlue: { dir: string; text: string } | null = null;
310
+
311
+ const CANVAS_TRANSFER =
312
+ 'var transferredCanvasNames=attr?(growMemViews(),HEAPU32)[attr+40>>2]:0;if(';
313
+
314
+ export function patchBlenderGlue(source: string): string {
315
+ // A headless build spawns no window and its glue carries no canvas transfer:
316
+ // then there is nothing to patch and the glue is served as-is. Only a build
317
+ // that DOES transfer a `#canvas` (the port's UI-derived release) needs the
318
+ // list emptied, because a worker has no canvas and `#canvas` asks
319
+ // `Module.canvas` for one that is not there.
320
+ if (!source.includes(CANVAS_TRANSFER)) return source;
321
+ return source.replace(
322
+ CANVAS_TRANSFER,
323
+ `${CANVAS_TRANSFER}true){transferredCanvasNames=0}else if(`,
324
+ );
325
+ }
326
+
327
+ export async function blenderGlueText(dir: string): Promise<string> {
328
+ if (patchedGlue?.dir === dir) return patchedGlue.text;
329
+ const found = await blenderWasmOnDisk(dir, 'blender_browser.js');
330
+ if (!found) throw new Error(`${join(dir, 'blender_browser.js')} is not there`);
331
+ const raw = await readFile(found.path);
332
+ // The glue is patched as TEXT, so a pre-compressed glue is inflated here.
333
+ const source =
334
+ found.encoding === 'br' ? brotliDecompressSync(raw).toString('utf8') : raw.toString('utf8');
335
+ const text = patchBlenderGlue(source);
336
+ patchedGlue = { dir, text };
337
+ return text;
338
+ }
339
+
340
+ export function blenderWasmReadStream(found: BlenderWasmOnDisk): NodeJS.ReadableStream {
341
+ return createReadStream(found.path);
342
+ }
343
+
344
+ // ---- The WALI skew: the runtime tree, and the substrate that runs the program.
345
+
346
+ export interface RuntimeIndex {
347
+ files: { path: string; size: number; mode: number }[];
348
+ }
349
+
350
+ let cachedRuntimeIndex: { dir: string; index: RuntimeIndex } | null = null;
351
+
352
+ /**
353
+ * Blender's runtime tree, as ONE index plus ONE blob.
354
+ *
355
+ * ~2,300 files and 78 MB, every one of which has to exist before Blender's
356
+ * first act. The tab fetches this index and then `runtime.bin`, whose bytes
357
+ * are the same files concatenated IN THIS ORDER, and slices
358
+ * (`blender-wali-engine.mts`'s `stageRuntime`). A request per file would be
359
+ * 2,300 round trips; a tar would be a dependency and a decoder. The order is
360
+ * the index's and nothing else may assume it.
361
+ */
362
+ export async function runtimeIndex(dir: string): Promise<RuntimeIndex> {
363
+ if (cachedRuntimeIndex?.dir === dir) return cachedRuntimeIndex.index;
364
+ const root = join(dir, BLENDER_WALI_RUNTIME);
365
+ const files: RuntimeIndex['files'] = [];
366
+ const walk = async (where: string): Promise<void> => {
367
+ for (const entry of (await readdir(where, { withFileTypes: true })).sort((left, right) =>
368
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
369
+ )) {
370
+ const path = join(where, entry.name);
371
+ if (entry.isDirectory()) {
372
+ await walk(path);
373
+ continue;
374
+ }
375
+ if (!entry.isFile()) continue;
376
+ const info = await stat(path);
377
+ files.push({
378
+ path: relative(root, path).split(/[\\/]/).join(posix.sep),
379
+ size: info.size,
380
+ mode: info.mode & 0o7777,
381
+ });
382
+ }
383
+ };
384
+ await walk(root);
385
+ if (files.length === 0) throw new Error(`${root} is empty`);
386
+ const index = { files };
387
+ cachedRuntimeIndex = { dir, index };
388
+ return index;
389
+ }
390
+
391
+ /** Stream the runtime blob: every indexed file's bytes, in index order. */
392
+ export async function writeRuntimeBlob(dir: string, write: (chunk: Buffer) => void): Promise<void> {
393
+ const root = join(dir, BLENDER_WALI_RUNTIME);
394
+ for (const file of (await runtimeIndex(dir)).files) write(await readFile(join(root, file.path)));
395
+ }
396
+
397
+ /**
398
+ * Where a substrate package's browser ESM is, or null when it is not
399
+ * installed. Resolved through Node's own resolution from THIS file, so it
400
+ * finds whatever the project that opted into this skew declared.
401
+ *
402
+ * `import.meta.resolve` and not `createRequire().resolve`: these packages
403
+ * export `"."` and `"./*.js"` under the `import` condition ONLY, so a CJS
404
+ * resolve raises ERR_PACKAGE_PATH_NOT_EXPORTED on a package that is plainly
405
+ * installed. The package's OWN ENTRY is resolved rather than its
406
+ * `package.json`, which is not exported at all; the entry's directory IS the
407
+ * published `dist`, which is where every served module comes from.
408
+ */
409
+ export function substrateDir(specifier: string): string | null {
410
+ try {
411
+ return dirname(fileURLToPath(import.meta.resolve(specifier)));
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ const SUBSTRATE_SEGMENTS = new Map<string, string>(
418
+ BLENDER_WALI_SUBSTRATE.map(({ segment, specifier }) => [segment, specifier] as const),
419
+ );
420
+
421
+ /**
422
+ * One substrate module, with its bare specifiers rewritten to this door's URLs.
423
+ *
424
+ * `@volter/browser-wali/dist/worker-program.js` imports
425
+ * `@volter/browser-runtime/browser-filesystem.js`, which a browser cannot
426
+ * resolve; and `BrowserWaliWorkerProgram` spawns its own workers with
427
+ * `new Worker(new URL("./worker-program-worker.js", import.meta.url))`, so
428
+ * those land back on this same route and must get the identically rewritten
429
+ * text. That is the glue patch's rule applied to a second artifact, for the
430
+ * same reason.
431
+ */
432
+ export async function substrateModule(
433
+ segment: string,
434
+ file: string,
435
+ ): Promise<{ text: string } | null> {
436
+ const specifier = SUBSTRATE_SEGMENTS.get(segment);
437
+ if (!specifier || !/^[\w.-]+\.(?:js|mjs|map)$/.test(file)) return null;
438
+ const root = substrateDir(specifier);
439
+ if (root === null) return null;
440
+ const path = join(root, file);
441
+ if (!existsSync(path)) return null;
442
+ let text = (await readFile(path)).toString('utf8');
443
+ for (const other of BLENDER_WALI_SUBSTRATE)
444
+ text = text.replaceAll(
445
+ `"${other.specifier}/`,
446
+ `"/__editor/blender-wasm/wali/${other.segment}/`,
447
+ );
448
+ return { text };
449
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@volter/editor-blender`'s server half (`package.json#vgai.serving`, the project-serving door in
3
+ * `@volter/editor-sdk/session/project-serving`): the routes the Blender in the tab reads and
4
+ * writes through.
5
+ */
6
+
7
+ import type { ProjectServingModule } from '@volter/editor-sdk/session/project-serving';
8
+ import { blenderRoutesPlugin } from './blender-routes';
9
+
10
+ export const servingPlugins: ProjectServingModule['servingPlugins'] = (services) => [blenderRoutesPlugin(services)];
package/src/layouts.tsx CHANGED
@@ -22,16 +22,15 @@ import {
22
22
  Workspace,
23
23
  type WorkspaceArrangement,
24
24
  } from '@volter/editor-sdk/layouts';
25
+ import { BLENDER_REGIONS } from './regions';
25
26
 
26
27
  export const ModelArrangement: WorkspaceArrangement = {
27
28
  id: 'model',
28
29
  title: 'Model',
29
- // `tabs` unstated — see `../contributions/model.layout.ts`: the workspace
30
- // layer wins over the style bundle, so restating the host default shadows it.
30
+ // `tabs` unstated — see `../contributions/model.layout.ts`.
31
+ // Blender's areas (`./regions.ts`), as every Blender workspace carries them.
31
32
  regions: {
32
- header: 'shown',
33
- shelf: 'shown',
34
- inspector: 'properties',
33
+ ...BLENDER_REGIONS,
35
34
  drawer: 'hidden',
36
35
  },
37
36
  };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * HOW THE BLENDER STAGE BEHAVES, as its starting presentation (`@volter/editor-sdk/kit/
3
+ * viewport-presentation`): this package builds the `model` stage, so it states the stage's
4
+ * function there, beneath every person's choice and never in the look (ARCHITECTURE.md rule 7).
5
+ */
6
+ import {
7
+ DOCUMENT_STUDIO_PRESET,
8
+ registerStartingPresentation,
9
+ type ViewportCamera,
10
+ } from '@volter/editor-sdk/kit/viewport-presentation';
11
+
12
+ /**
13
+ * A 3D View's field of view for its lens, as Blender computes it (`BKE_camera_params_from_view3d`,
14
+ * `BKE_camera_params_compute_viewplane`): the 36 mm sensor over the lens, times the viewport's
15
+ * `CAMERA_PARAM_ZOOM_INIT_PERSP` of 2, held on the region's LARGER side (sensor fit AUTO) — so a
16
+ * wider panel sees no more world sideways and a shorter one sees less vertically. The same zoom
17
+ * scales an orthographic view's `dist * sensor / lens`, so the two projections agree at the pivot.
18
+ */
19
+ export function blenderViewFieldOfView(lens: number): ViewportCamera['fov'] {
20
+ const safeLens = Number.isFinite(lens) && lens > 0 ? lens : 50;
21
+ return { degrees: (2 * Math.atan((36 * 2) / (2 * safeLens)) * 180) / Math.PI, axis: 'larger' };
22
+ }
23
+
24
+ const release = registerStartingPresentation('model', {
25
+ // BLENDER'S VIEW: the factory `View3D.lens` of 50 mm (read back from Blender 5.2), a 71.5°
26
+ // angle on the larger side; and its default user perspective, elevation 26.5° and azimuth
27
+ // −23.8° about the up axis, SOLVED from the reference's two floor axes (projected slopes
28
+ // +1.0093 for X and −0.1969 for Y in `modeling-edit-none.png`), in three's Y-up frame. A file's
29
+ // own saved view and lens are its document's (`blender-runtime.document.tsx`).
30
+ camera: { fov: blenderViewFieldOfView(50), opening: [0.8187, 0.4458, 0.3617] },
31
+ interaction: {
32
+ // BLENDER'S SHELF OPENS ON SELECT BOX, so a selected object carries no transform gizmo until
33
+ // one of the four transform tools is armed (`space_toolsystem_toolbar.py`:
34
+ // `_defs_view3d_generic.select_box` is the first entry of the Object Mode `_tools_default`
35
+ // tuple). Photographed at the engine's pin: `gizmo-select-box.png` has Select Box lit in the
36
+ // shelf, the default cube selected and outlined, and nothing at its origin but the 3D cursor
37
+ // and the object dot; `gizmo-move.png` has Move lit in the same frame.
38
+ bootTool: 'select',
39
+ // BLENDER'S SELECT BOX IS A TOUCH TEST: its object-mode box select reads the object-id buffer
40
+ // under the rectangle, so any drawn part of an object inside it selects that object.
41
+ boxSelect: 'touch',
42
+ },
43
+ // BLENDER'S SHADING TYPES EACH KEEP THEIR OWN LIGHTING, and switching the header's shading cell
44
+ // switches to that type's (`View3DShading` keeps `studio_light` for Solid, `studio_light` /
45
+ // `studiolight_*` for Material Preview, and Rendered lights by the scene). So they are stated per
46
+ // draw mode, and a person's Lighting choice is the current mode's (`stageLightsPerMode`).
47
+ // SOLID (`clay`, and Wireframe with it) is lit by Blender's four studio lights, the document's
48
+ // view-locked studio, and drawn under STANDARD: Blender gives Solid the display's default view
49
+ // and none of the scene's settings (`draw_color_management.cc`, `ViewTransform`), and the sRGB
50
+ // display's default is Standard (`config.ocio`). Blender 5.2's Workbench under Standard gives
51
+ // the factory cube 142 / 131 / 112 in the default view and 162 on the front face, agreeing with
52
+ // the viewport's own frames (141 / 129 / 111, 161). The scene's AgX is the two modes' below.
53
+ all: {
54
+ lighting: {
55
+ source: 'studio',
56
+ studioPreset: DOCUMENT_STUDIO_PRESET.id,
57
+ auto: null,
58
+ tone: { mapper: 'none', exposure: 1 },
59
+ },
60
+ // X-Ray per shading type, read back from Blender 5.2's factory View3DShading: Solid's off at
61
+ // 0.5 (`show_xray`, `xray_alpha`), Wireframe's on at 0 (`show_xray_wireframe`,
62
+ // `xray_alpha_wireframe`) — a wireframe with no surface.
63
+ xray: { enabled: false, alpha: 0.5 },
64
+ },
65
+ modes: {
66
+ wireframe: { xray: { enabled: true, alpha: 0 } },
67
+ // MATERIAL PREVIEW: the scene lit by a world studio light alone, Forest at strength 1 and
68
+ // rotation 0, fixed in the world, drawn over the viewport's own colour, in AgX. Read from
69
+ // Blender 5.2's factory View3DShading: `studio_light` Default (forest.exr),
70
+ // `studiolight_intensity` 1, `studiolight_rotate_z` 0, `studiolight_background_alpha` 0,
71
+ // `use_studiolight_view_rotation` (World Space Lighting) on, `use_scene_lights` off
72
+ // (`docs/reference-probes/blender-view3d-shading.py`). The images are `blender.environment.ts`'s.
73
+ // Blender's viewport never changes its shading on its own: a lamp does not take it over.
74
+ preview: {
75
+ lighting: {
76
+ source: 'preview',
77
+ auto: null,
78
+ preview: {
79
+ sceneLights: false,
80
+ sun: { enabled: false },
81
+ environment: { enabled: true, image: 'blender:forest', energy: 1, rotation: 0 },
82
+ },
83
+ tone: { mapper: 'agx', exposure: 1 },
84
+ },
85
+ backdrop: { source: 'fill' },
86
+ },
87
+ // RENDERED: the scene's own lights and World, drawn behind the model, in AgX — the lighting the
88
+ // document's render photographs with (`BlenderRuntimeView.holdRendered`). Not a path tracer:
89
+ // the viewport and a render agree because they are one drawing.
90
+ rendered: {
91
+ lighting: { source: 'scene', auto: null, tone: { mapper: 'agx', exposure: 1 } },
92
+ backdrop: { source: 'scene' },
93
+ },
94
+ },
95
+ overlays: {
96
+ // BLENDER DRAWS THE SELECTED OBJECTS' ORIGINS (Overlays › Origins, on by default): the dot at
97
+ // the default cube's centre in every reference frame (`gizmo-select-box.png`).
98
+ selection: { origins: true },
99
+ },
100
+ world: {
101
+ // BLENDER'S WORLD IS Z-UP, and the stage presents it through the signed permutation
102
+ // `(x, y, z) → (x, z, −y)` the presented root carries (`blender-runtime-view.ts`'s
103
+ // constructor). So three's Y is Blender's Z and three's Z is Blender's −Y, and the transform
104
+ // gizmo's colours, the side each arm is drawn on, the plane squares and the navigation
105
+ // gizmo's six labels all say so: the arm that points up is Z and it is blue.
106
+ upAxis: 'z',
107
+ },
108
+ });
109
+
110
+ // A contribution module re-evaluates on a hot update; release the registration first.
111
+ if (import.meta.hot) import.meta.hot.dispose(release);
package/src/regions.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * BLENDER'S AREAS, as the regions every Blender workspace declares (ARCHITECTURE.md rule 7:
3
+ * which regions exist and what a hierarchy row spells are function, the workspace's, never the
4
+ * look's). Each of this package's layouts spreads these beneath its own.
5
+ */
6
+ import type { WorkspaceLayoutContribution } from '@volter/editor-sdk/looks';
7
+
8
+ export const BLENDER_REGIONS: NonNullable<WorkspaceLayoutContribution['regions']> = {
9
+ workspaceTabs: 'shown',
10
+ // Blender has no document tab strip over its areas (the host's own note
11
+ // in `workspace-presets.ts` says so; the bundle contradicted it).
12
+ header: 'shown',
13
+ shelf: 'shown',
14
+ inspector: 'properties',
15
+ // Measured on `modeling.png`: Blender's top-right corner carries two quiet
16
+ // wells (Scene, ViewLayer) and nothing brighter than the 216 its labels
17
+ // ink at. Ours drew a yellow FPS/ms readout, an orange sparkline and a lit
18
+ // audio meter there — the brightest, highest-chroma thing in the frame and
19
+ // the first place the eye lands. The numbers keep their door (the Profiler
20
+ // utility, which the readout's own tooltip names).
21
+ telemetry: 'hidden',
22
+ // Measured on `outliner.png`: Scene Collection, Collection, Camera, Cube
23
+ // and Light each carry their NAME and nothing after it — no type text
24
+ // anywhere in the frame. The type is the GLYPH, which is why the glyph is
25
+ // coloured by category in the first place; a row that also spelled the
26
+ // type would say it twice. Ours printed `Hero Box ·HeroBox` at
27
+ // `content.dim` in a 10px face, one `space-2` past the name.
28
+ hierarchyTypeSuffix: 'hidden',
29
+ // Measured across all three Outliner frames — `outliner.png` (edit mode),
30
+ // `modeling-object-none.png` (object mode, nothing selected) and
31
+ // `modeling-object-selected.png` (object mode, everything selected):
32
+ // Blender draws NO rule, dotted or solid, under any name in that panel, in
33
+ // any selection state. Ours drew an orange dotted rule under every
34
+ // component-instance name — six of this scaffold's eleven rows — which is a
35
+ // mark with no counterpart in the frame at all. The instance fact keeps its
36
+ // doors (the label's tooltip, Go to Callsite / Open Component Source, the
37
+ // inspector's component sections); only the paint yields.
38
+ hierarchyInstanceRule: 'hidden',
39
+ // Measured on `outliner.png`: every object row carries an EYE (CSS ink
40
+ // 257.5-270.5, 32 px in from the area's right edge) and a render CAMERA
41
+ // (277.5-290.5, 12 px in) on a 20 px column pitch, and no lock glyph
42
+ // anywhere — Blender's Disable Selection column is off by default. Ours
43
+ // drew a lock 29-41 px in and the eye 6-18 px in, so the LOCK was sitting
44
+ // in Blender's eye column and the eye outboard of its camera column. The
45
+ // lock goes, and the render column is reserved blank so the eye lands on
46
+ // Blender's own; `workspace-regions.ts` carries why it stays blank.
47
+ hierarchyRestrictions: 'viewport+render',
48
+ // Measured at native 2x on `outliner.png` AND on the Properties area of
49
+ // `modeling-edit-none.png` — device x relative to each area's own left
50
+ // edge, and the two agree to half a pixel: every Blender area begins with
51
+ // an EDITOR-TYPE WELL at x 17..80, i.e. 8.5 CSS in and 32 CSS wide, 20
52
+ // tall, its glyph and a chevron inside a 1 px `#3c3c3c` outline over a
53
+ // `#272727` inner. It is there whether the area holds one editor or a
54
+ // dozen, and its dropdown is how an area changes editor. Ours began with
55
+ // a 203 px strip of WORDS, which is what squeezed the Outliner's search
56
+ // to 64 px against Blender's 115, truncated the third tab to `Libr`, and
57
+ // left the Properties area — a lone panel declaring no header controls —
58
+ // with no header band at all.
59
+ };