@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.
- package/contributions/blender-header-menus.tsx +4 -9
- package/contributions/blender-icons.source.mjs +74 -6
- package/contributions/blender-outliner-authoring.ts +10 -21
- package/contributions/blender-palette.source.mjs +16 -5
- package/contributions/blender-properties-view.tsx +28 -9
- package/contributions/blender-runtime-skin.ts +6 -0
- package/contributions/blender-runtime.document.tsx +219 -23
- package/contributions/blender.command.ts +5 -1
- package/contributions/blender.environment.ts +32 -0
- package/contributions/blender.icons.json +22 -2
- package/contributions/blender.icons.traced.json +28 -0
- package/contributions/blender.keymap.ts +33 -0
- package/contributions/blender.palette.json +6 -0
- package/contributions/blender.status.tsx +2 -6
- package/contributions/blender.style.ts +30 -89
- package/contributions/model.layout.ts +7 -7
- package/contributions/models.finder.ts +16 -0
- package/contributions/sculpt.layout.ts +3 -0
- package/contributions/shading.layout.ts +3 -0
- package/contributions/studiolights/world/city.exr +0 -0
- package/contributions/studiolights/world/courtyard.exr +0 -0
- package/contributions/studiolights/world/forest.exr +0 -0
- package/contributions/studiolights/world/interior.exr +0 -0
- package/contributions/studiolights/world/license.txt +15 -0
- package/contributions/studiolights/world/night.exr +0 -0
- package/contributions/studiolights/world/studio.exr +0 -0
- package/contributions/studiolights/world/sunrise.exr +0 -0
- package/contributions/studiolights/world/sunset.exr +0 -0
- package/contributions/uv-editing.layout.ts +3 -0
- package/dist-node/serving.mjs +624 -0
- package/host/blender-runtime-host.ts +11 -1
- package/node/blender-mcp.ts +2 -2
- package/package.json +20 -10
- package/serving/blender-routes.ts +427 -0
- package/serving/blender-wasm-artifact.ts +449 -0
- package/serving/index.ts +10 -0
- package/src/layouts.tsx +4 -5
- package/src/presentation.ts +111 -0
- 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
|
+
}
|
package/serving/index.ts
ADDED
|
@@ -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
|
|
30
|
-
//
|
|
30
|
+
// `tabs` unstated — see `../contributions/model.layout.ts`.
|
|
31
|
+
// Blender's areas (`./regions.ts`), as every Blender workspace carries them.
|
|
31
32
|
regions: {
|
|
32
|
-
|
|
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
|
+
};
|