@volter/editor-blender 0.1.7 → 0.1.9

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 (36) hide show
  1. package/LICENSE-MCP +21 -0
  2. package/contributions/blender-palette.source.mjs +15 -5
  3. package/contributions/blender-properties-view.tsx +28 -9
  4. package/contributions/blender-runtime.document.tsx +6 -1
  5. package/contributions/blender.command.ts +5 -1
  6. package/contributions/blender.environment.ts +32 -0
  7. package/contributions/blender.palette.json +8 -0
  8. package/contributions/blender.status.tsx +2 -6
  9. package/contributions/blender.style.ts +30 -89
  10. package/contributions/material-preview.view.ts +35 -0
  11. package/contributions/model.layout.ts +7 -7
  12. package/contributions/models.finder.ts +16 -0
  13. package/contributions/sculpt.layout.ts +3 -0
  14. package/contributions/shading.layout.ts +3 -0
  15. package/contributions/studiolights/world/city.exr +0 -0
  16. package/contributions/studiolights/world/courtyard.exr +0 -0
  17. package/contributions/studiolights/world/forest.exr +0 -0
  18. package/contributions/studiolights/world/interior.exr +0 -0
  19. package/contributions/studiolights/world/license.txt +15 -0
  20. package/contributions/studiolights/world/night.exr +0 -0
  21. package/contributions/studiolights/world/studio.exr +0 -0
  22. package/contributions/studiolights/world/sunrise.exr +0 -0
  23. package/contributions/studiolights/world/sunset.exr +0 -0
  24. package/contributions/uv-editing.layout.ts +3 -0
  25. package/dist-node/serving.mjs +624 -0
  26. package/host/blender-runtime-host.ts +11 -1
  27. package/node/blender-mcp-defaults.json +7 -0
  28. package/node/blender-mcp-tools.json +665 -0
  29. package/node/blender-mcp.ts +406 -0
  30. package/package.json +25 -9
  31. package/serving/blender-routes.ts +427 -0
  32. package/serving/blender-wasm-artifact.ts +449 -0
  33. package/serving/index.ts +10 -0
  34. package/src/layouts.tsx +4 -5
  35. package/src/presentation.ts +32 -0
  36. package/src/regions.ts +59 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * `@volter/editor-blender`'s server half (`package.json#vgai.serving`): the routes the Blender
3
+ * in the tab needs from the session — the engine's WebAssembly build, the file spool its
4
+ * transports move bytes through, the project's files as Blender reads them, and the two ways
5
+ * bytes it produced reach the project (a shipped output, the session's `.blend`). They were
6
+ * the kit's own routes; the kit now names no Blender, and reaches these only because the
7
+ * product that composes this package declares its serving module. Writes go through the kit's
8
+ * services (`@volter/editor-sdk/session/project-serving`), so they are attributed and recorded
9
+ * like every other project write.
10
+ */
11
+
12
+ import { createReadStream } from 'node:fs';
13
+ import { mkdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
14
+ import type { IncomingMessage, ServerResponse } from 'node:http';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { createBrotliDecompress } from 'node:zlib';
18
+ import { projectOutputRootOf } from '@volter/editor-sdk/project/output-roots';
19
+ import { isContainedRelativePath } from '@volter/editor-sdk/session/relative-path-guard';
20
+ import type { ProjectServingServices } from '@volter/editor-sdk/session/project-serving';
21
+ import type { Plugin } from 'vite';
22
+ import {
23
+ BLENDER_WALI_ARTIFACT,
24
+ BLENDER_WASM_FILES,
25
+ blenderGlueText,
26
+ blenderWasmOnDisk,
27
+ blenderWasmReadStream,
28
+ blenderWasmStatus,
29
+ runtimeIndex,
30
+ substrateModule,
31
+ writeRuntimeBlob,
32
+ } from './blender-wasm-artifact';
33
+
34
+ type Handler = (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void>;
35
+
36
+ function json(res: ServerResponse, body: unknown, status = 200): void {
37
+ res.statusCode = status;
38
+ res.setHeader('content-type', 'application/json');
39
+ res.end(JSON.stringify(body));
40
+ }
41
+
42
+ async function readBody(req: IncomingMessage): Promise<Buffer> {
43
+ const chunks: Buffer[] = [];
44
+ for await (const value of req) chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value as Uint8Array));
45
+ return Buffer.concat(chunks);
46
+ }
47
+
48
+ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
49
+ // ---- File bodies, which are NOT commands.
50
+ //
51
+ // A FILE IS A BODY, NOT A JSON FIELD. `blender-read-file` used to answer with the whole file
52
+ // base64'd inside a control-plane message, so a mesh the twin had written came back as a
53
+ // single JSON frame on the command socket (measured 2026-09-15: a 31 MB file became ~41.5 MB
54
+ // of base64 in one message and killed the socket). So bytes move as bytes: the page POSTs an
55
+ // octet-stream; the requester GETs it once and it is gone. The spool keeps PATH AUTHORITY
56
+ // WITH THE REQUESTER: the page never names a destination on disk, only an id the caller
57
+ // minted.
58
+ const transferRoot = join(tmpdir(), 'vgai-blender-transfer');
59
+ const transferPath = (id: string): string => join(transferRoot, id);
60
+ // Every id is a uuid minted by a caller in a PREVIOUS process, so nothing live is here yet:
61
+ // clearing once at startup bounds what a failed transfer can leave.
62
+ void rm(transferRoot, { recursive: true, force: true });
63
+ const transferId = (value: string | null): string | null =>
64
+ value !== null && /^[0-9a-f-]{36}$/.test(value) ? value : null;
65
+ const projectRoot = (): string | null => {
66
+ const root = services.currentProjectRoot();
67
+ return root === undefined || root === services.engineRoot ? null : root;
68
+ };
69
+ // The session's document is a project-relative `.blend` with no traversal and no dot
70
+ // segment: the page names a document, never a place on disk.
71
+ const isDocumentPath = (path: string): boolean => {
72
+ if (!path.endsWith('.blend') || path.startsWith('/') || path.includes('\\')) return false;
73
+ if (!isContainedRelativePath(path)) return false;
74
+ return path.split('/').every((segment) => segment !== '' && !segment.startsWith('.'));
75
+ };
76
+
77
+ const routes: { method: 'GET' | 'POST'; match: RegExp; handle: Handler }[] = [
78
+ // ---- The engine itself: headless Blender, compiled to WebAssembly. Its pthreads load
79
+ // `blender_browser.js` from this same URL as a classic worker, so the patched glue has to
80
+ // be what the URL answers with.
81
+ {
82
+ method: 'GET',
83
+ match: /^\/__editor\/blender-wasm\/status$/,
84
+ handle: async (_req, res) => {
85
+ services.allowCrossOriginFrameEmbedding(res);
86
+ json(res, await blenderWasmStatus());
87
+ },
88
+ },
89
+ // The substrate skew's own doors beside the standalone bundle's: Blender's runtime tree as
90
+ // one index and one blob (a request per file would be ~2,300 round trips), and the
91
+ // substrate's browser ESM with its bare specifiers rewritten.
92
+ {
93
+ method: 'GET',
94
+ match: /^\/__editor\/blender-wasm\/runtime\.idx$/,
95
+ handle: async (_req, res) => {
96
+ const status = await blenderWasmStatus();
97
+ if (status.skew !== 'wali' || status.dir === null) {
98
+ json(res, { error: 'This editor does not serve the substrate skew of Blender.' }, 404);
99
+ return;
100
+ }
101
+ json(res, await runtimeIndex(status.dir));
102
+ },
103
+ },
104
+ {
105
+ method: 'GET',
106
+ match: /^\/__editor\/blender-wasm\/runtime\.bin$/,
107
+ handle: async (_req, res) => {
108
+ const status = await blenderWasmStatus();
109
+ if (status.skew !== 'wali' || status.dir === null) {
110
+ json(res, { error: 'This editor does not serve the substrate skew of Blender.' }, 404);
111
+ return;
112
+ }
113
+ res.setHeader('content-type', 'application/octet-stream');
114
+ await writeRuntimeBlob(status.dir, (chunk) => void res.write(chunk));
115
+ res.end();
116
+ },
117
+ },
118
+ {
119
+ method: 'GET',
120
+ match: /^\/__editor\/blender-wasm\/wali\/([^/]+)\/([^/]+)$/,
121
+ handle: async (_req, res, url) => {
122
+ const [, segment = '', file = ''] = /^\/__editor\/blender-wasm\/wali\/([^/]+)\/([^/]+)$/.exec(url.pathname) ?? [];
123
+ const served = await substrateModule(decodeURIComponent(segment), decodeURIComponent(file));
124
+ if (!served) {
125
+ json(
126
+ res,
127
+ {
128
+ error:
129
+ `This editor does not serve ${segment}/${file}. The substrate skew runs Blender through ` +
130
+ '@volter/browser-wali and @volter/browser-runtime; a project that opts into it declares them.',
131
+ },
132
+ 404,
133
+ );
134
+ return;
135
+ }
136
+ res.setHeader('content-type', 'text/javascript');
137
+ res.end(served.text);
138
+ },
139
+ },
140
+ {
141
+ method: 'GET',
142
+ match: /^\/__editor\/blender-wasm\/([^/]+)$/,
143
+ handle: async (req, res, url) => {
144
+ const file = decodeURIComponent(url.pathname.slice('/__editor/blender-wasm/'.length));
145
+ // The frame's page is cross-origin-isolated and this session is not its origin, so every
146
+ // byte of the engine has to say it may be embedded — the glue above all, which Blender's
147
+ // pthreads load with `importScripts`.
148
+ services.allowCrossOriginFrameEmbedding(res);
149
+ const bundled = (BLENDER_WASM_FILES as readonly string[]).includes(file);
150
+ if (!bundled && file !== BLENDER_WALI_ARTIFACT) {
151
+ json(res, { error: `The Blender build has no ${file}.` }, 404);
152
+ return;
153
+ }
154
+ const status = await blenderWasmStatus();
155
+ if (!status.available) {
156
+ json(
157
+ res,
158
+ { error: `The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}` },
159
+ 404,
160
+ );
161
+ return;
162
+ }
163
+ if (file === 'blender_browser.js') {
164
+ res.setHeader('content-type', 'text/javascript');
165
+ res.end(await blenderGlueText(status.dir!));
166
+ return;
167
+ }
168
+ const found = await blenderWasmOnDisk(status.dir!, file);
169
+ if (!found) {
170
+ json(res, { error: `${file} vanished from ${status.dir}.` }, 404);
171
+ return;
172
+ }
173
+ // A VALIDATOR, SO THE COMPILED ENGINE IS CACHED. Chrome keeps the machine code it
174
+ // compiled from an `instantiateStreaming` response only while that response sits in its
175
+ // HTTP cache, and a response with no validator is never stored (measured: every boot
176
+ // compiled all 86 MB again, 12-36 s at load ~35). `no-cache` still asks every time, and
177
+ // this answers 304 while the file on disk is the same one.
178
+ const info = await stat(found.path);
179
+ // A pre-compressed file goes out as stored, declared as brotli, to a client that accepts
180
+ // brotli; one that does not (a service worker in the tab asks with no Accept-Encoding)
181
+ // gets it inflated here.
182
+ const accepted = String(req.headers['accept-encoding'] ?? '').toLowerCase();
183
+ const sendEncoded = found.encoding !== null && accepted.includes(found.encoding);
184
+ const etag = `"${sendEncoded ? found.encoding : 'identity'}-${info.size}-${Math.floor(info.mtimeMs)}"`;
185
+ res.setHeader('cache-control', 'no-cache');
186
+ res.setHeader('etag', etag);
187
+ res.setHeader('vary', 'accept-encoding');
188
+ if (req.headers['if-none-match'] === etag) {
189
+ res.statusCode = 304;
190
+ res.end();
191
+ return;
192
+ }
193
+ res.setHeader('content-type', file.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream');
194
+ if (sendEncoded) {
195
+ res.setHeader('content-encoding', found.encoding!);
196
+ res.setHeader('content-length', String(found.size));
197
+ blenderWasmReadStream(found).pipe(res);
198
+ return;
199
+ }
200
+ const stream = blenderWasmReadStream(found);
201
+ // A stored file that does not inflate ends this response, not the server.
202
+ const body = found.encoding ? stream.pipe(createBrotliDecompress()).on('error', () => res.destroy()) : stream;
203
+ body.pipe(res);
204
+ },
205
+ },
206
+ {
207
+ method: 'POST',
208
+ match: /^\/__editor\/blender-file$/,
209
+ handle: async (req, res, url) => {
210
+ const id = transferId(url.searchParams.get('id'));
211
+ if (id === null) {
212
+ json(res, { error: 'blender-file requires a uuid `id`.' }, 400);
213
+ return;
214
+ }
215
+ const path = transferPath(id);
216
+ await mkdir(dirname(path), { recursive: true });
217
+ const body = await readBody(req);
218
+ await writeFile(path, new Uint8Array(body));
219
+ json(res, { bytes: body.byteLength });
220
+ },
221
+ },
222
+ {
223
+ method: 'GET',
224
+ match: /^\/__editor\/blender-file$/,
225
+ handle: async (_req, res, url) => {
226
+ const id = transferId(url.searchParams.get('id'));
227
+ if (id === null) {
228
+ json(res, { error: 'blender-file requires a uuid `id`.' }, 400);
229
+ return;
230
+ }
231
+ const path = transferPath(id);
232
+ const info = await stat(path).catch(() => null);
233
+ if (info === null) {
234
+ json(res, { error: 'No transfer is spooled under that id.' }, 404);
235
+ return;
236
+ }
237
+ res.setHeader('content-type', 'application/octet-stream');
238
+ res.setHeader('content-length', String(info.size));
239
+ const stream = createReadStream(path);
240
+ stream.pipe(res);
241
+ stream.on('close', () => {
242
+ void unlink(path).catch(() => {});
243
+ });
244
+ },
245
+ },
246
+ {
247
+ method: 'GET',
248
+ match: /^\/__editor\/blender-project-index$/,
249
+ handle: async (_req, res) => {
250
+ const root = projectRoot();
251
+ if (root === null) {
252
+ json(res, { error: 'No project open.' }, 404);
253
+ return;
254
+ }
255
+ json(res, { root, files: await services.projectFileIndex() });
256
+ },
257
+ },
258
+ {
259
+ method: 'GET',
260
+ match: /^\/__editor\/blender-project-file$/,
261
+ handle: async (_req, res, url) => {
262
+ const relPath = url.searchParams.get('path') ?? '';
263
+ const scratchDocument =
264
+ isContainedRelativePath(relPath) && relPath.startsWith('.vgai/tmp/') && relPath.endsWith('.blend');
265
+ if (!services.isProjectOwnedPath(relPath) && !scratchDocument) {
266
+ json(res, { error: 'Not a project-owned path.' }, 400);
267
+ return;
268
+ }
269
+ const root = projectRoot();
270
+ if (root === null) {
271
+ json(res, { error: 'No project open.' }, 404);
272
+ return;
273
+ }
274
+ const absolute = join(root, relPath);
275
+ if (!(await services.isCanonicalPathInside(root, absolute))) {
276
+ json(res, { error: 'That path leaves the project root.' }, 403);
277
+ return;
278
+ }
279
+ try {
280
+ const bytes = await readFile(absolute);
281
+ res.setHeader('content-type', 'application/octet-stream');
282
+ res.end(bytes);
283
+ } catch {
284
+ json(res, { error: 'Not found.' }, 404);
285
+ }
286
+ },
287
+ },
288
+ // ---- A SHIPPED OUTPUT: bytes Blender produced for the game to ship (`public/`), recorded
289
+ // with the session and revision that re-derive them.
290
+ {
291
+ method: 'POST',
292
+ match: /^\/__editor\/blender-output$/,
293
+ handle: async (req, res, url) => {
294
+ const path = url.searchParams.get('path') ?? '';
295
+ const session = url.searchParams.get('session') ?? '';
296
+ const source = url.searchParams.get('source') ?? '';
297
+ const revision = Number(url.searchParams.get('revision'));
298
+ const callId = url.searchParams.get('callId') ?? '';
299
+ if (!isContainedRelativePath(path) || projectOutputRootOf(path) !== 'public') {
300
+ json(
301
+ res,
302
+ {
303
+ error:
304
+ 'A shipped output is a project-relative path under public/ with no traversal ' +
305
+ `(public/models/model.glb); got ${JSON.stringify(path)}.`,
306
+ },
307
+ 400,
308
+ );
309
+ return;
310
+ }
311
+ if (session === '' || !Number.isInteger(revision) || revision < 0) {
312
+ json(
313
+ res,
314
+ {
315
+ error:
316
+ 'A shipped output must name the session that produced it — `session` and a ' +
317
+ 'non-negative integer `revision` — so the record can say what re-derives the file.',
318
+ },
319
+ 400,
320
+ );
321
+ return;
322
+ }
323
+ if (source === '' || source.length > 64) {
324
+ json(res, { error: '`source` names the transport that wrote the file, in 1-64 characters.' }, 400);
325
+ return;
326
+ }
327
+ const inputs = url.searchParams.getAll('inputs');
328
+ const badInput = inputs.find((value) => !isContainedRelativePath(value));
329
+ if (badInput !== undefined) {
330
+ json(
331
+ res,
332
+ {
333
+ error:
334
+ 'Each `inputs` value is a project-relative path with no traversal (the source file ' +
335
+ `these bytes were produced from); got ${JSON.stringify(badInput)}.`,
336
+ },
337
+ 400,
338
+ );
339
+ return;
340
+ }
341
+ if (projectRoot() === null) {
342
+ json(res, { error: 'No project open, so there is nowhere to record the output.' }, 404);
343
+ return;
344
+ }
345
+ const body = await readBody(req);
346
+ if (body.byteLength === 0) {
347
+ json(res, { error: 'The output body was empty; nothing was written.' }, 400);
348
+ return;
349
+ }
350
+ try {
351
+ const written = await services.writeProjectOutput({
352
+ path,
353
+ content: body,
354
+ source,
355
+ session: {
356
+ id: session,
357
+ port: req.socket.localPort ?? 0,
358
+ revision,
359
+ ...(callId === '' ? {} : { callId }),
360
+ },
361
+ ...(inputs.length > 0 ? { inputs } : {}),
362
+ });
363
+ json(res, { ok: true, bytes: body.byteLength, provenanceOperationId: written.provenanceOperationId });
364
+ } catch (error) {
365
+ json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
366
+ }
367
+ },
368
+ },
369
+ // ---- The session's DOCUMENT, written back OUT into the project. A Blender session holds one
370
+ // `.blend`; its `save_as_mainfile` writes it into the engine's filesystem, and this is how
371
+ // those bytes reach the disk the project lives on. Unlike the spool, the destination shape is
372
+ // pinned BY THIS SERVER, and the write is the project's one attributed, conflict-checked
373
+ // transaction: a `.blend` is the project's file like any other.
374
+ {
375
+ method: 'POST',
376
+ match: /^\/__editor\/blender-document$/,
377
+ handle: async (req, res, url) => {
378
+ const path = url.searchParams.get('path') ?? '';
379
+ if (!isDocumentPath(path)) {
380
+ json(
381
+ res,
382
+ {
383
+ error:
384
+ 'A Blender document is a project-relative .blend path with no traversal ' +
385
+ `(models/model.blend); got ${JSON.stringify(path)}.`,
386
+ },
387
+ 400,
388
+ );
389
+ return;
390
+ }
391
+ if (projectRoot() === null) {
392
+ json(res, { error: 'No project open, so there is nowhere to save the document.' }, 404);
393
+ return;
394
+ }
395
+ const body = await readBody(req);
396
+ // A zero-byte .blend is not a document; writing one would replace a good file with an
397
+ // empty one at exactly the moment something went wrong.
398
+ if (body.byteLength === 0) {
399
+ json(res, { error: 'The Blender document body was empty; nothing was written.' }, 400);
400
+ return;
401
+ }
402
+ try {
403
+ const revision = await services.commitProjectMutation(req, [{ path, content: body }]);
404
+ json(res, { ok: true, bytes: body.byteLength, revision: revision?.revision ?? null });
405
+ } catch (error) {
406
+ services.answerProjectMutationError(res, error);
407
+ }
408
+ },
409
+ },
410
+ ];
411
+
412
+ return {
413
+ name: 'vgai-blender-routes',
414
+ configureServer(server) {
415
+ server.middlewares.use((req, res, next) => {
416
+ const url = new URL(req.url ?? '/', 'http://local');
417
+ if (!url.pathname.startsWith('/__editor/blender-')) return next();
418
+ const route = routes.find((candidate) => candidate.method === req.method && candidate.match.test(url.pathname));
419
+ if (!route) return next();
420
+ route.handle(req, res, url).catch((error: unknown) => {
421
+ if (!res.headersSent) json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
422
+ else res.destroy();
423
+ });
424
+ });
425
+ },
426
+ };
427
+ }