@volter/editor-blender 0.1.128 → 0.1.130
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-game-panel.tsx +764 -0
- package/contributions/blender-header-menus.tsx +7 -6
- package/contributions/blender-model-opening.tsx +12 -2
- package/contributions/blender-node-editor.document.tsx +1 -1
- package/contributions/blender-outliner-authoring.ts +6 -7
- package/contributions/blender-properties-context.ts +45 -0
- package/contributions/blender-properties-model.ts +15 -3
- package/contributions/blender-properties-view.tsx +84 -10
- package/contributions/blender-runtime.document.tsx +238 -29
- package/contributions/blender-timeline.document.tsx +33 -3
- package/contributions/blender-uv-editor.document.tsx +1 -1
- package/contributions/model.layout.ts +4 -0
- package/contributions/models.finder.ts +5 -1
- package/contributions/properties-object.inspector.tsx +26 -27
- package/contributions/properties-scene.inspector.tsx +9 -2
- package/dist-node/serving.mjs +17 -1
- package/host/blender-runtime-host.ts +39 -99
- package/node/blender-mcp.ts +58 -12
- package/package.json +11 -8
- package/serving/index.ts +29 -0
- package/src/node-view-state.ts +1 -1
- package/src/play-mode.ts +146 -0
- package/src/timeline-view-state.ts +1 -1
- package/src/uv-view-state.ts +1 -1
- package/view/blender-view-routes.ts +180 -0
package/dist-node/serving.mjs
CHANGED
|
@@ -815,6 +815,22 @@ function blenderRoutesPlugin(services) {
|
|
|
815
815
|
|
|
816
816
|
// serving/index.ts
|
|
817
817
|
var servingPlugins = (services) => [blenderRoutesPlugin(services)];
|
|
818
|
+
var viewSnapshotRoutes = async () => {
|
|
819
|
+
const base = "/__editor/blender-wasm";
|
|
820
|
+
const status = await blenderWasmStatus();
|
|
821
|
+
if (!status.available) return [`${base}/status`];
|
|
822
|
+
if (status.skew === "wali") return [`${base}/status`, `${base}/runtime.idx`, `${base}/runtime.bin`, `${base}/${BLENDER_WALI_ARTIFACT}`];
|
|
823
|
+
return [`${base}/status`, ...BLENDER_WASM_FILES.map((file) => `${base}/${file}`)];
|
|
824
|
+
};
|
|
825
|
+
var viewSnapshotScrub = (route, body) => {
|
|
826
|
+
if (route !== "/__editor/blender-wasm/status") return body;
|
|
827
|
+
const status = JSON.parse(body);
|
|
828
|
+
delete status["dir"];
|
|
829
|
+
status["missing"] = status["available"] === true ? [] : ["The Blender engine was not recorded into this limited view."];
|
|
830
|
+
return JSON.stringify(status);
|
|
831
|
+
};
|
|
818
832
|
export {
|
|
819
|
-
servingPlugins
|
|
833
|
+
servingPlugins,
|
|
834
|
+
viewSnapshotRoutes,
|
|
835
|
+
viewSnapshotScrub
|
|
820
836
|
};
|
|
@@ -45,17 +45,15 @@ import type {
|
|
|
45
45
|
BlenderRnaWrite,
|
|
46
46
|
BlenderUvLayout,
|
|
47
47
|
} from '@volter/blender-engine/browser/rna';
|
|
48
|
-
import {
|
|
49
|
-
import { displayTableUrl } from '@volter/blender-engine/browser/three/blender-display-lut';
|
|
50
|
-
import { filmicEncodeFrame } from '@volter/blender-engine/browser/three/blender-filmic';
|
|
48
|
+
import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
51
49
|
import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
52
|
-
import { standardEncodeFrame } from '@volter/blender-engine/browser/three/blender-standard';
|
|
53
50
|
import type { EditorCommandResult } from '@volter/editor-sdk/commands';
|
|
54
51
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
55
52
|
import { invokeViewVerb, type ViewVerbContribution } from '@volter/editor-sdk/views';
|
|
56
53
|
import {
|
|
57
54
|
captureSceneImage,
|
|
58
55
|
captureSceneLinear,
|
|
56
|
+
resolveSceneLinearDisplay,
|
|
59
57
|
type LinearCaptureFrame,
|
|
60
58
|
} from '@volter/editor-threejs/capture/scene';
|
|
61
59
|
import { fitClipPlanes } from '@volter/editor-threejs/viewport/clip-planes';
|
|
@@ -70,21 +68,6 @@ import {
|
|
|
70
68
|
setNodeViewState,
|
|
71
69
|
} from '../src/node-view-state';
|
|
72
70
|
|
|
73
|
-
/** Pinned OCIO display tables, loaded once. Missing data is a render failure. */
|
|
74
|
-
const displayTables = new Map<string, Promise<Uint16Array>>();
|
|
75
|
-
function displayTable(file: string): Promise<Uint16Array> {
|
|
76
|
-
let pending = displayTables.get(file);
|
|
77
|
-
if (!pending) {
|
|
78
|
-
pending = fetch(displayTableUrl(file)).then(async (response) => {
|
|
79
|
-
if (!response.ok)
|
|
80
|
-
throw new Error(`Blender display table ${file}: ${response.status} ${response.statusText}`);
|
|
81
|
-
return new Uint16Array(await response.arrayBuffer());
|
|
82
|
-
});
|
|
83
|
-
displayTables.set(file, pending);
|
|
84
|
-
}
|
|
85
|
-
return pending;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
71
|
/** Raw bytes of a base64 payload, as the half-float words a linear frame is. */
|
|
89
72
|
function halfFloatFrame(base64: string): Uint16Array {
|
|
90
73
|
const binary = atob(base64);
|
|
@@ -105,36 +88,14 @@ async function displayPhotograph(
|
|
|
105
88
|
width: number;
|
|
106
89
|
height: number;
|
|
107
90
|
exposure: number;
|
|
108
|
-
toneMapping:
|
|
91
|
+
toneMapping: RenderRequest['toneMapping'];
|
|
109
92
|
look?: string;
|
|
93
|
+
gamma?: number;
|
|
110
94
|
linear?: boolean;
|
|
111
95
|
transparent?: boolean;
|
|
112
96
|
linearInput?: { base64: string; width: number; height: number };
|
|
113
97
|
},
|
|
114
98
|
): Promise<{ base64: string; mimeType: string }> {
|
|
115
|
-
const look = render.look ?? 'None';
|
|
116
|
-
const filmic = render.toneMapping === 'filmic';
|
|
117
|
-
const standard = render.toneMapping === 'none';
|
|
118
|
-
if (!standard && render.toneMapping !== 'agx' && !filmic)
|
|
119
|
-
// `neutral` is three's own curve and has no scene-linear implementation
|
|
120
|
-
// here — refused BY NAME rather than answered with a different transform.
|
|
121
|
-
throw new Error(
|
|
122
|
-
`Blender's Khronos PBR Neutral view transform has no scene-linear implementation in the ` +
|
|
123
|
-
`browser, so it cannot resolve a composited or scene-referred frame (implemented: ` +
|
|
124
|
-
`Standard, AgX, Filmic)`,
|
|
125
|
-
);
|
|
126
|
-
// Standard is a curve, not a table (`blender-standard.ts`); the other two are
|
|
127
|
-
// the config's own baked LUTs.
|
|
128
|
-
const file = standard
|
|
129
|
-
? null
|
|
130
|
-
: filmic
|
|
131
|
-
? 'filmic-srgb.lut'
|
|
132
|
-
: look === 'None'
|
|
133
|
-
? 'agx-base-srgb.lut'
|
|
134
|
-
: AGX_LOOK_TABLES[look as keyof typeof AGX_LOOK_TABLES];
|
|
135
|
-
if (file === undefined)
|
|
136
|
-
throw new Error(`Blender display transform has no table for look ${look}`);
|
|
137
|
-
const lut = file ? await displayTable(file) : null;
|
|
138
99
|
const provided = render.linearInput;
|
|
139
100
|
const linear = provided
|
|
140
101
|
? {
|
|
@@ -145,14 +106,10 @@ async function displayPhotograph(
|
|
|
145
106
|
: linearFrame;
|
|
146
107
|
if (!linear) throw new Error('Blender display transform requires a linear capture');
|
|
147
108
|
const { pixels, width, height } = linear;
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
: agxEncodeFrame(lut!, pixels, width * height, {
|
|
153
|
-
exposure: render.exposure,
|
|
154
|
-
composedLook: look !== 'None',
|
|
155
|
-
});
|
|
109
|
+
const transform = await createBlenderDisplayTransform(blenderDisplaySettingsForRender(render));
|
|
110
|
+
let bytes: Uint8Array;
|
|
111
|
+
try { bytes = resolveSceneLinearDisplay(linear, transform); }
|
|
112
|
+
finally { transform.dispose(); }
|
|
156
113
|
const canvas = document.createElement('canvas');
|
|
157
114
|
canvas.width = width;
|
|
158
115
|
canvas.height = height;
|
|
@@ -202,19 +159,6 @@ export async function photographSnapshot(
|
|
|
202
159
|
render: RenderRequest,
|
|
203
160
|
assertCurrent: () => void = () => {},
|
|
204
161
|
): Promise<{ base64: string; mimeType: string }> {
|
|
205
|
-
// THE SCENE'S OWN VIEW TRANSFORM, for the duration of the photograph.
|
|
206
|
-
// Blender states one (`view_settings.view_transform`, AgX by default) and
|
|
207
|
-
// three.js has a curve of the same name, so the render is tone mapped the
|
|
208
|
-
// way the scene asks rather than the way the modeling viewport prefers —
|
|
209
|
-
// without this, every lit pixel differs by the gap between two unrelated
|
|
210
|
-
// curves and no difference in the image can be attributed to anything
|
|
211
|
-
// else. Python refuses a transform with no curve here, so the map is total.
|
|
212
|
-
const mappings: Record<string, THREE.ToneMapping> = {
|
|
213
|
-
none: THREE.NoToneMapping,
|
|
214
|
-
agx: THREE.AgXToneMapping,
|
|
215
|
-
neutral: THREE.NeutralToneMapping,
|
|
216
|
-
filmic: THREE.NoToneMapping,
|
|
217
|
-
};
|
|
218
162
|
await snapshot.prepare(renderCamera);
|
|
219
163
|
assertCurrent();
|
|
220
164
|
const captureOptions = {
|
|
@@ -222,31 +166,20 @@ export async function photographSnapshot(
|
|
|
222
166
|
width: render.width,
|
|
223
167
|
height: render.height,
|
|
224
168
|
transparent: render.transparent === true,
|
|
225
|
-
toneMapping:
|
|
226
|
-
exposure:
|
|
169
|
+
toneMapping: THREE.NoToneMapping,
|
|
170
|
+
exposure: 1,
|
|
227
171
|
};
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// composited — has no linear frame in it at all. Reading the
|
|
240
|
-
// half-float target is the only path that has one.
|
|
241
|
-
const display =
|
|
242
|
-
render.toneMapping === 'agx' || render.toneMapping === 'filmic' || render.linear === true
|
|
243
|
-
? await displayPhotograph(captureSceneLinear(scene, renderCamera, captureOptions), render)
|
|
244
|
-
: null;
|
|
245
|
-
assertCurrent();
|
|
246
|
-
if (display) return display;
|
|
247
|
-
const dataUrl = captureSceneImage(scene, renderCamera, captureOptions);
|
|
248
|
-
if (!dataUrl) throw new Error('Blender render could not capture its image');
|
|
249
|
-
return { base64: dataUrl.slice(dataUrl.indexOf(',') + 1), mimeType: 'image/png' };
|
|
172
|
+
if (render.linear === true) {
|
|
173
|
+
const display = await displayPhotograph(captureSceneLinear(scene, renderCamera, captureOptions), render);
|
|
174
|
+
assertCurrent();
|
|
175
|
+
return display;
|
|
176
|
+
}
|
|
177
|
+
const transform = await createBlenderDisplayTransform(blenderDisplaySettingsForRender(render));
|
|
178
|
+
try {
|
|
179
|
+
assertCurrent();
|
|
180
|
+
const dataUrl = captureSceneImage(scene, renderCamera, {...captureOptions, displayTransform: transform});
|
|
181
|
+
return {base64: dataUrl.slice(dataUrl.indexOf(',') + 1), mimeType: 'image/png'};
|
|
182
|
+
} finally { transform.dispose(); }
|
|
250
183
|
}
|
|
251
184
|
|
|
252
185
|
export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
|
|
@@ -299,7 +232,14 @@ function modelDocumentConflict(): string | null {
|
|
|
299
232
|
? activeId.slice('document:model:'.length)
|
|
300
233
|
: boundModel?.blend;
|
|
301
234
|
if (!requested || !held || requested === held) return null;
|
|
302
|
-
|
|
235
|
+
// Say how to get Blender onto the document the editor shows, not only how to go back: an agent told
|
|
236
|
+
// just "return to <held>" took it as the Blender door being unusable and built its level by
|
|
237
|
+
// another route (2026-10-06, the WSL obby run).
|
|
238
|
+
// Plain words: people see this in error notices too. Re-opening the document already showing does not
|
|
239
|
+
// remount a failed open (blender-runtime.document.tsx), so the steps that do are named instead.
|
|
240
|
+
return `Blender is editing ${held}, but the editor is showing ${requested}, which Blender has not loaded. ` +
|
|
241
|
+
`Wait for ${requested} to finish opening and retry. If it stays, switch to another document and back, ` +
|
|
242
|
+
`or reload the page; or return to ${held}.`;
|
|
303
243
|
}
|
|
304
244
|
|
|
305
245
|
/** The document id a present must reach: the open Model document's, or the
|
|
@@ -437,7 +377,7 @@ async function openBoundModelDocument(
|
|
|
437
377
|
* (`../contributions/blender-properties-*`) call these three rather than the
|
|
438
378
|
* `blender-rna*` commands: same session, same `session.py` functions, one
|
|
439
379
|
* fewer hop, and no `EditorCommandResult` envelope to unwrap in a render.
|
|
440
|
-
* The COMMANDS remain the wire's door onto the same calls, so a `
|
|
380
|
+
* The COMMANDS remain the wire's door onto the same calls, so a `cyclotron eval`
|
|
441
381
|
* and the panel read one thing.
|
|
442
382
|
*
|
|
443
383
|
* THEY NEVER START THE ENGINE. A panel asking what the engine holds must not
|
|
@@ -468,7 +408,7 @@ export function blenderSessionStarted(): boolean {
|
|
|
468
408
|
* So the version is minted HERE, at the door every RNA write goes through,
|
|
469
409
|
* and it is bumped by the write rather than by the picture. Both halves of
|
|
470
410
|
* each door bump it once: the in-page function is what the wire's
|
|
471
|
-
* `blender-rna-set` / `blender-outliner-set` case calls, so a `
|
|
411
|
+
* `blender-rna-set` / `blender-outliner-set` case calls, so a `cyclotron eval` and
|
|
472
412
|
* a panel click are one path. `blender-execute` bumps it too — arbitrary bpy
|
|
473
413
|
* can change anything RNA answers, and a view that went stale under a probe's
|
|
474
414
|
* own script would be the same defect one layer out.
|
|
@@ -679,7 +619,7 @@ export async function blenderOutlinerSet(
|
|
|
679
619
|
/**
|
|
680
620
|
* THE NODE EDITOR'S VERBS, published ONCE and reached two ways (U8's ruling 1,
|
|
681
621
|
* 2026-09-19). Under the Code-OSS frame each is a `volter.blender-node-view.<verb>`
|
|
682
|
-
* command the bridge dispatches into the view; standalone `
|
|
622
|
+
* command the bridge dispatches into the view; standalone `cyclotron edit`, which has
|
|
683
623
|
* no command service, reaches the SAME table through the session's
|
|
684
624
|
* `blender-node-view` verb below. One table, two doors — the shape
|
|
685
625
|
* `key-actions.ts`'s action table already has, and the reason the next
|
|
@@ -1173,7 +1113,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1173
1113
|
// how long it has been stuck — the loop that would send the number is the loop
|
|
1174
1114
|
// that is stuck — so this side keeps the clock and the host carries it out on
|
|
1175
1115
|
// the heartbeat, the one channel that still beats through a blocked main
|
|
1176
|
-
// thread. `
|
|
1116
|
+
// thread. `cyclotron status` prints the block. Published as a READ of the live
|
|
1177
1117
|
// meter rather than a snapshot, so the host always asks the running session:
|
|
1178
1118
|
// an in-flight call's age has to be computed at the moment it is reported.
|
|
1179
1119
|
// The page's own long-task half is the HOST's, behind this same door.
|
|
@@ -1344,7 +1284,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1344
1284
|
// RNA version is minted at the door (ruling 3, 2026-09-19), and
|
|
1345
1285
|
// ARBITRARY bpy can change anything RNA answers, so a script that
|
|
1346
1286
|
// reached the engine around it would leave every view drawing the tree
|
|
1347
|
-
// it had. One path for a `
|
|
1287
|
+
// it had. One path for a `cyclotron eval`, an MCP call and a panel's own
|
|
1348
1288
|
// operator.
|
|
1349
1289
|
// THE DOOR'S TEXT, verbatim — `execute_blender_code`'s MCP contract is
|
|
1350
1290
|
// that one string, so the wire keeps answering it while the in-page
|
|
@@ -1391,7 +1331,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1391
1331
|
}
|
|
1392
1332
|
// THE TREE DOOR on the wire, beside the RNA one: `blender-outliner`
|
|
1393
1333
|
// answers Blender's View Layer tree and `blender-outliner-set` writes one
|
|
1394
|
-
// restriction column, so a `
|
|
1334
|
+
// restriction column, so a `cyclotron eval` reads exactly what the hierarchy
|
|
1395
1335
|
// panel draws.
|
|
1396
1336
|
case 'blender-outliner': {
|
|
1397
1337
|
const selected = Array.isArray(cmd['selected'])
|
|
@@ -1402,7 +1342,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1402
1342
|
return { ok: true, data: { result: await session.outliner(selected) } };
|
|
1403
1343
|
}
|
|
1404
1344
|
// THE NODE-TREE DOOR on the wire, beside the other two: one material's
|
|
1405
|
-
// shader node tree, whole, so a `
|
|
1345
|
+
// shader node tree, whole, so a `cyclotron eval` reads exactly what the node
|
|
1406
1346
|
// view draws.
|
|
1407
1347
|
case 'blender-node-tree':
|
|
1408
1348
|
return {
|
|
@@ -1415,7 +1355,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1415
1355
|
},
|
|
1416
1356
|
};
|
|
1417
1357
|
// THE UV DOOR on the wire, beside the node one: one mesh's UV layout,
|
|
1418
|
-
// so a `
|
|
1358
|
+
// so a `cyclotron eval` reads exactly what the UV view draws.
|
|
1419
1359
|
case 'blender-uv-layout':
|
|
1420
1360
|
return {
|
|
1421
1361
|
ok: true,
|
|
@@ -1427,7 +1367,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1427
1367
|
},
|
|
1428
1368
|
};
|
|
1429
1369
|
// THE RIG AND CLIP DOORS on the wire, beside the UV one: the skin
|
|
1430
|
-
// binding and the action as three.js tracks, so a `
|
|
1370
|
+
// binding and the action as three.js tracks, so a `cyclotron eval` reads
|
|
1431
1371
|
// exactly what the presenter bound and what the Timeline plays.
|
|
1432
1372
|
case 'blender-rig':
|
|
1433
1373
|
return {
|
|
@@ -1454,7 +1394,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1454
1394
|
//
|
|
1455
1395
|
// THE SESSION IS THE STANDALONE DOOR ONTO `NODE_VIEW_VERBS`, not a second
|
|
1456
1396
|
// implementation (U8's ruling 1). Under the Code-OSS frame each verb is a
|
|
1457
|
-
// `volter.blender-node-view.<verb>` command; standalone `
|
|
1397
|
+
// `volter.blender-node-view.<verb>` command; standalone `cyclotron edit` has no
|
|
1458
1398
|
// command service, so this verb routes the SAME table. `invokeViewVerb`
|
|
1459
1399
|
// throws the view's own refusal, which is the sentence this door already
|
|
1460
1400
|
// answered with.
|
package/node/blender-mcp.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
15
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
-
import { dirname, relative, sep } from 'node:path';
|
|
16
|
+
import { delimiter, dirname, isAbsolute, relative, sep } from 'node:path';
|
|
17
17
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
18
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
19
|
import {
|
|
@@ -105,11 +105,31 @@ function projectRelative(project: string, absolute: string): string | null {
|
|
|
105
105
|
return rel;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* BLENDER'S FILESYSTEM IS POSIX, the host's may not be. The worker mounts a Windows root
|
|
110
|
+
* (`C:\Users\me\p`) at its URI path (`/C:/Users/me/p`) — `projectMount` in
|
|
111
|
+
* `@volter/blender-engine/browser/worker.ts`, which this mirrors because that module is the
|
|
112
|
+
* worker's and cannot load here. Every path crossing the transport goes through these two:
|
|
113
|
+
* the engine's spelling on the wire, the host's on disk. Until 2026-10-06 the Mirror sent the
|
|
114
|
+
* host's spelling, the worker listed nothing at a path it did not have, and on Windows no
|
|
115
|
+
* script output ever reached disk while every call reported success.
|
|
116
|
+
*/
|
|
117
|
+
export function engineSpelling(host: string): string {
|
|
118
|
+
return /^[A-Za-z]:[\\/]/.test(host) ? `/${host.replaceAll('\\', '/')}` : host;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** {@link engineSpelling} backwards: `/C:/Users/me/p/x.glb` → `C:\Users\me\p\x.glb` on
|
|
122
|
+
* Windows; a POSIX host's paths are the engine's own. */
|
|
123
|
+
export function hostSpelling(engine: string, platform: NodeJS.Platform = process.platform): string {
|
|
124
|
+
return platform === 'win32' && /^\/[A-Za-z]:\//.test(engine) ? engine.slice(1).replaceAll('/', '\\') : engine;
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
/**
|
|
109
128
|
* The worker's filesystem is the only place a script's outputs exist; this
|
|
110
129
|
* mirrors them to disk. Roots: the project (a script's `public/models/x.glb`
|
|
111
|
-
* lands in the project) plus any `VOLTER_BLENDER_MIRROR_ROOTS` (
|
|
112
|
-
*
|
|
130
|
+
* lands in the project) plus any `VOLTER_BLENDER_MIRROR_ROOTS` (absolute paths separated by
|
|
131
|
+
* the platform's own `path.delimiter` — `:` on POSIX, `;` on Windows, where a drive letter's
|
|
132
|
+
* colon is part of the path — the replay harness names its run directory). Only files
|
|
113
133
|
* whose size or mtime changed since the last mirror are read back, and only
|
|
114
134
|
* files the SESSION owns are listed at all — a project file it merely read
|
|
115
135
|
* belongs to the host and is never written back over it.
|
|
@@ -153,11 +173,14 @@ class Mirror {
|
|
|
153
173
|
session?: string;
|
|
154
174
|
revision?: number;
|
|
155
175
|
document?: string;
|
|
156
|
-
}>('blender-list-files', { path: root });
|
|
176
|
+
}>('blender-list-files', { path: engineSpelling(root) });
|
|
157
177
|
for (const entry of listing.entries) {
|
|
158
178
|
const stamp = `${entry.size}:${entry.mtime}`;
|
|
159
179
|
if (this.#seen.get(entry.path) === stamp) continue;
|
|
160
|
-
|
|
180
|
+
// `entry.path` is the engine's spelling (what `blender-read-file` takes); `onDisk` is
|
|
181
|
+
// where the same file lives on this machine.
|
|
182
|
+
const onDisk = hostSpelling(entry.path);
|
|
183
|
+
const projectPath = projectRelative(this.project, onDisk);
|
|
161
184
|
const shipped =
|
|
162
185
|
projectPath !== null && projectOutputRootOf(projectPath) === 'public'
|
|
163
186
|
? projectPath
|
|
@@ -184,15 +207,15 @@ class Mirror {
|
|
|
184
207
|
// adapter triggers project reload during this very MCP request.
|
|
185
208
|
// Only changed bytes should reach the filesystem watcher.
|
|
186
209
|
try {
|
|
187
|
-
if (readFileSync(
|
|
210
|
+
if (readFileSync(onDisk).equals(bytes)) {
|
|
188
211
|
this.#seen.set(entry.path, stamp);
|
|
189
212
|
continue;
|
|
190
213
|
}
|
|
191
214
|
} catch (error) {
|
|
192
215
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
193
216
|
}
|
|
194
|
-
mkdirSync(dirname(
|
|
195
|
-
writeFileSync(
|
|
217
|
+
mkdirSync(dirname(onDisk), { recursive: true });
|
|
218
|
+
writeFileSync(onDisk, bytes);
|
|
196
219
|
} else {
|
|
197
220
|
const query = new URLSearchParams({
|
|
198
221
|
path: shipped,
|
|
@@ -294,6 +317,25 @@ class TabSession {
|
|
|
294
317
|
|
|
295
318
|
const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
|
|
296
319
|
|
|
320
|
+
/** `VOLTER_BLENDER_MIRROR_ROOTS`, split on the platform's delimiter. A root Blender cannot mount
|
|
321
|
+
* is REFUSED by name rather than dropped: dropping it was how a Windows root (`C:\…`, split on
|
|
322
|
+
* its own colon) silently mirrored nothing. Blender's filesystem has a mount for a POSIX root
|
|
323
|
+
* (`/runs/x`) and a drive root (`C:\runs\x`, as `/C:/runs/x`) only, so a relative path, a UNC
|
|
324
|
+
* share (`\\server\share`) and a drive-less rooted path (`\runs\x`) — all of which Node calls
|
|
325
|
+
* absolute or nearly — are refused too (#147 review). */
|
|
326
|
+
function mirrorRoots(raw: string | undefined): string[] {
|
|
327
|
+
const roots = (raw ?? '').split(delimiter).filter((root) => root !== '');
|
|
328
|
+
const mountable = (root: string) => /^[A-Za-z]:[\\/]/.test(root) || (root.startsWith('/') && !root.startsWith('//'));
|
|
329
|
+
const refused = roots.filter((root) => !mountable(root) || !isAbsolute(root));
|
|
330
|
+
if (refused.length > 0)
|
|
331
|
+
throw new Error(
|
|
332
|
+
`VOLTER_BLENDER_MIRROR_ROOTS names ${refused.map((root) => JSON.stringify(root)).join(', ')}, ` +
|
|
333
|
+
`which Blender cannot mount: a root must be an absolute POSIX path (/runs/x) or a drive path (C:\\runs\\x), ` +
|
|
334
|
+
`not a relative path, a UNC share or a path without its drive. Separate roots with "${delimiter}".`,
|
|
335
|
+
);
|
|
336
|
+
return roots;
|
|
337
|
+
}
|
|
338
|
+
|
|
297
339
|
export async function serveBlenderMcp(
|
|
298
340
|
project: string,
|
|
299
341
|
ensureEditor: () => Promise<void>,
|
|
@@ -304,7 +346,13 @@ export async function serveBlenderMcp(
|
|
|
304
346
|
// it goes rendered one, wrote it into the project and ran the OS `open` on it: Preview started
|
|
305
347
|
// outside the editor. The editor already shows a picture as one of its documents.
|
|
306
348
|
const photographs = `A photograph is shown to the person in the editor, never in another app: get_viewport_screenshot returns one, and an image file written inside the project opens as an editor document with \`npx ${command} eval 'editor.openAsset("<project-relative path>")'\`. Do not run the OS \`open\` on it.`;
|
|
307
|
-
|
|
349
|
+
// Blender's own spelling of the root: on Windows the project's `C:\…` is `/C:/…` inside it, and
|
|
350
|
+
// a script told the host spelling writes to a path that is not the project's.
|
|
351
|
+
const mounted = engineSpelling(project);
|
|
352
|
+
// Files the agent wrote a moment ago are copied in before each call (blender-engine worker,
|
|
353
|
+
// stageProjectFiles), so a project script runs from its file; an agent unsure of that built its level
|
|
354
|
+
// through the eval CLI instead of this door (2026-10-06, the WSL obby run).
|
|
355
|
+
const filesystem = `Project files are mounted in Blender at the project's actual absolute path: ${mounted}${mounted !== project ? ` (this machine's ${project})` : ''}. Use that root for image loads, saves and exports; /project is not an alias. The project's source files written before a call, including ones written just now, are in place when that call runs (dotfiles and dot-directories, node_modules, dist, coverage and logs are not copied in), so run a project script from its file: exec(open(${JSON.stringify(`${mounted}/src/models/<name>.py`)}).read()).`;
|
|
308
356
|
const instructions = `${runtimeIdentity}\n${filesystem}\n${photographs}`;
|
|
309
357
|
const tools = (blenderTools as ToolShape[]).map((tool) =>
|
|
310
358
|
tool.name === 'execute_blender_code'
|
|
@@ -314,9 +362,7 @@ export async function serveBlenderMcp(
|
|
|
314
362
|
const defaults = blenderDefaults as Record<string, string>;
|
|
315
363
|
const mirror = new Mirror(project, [
|
|
316
364
|
project,
|
|
317
|
-
...(process.env['VOLTER_BLENDER_MIRROR_ROOTS']
|
|
318
|
-
.split(':')
|
|
319
|
-
.filter((root) => root.startsWith('/')),
|
|
365
|
+
...mirrorRoots(process.env['VOLTER_BLENDER_MIRROR_ROOTS']),
|
|
320
366
|
], `${command} blender-mcp`);
|
|
321
367
|
const tab = new TabSession(project, ensureEditor);
|
|
322
368
|
const server = new Server(
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-blender",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only AND GPL-3.0-or-later",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.130",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"LICENSE",
|
|
22
22
|
"LICENSE-MCP",
|
|
23
23
|
"serving",
|
|
24
|
+
"view",
|
|
24
25
|
"dist-node"
|
|
25
26
|
],
|
|
26
27
|
"exports": {
|
|
@@ -66,18 +67,20 @@
|
|
|
66
67
|
"./contributions/uv-editing.layout.ts",
|
|
67
68
|
"./contributions/texture.layout.ts"
|
|
68
69
|
],
|
|
69
|
-
"serving": "./dist-node/serving.mjs"
|
|
70
|
+
"serving": "./dist-node/serving.mjs",
|
|
71
|
+
"viewServing": "./view/blender-view-routes.ts"
|
|
70
72
|
},
|
|
71
73
|
"dependencies": {
|
|
72
74
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
73
|
-
"@volter/blender-engine": "0.1.
|
|
74
|
-
"@volter/editor-threejs": "0.5.
|
|
75
|
-
"zod": "^4.3.6"
|
|
75
|
+
"@volter/blender-engine": "0.1.128",
|
|
76
|
+
"@volter/editor-threejs": "0.5.191",
|
|
77
|
+
"zod": "^4.3.6",
|
|
78
|
+
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
76
79
|
},
|
|
77
80
|
"peerDependencies": {
|
|
78
|
-
"@volter/editor-live": "0.5.
|
|
79
|
-
"@volter/editor-project": "0.5.
|
|
80
|
-
"@volter/editor-sdk": "0.5.
|
|
81
|
+
"@volter/editor-live": "0.5.191",
|
|
82
|
+
"@volter/editor-project": "0.5.191",
|
|
83
|
+
"@volter/editor-sdk": "0.5.191",
|
|
81
84
|
"react": "^19.0.0",
|
|
82
85
|
"react-dom": "^19.0.0",
|
|
83
86
|
"three": "^0.180.0",
|
package/serving/index.ts
CHANGED
|
@@ -6,5 +6,34 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ProjectServingModule } from '@volter/editor-sdk/session/project-serving';
|
|
8
8
|
import { blenderRoutesPlugin } from './blender-routes';
|
|
9
|
+
import { BLENDER_WALI_ARTIFACT, BLENDER_WASM_FILES, blenderWasmStatus } from './blender-wasm-artifact';
|
|
9
10
|
|
|
10
11
|
export const servingPlugins: ProjectServingModule['servingPlugins'] = (services) => [blenderRoutesPlugin(services)];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The engine's bytes a LIMITED VIEW ships (docs/LIMITED-VIEW.md): fixed for a build, so the view
|
|
15
|
+
* records what the session answers here and serves it at the same URL. The routes that touch the
|
|
16
|
+
* project are the page's (`view/blender-view-routes.ts`). The substrate skew's browser modules
|
|
17
|
+
* (`/__editor/blender-wasm/wali/…`) are not listed: they are named only at run time.
|
|
18
|
+
*/
|
|
19
|
+
export const viewSnapshotRoutes: NonNullable<ProjectServingModule['viewSnapshotRoutes']> = async () => {
|
|
20
|
+
const base = '/__editor/blender-wasm';
|
|
21
|
+
const status = await blenderWasmStatus();
|
|
22
|
+
if (!status.available) return [`${base}/status`];
|
|
23
|
+
if (status.skew === 'wali') return [`${base}/status`, `${base}/runtime.idx`, `${base}/runtime.bin`, `${base}/${BLENDER_WALI_ARTIFACT}`];
|
|
24
|
+
return [`${base}/status`, ...BLENDER_WASM_FILES.map((file) => `${base}/${file}`)];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The recorded status, as a view may publish it: what the page reads (whether the engine is there,
|
|
29
|
+
* which skew, sizes, encodings, digests, integrity, workers) and never `dir` — the engine's folder on
|
|
30
|
+
* the machine that built the view, `VOLTER_BLENDER_WASM_DIR` or the installed package — nor the
|
|
31
|
+
* `missing` sentences, which quote paths.
|
|
32
|
+
*/
|
|
33
|
+
export const viewSnapshotScrub: NonNullable<ProjectServingModule['viewSnapshotScrub']> = (route, body) => {
|
|
34
|
+
if (route !== '/__editor/blender-wasm/status') return body;
|
|
35
|
+
const status = JSON.parse(body) as Record<string, unknown>;
|
|
36
|
+
delete status['dir'];
|
|
37
|
+
status['missing'] = status['available'] === true ? [] : ['The Blender engine was not recorded into this limited view.'];
|
|
38
|
+
return JSON.stringify(status);
|
|
39
|
+
};
|
package/src/node-view-state.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* own header says so: "a selector resolving outside that document's container
|
|
7
7
|
* is refused by a message naming the scope". The node editor is a DRAWER
|
|
8
8
|
* utility, so nothing in the product could read or drive it: the sighted door
|
|
9
|
-
* (`editor.captureEditorChrome`, `
|
|
9
|
+
* (`editor.captureEditorChrome`, `cyclotron screenshot editor`) photographs it, and
|
|
10
10
|
* a photograph cannot click.
|
|
11
11
|
*
|
|
12
12
|
* So the view's actions are SESSION VERBS, which is the pattern the keyboard
|