@volter/editor-blender 0.1.150 → 0.1.152

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 +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  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 +99 -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 +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  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 +145 -333
  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 +45 -31
  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 +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  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 +3 -30
  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 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -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 -48
  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/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  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,9 @@ 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 { sessionFetch } from '@volter/live/registry-format';
27
+ import type { EditorView } from '@volter/sdk';
26
28
  import blenderBundle from '@volter/blender-engine/wasm/BUNDLE.json';
27
29
  import { projectOutputRootOf } from '@volter/sdk/project/output-roots';
28
30
  import blenderDefaults from './blender-mcp-defaults.json';
@@ -110,52 +112,21 @@ function projectRelative(project: string, absolute: string): string | null {
110
112
  * (`C:\Users\me\p`) at its URI path (`/C:/Users/me/p`) — `projectMount` in
111
113
  * `@volter/blender-engine/browser/worker.ts`, which this mirrors because that module is the
112
114
  * 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.
115
+ * the engine's spelling on the wire, the host's on disk. Sending the host's spelling would have
116
+ * the worker list nothing at a path it does not have, and on Windows no script output would
117
+ * reach disk while every call reported success.
116
118
  */
117
- export function engineSpelling(host: string): string {
119
+ function engineSpelling(host: string): string {
118
120
  return /^[A-Za-z]:[\\/]/.test(host) ? `/${host.replaceAll('\\', '/')}` : host;
119
121
  }
120
122
 
121
123
  /** {@link engineSpelling} backwards: `/C:/Users/me/p/x.glb` → `C:\Users\me\p\x.glb` on
122
124
  * 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
+ function hostSpelling(engine: string): string {
126
+ return process.platform === 'win32' && /^\/[A-Za-z]:\//.test(engine) ? engine.slice(1).replaceAll('/', '\\') : engine;
125
127
  }
126
128
 
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
- */
129
+ /** 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
130
  class Mirror {
160
131
  readonly #seen = new Map<string, string>();
161
132
  constructor(
@@ -165,13 +136,12 @@ class Mirror {
165
136
  private readonly source: string,
166
137
  ) {}
167
138
 
168
- async sync(tab: TabSession, callId: string | null): Promise<number> {
169
- let written = 0;
139
+ async sync(tab: TabSession, callId: string): Promise<void> {
170
140
  for (const root of this.roots) {
171
- const listing = await tab.command<{
141
+ const listing = await tab.commandHeld<{
172
142
  entries: FileEntry[];
173
- session?: string;
174
- revision?: number;
143
+ session: string;
144
+ revision: number;
175
145
  document?: string;
176
146
  }>('blender-list-files', { path: engineSpelling(root) });
177
147
  for (const entry of listing.entries) {
@@ -185,21 +155,11 @@ class Mirror {
185
155
  projectPath !== null && projectOutputRootOf(projectPath) === 'public'
186
156
  ? projectPath
187
157
  : 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
158
  // WE mint the transfer id and WE choose where the bytes land; the page
199
159
  // only hands over a body. See `/__editor/blender-file`.
200
160
  const transferId = randomUUID();
201
- await tab.command<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
202
- const spooled = await fetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
161
+ await tab.commandHeld<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
162
+ const spooled = await sessionFetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
203
163
  if (!spooled.ok) throw new Error(`Transfer of ${entry.path} failed: ${spooled.status}`);
204
164
  const bytes = Buffer.from(await spooled.arrayBuffer());
205
165
  if (shipped === null) {
@@ -219,10 +179,10 @@ class Mirror {
219
179
  } else {
220
180
  const query = new URLSearchParams({
221
181
  path: shipped,
222
- session: listing.session as string,
223
- revision: String(listing.revision as number),
182
+ session: listing.session,
183
+ revision: String(listing.revision),
224
184
  source: this.source,
225
- ...(callId === null ? {} : { callId }),
185
+ callId,
226
186
  });
227
187
  // WHAT THE BYTES WERE MADE FROM, when the session holds a document:
228
188
  // the `.blend` this export came out of. Passed as a repeatable
@@ -230,7 +190,7 @@ class Mirror {
230
190
  // server validates each one as a project-relative path of its own.
231
191
  if (typeof listing.document === 'string' && listing.document !== '')
232
192
  query.append('inputs', listing.document);
233
- const recorded = await fetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
193
+ const recorded = await sessionFetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
234
194
  method: 'POST',
235
195
  body: new Uint8Array(bytes),
236
196
  });
@@ -242,10 +202,8 @@ class Mirror {
242
202
  }
243
203
  }
244
204
  this.#seen.set(entry.path, stamp);
245
- written += 1;
246
205
  }
247
206
  }
248
- return written;
249
207
  }
250
208
  }
251
209
 
@@ -253,7 +211,6 @@ class TabSession {
253
211
  #client: EditorClient | null = null;
254
212
  #connecting: Promise<EditorClient> | null = null;
255
213
  #origin: string | null = null;
256
- #port: number | null = null;
257
214
  /** A replay starts with a fresh model once; ordinary agent sessions persist. */
258
215
  #freshSessionPending = Boolean(process.env['VOLTER_BLENDER_FRESH_SESSION']);
259
216
  constructor(
@@ -268,7 +225,6 @@ class TabSession {
268
225
  await this.ensureEditor();
269
226
  const { resolveSession } = await import('@volter/live');
270
227
  const session = await resolveSession(this.project);
271
- this.#port = session.port;
272
228
  this.#origin = `http://127.0.0.1:${session.port}`;
273
229
  this.#client = new EditorClient({ url: this.#origin });
274
230
  return this.#client;
@@ -285,44 +241,36 @@ class TabSession {
285
241
  return this.#origin as string;
286
242
  }
287
243
 
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
244
  /**
296
245
  * The Model document open and the session started at this project's path.
297
246
  *
298
247
  * Asked every call, never remembered: the session lives in the editor tab,
299
248
  * 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.
249
+ * The editor owns opening the Model document and idempotent startup on each call.
303
250
  */
304
251
  async started(): Promise<EditorClient> {
305
252
  const client = await this.client();
306
253
  const fresh = this.#freshSessionPending;
307
254
  this.#freshSessionPending = false;
308
- await client.blender('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
255
+ await client.contributedVerb('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
309
256
  return client;
310
257
  }
311
258
 
312
259
  async command<T>(type: string, fields: Record<string, unknown> = {}): Promise<T> {
313
260
  const client = await this.started();
314
- return client.blender<T & object>(type as `blender-${string}`, fields);
261
+ return client.contributedVerb<T & object>(type, fields);
262
+ }
263
+
264
+ /** 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. */
265
+ async commandHeld<T>(type: string, fields: Record<string, unknown>): Promise<T> {
266
+ const client = await this.client();
267
+ return client.contributedVerb<T & object>(type, fields);
315
268
  }
316
269
  }
317
270
 
318
271
  const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
319
272
 
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). */
273
+ /** 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
274
  function mirrorRoots(raw: string | undefined): string[] {
327
275
  const roots = (raw ?? '').split(delimiter).filter((root) => root !== '');
328
276
  const mountable = (root: string) => /^[A-Za-z]:[\\/]/.test(root) || (root.startsWith('/') && !root.startsWith('//'));
@@ -342,17 +290,14 @@ export async function serveBlenderMcp(
342
290
  command: string,
343
291
  ): Promise<void> {
344
292
  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.
293
+ // Photographs remain visible in the editor's own image documents.
348
294
  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
295
  // Blender's own spelling of the root: on Windows the project's `C:\…` is `/C:/…` inside it, and
350
296
  // a script told the host spelling writes to a path that is not the project's.
351
297
  const mounted = engineSpelling(project);
352
298
  // 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()).`;
299
+ // stageProjectFiles), so a project script runs from its file; the instructions say so.
300
+ 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
301
  const instructions = `${runtimeIdentity}\n${filesystem}\n${photographs}`;
357
302
  const tools = (blenderTools as ToolShape[]).map((tool) =>
358
303
  tool.name === 'execute_blender_code'
@@ -389,7 +334,7 @@ export async function serveBlenderMcp(
389
334
  // The transport's own number for THIS call. A file the script writes is
390
335
  // recorded under it, so a ledger entry points back at the exact request
391
336
  // that produced the bytes.
392
- const callId = extra.requestId === undefined ? null : String(extra.requestId);
337
+ const callId = String(extra.requestId);
393
338
  if (name in STATIC_TOOLS) return text(STATIC_TOOLS[name]!);
394
339
  if (name in defaults) return text(defaults[name]!);
395
340
  switch (name) {
@@ -419,7 +364,20 @@ export async function serveBlenderMcp(
419
364
  const { result } = await tab.command<{ result: string }>('blender-execute', {
420
365
  code: String(args['code'] ?? ''),
421
366
  });
422
- await mirror.sync(tab, callId);
367
+ // The script has run and what it saved is saved: a failure to copy its other files is said BESIDE its
368
+ // answer, preserving the script’s successful result.
369
+ try {
370
+ await mirror.sync(tab, callId);
371
+ } catch (error) {
372
+ return {
373
+ ...text(
374
+ `${result}\n\nThe script ran, but the files it wrote did not all reach the project's disk: ` +
375
+ `${error instanceof Error ? error.message : String(error)}. The next execute_blender_code call ` +
376
+ 'copies what is still missing.',
377
+ ),
378
+ isError: true,
379
+ };
380
+ }
423
381
  return text(result);
424
382
  }
425
383
  case 'get_viewport_screenshot': {
@@ -429,7 +387,6 @@ export async function serveBlenderMcp(
429
387
  view?: { size: number; position?: number[]; target?: number[] };
430
388
  error?: string;
431
389
  }>('blender-screenshot-view', { maxSize });
432
- if (!answer.document) throw new Error('Screenshot failed: Blender supplied no document address');
433
390
  if (!answer.view)
434
391
  throw new Error(answer.error ?? 'Screenshot failed: No 3D viewport found');
435
392
  const { view } = answer;