@volter/editor-blender 0.1.8 → 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 (33) hide show
  1. package/contributions/blender-palette.source.mjs +15 -5
  2. package/contributions/blender-properties-view.tsx +28 -9
  3. package/contributions/blender-runtime.document.tsx +6 -1
  4. package/contributions/blender.command.ts +5 -1
  5. package/contributions/blender.environment.ts +32 -0
  6. package/contributions/blender.palette.json +8 -0
  7. package/contributions/blender.status.tsx +2 -6
  8. package/contributions/blender.style.ts +30 -89
  9. package/contributions/material-preview.view.ts +35 -0
  10. package/contributions/model.layout.ts +7 -7
  11. package/contributions/models.finder.ts +16 -0
  12. package/contributions/sculpt.layout.ts +3 -0
  13. package/contributions/shading.layout.ts +3 -0
  14. package/contributions/studiolights/world/city.exr +0 -0
  15. package/contributions/studiolights/world/courtyard.exr +0 -0
  16. package/contributions/studiolights/world/forest.exr +0 -0
  17. package/contributions/studiolights/world/interior.exr +0 -0
  18. package/contributions/studiolights/world/license.txt +15 -0
  19. package/contributions/studiolights/world/night.exr +0 -0
  20. package/contributions/studiolights/world/studio.exr +0 -0
  21. package/contributions/studiolights/world/sunrise.exr +0 -0
  22. package/contributions/studiolights/world/sunset.exr +0 -0
  23. package/contributions/uv-editing.layout.ts +3 -0
  24. package/dist-node/serving.mjs +624 -0
  25. package/host/blender-runtime-host.ts +11 -1
  26. package/node/blender-mcp.ts +2 -2
  27. package/package.json +21 -10
  28. package/serving/blender-routes.ts +427 -0
  29. package/serving/blender-wasm-artifact.ts +449 -0
  30. package/serving/index.ts +10 -0
  31. package/src/layouts.tsx +4 -5
  32. package/src/presentation.ts +32 -0
  33. package/src/regions.ts +59 -0
@@ -0,0 +1,624 @@
1
+ // serving/blender-routes.ts
2
+ import { createReadStream as createReadStream2 } from "node:fs";
3
+ import { mkdir, readFile as readFile2, rm, stat as stat2, unlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname as dirname2, join as join2 } from "node:path";
6
+ import { createBrotliDecompress } from "node:zlib";
7
+
8
+ // ../editor-sdk/src/project/output-roots.ts
9
+ var PROJECT_OUTPUT_ROOTS = ["public", "references"];
10
+ function normalizeSlashes(path) {
11
+ const slashed = path.replaceAll("\\", "/");
12
+ const segments = [];
13
+ for (const segment of slashed.split("/")) {
14
+ if (segment === "" || segment === ".") continue;
15
+ segments.push(segment);
16
+ }
17
+ return segments.join("/");
18
+ }
19
+ function projectOutputRootOf(path) {
20
+ const normalized = normalizeSlashes(path);
21
+ return PROJECT_OUTPUT_ROOTS.find(
22
+ (root) => normalized === root || normalized.startsWith(`${root}/`)
23
+ );
24
+ }
25
+
26
+ // ../editor-sdk/src/session/relative-path-guard.ts
27
+ function relativePathRejection(path) {
28
+ if (!path || path.trim() === "") return "empty";
29
+ if (path.includes("\0")) return "nul";
30
+ if (path.startsWith("/") || path.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(path)) {
31
+ return "absolute";
32
+ }
33
+ if (path.split(/[\\/]/).includes("..")) return "traversal";
34
+ return null;
35
+ }
36
+ function isContainedRelativePath(path) {
37
+ return relativePathRejection(path) === null;
38
+ }
39
+
40
+ // serving/blender-wasm-artifact.ts
41
+ import { createHash } from "node:crypto";
42
+ import { createReadStream, existsSync } from "node:fs";
43
+ import { readdir, readFile, stat } from "node:fs/promises";
44
+ import { dirname, isAbsolute, join, posix, relative } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import { brotliDecompressSync } from "node:zlib";
47
+ var BLENDER_WASM_FILES = [
48
+ "blender_browser.js",
49
+ "blender_browser.wasm",
50
+ "blender_browser.data",
51
+ "essentials.json",
52
+ "essentials.bin"
53
+ ];
54
+ var BLENDER_WALI_ARTIFACT = "blender.wasm";
55
+ var BLENDER_WALI_RUNTIME = "runtime";
56
+ var BLENDER_WALI_WORKERS = { pool: 16, blender: 4 };
57
+ var BLENDER_WALI_SUBSTRATE = [
58
+ { segment: "browser-wali", specifier: "@volter/browser-wali" },
59
+ { segment: "browser-runtime", specifier: "@volter/browser-runtime" }
60
+ ];
61
+ var BLENDER_WASM_DIR_VARIABLE = "VGAI_BLENDER_WASM_DIR";
62
+ var SHIPPED_BLENDER_WASM_DIR = (() => {
63
+ const candidate = fileURLToPath(new URL("../../blender-engine/wasm/", import.meta.url));
64
+ return existsSync(candidate) ? candidate.replace(/\/$/, "") : null;
65
+ })();
66
+ function blenderWasmDir(env = process.env) {
67
+ const named = env[BLENDER_WASM_DIR_VARIABLE];
68
+ if (typeof named === "string" && named.trim() !== "") {
69
+ const dir = named.trim();
70
+ return isAbsolute(dir) ? dir : null;
71
+ }
72
+ return SHIPPED_BLENDER_WASM_DIR;
73
+ }
74
+ async function blenderWasmOnDisk(dir, file) {
75
+ for (const [suffix, encoding] of [
76
+ [".br", "br"],
77
+ ["", null]
78
+ ]) {
79
+ const path = join(dir, `${file}${suffix}`);
80
+ try {
81
+ const info = await stat(path);
82
+ if (info.isFile()) return { path, size: info.size, encoding };
83
+ } catch {
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ async function skewOf(dir) {
89
+ if (await blenderWasmOnDisk(dir, "blender_browser.js")) return "emscripten";
90
+ if (existsSync(join(dir, BLENDER_WALI_ARTIFACT))) return "wali";
91
+ return null;
92
+ }
93
+ async function blenderWasmStatus(env = process.env) {
94
+ const dir = blenderWasmDir(env);
95
+ if (dir === null)
96
+ return {
97
+ available: false,
98
+ skew: null,
99
+ dir: null,
100
+ sizes: {},
101
+ encoded: {},
102
+ missing: [
103
+ `no packages/blender-engine/wasm beside this editor, and ${BLENDER_WASM_DIR_VARIABLE} is not set to an absolute directory holding either ${BLENDER_WASM_FILES.join(", ")} or ${BLENDER_WALI_ARTIFACT} beside its ${BLENDER_WALI_RUNTIME}/ tree`
104
+ ]
105
+ };
106
+ const skew = await skewOf(dir);
107
+ if (skew === null)
108
+ return {
109
+ available: false,
110
+ skew: null,
111
+ dir,
112
+ sizes: {},
113
+ encoded: {},
114
+ missing: [
115
+ `${dir} holds neither ${BLENDER_WASM_FILES.join(", ")} (the standalone skew) nor ${BLENDER_WALI_ARTIFACT} (the substrate skew)`
116
+ ]
117
+ };
118
+ return skew === "wali" ? waliStatus(dir) : emscriptenStatus(dir);
119
+ }
120
+ async function emscriptenStatus(dir) {
121
+ const sizes = {};
122
+ const encoded = {};
123
+ const missing = [];
124
+ for (const file of BLENDER_WASM_FILES) {
125
+ const found = await blenderWasmOnDisk(dir, file);
126
+ if (!found) {
127
+ missing.push(`${join(dir, file)} (or .br)`);
128
+ continue;
129
+ }
130
+ sizes[file] = found.size;
131
+ if (found.encoding) encoded[file] = found.encoding;
132
+ }
133
+ return { available: missing.length === 0, skew: "emscripten", dir, sizes, encoded, missing, digests: await emscriptenDigests(dir) };
134
+ }
135
+ async function emscriptenDigests(dir) {
136
+ const digests = {};
137
+ const read = async (file) => {
138
+ try {
139
+ return JSON.parse(await readFile(join(dir, file), "utf8"));
140
+ } catch {
141
+ return void 0;
142
+ }
143
+ };
144
+ const bundle = await read("BUNDLE.json");
145
+ for (const [file, record] of Object.entries(bundle?.rawFiles ?? {})) {
146
+ if (typeof record?.sha256 === "string") digests[file] = record.sha256;
147
+ }
148
+ const essentials = await read("essentials.json");
149
+ if (typeof essentials?.sha256 === "string") digests["essentials.bin"] = essentials.sha256;
150
+ return digests;
151
+ }
152
+ async function waliStatus(dir) {
153
+ const sizes = {};
154
+ const missing = [];
155
+ const artifact = join(dir, BLENDER_WALI_ARTIFACT);
156
+ try {
157
+ sizes[BLENDER_WALI_ARTIFACT] = (await stat(artifact)).size;
158
+ } catch {
159
+ missing.push(artifact);
160
+ }
161
+ let index = null;
162
+ try {
163
+ index = await runtimeIndex(dir);
164
+ sizes[`${BLENDER_WALI_RUNTIME}/`] = index.files.reduce((total, file) => total + file.size, 0);
165
+ } catch (error) {
166
+ missing.push(
167
+ `${join(dir, BLENDER_WALI_RUNTIME)} (Blender's scripts, datafiles and python; ${error instanceof Error ? error.message : String(error)})`
168
+ );
169
+ }
170
+ for (const { segment, specifier } of BLENDER_WALI_SUBSTRATE)
171
+ if (substrateDir(specifier) === null)
172
+ missing.push(
173
+ `${specifier} (served at wali/${segment}/; the substrate skew runs Blender through it, so a project that opts into this skew declares it)`
174
+ );
175
+ const integrity = missing.length === 0 ? `sha256-${createHash("sha256").update(await readFile(artifact)).digest("base64")}` : void 0;
176
+ return {
177
+ available: missing.length === 0,
178
+ skew: "wali",
179
+ dir,
180
+ sizes,
181
+ encoded: {},
182
+ missing,
183
+ ...integrity ? { integrity } : {},
184
+ workers: { ...BLENDER_WALI_WORKERS }
185
+ };
186
+ }
187
+ var patchedGlue = null;
188
+ var CANVAS_TRANSFER = "var transferredCanvasNames=attr?(growMemViews(),HEAPU32)[attr+40>>2]:0;if(";
189
+ function patchBlenderGlue(source) {
190
+ if (!source.includes(CANVAS_TRANSFER)) return source;
191
+ return source.replace(
192
+ CANVAS_TRANSFER,
193
+ `${CANVAS_TRANSFER}true){transferredCanvasNames=0}else if(`
194
+ );
195
+ }
196
+ async function blenderGlueText(dir) {
197
+ if (patchedGlue?.dir === dir) return patchedGlue.text;
198
+ const found = await blenderWasmOnDisk(dir, "blender_browser.js");
199
+ if (!found) throw new Error(`${join(dir, "blender_browser.js")} is not there`);
200
+ const raw = await readFile(found.path);
201
+ const source = found.encoding === "br" ? brotliDecompressSync(raw).toString("utf8") : raw.toString("utf8");
202
+ const text = patchBlenderGlue(source);
203
+ patchedGlue = { dir, text };
204
+ return text;
205
+ }
206
+ function blenderWasmReadStream(found) {
207
+ return createReadStream(found.path);
208
+ }
209
+ var cachedRuntimeIndex = null;
210
+ async function runtimeIndex(dir) {
211
+ if (cachedRuntimeIndex?.dir === dir) return cachedRuntimeIndex.index;
212
+ const root = join(dir, BLENDER_WALI_RUNTIME);
213
+ const files = [];
214
+ const walk = async (where) => {
215
+ for (const entry of (await readdir(where, { withFileTypes: true })).sort(
216
+ (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
217
+ )) {
218
+ const path = join(where, entry.name);
219
+ if (entry.isDirectory()) {
220
+ await walk(path);
221
+ continue;
222
+ }
223
+ if (!entry.isFile()) continue;
224
+ const info = await stat(path);
225
+ files.push({
226
+ path: relative(root, path).split(/[\\/]/).join(posix.sep),
227
+ size: info.size,
228
+ mode: info.mode & 4095
229
+ });
230
+ }
231
+ };
232
+ await walk(root);
233
+ if (files.length === 0) throw new Error(`${root} is empty`);
234
+ const index = { files };
235
+ cachedRuntimeIndex = { dir, index };
236
+ return index;
237
+ }
238
+ async function writeRuntimeBlob(dir, write) {
239
+ const root = join(dir, BLENDER_WALI_RUNTIME);
240
+ for (const file of (await runtimeIndex(dir)).files) write(await readFile(join(root, file.path)));
241
+ }
242
+ function substrateDir(specifier) {
243
+ try {
244
+ return dirname(fileURLToPath(import.meta.resolve(specifier)));
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+ var SUBSTRATE_SEGMENTS = new Map(
250
+ BLENDER_WALI_SUBSTRATE.map(({ segment, specifier }) => [segment, specifier])
251
+ );
252
+ async function substrateModule(segment, file) {
253
+ const specifier = SUBSTRATE_SEGMENTS.get(segment);
254
+ if (!specifier || !/^[\w.-]+\.(?:js|mjs|map)$/.test(file)) return null;
255
+ const root = substrateDir(specifier);
256
+ if (root === null) return null;
257
+ const path = join(root, file);
258
+ if (!existsSync(path)) return null;
259
+ let text = (await readFile(path)).toString("utf8");
260
+ for (const other of BLENDER_WALI_SUBSTRATE)
261
+ text = text.replaceAll(
262
+ `"${other.specifier}/`,
263
+ `"/__editor/blender-wasm/wali/${other.segment}/`
264
+ );
265
+ return { text };
266
+ }
267
+
268
+ // serving/blender-routes.ts
269
+ function json(res, body, status = 200) {
270
+ res.statusCode = status;
271
+ res.setHeader("content-type", "application/json");
272
+ res.end(JSON.stringify(body));
273
+ }
274
+ async function readBody(req) {
275
+ const chunks = [];
276
+ for await (const value of req) chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value));
277
+ return Buffer.concat(chunks);
278
+ }
279
+ function blenderRoutesPlugin(services) {
280
+ const transferRoot = join2(tmpdir(), "vgai-blender-transfer");
281
+ const transferPath = (id) => join2(transferRoot, id);
282
+ void rm(transferRoot, { recursive: true, force: true });
283
+ const transferId = (value) => value !== null && /^[0-9a-f-]{36}$/.test(value) ? value : null;
284
+ const projectRoot = () => {
285
+ const root = services.currentProjectRoot();
286
+ return root === void 0 || root === services.engineRoot ? null : root;
287
+ };
288
+ const isDocumentPath = (path) => {
289
+ if (!path.endsWith(".blend") || path.startsWith("/") || path.includes("\\")) return false;
290
+ if (!isContainedRelativePath(path)) return false;
291
+ return path.split("/").every((segment) => segment !== "" && !segment.startsWith("."));
292
+ };
293
+ const routes = [
294
+ // ---- The engine itself: headless Blender, compiled to WebAssembly. Its pthreads load
295
+ // `blender_browser.js` from this same URL as a classic worker, so the patched glue has to
296
+ // be what the URL answers with.
297
+ {
298
+ method: "GET",
299
+ match: /^\/__editor\/blender-wasm\/status$/,
300
+ handle: async (_req, res) => {
301
+ services.allowCrossOriginFrameEmbedding(res);
302
+ json(res, await blenderWasmStatus());
303
+ }
304
+ },
305
+ // The substrate skew's own doors beside the standalone bundle's: Blender's runtime tree as
306
+ // one index and one blob (a request per file would be ~2,300 round trips), and the
307
+ // substrate's browser ESM with its bare specifiers rewritten.
308
+ {
309
+ method: "GET",
310
+ match: /^\/__editor\/blender-wasm\/runtime\.idx$/,
311
+ handle: async (_req, res) => {
312
+ const status = await blenderWasmStatus();
313
+ if (status.skew !== "wali" || status.dir === null) {
314
+ json(res, { error: "This editor does not serve the substrate skew of Blender." }, 404);
315
+ return;
316
+ }
317
+ json(res, await runtimeIndex(status.dir));
318
+ }
319
+ },
320
+ {
321
+ method: "GET",
322
+ match: /^\/__editor\/blender-wasm\/runtime\.bin$/,
323
+ handle: async (_req, res) => {
324
+ const status = await blenderWasmStatus();
325
+ if (status.skew !== "wali" || status.dir === null) {
326
+ json(res, { error: "This editor does not serve the substrate skew of Blender." }, 404);
327
+ return;
328
+ }
329
+ res.setHeader("content-type", "application/octet-stream");
330
+ await writeRuntimeBlob(status.dir, (chunk) => void res.write(chunk));
331
+ res.end();
332
+ }
333
+ },
334
+ {
335
+ method: "GET",
336
+ match: /^\/__editor\/blender-wasm\/wali\/([^/]+)\/([^/]+)$/,
337
+ handle: async (_req, res, url) => {
338
+ const [, segment = "", file = ""] = /^\/__editor\/blender-wasm\/wali\/([^/]+)\/([^/]+)$/.exec(url.pathname) ?? [];
339
+ const served = await substrateModule(decodeURIComponent(segment), decodeURIComponent(file));
340
+ if (!served) {
341
+ json(
342
+ res,
343
+ {
344
+ error: `This editor does not serve ${segment}/${file}. The substrate skew runs Blender through @volter/browser-wali and @volter/browser-runtime; a project that opts into it declares them.`
345
+ },
346
+ 404
347
+ );
348
+ return;
349
+ }
350
+ res.setHeader("content-type", "text/javascript");
351
+ res.end(served.text);
352
+ }
353
+ },
354
+ {
355
+ method: "GET",
356
+ match: /^\/__editor\/blender-wasm\/([^/]+)$/,
357
+ handle: async (req, res, url) => {
358
+ const file = decodeURIComponent(url.pathname.slice("/__editor/blender-wasm/".length));
359
+ services.allowCrossOriginFrameEmbedding(res);
360
+ const bundled = BLENDER_WASM_FILES.includes(file);
361
+ if (!bundled && file !== BLENDER_WALI_ARTIFACT) {
362
+ json(res, { error: `The Blender build has no ${file}.` }, 404);
363
+ return;
364
+ }
365
+ const status = await blenderWasmStatus();
366
+ if (!status.available) {
367
+ json(
368
+ res,
369
+ { error: `The headless Blender WebAssembly build is not served by this editor: ${status.missing.join("; ")}` },
370
+ 404
371
+ );
372
+ return;
373
+ }
374
+ if (file === "blender_browser.js") {
375
+ res.setHeader("content-type", "text/javascript");
376
+ res.end(await blenderGlueText(status.dir));
377
+ return;
378
+ }
379
+ const found = await blenderWasmOnDisk(status.dir, file);
380
+ if (!found) {
381
+ json(res, { error: `${file} vanished from ${status.dir}.` }, 404);
382
+ return;
383
+ }
384
+ const info = await stat2(found.path);
385
+ const accepted = String(req.headers["accept-encoding"] ?? "").toLowerCase();
386
+ const sendEncoded = found.encoding !== null && accepted.includes(found.encoding);
387
+ const etag = `"${sendEncoded ? found.encoding : "identity"}-${info.size}-${Math.floor(info.mtimeMs)}"`;
388
+ res.setHeader("cache-control", "no-cache");
389
+ res.setHeader("etag", etag);
390
+ res.setHeader("vary", "accept-encoding");
391
+ if (req.headers["if-none-match"] === etag) {
392
+ res.statusCode = 304;
393
+ res.end();
394
+ return;
395
+ }
396
+ res.setHeader("content-type", file.endsWith(".wasm") ? "application/wasm" : "application/octet-stream");
397
+ if (sendEncoded) {
398
+ res.setHeader("content-encoding", found.encoding);
399
+ res.setHeader("content-length", String(found.size));
400
+ blenderWasmReadStream(found).pipe(res);
401
+ return;
402
+ }
403
+ const stream = blenderWasmReadStream(found);
404
+ const body = found.encoding ? stream.pipe(createBrotliDecompress()).on("error", () => res.destroy()) : stream;
405
+ body.pipe(res);
406
+ }
407
+ },
408
+ {
409
+ method: "POST",
410
+ match: /^\/__editor\/blender-file$/,
411
+ handle: async (req, res, url) => {
412
+ const id = transferId(url.searchParams.get("id"));
413
+ if (id === null) {
414
+ json(res, { error: "blender-file requires a uuid `id`." }, 400);
415
+ return;
416
+ }
417
+ const path = transferPath(id);
418
+ await mkdir(dirname2(path), { recursive: true });
419
+ const body = await readBody(req);
420
+ await writeFile(path, new Uint8Array(body));
421
+ json(res, { bytes: body.byteLength });
422
+ }
423
+ },
424
+ {
425
+ method: "GET",
426
+ match: /^\/__editor\/blender-file$/,
427
+ handle: async (_req, res, url) => {
428
+ const id = transferId(url.searchParams.get("id"));
429
+ if (id === null) {
430
+ json(res, { error: "blender-file requires a uuid `id`." }, 400);
431
+ return;
432
+ }
433
+ const path = transferPath(id);
434
+ const info = await stat2(path).catch(() => null);
435
+ if (info === null) {
436
+ json(res, { error: "No transfer is spooled under that id." }, 404);
437
+ return;
438
+ }
439
+ res.setHeader("content-type", "application/octet-stream");
440
+ res.setHeader("content-length", String(info.size));
441
+ const stream = createReadStream2(path);
442
+ stream.pipe(res);
443
+ stream.on("close", () => {
444
+ void unlink(path).catch(() => {
445
+ });
446
+ });
447
+ }
448
+ },
449
+ {
450
+ method: "GET",
451
+ match: /^\/__editor\/blender-project-index$/,
452
+ handle: async (_req, res) => {
453
+ const root = projectRoot();
454
+ if (root === null) {
455
+ json(res, { error: "No project open." }, 404);
456
+ return;
457
+ }
458
+ json(res, { root, files: await services.projectFileIndex() });
459
+ }
460
+ },
461
+ {
462
+ method: "GET",
463
+ match: /^\/__editor\/blender-project-file$/,
464
+ handle: async (_req, res, url) => {
465
+ const relPath = url.searchParams.get("path") ?? "";
466
+ const scratchDocument = isContainedRelativePath(relPath) && relPath.startsWith(".vgai/tmp/") && relPath.endsWith(".blend");
467
+ if (!services.isProjectOwnedPath(relPath) && !scratchDocument) {
468
+ json(res, { error: "Not a project-owned path." }, 400);
469
+ return;
470
+ }
471
+ const root = projectRoot();
472
+ if (root === null) {
473
+ json(res, { error: "No project open." }, 404);
474
+ return;
475
+ }
476
+ const absolute = join2(root, relPath);
477
+ if (!await services.isCanonicalPathInside(root, absolute)) {
478
+ json(res, { error: "That path leaves the project root." }, 403);
479
+ return;
480
+ }
481
+ try {
482
+ const bytes = await readFile2(absolute);
483
+ res.setHeader("content-type", "application/octet-stream");
484
+ res.end(bytes);
485
+ } catch {
486
+ json(res, { error: "Not found." }, 404);
487
+ }
488
+ }
489
+ },
490
+ // ---- A SHIPPED OUTPUT: bytes Blender produced for the game to ship (`public/`), recorded
491
+ // with the session and revision that re-derive them.
492
+ {
493
+ method: "POST",
494
+ match: /^\/__editor\/blender-output$/,
495
+ handle: async (req, res, url) => {
496
+ const path = url.searchParams.get("path") ?? "";
497
+ const session = url.searchParams.get("session") ?? "";
498
+ const source = url.searchParams.get("source") ?? "";
499
+ const revision = Number(url.searchParams.get("revision"));
500
+ const callId = url.searchParams.get("callId") ?? "";
501
+ if (!isContainedRelativePath(path) || projectOutputRootOf(path) !== "public") {
502
+ json(
503
+ res,
504
+ {
505
+ error: `A shipped output is a project-relative path under public/ with no traversal (public/models/model.glb); got ${JSON.stringify(path)}.`
506
+ },
507
+ 400
508
+ );
509
+ return;
510
+ }
511
+ if (session === "" || !Number.isInteger(revision) || revision < 0) {
512
+ json(
513
+ res,
514
+ {
515
+ error: "A shipped output must name the session that produced it \u2014 `session` and a non-negative integer `revision` \u2014 so the record can say what re-derives the file."
516
+ },
517
+ 400
518
+ );
519
+ return;
520
+ }
521
+ if (source === "" || source.length > 64) {
522
+ json(res, { error: "`source` names the transport that wrote the file, in 1-64 characters." }, 400);
523
+ return;
524
+ }
525
+ const inputs = url.searchParams.getAll("inputs");
526
+ const badInput = inputs.find((value) => !isContainedRelativePath(value));
527
+ if (badInput !== void 0) {
528
+ json(
529
+ res,
530
+ {
531
+ error: `Each \`inputs\` value is a project-relative path with no traversal (the source file these bytes were produced from); got ${JSON.stringify(badInput)}.`
532
+ },
533
+ 400
534
+ );
535
+ return;
536
+ }
537
+ if (projectRoot() === null) {
538
+ json(res, { error: "No project open, so there is nowhere to record the output." }, 404);
539
+ return;
540
+ }
541
+ const body = await readBody(req);
542
+ if (body.byteLength === 0) {
543
+ json(res, { error: "The output body was empty; nothing was written." }, 400);
544
+ return;
545
+ }
546
+ try {
547
+ const written = await services.writeProjectOutput({
548
+ path,
549
+ content: body,
550
+ source,
551
+ session: {
552
+ id: session,
553
+ port: req.socket.localPort ?? 0,
554
+ revision,
555
+ ...callId === "" ? {} : { callId }
556
+ },
557
+ ...inputs.length > 0 ? { inputs } : {}
558
+ });
559
+ json(res, { ok: true, bytes: body.byteLength, provenanceOperationId: written.provenanceOperationId });
560
+ } catch (error) {
561
+ json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
562
+ }
563
+ }
564
+ },
565
+ // ---- The session's DOCUMENT, written back OUT into the project. A Blender session holds one
566
+ // `.blend`; its `save_as_mainfile` writes it into the engine's filesystem, and this is how
567
+ // those bytes reach the disk the project lives on. Unlike the spool, the destination shape is
568
+ // pinned BY THIS SERVER, and the write is the project's one attributed, conflict-checked
569
+ // transaction: a `.blend` is the project's file like any other.
570
+ {
571
+ method: "POST",
572
+ match: /^\/__editor\/blender-document$/,
573
+ handle: async (req, res, url) => {
574
+ const path = url.searchParams.get("path") ?? "";
575
+ if (!isDocumentPath(path)) {
576
+ json(
577
+ res,
578
+ {
579
+ error: `A Blender document is a project-relative .blend path with no traversal (models/model.blend); got ${JSON.stringify(path)}.`
580
+ },
581
+ 400
582
+ );
583
+ return;
584
+ }
585
+ if (projectRoot() === null) {
586
+ json(res, { error: "No project open, so there is nowhere to save the document." }, 404);
587
+ return;
588
+ }
589
+ const body = await readBody(req);
590
+ if (body.byteLength === 0) {
591
+ json(res, { error: "The Blender document body was empty; nothing was written." }, 400);
592
+ return;
593
+ }
594
+ try {
595
+ const revision = await services.commitProjectMutation(req, [{ path, content: body }]);
596
+ json(res, { ok: true, bytes: body.byteLength, revision: revision?.revision ?? null });
597
+ } catch (error) {
598
+ services.answerProjectMutationError(res, error);
599
+ }
600
+ }
601
+ }
602
+ ];
603
+ return {
604
+ name: "vgai-blender-routes",
605
+ configureServer(server) {
606
+ server.middlewares.use((req, res, next) => {
607
+ const url = new URL(req.url ?? "/", "http://local");
608
+ if (!url.pathname.startsWith("/__editor/blender-")) return next();
609
+ const route = routes.find((candidate) => candidate.method === req.method && candidate.match.test(url.pathname));
610
+ if (!route) return next();
611
+ route.handle(req, res, url).catch((error) => {
612
+ if (!res.headersSent) json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
613
+ else res.destroy();
614
+ });
615
+ });
616
+ }
617
+ };
618
+ }
619
+
620
+ // serving/index.ts
621
+ var servingPlugins = (services) => [blenderRoutesPlugin(services)];
622
+ export {
623
+ servingPlugins
624
+ };
@@ -801,6 +801,16 @@ export function blenderRuntime(): BlenderRuntime {
801
801
  present: async (frame, description, capture) => {
802
802
  const conflict = modelDocumentConflict();
803
803
  if (conflict) throw new Error(conflict);
804
+ // THE ENGINE DOES NOT NEED A VIEW. With no Model document bound, no presenter is coming:
805
+ // the frame is presented to nobody and the session keeps running (a headless agent, a save
806
+ // on close). The Model document presents the session's current state when it binds. Only a
807
+ // photograph needs a view, so only a capture is refused.
808
+ if (boundModel === null) {
809
+ if (capture?.render) {
810
+ throw new Error('Rendering a Blender frame needs the Model document open; nothing is presenting.');
811
+ }
812
+ return {};
813
+ }
804
814
  const documentId = presentationDocumentId();
805
815
  const endWait = beginBlenderWork('waiting for Model presenter');
806
816
  let view: RuntimeView;
@@ -1024,7 +1034,7 @@ export function blenderRuntime(): BlenderRuntime {
1024
1034
  // meter rather than a snapshot, so the host always asks the running session:
1025
1035
  // an in-flight call's age has to be computed at the moment it is reported.
1026
1036
  // The page's own long-task half is the HOST's, behind this same door.
1027
- editorHost().session.reportWorkerCallMeter(() => session.metrics());
1037
+ editorHost().session.reportWorkerCallMeter('Blender', () => session.metrics());
1028
1038
  return runtime;
1029
1039
  }
1030
1040
 
@@ -296,8 +296,8 @@ const text = (value: string) => ({ content: [{ type: 'text' as const, text: valu
296
296
 
297
297
  export async function serveBlenderMcp(
298
298
  project: string,
299
- ensureEditor: () => Promise<void> = async () => {},
300
- command = 'volter-editor',
299
+ ensureEditor: () => Promise<void>,
300
+ command: string,
301
301
  ): Promise<void> {
302
302
  const runtimeIdentity = `Blender ${blenderBundle.blender} (source ${blenderBundle.source}), compiled to WebAssembly and running headless in the editor tab; three.js takes its photographs. Factory bpy.context.scene.render.engine: '${blenderBundle.factoryEngine}'. Documents are .blend. save_as_mainfile records the actual saved path in bpy.data.filepath; open_mainfile reopens it.`;
303
303
  const tools = (blenderTools as ToolShape[]).map((tool) =>