@volter/editor-blender 0.1.129 → 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.
@@ -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 { AGX_LOOK_TABLES, agxEncodeFrame } from '@volter/blender-engine/browser/three/blender-agx';
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: string;
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 bytes = standard
149
- ? standardEncodeFrame(pixels, width * height, render.exposure)
150
- : filmic
151
- ? filmicEncodeFrame(lut!, pixels, width * height, render.exposure)
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: mappings[render.toneMapping] ?? THREE.AgXToneMapping,
226
- exposure: render.exposure,
169
+ toneMapping: THREE.NoToneMapping,
170
+ exposure: 1,
227
171
  };
228
- // BLENDER'S OWN VIEW TRANSFORM, off the linear frame. three's AgX is
229
- // Filament's approximation and lands 23 to 48 levels of 255 away from
230
- // Blender (42 at middle grey); the config's actual transform is a 57^3
231
- // LUT over a log allocation, and a LOOK grades in a log space with two
232
- // LUT inversions in it. Neither is reachable from resolved bytes, and
233
- // both are reachable from the half-float target `captureImage` already
234
- // allocates -- so an AgX render reads that and runs
235
- // `blender-agx.ts`, which is verified byte-for-byte against OCIO.
236
- // AND WHENEVER THE SCENE-REFERRED FRAME IS WANTED, whatever the
237
- // transform: `captureImage` answers with resolved bytes and nothing
238
- // else, so a Standard render asked for an EXR — or asked to be
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
- return `Blender is editing ${held}; ${requested} is not open. Return to ${held} before editing.`;
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 `volter-model-editor eval`
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 `volter-model-editor eval` and
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 `volter-model-editor edit`, which has
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. `volter-model-editor status` prints the block. Published as a READ of the live
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 `volter-model-editor eval`, an MCP call and a panel's own
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 `volter-model-editor eval` reads exactly what the hierarchy
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 `volter-model-editor eval` reads exactly what the node
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 `volter-model-editor eval` reads exactly what the UV view draws.
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 `volter-model-editor eval` reads
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 `volter-model-editor edit` has no
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.
@@ -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` (colon-separated
112
- * absolute paths — the replay harness names its run directory). Only files
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
- const projectPath = projectRelative(this.project, entry.path);
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(entry.path).equals(bytes)) {
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(entry.path), { recursive: true });
195
- writeFileSync(entry.path, bytes);
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
- const filesystem = `Project files are mounted in Blender at the project's actual absolute path: ${project}. Use that root for image loads, saves and exports; /project is not an alias.`;
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.129",
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.127",
74
- "@volter/editor-threejs": "0.5.190",
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.190",
79
- "@volter/editor-project": "0.5.190",
80
- "@volter/editor-sdk": "0.5.190",
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
+ };
@@ -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`, `volter-model-editor screenshot editor`) photographs it, and
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