@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.
- package/contributions/blender-palette.source.mjs +15 -5
- package/contributions/blender-properties-view.tsx +28 -9
- package/contributions/blender-runtime.document.tsx +6 -1
- package/contributions/blender.command.ts +5 -1
- package/contributions/blender.environment.ts +32 -0
- package/contributions/blender.palette.json +8 -0
- package/contributions/blender.status.tsx +2 -6
- package/contributions/blender.style.ts +30 -89
- package/contributions/material-preview.view.ts +35 -0
- 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 +21 -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 +32 -0
- 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
|
|
package/node/blender-mcp.ts
CHANGED
|
@@ -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
|
|
300
|
-
command
|
|
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) =>
|