@volter/blender-engine 0.1.0
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/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
3
|
+
* 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
|
|
4
|
+
* WebAssembly, running headless in this worker (`blender-engine.mts`), with
|
|
5
|
+
* `session.py` as its one door and three.js as its renderer. The artifact is
|
|
6
|
+
* served by the editor (`/__editor/blender-wasm/*`, `blender-wasm-artifact.ts`);
|
|
7
|
+
* when it is not served, `start` refuses BY NAME. There is no other engine.
|
|
8
|
+
*
|
|
9
|
+
* NOTHING HERE KNOWS WHICH SKEW ANSWERED. Since the 2026-09-18 amendment there
|
|
10
|
+
* are two builds -- the standalone Emscripten module and Blender as a WALI
|
|
11
|
+
* program on browser-substrate -- and `blender-engine.mts` is where they meet.
|
|
12
|
+
* Above that seam this file uses `engine.files`, `engine.readArena()` and
|
|
13
|
+
* `engine.memoryBytes()` and could not tell them apart; ONE GATE, BOTH SKEWS
|
|
14
|
+
* is what that buys.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here touches a server beyond the editor's own routes. A frame
|
|
17
|
+
* leaves through `present` to the Model document; a photograph comes back the
|
|
18
|
+
* same way. The project's own files are staged into the engine's filesystem
|
|
19
|
+
* before every call, and what the session writes is mirrored back out by the
|
|
20
|
+
* transport (`list-files`/`read-file`).
|
|
21
|
+
*
|
|
22
|
+
* THE DOCUMENT'S DEBOUNCE LIVES HERE, because this is the only side of the
|
|
23
|
+
* session that has a clock. Python's loop cannot ask the tab for anything
|
|
24
|
+
* while it is idle — `serveAsks` only runs inside a request's poll loop, so an
|
|
25
|
+
* `ask` raised between calls is never answered and wedges the Blender pthread.
|
|
26
|
+
* So the session marks a present `saveDue`, this file waits out one idle
|
|
27
|
+
* second, calls `save-document` as an ordinary request (which Python's loop
|
|
28
|
+
* picks up BETWEEN calls, never mid-call), and carries the bytes to the
|
|
29
|
+
* project through `/__editor/blender-document`.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE CARRY IS NOT THE MIRROR'S JOB (`vgai blender-mcp`, class Mirror):
|
|
32
|
+
* the Mirror is pull-based and runs only after an `execute_blender_code`, so
|
|
33
|
+
* a document saved one idle second after the LAST call of a modeling session
|
|
34
|
+
* would never leave the worker — which is exactly the state this closes
|
|
35
|
+
* ("closing the tab loses the model"). The Mirror still lists and mirrors the
|
|
36
|
+
* same file in the MCP lane; it just is not what persistence depends on.
|
|
37
|
+
*/
|
|
38
|
+
/// <reference types="vite/client" />
|
|
39
|
+
|
|
40
|
+
import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
41
|
+
import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
|
|
42
|
+
import { columnsToTypedArrays, describeFrame } from './session-frame.mts';
|
|
43
|
+
|
|
44
|
+
const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(reply);
|
|
45
|
+
const log = (level: 'log' | 'error', text: string) => post({ op: 'log', level, text });
|
|
46
|
+
|
|
47
|
+
interface Session {
|
|
48
|
+
start(project: string): Promise<unknown>;
|
|
49
|
+
execute(code: string): Promise<unknown>;
|
|
50
|
+
sceneInfo(): Promise<unknown>;
|
|
51
|
+
objectInfo(name: string): Promise<unknown>;
|
|
52
|
+
screenshotView(maxSize: number): Promise<unknown>;
|
|
53
|
+
}
|
|
54
|
+
let session: Session | null = null;
|
|
55
|
+
/** The project this session is rooted at, for the per-call project sync. */
|
|
56
|
+
let projectRoot: string | null = null;
|
|
57
|
+
let presentId = 0;
|
|
58
|
+
/** What the tab answered a present with: the capture it produced, and the
|
|
59
|
+
* presenter's own report of what it HELD before the frame (`protocol.ts`). */
|
|
60
|
+
interface PresentAnswer {
|
|
61
|
+
capture?: unknown;
|
|
62
|
+
held?: { session: string; revision: number } | null;
|
|
63
|
+
}
|
|
64
|
+
const pendingPresents = new Map<
|
|
65
|
+
number,
|
|
66
|
+
{ resolve: (value: PresentAnswer) => void; reject: (error: Error) => void }
|
|
67
|
+
>();
|
|
68
|
+
|
|
69
|
+
function presentToTab(
|
|
70
|
+
frame: unknown,
|
|
71
|
+
description: unknown,
|
|
72
|
+
capture?: CaptureRequest,
|
|
73
|
+
): Promise<PresentAnswer> {
|
|
74
|
+
const id = ++presentId;
|
|
75
|
+
// A drawn mesh's buffers move to the tab rather than being copied: the
|
|
76
|
+
// frame is the tab's from here on.
|
|
77
|
+
const transfer: ArrayBuffer[] = [];
|
|
78
|
+
// A picture travels the same way and for the same reason: an entry in the
|
|
79
|
+
// frame's `images` carries a raw RGBA raster the tab uploads into a
|
|
80
|
+
// `DataTexture` (`blender-runtime-view.ts`), once per image per revision.
|
|
81
|
+
const parts = frame as {
|
|
82
|
+
meshes?: Record<string, unknown>;
|
|
83
|
+
images?: Record<string, unknown>;
|
|
84
|
+
} | null;
|
|
85
|
+
for (const carrier of [parts?.meshes ?? {}, parts?.images ?? {}])
|
|
86
|
+
for (const held of Object.values(carrier))
|
|
87
|
+
for (const value of Object.values(held as Record<string, unknown>))
|
|
88
|
+
if (
|
|
89
|
+
ArrayBuffer.isView(value) &&
|
|
90
|
+
value.buffer instanceof ArrayBuffer &&
|
|
91
|
+
!transfer.includes(value.buffer)
|
|
92
|
+
)
|
|
93
|
+
transfer.push(value.buffer);
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
pendingPresents.set(id, { resolve, reject });
|
|
96
|
+
(self as unknown as Worker).postMessage(
|
|
97
|
+
{
|
|
98
|
+
op: 'present',
|
|
99
|
+
id,
|
|
100
|
+
frame,
|
|
101
|
+
description,
|
|
102
|
+
...(capture ? { capture } : {}),
|
|
103
|
+
} satisfies WorkerReply,
|
|
104
|
+
transfer,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let engine: BlenderEngine | null = null;
|
|
110
|
+
|
|
111
|
+
// ---- The session's document.
|
|
112
|
+
//
|
|
113
|
+
// One session, one `.blend`. `documentPath` is the PROJECT-RELATIVE spelling —
|
|
114
|
+
// the only one that crosses to the server, which joins it to its own root so
|
|
115
|
+
// no host path is ever on the wire.
|
|
116
|
+
let documentPath: string | null = null;
|
|
117
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
/** Calls the tab is waiting on. A save waits for zero. */
|
|
119
|
+
let callsInFlight = 0;
|
|
120
|
+
|
|
121
|
+
/** The idle second. One save per second of quiet, however many presents
|
|
122
|
+
* arrived during it: the timer is RESET by each, so a script presenting in a
|
|
123
|
+
* tight loop writes the document once, when it stops. */
|
|
124
|
+
const DOCUMENT_SAVE_IDLE_MS = 1_000;
|
|
125
|
+
|
|
126
|
+
function armDocumentSave(): void {
|
|
127
|
+
if (documentPath === null) return;
|
|
128
|
+
if (saveTimer !== null) clearTimeout(saveTimer);
|
|
129
|
+
saveTimer = setTimeout(() => {
|
|
130
|
+
saveTimer = null;
|
|
131
|
+
void saveDocument();
|
|
132
|
+
}, DOCUMENT_SAVE_IDLE_MS);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Save the document and land it in the project.
|
|
137
|
+
*
|
|
138
|
+
* NEVER MID-CALL: a call still outstanding means a script is running, and its
|
|
139
|
+
* half-built model is not the document. The timer re-arms instead of writing.
|
|
140
|
+
*/
|
|
141
|
+
async function saveDocument(): Promise<void> {
|
|
142
|
+
if (!engine || documentPath === null) return;
|
|
143
|
+
if (callsInFlight > 0) {
|
|
144
|
+
armDocumentSave();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const relative = documentPath;
|
|
148
|
+
let answer: { saved?: boolean; path?: string; size?: number };
|
|
149
|
+
try {
|
|
150
|
+
answer = (await engine.request({ op: 'save-document' })) as typeof answer;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// A document that cannot be written is the session's work at risk, so it
|
|
153
|
+
// is a named condition in the editor's console, not a debug line.
|
|
154
|
+
log(
|
|
155
|
+
'error',
|
|
156
|
+
`@@VGAI-ERROR the Blender document ${relative} could not be saved: ${describeThrown(error)}`,
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!answer?.saved || typeof answer.path !== 'string') return;
|
|
161
|
+
let bytes: Uint8Array;
|
|
162
|
+
try {
|
|
163
|
+
bytes = await engine.files.readFile(answer.path);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
log(
|
|
166
|
+
'error',
|
|
167
|
+
`@@VGAI-ERROR the Blender document ${relative} was saved but could not be read back out of the engine: ${describeThrown(error)}`,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// The engine's copy is now the newer one, so the stager must stop treating
|
|
172
|
+
// this path as the host's: an entry left in `staged` would make the next
|
|
173
|
+
// call re-fetch the whole document over the session's own save, and would
|
|
174
|
+
// hide it from `list-files` (which lists only what the SESSION owns).
|
|
175
|
+
staged.delete(answer.path);
|
|
176
|
+
try {
|
|
177
|
+
const posted = await fetch(`/__editor/blender-document?path=${encodeURIComponent(relative)}`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
180
|
+
body: new Blob([bytes as BlobPart]),
|
|
181
|
+
});
|
|
182
|
+
if (!posted.ok) {
|
|
183
|
+
const said = await posted.text().catch(() => '');
|
|
184
|
+
log(
|
|
185
|
+
'error',
|
|
186
|
+
`@@VGAI-ERROR the Blender document ${relative} was not written to the project: HTTP ${posted.status} ${said}`,
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
log(
|
|
192
|
+
'error',
|
|
193
|
+
`@@VGAI-ERROR the Blender document ${relative} was not written to the project: ${describeThrown(error)}`,
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
log('log', `@@VGAI-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function startBlender(project: string, document?: string): Promise<unknown> {
|
|
201
|
+
// The engine is named through a holder rather than the module-level
|
|
202
|
+
// `engine`, because `ask` is handed to the engine before the engine exists.
|
|
203
|
+
const holder: { engine: BlenderEngine | null } = { engine: null };
|
|
204
|
+
const started = await startBlenderEngine({
|
|
205
|
+
project,
|
|
206
|
+
log,
|
|
207
|
+
ask: async ({ frame, capture, saveDue }) => {
|
|
208
|
+
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
209
|
+
// The session says this present left the document behind the model. The
|
|
210
|
+
// clock is here; the write is one idle second away.
|
|
211
|
+
if (saveDue) armDocumentSave();
|
|
212
|
+
// THE ARENA IS READ ONCE, HERE, and both readers share those bytes: the
|
|
213
|
+
// typed arrays the tab draws from, and the record of what was sent
|
|
214
|
+
// (`describeFrame`). After the post the buffers are detached and the
|
|
215
|
+
// next `export_frame` overwrites the arena -- on either skew -- so there
|
|
216
|
+
// is no later moment at which either could be taken.
|
|
217
|
+
const arena = await holder.engine.readArena();
|
|
218
|
+
const description = await describeFrame(arena, frame);
|
|
219
|
+
const answered = await presentToTab(
|
|
220
|
+
columnsToTypedArrays(arena, frame),
|
|
221
|
+
description,
|
|
222
|
+
capture as CaptureRequest | undefined,
|
|
223
|
+
);
|
|
224
|
+
// THE CAPTURE IS THE ANSWER'S BODY, and `held` rides beside it: the
|
|
225
|
+
// session reads a photograph's own fields off this object
|
|
226
|
+
// (`session.py::_photograph`), and reads `held` to judge whether its
|
|
227
|
+
// record of what the presenter holds is still that presenter's.
|
|
228
|
+
const body =
|
|
229
|
+
typeof answered.capture === 'object' && answered.capture !== null
|
|
230
|
+
? (answered.capture as Record<string, unknown>)
|
|
231
|
+
: {};
|
|
232
|
+
return { ...body, ...('held' in answered ? { held: answered.held } : {}) };
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
holder.engine = started;
|
|
236
|
+
engine = started;
|
|
237
|
+
// THE PROJECT'S FILES BEFORE THE SESSION'S FIRST ACT, because that act may
|
|
238
|
+
// be `open_mainfile` on the document — which lives on the host's disk and is
|
|
239
|
+
// not in the engine's filesystem until it is staged. Every other call stages
|
|
240
|
+
// on its way in (`execute`); start had nothing to open before it did.
|
|
241
|
+
if (document) await stageProjectFiles(started.files, project);
|
|
242
|
+
const banner = (await started.request({
|
|
243
|
+
op: 'start',
|
|
244
|
+
project,
|
|
245
|
+
...(document ? { document } : {}),
|
|
246
|
+
})) as Record<string, unknown>;
|
|
247
|
+
if (document) documentPath = document;
|
|
248
|
+
session = {
|
|
249
|
+
start: async () => banner,
|
|
250
|
+
execute: async (code: string) => {
|
|
251
|
+
const answer = (await started.request({ op: 'execute', code })) as {
|
|
252
|
+
executed: boolean;
|
|
253
|
+
result: string;
|
|
254
|
+
error?: string;
|
|
255
|
+
};
|
|
256
|
+
// The MCP door's own shape: a script's failure is TEXT, not a rejection.
|
|
257
|
+
return answer.error
|
|
258
|
+
? `Error executing code: ${answer.error}`
|
|
259
|
+
: `Code executed successfully: ${answer.result}`;
|
|
260
|
+
},
|
|
261
|
+
sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
|
|
262
|
+
objectInfo: async (name: string) =>
|
|
263
|
+
JSON.stringify(await started.request({ op: 'object-info', name }), null, 2),
|
|
264
|
+
screenshotView: async (maxSize: number) => {
|
|
265
|
+
await started.request({ op: 'present', capture: { size: maxSize } });
|
|
266
|
+
return JSON.stringify({ view: { size: maxSize } });
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
return {
|
|
270
|
+
...banner,
|
|
271
|
+
engine: 'blender-wasm',
|
|
272
|
+
// WHICH BUILD ANSWERED. One gate, both skews: a board that cannot say
|
|
273
|
+
// which one it measured cannot call a difference a defect in either.
|
|
274
|
+
skew: started.skew,
|
|
275
|
+
bootMs: Math.round(started.bootMs),
|
|
276
|
+
// What the boot handed back (`blender-engine.mts`). Null says this bundle
|
|
277
|
+
// has no release door and is carrying the payload twice.
|
|
278
|
+
releasedPayloadMB:
|
|
279
|
+
started.releasedPayloadBytes === null
|
|
280
|
+
? null
|
|
281
|
+
: Math.round(started.releasedPayloadBytes / 1048576),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function blenderIsServed(): Promise<{ available: boolean; missing: string[] }> {
|
|
286
|
+
try {
|
|
287
|
+
const answer = await fetch('/__editor/blender-wasm/status');
|
|
288
|
+
if (!answer.ok) return { available: false, missing: [`status answered ${answer.status}`] };
|
|
289
|
+
return (await answer.json()) as { available: boolean; missing: string[] };
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return { available: false, missing: [String(error)] };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function start(project: string, document?: string): Promise<unknown> {
|
|
296
|
+
if (session) throw new Error('The Blender session is already started');
|
|
297
|
+
if (!project.startsWith('/'))
|
|
298
|
+
throw new Error("The Blender session needs the project's absolute path");
|
|
299
|
+
// The document is the project's own file and is named the project's own way.
|
|
300
|
+
// An absolute path here would be a host path on the wire and a destination
|
|
301
|
+
// the page chose, which is the one thing this transport does not carry.
|
|
302
|
+
if (document !== undefined && !isDocumentPath(document))
|
|
303
|
+
throw new Error(
|
|
304
|
+
`The Blender document must be a project-relative .blend path with no traversal ` +
|
|
305
|
+
`(models/model.blend); got ${JSON.stringify(document)}`,
|
|
306
|
+
);
|
|
307
|
+
const served = await blenderIsServed();
|
|
308
|
+
if (!served.available)
|
|
309
|
+
throw new Error(
|
|
310
|
+
'Headless Blender is not served by this editor, so there is no modeling engine: ' +
|
|
311
|
+
`${served.missing.join('; ')}. The engine is Blender compiled to WebAssembly ` +
|
|
312
|
+
'(packages/blender-engine/wasm, or the directory VGAI_BLENDER_WASM_DIR names); nothing stands in for it.',
|
|
313
|
+
);
|
|
314
|
+
projectRoot = project;
|
|
315
|
+
return startBlender(project, document);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A project-relative `.blend`, with no traversal and no absolute root. The
|
|
319
|
+
* server checks the same shape again; this is the half that keeps a bad path
|
|
320
|
+
* from ever reaching the session. */
|
|
321
|
+
export function isDocumentPath(path: string): boolean {
|
|
322
|
+
if (!path.endsWith('.blend') || path.startsWith('/') || path.includes('\\')) return false;
|
|
323
|
+
const segments = path.split('/');
|
|
324
|
+
return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface ProjectFile {
|
|
328
|
+
path: string;
|
|
329
|
+
size: number;
|
|
330
|
+
mtime: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const saidOnce = new Set<string>();
|
|
334
|
+
function say(line: string): void {
|
|
335
|
+
if (saidOnce.has(line)) return;
|
|
336
|
+
saidOnce.add(line);
|
|
337
|
+
log('error', line);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function projectIndex(project: string): Promise<ProjectFile[] | null> {
|
|
341
|
+
const unreadable = (reason: string): null => {
|
|
342
|
+
say(`The project's files are not readable from Python: ${reason}`);
|
|
343
|
+
return null;
|
|
344
|
+
};
|
|
345
|
+
let answer: Response;
|
|
346
|
+
try {
|
|
347
|
+
answer = await fetch('/__editor/blender-project-index');
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return unreadable(String(error));
|
|
350
|
+
}
|
|
351
|
+
if (!answer.ok) return unreadable(`/__editor/blender-project-index answered ${answer.status}`);
|
|
352
|
+
let payload: { root: string; files: ProjectFile[] };
|
|
353
|
+
try {
|
|
354
|
+
payload = await answer.json();
|
|
355
|
+
if (typeof payload?.root !== 'string' || !Array.isArray(payload.files))
|
|
356
|
+
return unreadable('invalid project index');
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return unreadable(String(error));
|
|
359
|
+
}
|
|
360
|
+
const { root, files } = payload;
|
|
361
|
+
if (root !== project)
|
|
362
|
+
say(
|
|
363
|
+
`The editor serving this tab has ${root} open, not ${project}; its files ` +
|
|
364
|
+
`are mounted at ${project}, which is where Python is looking.`,
|
|
365
|
+
);
|
|
366
|
+
return files;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The project files this session has staged IN, and the two stamps that say
|
|
371
|
+
* so: `host`, what the host's index reported (so a re-stage can skip a file
|
|
372
|
+
* that has not moved on disk), and `engine`, what the file looked like in the
|
|
373
|
+
* engine's own filesystem the instant after it was written there.
|
|
374
|
+
*
|
|
375
|
+
* THE SECOND ONE IS WHY THIS IS A PAIR. Ownership is not a property of the
|
|
376
|
+
* PATH, it is a property of the BYTES: a staged file Python never touched is
|
|
377
|
+
* the host's, and the same path after `export_scene.gltf` has written over it
|
|
378
|
+
* is the session's output and has to reach the project. Keyed on the path
|
|
379
|
+
* alone, a re-bake of an artifact that already exists — which is what every
|
|
380
|
+
* bake after the first one is — was listed by nobody and silently went
|
|
381
|
+
* nowhere, while a first bake of a NEW path worked, so nothing about the door
|
|
382
|
+
* looked broken (measured 2026-09-19 re-baking cinematic-story's sky city).
|
|
383
|
+
*
|
|
384
|
+
* `engine` is `size:mtimeMs`, which is what both skews' `stat` answers. A
|
|
385
|
+
* rewrite that lands in the same millisecond at exactly the same length would
|
|
386
|
+
* read as untouched; a bake is seconds long and this is the strongest signal
|
|
387
|
+
* the filesystem offers.
|
|
388
|
+
*/
|
|
389
|
+
const staged = new Map<string, { host: string; engine: string }>();
|
|
390
|
+
|
|
391
|
+
async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
|
|
392
|
+
const info = await files_.stat(path);
|
|
393
|
+
return info ? `${info.size}:${info.mtimeMs}` : '';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<void> {
|
|
397
|
+
const files = await projectIndex(project);
|
|
398
|
+
if (!files) return;
|
|
399
|
+
const present = new Set(files.map((file) => `${project}/${file.path}`));
|
|
400
|
+
for (const path of [...staged.keys()]) {
|
|
401
|
+
if (present.has(path)) continue;
|
|
402
|
+
try {
|
|
403
|
+
await files_.unlink(path);
|
|
404
|
+
} catch {
|
|
405
|
+
/* already gone */
|
|
406
|
+
}
|
|
407
|
+
staged.delete(path);
|
|
408
|
+
}
|
|
409
|
+
for (const file of files) {
|
|
410
|
+
const path = `${project}/${file.path}`;
|
|
411
|
+
const stamp = `${file.size}:${file.mtime}`;
|
|
412
|
+
if (staged.get(path)?.host === stamp) continue;
|
|
413
|
+
// A path the session already owns keeps its own version.
|
|
414
|
+
if (!staged.has(path) && (await files_.stat(path)) !== null) continue;
|
|
415
|
+
const answer = await fetch(
|
|
416
|
+
`/__editor/blender-project-file?path=${encodeURIComponent(file.path)}`,
|
|
417
|
+
);
|
|
418
|
+
if (!answer.ok) {
|
|
419
|
+
say(`${file.path}: HTTP ${answer.status}`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const dir = path.slice(0, path.lastIndexOf('/'));
|
|
423
|
+
if (dir) await files_.mkdirTree(dir);
|
|
424
|
+
await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
|
|
425
|
+
staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Everything under `root` this SESSION owns -- what the transport mirrors out.
|
|
430
|
+
* A host file staged in and NEVER WRITTEN is the host's and is not output;
|
|
431
|
+
* one Python has written over since it was staged is this session's output,
|
|
432
|
+
* the same as a path it created (see {@link staged}). */
|
|
433
|
+
async function listSessionFiles(files: BlenderFiles, root: string): Promise<FileEntry[]> {
|
|
434
|
+
const out: FileEntry[] = [];
|
|
435
|
+
const walk = async (dir: string): Promise<void> => {
|
|
436
|
+
let names: string[];
|
|
437
|
+
try {
|
|
438
|
+
names = await files.readdir(dir);
|
|
439
|
+
} catch {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
for (const name of names) {
|
|
443
|
+
if (name === '.' || name === '..') continue;
|
|
444
|
+
const path = `${dir.replace(/\/$/, '')}/${name}`;
|
|
445
|
+
const info = await files.stat(path);
|
|
446
|
+
if (!info) continue;
|
|
447
|
+
// 0o040000 is S_IFDIR; both skews answer the raw mode.
|
|
448
|
+
if ((info.mode & 0o170000) === 0o040000) {
|
|
449
|
+
await walk(path);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (staged.get(path)?.engine === `${info.size}:${info.mtimeMs}`) continue;
|
|
453
|
+
out.push({ path, size: info.size, mtime: info.mtimeMs });
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
await walk(root);
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function handle(request: WorkerRequest): Promise<unknown> {
|
|
461
|
+
switch (request.op) {
|
|
462
|
+
case 'start':
|
|
463
|
+
return start(request.project, request.document);
|
|
464
|
+
case 'present-result': {
|
|
465
|
+
const pending = pendingPresents.get(request.id);
|
|
466
|
+
pendingPresents.delete(request.id);
|
|
467
|
+
if (pending)
|
|
468
|
+
request.error
|
|
469
|
+
? pending.reject(new Error(request.error))
|
|
470
|
+
: pending.resolve({
|
|
471
|
+
...(request.capture === undefined ? {} : { capture: request.capture }),
|
|
472
|
+
...('held' in request ? { held: request.held } : {}),
|
|
473
|
+
});
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!session || !projectRoot || !engine) throw new Error('The Blender session has not started');
|
|
478
|
+
const files = engine.files;
|
|
479
|
+
/** The session channel itself, for the requests whose answer is already the
|
|
480
|
+
* shape the caller wants (the RNA door). */
|
|
481
|
+
const ask = engine.request.bind(engine);
|
|
482
|
+
switch (request.op) {
|
|
483
|
+
case 'execute':
|
|
484
|
+
// Code about to run may open a file the host wrote since the last call.
|
|
485
|
+
await stageProjectFiles(files, projectRoot);
|
|
486
|
+
return session.execute(request.code);
|
|
487
|
+
case 'present':
|
|
488
|
+
// Straight through to `session.py`'s own `present` op — the worker adds
|
|
489
|
+
// nothing, and a capture-less present answers `{ presented, revision }`.
|
|
490
|
+
return ask({ op: 'present' });
|
|
491
|
+
case 'scene-info':
|
|
492
|
+
return session.sceneInfo();
|
|
493
|
+
case 'object-info':
|
|
494
|
+
return session.objectInfo(request.name);
|
|
495
|
+
// THE RNA DOOR, straight through: the session answers JSON and there is
|
|
496
|
+
// nothing in the middle to shape it. Unlike `scene-info`/`object-info`,
|
|
497
|
+
// which the MCP door's own contract renders as a TEXT blob, these three
|
|
498
|
+
// answer a panel — so the object crosses as an object.
|
|
499
|
+
case 'rna':
|
|
500
|
+
return ask({
|
|
501
|
+
op: 'rna',
|
|
502
|
+
path: request.path,
|
|
503
|
+
...(request.names === undefined ? {} : { names: request.names }),
|
|
504
|
+
});
|
|
505
|
+
case 'rna-context':
|
|
506
|
+
// EVERY FIELD IS NAMED HERE, and the omission is silent: this switch
|
|
507
|
+
// REBUILDS the request rather than forwarding it, so a field added at
|
|
508
|
+
// `runtime.ts` and read in `session.py` still arrives as `None` and the
|
|
509
|
+
// door answers about something else entirely. Measured 2026-09-19 (I4
|
|
510
|
+
// follow-up (a)): `collection` crossed both ends and never the middle,
|
|
511
|
+
// and the Collection tab simply did not stand.
|
|
512
|
+
return ask({
|
|
513
|
+
op: 'rna-context',
|
|
514
|
+
...(request.object === undefined ? {} : { object: request.object }),
|
|
515
|
+
...(request.collection === undefined ? {} : { collection: request.collection }),
|
|
516
|
+
});
|
|
517
|
+
case 'rna-set':
|
|
518
|
+
return ask({
|
|
519
|
+
op: 'rna-set',
|
|
520
|
+
path: request.path,
|
|
521
|
+
property: request.property,
|
|
522
|
+
value: request.value,
|
|
523
|
+
...(request.index === undefined ? {} : { index: request.index }),
|
|
524
|
+
});
|
|
525
|
+
// THE TREE DOOR, the same way through: `rna_outliner` answers JSON and
|
|
526
|
+
// the worker adds nothing to it.
|
|
527
|
+
case 'outliner':
|
|
528
|
+
return ask({
|
|
529
|
+
op: 'outliner',
|
|
530
|
+
...(request.selected === undefined ? {} : { selected: [...request.selected] }),
|
|
531
|
+
});
|
|
532
|
+
// THE NODE-TREE DOOR, the same way through — and EVERY FIELD IS NAMED,
|
|
533
|
+
// for the reason `rna-context` above records: this switch REBUILDS the
|
|
534
|
+
// request, so a field added at both ends and not here arrives as `None`.
|
|
535
|
+
case 'node-tree':
|
|
536
|
+
return ask({
|
|
537
|
+
op: 'node-tree',
|
|
538
|
+
...(request.path === undefined ? {} : { path: request.path }),
|
|
539
|
+
...(request.material === undefined ? {} : { material: request.material }),
|
|
540
|
+
});
|
|
541
|
+
// THE UV DOOR, every field named for the same reason.
|
|
542
|
+
case 'uv-layout':
|
|
543
|
+
return ask({
|
|
544
|
+
op: 'uv-layout',
|
|
545
|
+
...(request.object === undefined ? {} : { object: request.object }),
|
|
546
|
+
...(request.uvLayer === undefined ? {} : { uvLayer: request.uvLayer }),
|
|
547
|
+
});
|
|
548
|
+
// THE RIG AND CLIP DOORS, every field named for the same reason — and the
|
|
549
|
+
// reason is a measured defect: I4's Collection tab simply did not stand
|
|
550
|
+
// because `collection` crossed `runtime.ts` and `session.py` and never
|
|
551
|
+
// this switch, with no error anywhere.
|
|
552
|
+
case 'rig':
|
|
553
|
+
return ask({
|
|
554
|
+
op: 'rig',
|
|
555
|
+
...(request.object === undefined ? {} : { object: request.object }),
|
|
556
|
+
});
|
|
557
|
+
case 'action-clip':
|
|
558
|
+
return ask({
|
|
559
|
+
op: 'action-clip',
|
|
560
|
+
...(request.object === undefined ? {} : { object: request.object }),
|
|
561
|
+
...(request.bake === undefined ? {} : { bake: request.bake }),
|
|
562
|
+
});
|
|
563
|
+
case 'outliner-set':
|
|
564
|
+
return ask({
|
|
565
|
+
op: 'outliner-set',
|
|
566
|
+
path: request.path,
|
|
567
|
+
column: request.column,
|
|
568
|
+
value: request.value,
|
|
569
|
+
});
|
|
570
|
+
case 'screenshot-view':
|
|
571
|
+
return JSON.parse((await session.screenshotView(request.maxSize)) as string);
|
|
572
|
+
case 'read-file':
|
|
573
|
+
return files.readFile(request.path);
|
|
574
|
+
case 'write-file': {
|
|
575
|
+
const dir = request.path.slice(0, request.path.lastIndexOf('/'));
|
|
576
|
+
if (dir) await files.mkdirTree(dir);
|
|
577
|
+
await files.writeFile(request.path, request.bytes);
|
|
578
|
+
staged.delete(request.path);
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
case 'list-files':
|
|
582
|
+
return listSessionFiles(files, request.path);
|
|
583
|
+
}
|
|
584
|
+
throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Anything thrown, rendered so the message SURVIVES the boundary.
|
|
588
|
+
*
|
|
589
|
+
* `error instanceof Error ? ... : String(error)` renders a thrown plain object
|
|
590
|
+
* as `[object Object]`, and that is the whole error a script sees: the worker
|
|
591
|
+
* answered `17-workshop-interior` seq 7 with exactly that, which named neither
|
|
592
|
+
* the operation nor the cause and left the next step with nothing to go on.
|
|
593
|
+
* A DOMException carries its name, and a plain object carries its own fields,
|
|
594
|
+
* so both are spelled out rather than coerced. */
|
|
595
|
+
export function describeThrown(error: unknown): string {
|
|
596
|
+
if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`;
|
|
597
|
+
if (typeof error === 'object' && error !== null) {
|
|
598
|
+
const named = error as { name?: unknown; message?: unknown };
|
|
599
|
+
if (typeof named.message === 'string') {
|
|
600
|
+
return typeof named.name === 'string' ? `${named.name}: ${named.message}` : named.message;
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
return JSON.stringify(error) ?? Object.prototype.toString.call(error);
|
|
604
|
+
} catch {
|
|
605
|
+
return Object.prototype.toString.call(error);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return String(error);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Tell the tab how big the engine's memory is now.
|
|
613
|
+
*
|
|
614
|
+
* O(1) on the standalone skew — a read of the `HEAPU8` view's length — and
|
|
615
|
+
* posted after every call because that is when it can have changed. It is the
|
|
616
|
+
* engine's memory as opposed to the page's: the census already carries
|
|
617
|
+
* `heapUsedMB` (the PAGE's JS heap) and had no field at all for the wasm,
|
|
618
|
+
* which is the larger half. Before the session exists there is nothing to read
|
|
619
|
+
* and nothing is posted, and the substrate skew answers null — its module's
|
|
620
|
+
* memory lives in a worker with no memory door, and a zero there would be a
|
|
621
|
+
* measurement nobody took.
|
|
622
|
+
*/
|
|
623
|
+
function reportMemory(): void {
|
|
624
|
+
if (!engine) return;
|
|
625
|
+
const bytes = engine.memoryBytes();
|
|
626
|
+
if (bytes !== null) post({ op: 'memory', bytes });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|
630
|
+
const request = event.data;
|
|
631
|
+
if (request.op === 'present-result') {
|
|
632
|
+
await handle(request);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Held across the WHOLE call, so the document's save timer can tell "the
|
|
636
|
+
// session is quiet" from "a script is still running".
|
|
637
|
+
callsInFlight += 1;
|
|
638
|
+
try {
|
|
639
|
+
post({ id: request.id, result: await handle(request) });
|
|
640
|
+
} catch (error) {
|
|
641
|
+
post({ id: request.id, error: describeThrown(error) });
|
|
642
|
+
} finally {
|
|
643
|
+
callsInFlight -= 1;
|
|
644
|
+
}
|
|
645
|
+
// AFTER the answer, never before it: the reading is a passenger and must not
|
|
646
|
+
// sit between a finished call and the reply the caller is waiting on.
|
|
647
|
+
reportMemory();
|
|
648
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/blender-engine",
|
|
3
|
+
"author": "Volter AI, Inc.",
|
|
4
|
+
"license": "GPL-3.0-or-later",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"description": "Blender WebAssembly engine, worker, wire protocol and Three.js frame presentation.",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/volter-ai/editor.git",
|
|
14
|
+
"directory": "packages/blender-engine"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"browser",
|
|
18
|
+
"wasm",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
"./browser": "./browser/index.ts",
|
|
23
|
+
"./browser/protocol": "./browser/protocol.ts",
|
|
24
|
+
"./browser/rna": "./browser/rna.ts",
|
|
25
|
+
"./wasm/*": "./wasm/*",
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
"./browser/three/*": {
|
|
28
|
+
"types": "./browser/three/*.ts",
|
|
29
|
+
"default": "./browser/three/*"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"three": "^0.180.0",
|
|
34
|
+
"zod": "^3.0.0 || ^4.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"three": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"zod": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build:blender-three": "vite build --config vite.release.config.ts",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.browser.json"
|
|
47
|
+
}
|
|
48
|
+
}
|