@volter/editor-blender 0.1.149 → 0.1.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +3 -7
  13. package/contributions/blender-node-editor.document.tsx +28 -180
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +100 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +15 -90
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +15 -147
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +217 -339
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +142 -332
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +43 -23
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +63 -0
  40. package/contributions/blender.status.tsx +9 -145
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +12 -32
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -30
  64. package/host/blender-runtime-host.ts +541 -372
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +49 -93
  67. package/package.json +1209 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -43
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/model-play-extension.ts +173 -0
  78. package/src/node-view-state.ts +3 -25
  79. package/src/play-mode.ts +64 -38
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -0,0 +1,143 @@
1
+ /**
2
+ * THE SCENE'S SOUND, MIXED IN THE PAGE (docs/adr/movie-sound-and-react-frames.md, decision 2): this
3
+ * Blender build has no audio (`aud` and `sound.mixdown` are unavailable), so `render-movie` mixes what `session.py`'s `sound-scene` describes in an
4
+ * OfflineAudioContext, 48 kHz stereo, from the movie's first frame at 0 s:
5
+ *
6
+ * - a sound strip plays its file from where the strip is cut, at its speed, through its volume per
7
+ * frame (a fade is keys on volume) and, when its sound is mono, its pan per frame;
8
+ * - a speaker plays its file from each of its NLA sound strips' start frames, through its volume per
9
+ * frame, from its place and facing per frame, heard from the scene camera's (the listener's);
10
+ * - the scene's own volume scales the mix, and a muted strip, channel, speaker or scene is silent.
11
+ *
12
+ * What differs from Blender's own mix is listed in the ADR and in SCENE-ANIMATION.md.
13
+ */
14
+ import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
15
+ import type { BlenderClipColumn, BlenderSoundFile, BlenderSoundScene } from '@volter/blender-engine/browser/rna';
16
+
17
+ const SCENE_SOUND_RATE = 48_000;
18
+
19
+ /** A per-frame column as `count` values of `stride` floats (a constant column repeated). */
20
+ function column(source: BlenderClipColumn, count: number, stride: number): Float32Array {
21
+ const bytes = bytesFromBase64(source.base64);
22
+ const values = new Float32Array(bytes.buffer);
23
+ if (source.count >= count) return values;
24
+ const out = new Float32Array(count * stride);
25
+ for (let i = 0; i < count; i++) out.set(values.subarray(0, stride), i * stride);
26
+ return out;
27
+ }
28
+
29
+ /** `param` follows `values` (one per frame), ramped linearly between frames from 0 s. */
30
+ function follow(param: AudioParam, values: Float32Array, fps: number, stride = 1, offset = 0, map: (v: number) => number = (v) => v): void {
31
+ const count = values.length / stride;
32
+ param.setValueAtTime(map(values[offset]!), 0);
33
+ for (let i = 1; i < count; i++) param.linearRampToValueAtTime(map(values[i * stride + offset]!), i / fps);
34
+ }
35
+
36
+ const DISTANCE_MODELS: Readonly<Record<string, DistanceModelType>> = {
37
+ INVERSE: 'inverse', INVERSE_CLAMPED: 'inverse', LINEAR: 'linear', LINEAR_CLAMPED: 'linear', EXPONENT: 'exponential', EXPONENT_CLAMPED: 'exponential',
38
+ };
39
+
40
+ /**
41
+ * The movie's sound as one 48 kHz stereo buffer's planes, or null when the scene has none to play
42
+ * (no unmuted strip or speaker with a file). `read` gives a file's bytes.
43
+ */
44
+ export async function mixSceneSound(
45
+ scene: BlenderSoundScene,
46
+ read: (file: BlenderSoundFile) => Promise<Uint8Array>,
47
+ ): Promise<{ readonly planes: Float32Array[]; readonly sampleRate: number; readonly strips: number; readonly speakers: number } | null> {
48
+ if (scene.muted && scene.speakers.length === 0) return null;
49
+ const strips = scene.muted ? [] : scene.strips.filter((strip) => !strip.muted && strip.file !== null);
50
+ const speakers = scene.speakers.filter((speaker) => !speaker.muted && speaker.file !== null && speaker.starts.length > 0);
51
+ if (strips.length === 0 && speakers.length === 0) return null;
52
+
53
+ const { fps, first, count } = scene;
54
+ const seconds = count / fps;
55
+ const context = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(seconds * SCENE_SOUND_RATE), sampleRate: SCENE_SOUND_RATE });
56
+ const master = context.createGain();
57
+ master.gain.value = scene.volume;
58
+ master.connect(context.destination);
59
+
60
+ const decoded = new Map<string, Promise<AudioBuffer>>();
61
+ const decode = (file: BlenderSoundFile): Promise<AudioBuffer> => {
62
+ const key = 'path' in file ? file.path : file.packed;
63
+ let buffer = decoded.get(key);
64
+ if (!buffer) {
65
+ buffer = read(file).then((bytes) => context.decodeAudioData(bytes.slice().buffer as ArrayBuffer)).catch((thrown: unknown) => {
66
+ throw new Error(`The sound ${key} could not be read or decoded in this browser: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
67
+ });
68
+ decoded.set(key, buffer);
69
+ }
70
+ return buffer;
71
+ };
72
+
73
+ for (const strip of strips) {
74
+ const from = Math.max(strip.finalStart, first);
75
+ const to = Math.min(strip.finalEnd, first + count);
76
+ if (to <= from) continue;
77
+ const buffer = await decode(strip.file!);
78
+ const source = context.createBufferSource();
79
+ source.buffer = buffer;
80
+ source.playbackRate.value = strip.speed;
81
+ const gain = context.createGain();
82
+ follow(gain.gain, column(strip.volume, count, 1), fps);
83
+ source.connect(gain);
84
+ let out: AudioNode = gain;
85
+ if (strip.mono || buffer.numberOfChannels === 1) {
86
+ // Blender pans only a mono sound; a mono strip of a stereo file is mixed down first
87
+ gain.channelCount = 1;
88
+ gain.channelCountMode = 'explicit';
89
+ gain.channelInterpretation = 'speakers';
90
+ const pan = context.createStereoPanner();
91
+ follow(pan.pan, column(strip.pan, count, 1), fps, 1, 0, (v) => Math.max(-1, Math.min(1, v)));
92
+ gain.connect(pan);
93
+ out = pan;
94
+ }
95
+ out.connect(master);
96
+ source.start((from - first) / fps, ((from - strip.start) / fps) * strip.speed, ((to - from) / fps) * strip.speed);
97
+ }
98
+
99
+ if (speakers.length > 0 && scene.listener) {
100
+ const listener = context.listener;
101
+ const position = column(scene.listener.position, count, 3);
102
+ const forward = column(scene.listener.forward, count, 3);
103
+ const up = column(scene.listener.up, count, 3);
104
+ follow(listener.positionX, position, fps, 3, 0); follow(listener.positionY, position, fps, 3, 1); follow(listener.positionZ, position, fps, 3, 2);
105
+ follow(listener.forwardX, forward, fps, 3, 0); follow(listener.forwardY, forward, fps, 3, 1); follow(listener.forwardZ, forward, fps, 3, 2);
106
+ follow(listener.upX, up, fps, 3, 0); follow(listener.upY, up, fps, 3, 1); follow(listener.upZ, up, fps, 3, 2);
107
+ }
108
+ for (const speaker of speakers) {
109
+ const buffer = await decode(speaker.file!);
110
+ const gain = context.createGain();
111
+ follow(gain.gain, column(speaker.volume, count, 1), fps, 1, 0, (v) => Math.max(speaker.volumeMin, Math.min(speaker.volumeMax, v)));
112
+ const panner = context.createPanner();
113
+ panner.panningModel = 'equalpower';
114
+ panner.distanceModel = DISTANCE_MODELS[scene.distanceModel] ?? 'inverse';
115
+ panner.refDistance = speaker.distanceReference;
116
+ panner.maxDistance = speaker.distanceMax;
117
+ panner.rolloffFactor = scene.distanceModel === 'NONE' ? 0 : speaker.attenuation;
118
+ panner.coneInnerAngle = (speaker.coneInner * 180) / Math.PI;
119
+ panner.coneOuterAngle = (speaker.coneOuter * 180) / Math.PI;
120
+ panner.coneOuterGain = speaker.coneOuterVolume;
121
+ const position = column(speaker.position, count, 3);
122
+ const facing = column(speaker.facing, count, 3);
123
+ follow(panner.positionX, position, fps, 3, 0); follow(panner.positionY, position, fps, 3, 1); follow(panner.positionZ, position, fps, 3, 2);
124
+ follow(panner.orientationX, facing, fps, 3, 0); follow(panner.orientationY, facing, fps, 3, 1); follow(panner.orientationZ, facing, fps, 3, 2);
125
+ gain.connect(panner).connect(master);
126
+ for (const start of speaker.starts) {
127
+ const end = start + (buffer.duration * fps);
128
+ if (end <= first || start >= first + count) continue;
129
+ const source = context.createBufferSource();
130
+ source.buffer = buffer;
131
+ source.connect(gain);
132
+ source.start(Math.max(0, (start - first) / fps), Math.max(0, (first - start) / fps));
133
+ }
134
+ }
135
+
136
+ const mixed = await context.startRendering();
137
+ return {
138
+ planes: [mixed.getChannelData(0), mixed.getChannelData(1)],
139
+ sampleRate: SCENE_SOUND_RATE,
140
+ strips: strips.length,
141
+ speakers: speakers.length,
142
+ };
143
+ }
@@ -6,10 +6,10 @@
6
6
  * No modeling happens in this process. `execute_blender_code`,
7
7
  * `get_scene_info`, `get_object_info` and `get_viewport_screenshot` become
8
8
  * `blender-*` control commands answered by the tab's worker
9
- * (`packages/editor-blender/contributions/blender.command.ts`, the engine's own
10
- * `workspace.command` contribution); the screenshot is the tab
11
- * photographing its own Model document. The 24 integration tools answer as
12
- * an add-on with no integrations, exactly as the Python server did.
9
+ * (`packages/editor-blender/contributions/blender.verbs.ts`, the engine's own
10
+ * Code-OSS commands); the screenshot is the tab
11
+ * photographing its own Model document. The remaining tools return static
12
+ * status or disabled-integration responses.
13
13
  */
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -22,7 +22,8 @@ import {
22
22
  ListPromptsRequestSchema,
23
23
  ListToolsRequestSchema,
24
24
  } from '@modelcontextprotocol/sdk/types.js';
25
- import { EditorClient, type EditorView } from '@volter/sdk';
25
+ import { EditorClient } from '@volter/live/client';
26
+ import type { EditorView } from '@volter/sdk';
26
27
  import blenderBundle from '@volter/blender-engine/wasm/BUNDLE.json';
27
28
  import { projectOutputRootOf } from '@volter/sdk/project/output-roots';
28
29
  import blenderDefaults from './blender-mcp-defaults.json';
@@ -110,52 +111,21 @@ function projectRelative(project: string, absolute: string): string | null {
110
111
  * (`C:\Users\me\p`) at its URI path (`/C:/Users/me/p`) — `projectMount` in
111
112
  * `@volter/blender-engine/browser/worker.ts`, which this mirrors because that module is the
112
113
  * 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.
114
+ * the engine's spelling on the wire, the host's on disk. Sending the host's spelling would have
115
+ * the worker list nothing at a path it does not have, and on Windows no script output would
116
+ * reach disk while every call reported success.
116
117
  */
117
- export function engineSpelling(host: string): string {
118
+ function engineSpelling(host: string): string {
118
119
  return /^[A-Za-z]:[\\/]/.test(host) ? `/${host.replaceAll('\\', '/')}` : host;
119
120
  }
120
121
 
121
122
  /** {@link engineSpelling} backwards: `/C:/Users/me/p/x.glb` → `C:\Users\me\p\x.glb` on
122
123
  * 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;
124
+ function hostSpelling(engine: string): string {
125
+ return process.platform === 'win32' && /^\/[A-Za-z]:\//.test(engine) ? engine.slice(1).replaceAll('/', '\\') : engine;
125
126
  }
126
127
 
127
- /**
128
- * The worker's filesystem is the only place a script's outputs exist; this
129
- * mirrors them to disk. Roots: the project (a script's `public/models/x.glb`
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
133
- * whose size or mtime changed since the last mirror are read back, and only
134
- * files the SESSION owns are listed at all — a project file it merely read
135
- * belongs to the host and is never written back over it.
136
- *
137
- * THIS IS THE DOOR THAT RECORDS, because it is the door a session's bytes
138
- * enter the project through. MEASURED 2026-09-18: a script's
139
- * `export_scene.gltf` to `<project>/public/models/lantern.glb` landed here and
140
- * `.volter/provenance.json` was never created — the one state
141
- * `scripts/validate-project-provenance.mjs` calls fatal, and the opposite of
142
- * the doctrine that a generated artifact enters `public/` through a door that
143
- * records it atomically. So a file this lands under `public/` is POSTed to the
144
- * session's `/__editor/blender-output`, which commits the bytes and the ledger
145
- * rewrite as ONE rollback-safe transaction through the same writer the
146
- * editor's own bake and acceptance tools use; the record's kind is
147
- * `project.session.write` and it names the session (see
148
- * `ProjectProvenanceSessionSchema`).
149
- *
150
- * The session process owns the writer and the project's ledger. This
151
- * transport uses that same session boundary as the other product commands;
152
- * it never creates a second ledger writer in the MCP process.
153
- *
154
- * Everything else the session owns — `.volter/tmp/*.png`, a `.blend`, a replay
155
- * harness's run directory outside the project — is mirrored with a plain write
156
- * and deliberately NOT recorded: the ledger is about what the project SHIPS,
157
- * and `public/` is what ships.
158
- */
128
+ /** Mirror session-owned output files whose size or mtime changed to the project or VOLTER_BLENDER_MIRROR_ROOTS. Files under public/ go through /__editor/blender-output, which commits bytes and provenance atomically under the session writer. Other outputs use plain writes. Never mirror a host input merely read by Blender. */
159
129
  class Mirror {
160
130
  readonly #seen = new Map<string, string>();
161
131
  constructor(
@@ -165,13 +135,12 @@ class Mirror {
165
135
  private readonly source: string,
166
136
  ) {}
167
137
 
168
- async sync(tab: TabSession, callId: string | null): Promise<number> {
169
- let written = 0;
138
+ async sync(tab: TabSession, callId: string): Promise<void> {
170
139
  for (const root of this.roots) {
171
- const listing = await tab.command<{
140
+ const listing = await tab.commandHeld<{
172
141
  entries: FileEntry[];
173
- session?: string;
174
- revision?: number;
142
+ session: string;
143
+ revision: number;
175
144
  document?: string;
176
145
  }>('blender-list-files', { path: engineSpelling(root) });
177
146
  for (const entry of listing.entries) {
@@ -185,20 +154,10 @@ class Mirror {
185
154
  projectPath !== null && projectOutputRootOf(projectPath) === 'public'
186
155
  ? projectPath
187
156
  : null;
188
- // ANTI-SHIM: an unrecordable shipped file is REFUSED, not written
189
- // anyway. Landing it unrecorded is the defect this door exists to
190
- // close, and inventing a session for it would be worse than both.
191
- if (shipped !== null && (listing.session === undefined || listing.revision === undefined))
192
- throw new Error(
193
- `The Blender session wrote ${shipped} but has not named itself — it has presented no ` +
194
- 'frame, so there is no session identity or revision to record the file under, and ' +
195
- 'an unrecorded binary under public/ is what `npm run validate-provenance` calls ' +
196
- 'fatal. Nothing was written for it.',
197
- );
198
157
  // WE mint the transfer id and WE choose where the bytes land; the page
199
158
  // only hands over a body. See `/__editor/blender-file`.
200
159
  const transferId = randomUUID();
201
- await tab.command<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
160
+ await tab.commandHeld<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
202
161
  const spooled = await fetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
203
162
  if (!spooled.ok) throw new Error(`Transfer of ${entry.path} failed: ${spooled.status}`);
204
163
  const bytes = Buffer.from(await spooled.arrayBuffer());
@@ -219,10 +178,10 @@ class Mirror {
219
178
  } else {
220
179
  const query = new URLSearchParams({
221
180
  path: shipped,
222
- session: listing.session as string,
223
- revision: String(listing.revision as number),
181
+ session: listing.session,
182
+ revision: String(listing.revision),
224
183
  source: this.source,
225
- ...(callId === null ? {} : { callId }),
184
+ callId,
226
185
  });
227
186
  // WHAT THE BYTES WERE MADE FROM, when the session holds a document:
228
187
  // the `.blend` this export came out of. Passed as a repeatable
@@ -242,10 +201,8 @@ class Mirror {
242
201
  }
243
202
  }
244
203
  this.#seen.set(entry.path, stamp);
245
- written += 1;
246
204
  }
247
205
  }
248
- return written;
249
206
  }
250
207
  }
251
208
 
@@ -253,7 +210,6 @@ class TabSession {
253
210
  #client: EditorClient | null = null;
254
211
  #connecting: Promise<EditorClient> | null = null;
255
212
  #origin: string | null = null;
256
- #port: number | null = null;
257
213
  /** A replay starts with a fresh model once; ordinary agent sessions persist. */
258
214
  #freshSessionPending = Boolean(process.env['VOLTER_BLENDER_FRESH_SESSION']);
259
215
  constructor(
@@ -268,7 +224,6 @@ class TabSession {
268
224
  await this.ensureEditor();
269
225
  const { resolveSession } = await import('@volter/live');
270
226
  const session = await resolveSession(this.project);
271
- this.#port = session.port;
272
227
  this.#origin = `http://127.0.0.1:${session.port}`;
273
228
  this.#client = new EditorClient({ url: this.#origin });
274
229
  return this.#client;
@@ -285,44 +240,36 @@ class TabSession {
285
240
  return this.#origin as string;
286
241
  }
287
242
 
288
- /** The machine-local port this project's session answers on — the address a
289
- * provenance record names so a reader can get back to the tab. */
290
- async port(): Promise<number> {
291
- await this.client();
292
- return this.#port as number;
293
- }
294
-
295
243
  /**
296
244
  * The Model document open and the session started at this project's path.
297
245
  *
298
246
  * Asked every call, never remembered: the session lives in the editor tab,
299
247
  * so an editor restart ends it while this process keeps running.
300
- * The editor owns opening the Model document and idempotent startup. A
301
- * separate status query would put a five-second check before that startup
302
- * request and introduce a race with an editor restart.
248
+ * The editor owns opening the Model document and idempotent startup on each call.
303
249
  */
304
250
  async started(): Promise<EditorClient> {
305
251
  const client = await this.client();
306
252
  const fresh = this.#freshSessionPending;
307
253
  this.#freshSessionPending = false;
308
- await client.blender('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
254
+ await client.contributedVerb('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
309
255
  return client;
310
256
  }
311
257
 
312
258
  async command<T>(type: string, fields: Record<string, unknown> = {}): Promise<T> {
313
259
  const client = await this.started();
314
- return client.blender<T & object>(type as `blender-${string}`, fields);
260
+ return client.contributedVerb<T & object>(type, fields);
261
+ }
262
+
263
+ /** Read outputs through the session the call used, without another start. During Save As the tab can briefly lag Blender’s file; output reads need no document binding. */
264
+ async commandHeld<T>(type: string, fields: Record<string, unknown>): Promise<T> {
265
+ const client = await this.client();
266
+ return client.contributedVerb<T & object>(type, fields);
315
267
  }
316
268
  }
317
269
 
318
270
  const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
319
271
 
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). */
272
+ /** Split VOLTER_BLENDER_MIRROR_ROOTS by the platform delimiter. Accept only mountable POSIX or drive roots; refuse relative, UNC and drive-less rooted paths by name. */
326
273
  function mirrorRoots(raw: string | undefined): string[] {
327
274
  const roots = (raw ?? '').split(delimiter).filter((root) => root !== '');
328
275
  const mountable = (root: string) => /^[A-Za-z]:[\\/]/.test(root) || (root.startsWith('/') && !root.startsWith('//'));
@@ -342,17 +289,14 @@ export async function serveBlenderMcp(
342
289
  command: string,
343
290
  ): Promise<void> {
344
291
  const runtimeIdentity = `Blender ${blenderBundle.blender} (source ${blenderBundle.source}), compiled to WebAssembly and running headless in the editor tab; three.js takes its photographs. Factory bpy.context.scene.render.engine: '${blenderBundle.factoryEngine}'. Documents are .blend. save_as_mainfile records the actual saved path in bpy.data.filepath; open_mainfile reopens it.`;
345
- // THE PHOTOGRAPH STAYS IN THE EDITOR. An agent asked to "take a photograph" with no word on where
346
- // it goes rendered one, wrote it into the project and ran the OS `open` on it: Preview started
347
- // outside the editor. The editor already shows a picture as one of its documents.
292
+ // Photographs remain visible in the editor's own image documents.
348
293
  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.`;
349
294
  // Blender's own spelling of the root: on Windows the project's `C:\…` is `/C:/…` inside it, and
350
295
  // a script told the host spelling writes to a path that is not the project's.
351
296
  const mounted = engineSpelling(project);
352
297
  // 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()).`;
298
+ // stageProjectFiles), so a project script runs from its file; the instructions say so.
299
+ 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: import runpy; script_globals = runpy.run_path(${JSON.stringify(`${mounted}/src/models/<name>.py`)}, run_name="__main__"). This sets __file__ for sibling asset paths and returns the script's own globals as script_globals.`;
356
300
  const instructions = `${runtimeIdentity}\n${filesystem}\n${photographs}`;
357
301
  const tools = (blenderTools as ToolShape[]).map((tool) =>
358
302
  tool.name === 'execute_blender_code'
@@ -389,7 +333,7 @@ export async function serveBlenderMcp(
389
333
  // The transport's own number for THIS call. A file the script writes is
390
334
  // recorded under it, so a ledger entry points back at the exact request
391
335
  // that produced the bytes.
392
- const callId = extra.requestId === undefined ? null : String(extra.requestId);
336
+ const callId = String(extra.requestId);
393
337
  if (name in STATIC_TOOLS) return text(STATIC_TOOLS[name]!);
394
338
  if (name in defaults) return text(defaults[name]!);
395
339
  switch (name) {
@@ -419,7 +363,20 @@ export async function serveBlenderMcp(
419
363
  const { result } = await tab.command<{ result: string }>('blender-execute', {
420
364
  code: String(args['code'] ?? ''),
421
365
  });
422
- await mirror.sync(tab, callId);
366
+ // The script has run and what it saved is saved: a failure to copy its other files is said BESIDE its
367
+ // answer, preserving the script’s successful result.
368
+ try {
369
+ await mirror.sync(tab, callId);
370
+ } catch (error) {
371
+ return {
372
+ ...text(
373
+ `${result}\n\nThe script ran, but the files it wrote did not all reach the project's disk: ` +
374
+ `${error instanceof Error ? error.message : String(error)}. The next execute_blender_code call ` +
375
+ 'copies what is still missing.',
376
+ ),
377
+ isError: true,
378
+ };
379
+ }
423
380
  return text(result);
424
381
  }
425
382
  case 'get_viewport_screenshot': {
@@ -429,7 +386,6 @@ export async function serveBlenderMcp(
429
386
  view?: { size: number; position?: number[]; target?: number[] };
430
387
  error?: string;
431
388
  }>('blender-screenshot-view', { maxSize });
432
- if (!answer.document) throw new Error('Screenshot failed: Blender supplied no document address');
433
389
  if (!answer.view)
434
390
  throw new Error(answer.error ?? 'Screenshot failed: No 3D viewport found');
435
391
  const { view } = answer;